diff --git a/CHANGELOG.md b/CHANGELOG.md index 9587b18857b4b4fbbaa7b1b6a26146635b9253c9..b2cb3685684fa3eeda24a13ba72e38bc6bf9aa22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added ### +* `apiutils.merge_entities` now has a `merge_id_with_resolved_entity` keyword + which allows to identify property values with each other in case that one is + an id and the other is an Entity with this id. Default is ``False``, so no + change to the default behavior. + ### Changed ### ### Deprecated ### diff --git a/src/linkahead/apiutils.py b/src/linkahead/apiutils.py index 597342e38a961c628edd84dd8dff37471ef2570b..39f97fcd49b88fa727102facd01a1579b5b36404 100644 --- a/src/linkahead/apiutils.py +++ b/src/linkahead/apiutils.py @@ -354,7 +354,7 @@ def empty_diff(old_entity: Entity, new_entity: Entity, compare_referenced_record def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_empty_diffs=True, - force=False): + force=False, merge_id_with_resolved_entity: bool = False): """Merge entity_b into entity_a such that they have the same parents and properties. datatype, unit, value, name and description will only be changed in entity_a @@ -372,16 +372,22 @@ def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_emp Parameters ---------- entity_a, entity_b : Entity - The entities to be merged. entity_b will be merged into entity_a in place + The entities to be merged. entity_b will be merged into entity_a in place merge_references_with_empty_diffs : bool, optional - Whether the merge is performed if entity_a and entity_b both reference - 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. + Whether the merge is performed if entity_a and entity_b both reference + 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`, an EntityMergeConflictError is raised instead. Default is False. + 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`, an EntityMergeConflictError is raised + instead. Default is False. + merge_id_with_resolved_entity : bool, optional + If true, the values of two reference properties will be considered the + same if one is an integer id and the other is a db.Entity with this + id. I.e., a value 123 is identified with a value ``<Record + id=123/>``. Default is False. Returns ------- @@ -427,13 +433,31 @@ def merge_entities(entity_a: Entity, entity_b: Entity, merge_references_with_emp setattr(entity_a.get_property(key), attribute, diff_r2["properties"][key][attribute]) else: - raise EntityMergeConflictError( - f"Entity a ({entity_a.id}, {entity_a.name}) " - f"has a Property '{key}' with {attribute}=" - f"{diff_r2['properties'][key][attribute]}\n" - f"Entity b ({entity_b.id}, {entity_b.name}) " - f"has a Property '{key}' with {attribute}=" - f"{diff_r1['properties'][key][attribute]}") + raise_error = True + if merge_id_with_resolved_entity is True and attribute == "value": + # Do a special check for the case of an id value on the + # one hand, and a resolved entity on the other side. + this = entity_a.get_property(key).value + that = entity_b.get_property(key).value + same = False + if isinstance(this, list) and isinstance(that, list): + if len(this) == len(that): + same = all([_same_id_as_resolved_entity(a, b) + for a, b in zip(this, that)]) + else: + same = _same_id_as_resolved_entity(this, that) + if same is True: + setattr(entity_a.get_property(key), attribute, + diff_r2["properties"][key][attribute]) + raise_error = False + if raise_error is True: + raise EntityMergeConflictError( + f"Entity a ({entity_a.id}, {entity_a.name}) " + f"has a Property '{key}' with {attribute}=" + f"{diff_r2['properties'][key][attribute]}\n" + f"Entity b ({entity_b.id}, {entity_b.name}) " + f"has a Property '{key}' with {attribute}=" + f"{diff_r1['properties'][key][attribute]}") else: # TODO: This is a temporary FIX for # https://gitlab.indiscale.com/caosdb/src/caosdb-pylib/-/issues/105 @@ -586,3 +610,16 @@ def create_flat_list(ent_list: List[Entity], flat: List[Entity]): flat.append(p.value) # TODO: move inside if block? create_flat_list([p.value], flat) + + +def _same_id_as_resolved_entity(this, that): + """Checks whether ``this`` and ``that`` either are the same or whether one + is an id and the other is a db.Entity with this id. + + """ + if isinstance(this, Entity) and not isinstance(that, Entity): + # this is an Entity with an id, that is not + return this.id is not None and this.id == that + if not isinstance(this, Entity) and isinstance(that, Entity): + return that.id is not None and that.id == this + return this == that diff --git a/unittests/test_apiutils.py b/unittests/test_apiutils.py index b9a02926803c1e7b8134cde904ea2021d0281ff4..549312c367eea90eac79a9bbb4898cde76f8e8ac 100644 --- a/unittests/test_apiutils.py +++ b/unittests/test_apiutils.py @@ -588,3 +588,48 @@ def test_merge_missing_list_datatype_82(): with pytest.raises(TypeError) as te: merge_entities(recA, recB_without_DT, force=True) assert "Invalid datatype: List valued properties" in str(te.value) + + +def test_merge_id_with_resolved_entity(): + + rtname = "TestRT" + ref_id = 123 + ref_rec = db.Record(id=ref_id).add_parent(name=rtname) + + # recA has the resolved referenced record as value, recB its id. Otherwise, + # they are identical. + recA = db.Record().add_property(name=rtname, value=ref_rec) + recB = db.Record().add_property(name=rtname, value=ref_id) + + # default is strict: raise error since values are different + with pytest.raises(EntityMergeConflictError): + merge_entities(recA, recB) + + # Overwrite from right to left in both cases + merge_entities(recA, recB, merge_id_with_resolved_entity=True) + assert recA.get_property(rtname).value == ref_id + assert recA.get_property(rtname).value == recB.get_property(rtname).value + + recA = db.Record().add_property(name=rtname, value=ref_rec) + merge_entities(recB, recA, merge_id_with_resolved_entity=True) + assert recB.get_property(rtname).value == ref_rec + assert recA.get_property(rtname).value == recB.get_property(rtname).value + + # id mismatches + recB = db.Record().add_property(name=rtname, value=ref_id*2) + with pytest.raises(EntityMergeConflictError): + merge_entities(recA, recB, merge_id_with_resolved_entity=True) + + other_rec = db.Record(id=None).add_parent(name=rtname) + recA = db.Record().add_property(name=rtname, value=other_rec) + recB = db.Record().add_property(name=rtname, value=ref_id) + with pytest.raises(EntityMergeConflictError): + merge_entities(recA, recB, merge_id_with_resolved_entity=True) + + # also works in lists: + recA = db.Record().add_property( + name=rtname, datatype=db.LIST(rtname), value=[ref_rec, ref_id*2]) + recB = db.Record().add_property(name=rtname, datatype=db.LIST(rtname), value=[ref_id, ref_id*2]) + merge_entities(recA, recB, merge_id_with_resolved_entity=True) + assert recA.get_property(rtname).value == [ref_id, ref_id*2] + assert recA.get_property(rtname).value == recB.get_property(rtname).value