Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions doc/release_notes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <https://github.com/HumanBrainProject/fairgraph/issues/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.

Expand Down
28 changes: 28 additions & 0 deletions fairgraph/kgobject.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
40 changes: 40 additions & 0 deletions test/test_openminds_core.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
3 changes: 2 additions & 1 deletion test/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading