diff --git a/CHANGELOG.md b/CHANGELOG.md index 51aa8cf0a3c8c2409b032c2f6f0ed7be299d9631..0b9f7a0c0c0da499fb60547ee058156c4959999b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 th above `empty_diff` function). Formerly this would have caused a merge conflict if the referenced record(s) were identical, but stored in different Python objects. +* `apiutils.merge_entities` now has an optional `force` argument (defaults to + `False`, i.e., the old behavior) which determines whether in case of merge + conflicts errors will be raised or the properties and attributes of entity A + will be overwritten by entity B. ### Deprecated ### diff --git a/src/caosdb/apiutils.py b/src/caosdb/apiutils.py index 9d2f3c4cc343305eaff986031dfe3adc8f45dbc2..0651e4930072242524dc955c6ae69ca70b5f877a 100644 --- a/src/caosdb/apiutils.py +++ b/src/caosdb/apiutils.py @@ -361,7 +361,7 @@ def empty_diff(old_entity: Entity, new_entity: Entity, compare_referenced_record return True -def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_empty_diffs=True): +def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_empty_diffs=True, force=False): """ Merge entity_b into entity_a such that they have the same parents and properties. @@ -385,6 +385,10 @@ def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_emp record(s) that may be different Python objects but have empty diffs. If set to `False` a merge conflict will be raised in this case instead. Default is True. + force : bool, optional + If True, in case `entity_a` and `entity_b` have the same properties, the + values of `entity_a` are replaced by those of `entity_b` in the merge. + If `False`, a RuntimeError is raised instead. Default is False. Returns ------- @@ -421,6 +425,9 @@ def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_emp if (diff_r1["properties"][key][attribute] is None): setattr(entity_a.get_property(key), attribute, diff_r2["properties"][key][attribute]) + elif force: + setattr(entity_a.get_property(key), attribute, + diff_r2["properties"][key][attribute]) else: raise RuntimeError( f"Merge conflict:\nEntity a ({entity_a.id}, {entity_a.name}) " @@ -448,6 +455,9 @@ def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_emp if sa_a != sa_b: if sa_a is None: setattr(entity_a, special_attribute, sa_b) + elif force: + # force overwrite + setattr(entity_a, special_attribute, sa_b) else: raise RuntimeError("Merge conflict.") return entity_a diff --git a/unittests/test_apiutils.py b/unittests/test_apiutils.py index 89be9b86ae840e39271cc1b2aca7f0e0a82100cc..7b22d9956931fff4bded7f4eab813e9b91181997 100644 --- a/unittests/test_apiutils.py +++ b/unittests/test_apiutils.py @@ -1,11 +1,11 @@ -# -*- encoding: utf-8 -*- # # This file is a part of the CaosDB Project. # -# Copyright (C) 2018 Research Group Biomedical Physics, -# Max-Planck-Institute for Dynamics and Self-Organization Göttingen # Copyright (C) 2020 Timm Fitschen <t.fitschen@indiscale.com> +# Copyright (C) 2022 Florian Spreckelsen <f.spreckelsen@indiscale.com> # Copyright (C) 2020-2022 IndiScale GmbH <info@indiscale.com> +# Copyright (C) 2018 Research Group Biomedical Physics, +# Max-Planck-Institute for Dynamics and Self-Organization Göttingen # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as @@ -20,7 +20,6 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <https://www.gnu.org/licenses/>. # -# ** end header # # Test apiutils # A. Schlemmer, 02/2018 @@ -466,3 +465,90 @@ def test_empty_diff(): "RefType"), value=[ref_rec_b, ref_rec_b]) assert not empty_diff(rec_a, rec_b) assert empty_diff(rec_a, rec_b, compare_referenced_records=True) + + +def test_force_merge(): + """Test whether a forced merge overwrites existing properties correctly.""" + + # name overwrite + recA = db.Record(name="A") + recB = db.Record(name="B") + + with pytest.raises(RuntimeError) as re: + merge_entities(recA, recB) + assert "Merge conflict" in str(re.value) + + merge_entities(recA, recB, force=True) + assert "B" == recA.name + # unchanged + assert "B" == recB.name + + # description overwrite + recA = db.Record() + recA.description = "something" + recB = db.Record() + recB.description = "something else" + + with pytest.raises(RuntimeError) as re: + merge_entities(recA, recB) + assert "Merge conflict" in str(re.value) + + merge_entities(recA, recB, force=True) + assert recA.description == "something else" + # unchanged + assert recB.description == "something else" + + # property overwrite + recA = db.Record() + recA.add_property(name="propA", value="something") + recB = db.Record() + recB.add_property(name="propA", value="something else") + + with pytest.raises(RuntimeError) as re: + merge_entities(recA, recB) + assert "Merge conflict" in str(re.value) + + merge_entities(recA, recB, force=True) + assert recA.get_property("propA").value == "something else" + # unchanged + assert recB.get_property("propA").value == "something else" + + # don't remove a property that's not in recB + recA = db.Record() + recA.add_property(name="propA", value="something") + recA.add_property(name="propB", value=5.0) + recB = db.Record() + recB.add_property(name="propA", value="something else") + + merge_entities(recA, recB, force=True) + assert recA.get_property("propA").value == "something else" + assert recA.get_property("propB").value == 5.0 + + # also overwrite datatypes ... + rtA = db.RecordType() + rtA.add_property(name="propA", datatype=db.INTEGER) + rtB = db.RecordType() + rtB.add_property(name="propA", datatype=db.TEXT) + + with pytest.raises(RuntimeError) as re: + merge_entities(rtA, rtB) + assert "Merge conflict" in str(re.value) + + merge_entities(rtA, rtB, force=True) + assert rtA.get_property("propA").datatype == db.TEXT + # unchanged + assert rtB.get_property("propA").datatype == db.TEXT + + # ... and units + recA = db.Record() + recA.add_property(name="propA", value=5, unit="m") + recB = db.Record() + recB.add_property(name="propA", value=5, unit="cm") + + with pytest.raises(RuntimeError) as re: + merge_entities(recA, recB) + assert "Merge conflict" in str(re.value) + merge_entities(recA, recB, force=True) + assert recA.get_property("propA").unit == "cm" + # unchanged + assert recB.get_property("propA").unit == "cm"