diff --git a/CHANGELOG.md b/CHANGELOG.md index 75317f22362c35df9ebc2399599226f506b1e945..6eb0dabcad7a7510de90899d1a651ed70f791767 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added ### +- New function in apiutils that copies an Entity. + ### Changed ### ### Deprecated ### diff --git a/src/caosdb/apiutils.py b/src/caosdb/apiutils.py index dc9209b58c8163da552f29e7a4435a0c640b1ecf..08f31daad56c0ab471322197cadc1a1378267f35 100644 --- a/src/caosdb/apiutils.py +++ b/src/caosdb/apiutils.py @@ -37,7 +37,9 @@ from caosdb.common.datatype import (BOOLEAN, DATETIME, DOUBLE, FILE, INTEGER, REFERENCE, TEXT, is_reference) from caosdb.common.models import (Container, Entity, File, Property, Query, Record, RecordType, execute_query, - get_config) + get_config, SPECIAL_ATTRIBUTES) + +import logging def new_record(record_type, name=None, description=None, @@ -565,10 +567,6 @@ def getCommitIn(folder): return t.readline().strip() -COMPARED = ["name", "role", "datatype", "description", "importance", - "id", "path", "checksum", "size"] - - def compare_entities(old_entity: Entity, new_entity: Entity): """ Compare two entites. @@ -592,7 +590,7 @@ def compare_entities(old_entity: Entity, new_entity: Entity): if old_entity is new_entity: return (olddiff, newdiff) - for attr in COMPARED: + for attr in SPECIAL_ATTRIBUTES: try: oldattr = old_entity.__getattribute__(attr) old_entity_attr_exists = True @@ -681,10 +679,77 @@ def compare_entities(old_entity: Entity, new_entity: Entity): return (olddiff, newdiff) +def merge_entities(entity_a: Entity, entity_b: Entity): + """ + 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 if they + are None for entity_a and set for entity_b. If there is a corresponding value + for entity_a different from None a RuntimeError will be raised informing of an + unresolvable merge conflict. + + The merge operation is done in place. + + Returns entity_a. + + WARNING: This function is currently experimental and insufficiently tested. Use with care. + """ + + logging.warning( + "This function is currently experimental and insufficiently tested. Use with care.") + + # Compare both entities: + diff_r1, diff_r2 = compare_entities(entity_a, entity_b) + + # Go through the comparison and try to apply changes to entity_a: + for key in diff_r2["parents"]: + entity_a.add_parent(entity_b.get_parent(key)) + + for key in diff_r2["properties"]: + if key in diff_r1["properties"]: + if ("importance" in diff_r1["properties"][key] and + "importance" in diff_r2["properties"][key]): + if (diff_r1["properties"][key]["importance"] != + diff_r2["properties"][key]["importance"]): + raise NotImplementedError() + elif ("importance" in diff_r1["properties"][key] or + "importance" in diff_r2["properties"][key]): + raise NotImplementedError() + + for attribute in ("datatype", "unit", "value"): + if diff_r1["properties"][key][attribute] is None: + setattr(entity_a.get_property(key), attribute, + diff_r2["properties"][key][attribute]) + else: + raise RuntimeError("Merge conflict.") + else: + # TODO: This is a temporary FIX for + # https://gitlab.indiscale.com/caosdb/src/caosdb-pylib/-/issues/105 + entity_a.add_property(id=entity_b.get_property(key).id, + name=entity_b.get_property(key).name, + datatype=entity_b.get_property(key).datatype, + value=entity_b.get_property(key).value, + unit=entity_b.get_property(key).unit, + importance=entity_b.get_importance(key)) + # entity_a.add_property( + # entity_b.get_property(key), + # importance=entity_b.get_importance(key)) + + for special_attribute in ("name", "description"): + sa_a = getattr(entity_a, special_attribute) + sa_b = getattr(entity_b, special_attribute) + if sa_a != sa_b: + if sa_a is None: + setattr(entity_a, special_attribute, sa_b) + else: + raise RuntimeError("Merge conflict.") + return entity_a + + def describe_diff(olddiff, newdiff, name=None, as_update=True): description = "" - for attr in list(set(list(olddiff.keys())+list(newdiff.keys()))): + for attr in list(set(list(olddiff.keys()) + list(newdiff.keys()))): if attr == "parents" or attr == "properties": continue description += "{} differs:\n".format(attr) diff --git a/src/caosdb/common/models.py b/src/caosdb/common/models.py index 181750aae6fd3e1aeab2c61b59f53d8b8111d5bd..6475bc99ec825e102d5eac1b38d506247c11ebcb 100644 --- a/src/caosdb/common/models.py +++ b/src/caosdb/common/models.py @@ -79,6 +79,10 @@ ALL = "ALL" NONE = "NONE" +SPECIAL_ATTRIBUTES = ["name", "role", "datatype", "description", + "id", "path", "checksum", "size"] + + class Entity(object): """Entity is a generic CaosDB object. @@ -121,6 +125,48 @@ class Entity(object): self.id = id self.state = None + + def copy(self): + """ + Return a copy of entity. + + If deep == True return a deep copy, recursively copying all sub entities. + + Standard properties are copied using add_property. + Special attributes, as defined by the global variable SPECIAL_ATTRIBUTES and additionaly + the "value" are copied using setattr. + """ + if self.role == "File": + new = File() + elif self.role == "Property": + new = Property() + elif self.role == "RecordType": + new = RecordType() + elif self.role == "Record": + new = Record() + elif self.role == "Entity": + new = Entity() + else: + raise RuntimeError("Unkonwn role.") + + # Copy special attributes: + # TODO: this might rise an exception when copying + # special file attributes like checksum and size. + for attribute in SPECIAL_ATTRIBUTES + ["value"]: + val = getattr(self, attribute) + if val is not None: + setattr(new, attribute, val) + + # Copy parents: + for p in self.parents: + new.add_parent(p) + + # Copy properties: + for p in self.properties: + new.add_property(p, importance=self.get_importance(p)) + + return new + @property def version(self): if self._version is not None or self._wrapped_entity is None: diff --git a/unittests/test_apiutils.py b/unittests/test_apiutils.py index 0294646f6c526230a8e9fb722d56aa23a8f9285c..13603f4caae8b1212ffa041f37d9be4b462223ff 100644 --- a/unittests/test_apiutils.py +++ b/unittests/test_apiutils.py @@ -31,10 +31,14 @@ import tempfile import caosdb as db import caosdb.apiutils from caosdb.apiutils import (apply_to_ids, compare_entities, create_id_query, - resolve_reference) + resolve_reference, merge_entities) + +from caosdb.common.models import SPECIAL_ATTRIBUTES from .test_property import testrecord +import pytest + def test_convert_object(): r2 = db.apiutils.convert_to_python_object(testrecord) @@ -230,3 +234,92 @@ def test_compare_special_properties(): assert diff_r2[key] == 2 assert len(diff_r1["properties"]) == 0 assert len(diff_r2["properties"]) == 0 + + +def test_copy_entities(): + r = db.Record(name="A") + r.add_parent(name="B") + r.add_property(name="C", value=4, importance="OBLIGATORY") + r.add_property(name="D", value=[3, 4, 7], importance="OBLIGATORY") + r.description = "A fancy test record" + + c = r.copy() + + assert c is not r + assert c.name == "A" + assert c.role == r.role + assert c.parents[0].name == "B" + # parent and property objects are not shared among copy and original: + assert c.parents[0] is not r.parents[0] + + for i in [0, 1]: + assert c.properties[i] is not r.properties[i] + for special in SPECIAL_ATTRIBUTES: + assert getattr(c.properties[i], special) == getattr(r.properties[i], special) + assert c.get_importance(c.properties[i]) == r.get_importance(r.properties[i]) + + +def test_merge_entities(): + r = db.Record(name="A") + r.add_parent(name="B") + r.add_property(name="C", value=4, importance="OBLIGATORY") + r.add_property(name="D", value=[3, 4, 7], importance="OBLIGATORY") + r.description = "A fancy test record" + + r2 = db.Record() + r2.add_property(name="F", value="text") + merge_entities(r2, r) + assert r2.get_parents()[0].name == "B" + assert r2.get_property("C").name == "C" + assert r2.get_property("C").value == 4 + assert r2.get_property("D").name == "D" + assert r2.get_property("D").value == [3, 4, 7] + + assert r2.get_property("F").name == "F" + assert r2.get_property("F").value == "text" + + +def test_merge_bug_109(): + rt = db.RecordType(name="TestBug") + p = db.Property(name="test_bug_property", datatype=db.LIST(db.INTEGER)) + + r_b = db.Record(name="TestRecord") + r_b.add_parent(rt) + r_b.add_property(p, value=[18, 19]) + + r_a = db.Record(name="TestRecord") + r_a.add_parent(rt) + + merge_entities(r_a, r_b) + + assert r_b.get_property("test_bug_property").value == [18, 19] + assert r_a.get_property("test_bug_property").value == [18, 19] + + assert "<Value>18</Value>\n <Value>19</Value>" in str(r_b) + assert "<Value>18</Value>\n <Value>19</Value>\n <Value>18</Value>\n <Value>19</Value>" not in str(r_b) + + assert "<Value>18</Value>\n <Value>19</Value>" in str(r_a) + assert "<Value>18</Value>\n <Value>19</Value>\n <Value>18</Value>\n <Value>19</Value>" not in str(r_a) + + +@pytest.mark.xfail +def test_bug_109(): + rt = db.RecordType(name="TestBug") + p = db.Property(name="test_bug_property", datatype=db.LIST(db.INTEGER)) + + r_b = db.Record(name="TestRecord") + r_b.add_parent(rt) + r_b.add_property(p, value=[18, 19]) + + r_a = db.Record(name="TestRecord") + r_a.add_parent(rt) + r_a.add_property(r_b.get_property("test_bug_property")) + + assert r_b.get_property("test_bug_property").value == [18, 19] + assert r_a.get_property("test_bug_property").value == [18, 19] + + assert "<Value>18</Value>\n <Value>19</Value>" in str(r_b) + assert "<Value>18</Value>\n <Value>19</Value>\n <Value>18</Value>\n <Value>19</Value>" not in str(r_b) + + assert "<Value>18</Value>\n <Value>19</Value>" in str(r_a) + assert "<Value>18</Value>\n <Value>19</Value>\n <Value>18</Value>\n <Value>19</Value>" not in str(r_a)