diff --git a/doc/release_notes.rst b/doc/release_notes.rst index 2d5e4a7b..e0e81e46 100644 --- a/doc/release_notes.rst +++ b/doc/release_notes.rst @@ -29,6 +29,13 @@ New features Changes in behaviour -------------------- +- When an existence query (in :meth:`~fairgraph.kgobject.KGObject.exists` or + :meth:`~fairgraph.kgobject.KGObject.save`) matches an object with a stored instance whose value differs + from the local one only in case, or, with ``existence_match="contains"``, contains the local value, + the object's attribute is now set to the stored value. Previously, the local value was kept and treated + as a modification, so ``save()`` wrote the search term over the stored name + (`#170 `_). + - fairgraph now requires Python 3.10 or later, as Python 3.9 has reached end of life. It is tested with Python 3.10 to 3.15. diff --git a/fairgraph/kgobject.py b/fairgraph/kgobject.py index 536b226e..57fa26e1 100644 --- a/fairgraph/kgobject.py +++ b/fairgraph/kgobject.py @@ -698,6 +698,7 @@ def _exists_without_query(self, client: KGClient, existence_match: str = "equals # locally but present remotely would then look like a deliberate # deletion, and be set to null by the next call to save(). self._update_empty_properties(data) + self._adopt_matched_values(data, existence_match) return True return None @@ -761,8 +762,35 @@ def _use_matching_instance(self, client: KGClient, match: JSONdict, existence_ma self._space = match["https://schema.hbp.eu/myQuery/space"] client._existence_cache[self.__class__][self._existence_cache_keys(existence_match)[-1]] = self.id self._update_empty_properties(instance) # also updates `remote_data` + self._adopt_matched_values(instance, existence_match) return True + def _adopt_matched_values(self, data: JSONdict, existence_match: str): + """ + After an existence query has matched this object with the instance in `data`, replace the local value + of each property in the existence query whose value is a string with the stored one, where the two + differ only in ways the query ignores: case, and, with `existence_match="contains"`, the rest of the + stored value. Other properties are not changed. + + Otherwise, the difference would count as a local modification, and saving would write the search term + over the stored value. + """ + if not self.existence_query_properties: + return + + stored_values = self.__class__._deserialize_data(data) + for property_name in self.existence_query_properties: + local_value = getattr(self, property_name, None) + stored_value = stored_values.get(property_name, None) + if not (isinstance(local_value, str) and isinstance(stored_value, str)) or local_value == stored_value: + continue + if existence_match == "contains": + matched = local_value.lower() in stored_value.lower() + else: + matched = local_value.lower() == stored_value.lower() + if matched: + setattr(self, property_name, stored_value) + def modified_data(self) -> JSONdict: """ Return a dict containing the properties that have been modified locally diff --git a/test/test_openminds_core.py b/test/test_openminds_core.py index d173f971..e08aeee1 100644 --- a/test/test_openminds_core.py +++ b/test/test_openminds_core.py @@ -514,6 +514,46 @@ def test__update(): assert len(updated_data) == 0 +LONELY_MOUNTAIN_ID = "https://kg.ebrains.eu/api/instances/45678901-bcde-f012-3456-7890abcdef01" + + +def _seed_lonely_mountain(mock_client): + mock_client.instances[LONELY_MOUNTAIN_ID] = { + "@id": LONELY_MOUNTAIN_ID, + "@type": ["https://openminds.om-i.org/types/Organization"], + "https://core.kg.ebrains.eu/vocab/meta/space": "common", + "https://openminds.om-i.org/props/fullName": "Kingdom under the Lonely Mountain", + } + + +@pytest.mark.parametrize( + "full_name,existence_match", + [ + ("kingdom under the lonely mountain", "equals"), # differs only in case + ("Lonely Mountain", "contains"), + ("lonely mountain", "contains"), + ], +) +def test_inexact_existence_match_adopts_the_stored_value(mock_client, full_name, existence_match): + # see issue #170 + _seed_lonely_mountain(mock_client) + org = omcore.Organization(full_name=full_name) + log = ActivityLog() + org.save(mock_client, space="common", existence_match=existence_match, activity_log=log) + assert org.id == LONELY_MOUNTAIN_ID + assert org.full_name == "Kingdom under the Lonely Mountain" + assert org.modified_data() == {} + assert [entry.type for entry in log.entries] == ["no-op"] + assert mock_client.updates == [] + + # a second object with the same search term is found in the existence cache, with the same result + org2 = omcore.Organization(full_name=full_name) + assert org2.exists(mock_client, existence_match=existence_match) + assert org2.id == LONELY_MOUNTAIN_ID + assert org2.full_name == "Kingdom under the Lonely Mountain" + assert org2.modified_data() == {} + + def _person_document(extra_key=None, extra_key_in_link=None): """A JSON-LD document for a Person, linking to an ORCID that is included in full.""" orcid = { diff --git a/test/utils.py b/test/utils.py index 02ff40be..fda5faa2 100644 --- a/test/utils.py +++ b/test/utils.py @@ -237,7 +237,8 @@ def _value_matches(stored, op, value): if op == "EQUALS" and isinstance(item, str) and isinstance(value, str) and item.lower() == value.lower(): # the KG ignores case, but not whitespace, when matching return True - if op == "CONTAINS" and isinstance(item, str) and isinstance(value, str) and value in item: + if op == "CONTAINS" and isinstance(item, str) and isinstance(value, str) and value.lower() in item.lower(): + # the KG also ignores case for "contains" return True return False