Skip to content

Fill in empty properties when an object is recognized through the save cache - #135

Open
apdavison wants to merge 3 commits into
HumanBrainProject:masterfrom
apdavison:fix-exists-cache-empty-properties
Open

Fill in empty properties when an object is recognized through the save cache#135
apdavison wants to merge 3 commits into
HumanBrainProject:masterfrom
apdavison:fix-exists-cache-empty-properties

Conversation

@apdavison

Copy link
Copy Markdown
Member

Fixes #134.

KGObject.exists() has three ways of finding an object that already exists in the KG, but only two of them fill in the properties that were left empty locally. The branch that recognizes an object from the save cache took the cached object's remote_data without doing so.

That breaks the invariant save() depends on: _update_empty_properties() distinguishes "never set" from "deliberately set to None" by diffing against remote_data, so populating remote_data while leaving a property empty makes that property look like a deliberate deletion. modified_data() then reports it as changed, and save() sends it to the KG as null.

The trigger is simply saving the same object twice in one session from separately-constructed objects. Metadata-harvesting scripts do this routinely — they build a fresh Person for each role someone holds and each project they appear in — and were silently erasing people's contactInformation, affiliation, alternateName and digitalIdentifier on the second save.

The fix calls _update_empty_properties() in that branch too, so all three paths through exists() behave the same way. Assigning remote_data directly also left the two objects sharing a single dict, as the # copy or update needed? comment there suspected; the back-fill gives each its own equal but independent copy.

Tests in test/test_base.py cover the back-fill, the fact that values set locally still win and are still written, and a save that must send nothing; test/test_openminds_core.py has a regression test for the person-saved-twice case. All run offline against the mock client, which can now serve seeded instances, answer existence queries against them, and record what gets written.

…e cache

exists() has three ways of finding an object that already exists in the KG, but only two of them filled in the properties that were left empty locally. The save cache branch took the cached object's remote_data without doing so, which made every property that is set in the KG but absent from the object look like a deliberate deletion to modified_data(), so the next call to save() set it to null. Metadata-harvesting scripts, which typically build a fresh object for each role a person holds, were losing people's contact information, affiliations and ORCIDs the second time a person was saved in a single run.

Assigning remote_data directly also left the two objects sharing a single dict, as the comment there suspected; the back-fill gives each its own.

The mock client used in the tests can now serve seeded instances, answer existence queries against them, and record what gets written, so a save can be followed end to end.
@apdavison apdavison added this to the 0.15 milestone Aug 31, 2026
@apdavison apdavison added the bug Something isn't working label Aug 31, 2026
@apdavison apdavison moved this from Todo to In Progress in fairgraph development Aug 31, 2026
Review follow-up to the previous commit; no change in behaviour.

The "@type" fallback was unreachable: to_jsonld always emits @type with include_empty_properties either way, normalize_data preserves it, and _update_empty_properties copies it in from the KG document, so every source of remote_data already carries one. Worse, it would have papered over a genuine class mismatch that _deserialize_data is right to reject, so it is gone rather than kept as belt-and-braces. The guard on _raw_remote_data was likewise a no-op: this branch is only reached when the object has no http id, and _raw_remote_data is only ever assigned when it does, so it is always None here.

The comment on the remote_data assertion overstated what the code delivers. The two objects get separate top-level dicts, so neither can rewrite the other's record, but nested values are shared by reference; that is safe only because remote_data is written a key at a time or replaced wholesale, never mutated in place, so the comment now says so.

RecordingMockClient duplicated what MockKGClient already does now that it serves seeded instances and records writes, so the test uses the shared fixture instead and there is one less mock to keep in step.

MockKGClient._match_instances now returns None for any query shape it cannot honour faithfully - a filter on a nested node, an operator other than EQUALS or CONTAINS, or a filter on @id - so those fall through to NotImplementedError as the rest of the mock expects. Previously it ignored the operator and any nested filters, which would have returned a plausible but wrong match set and let a test pass for the wrong reason. CONTAINS is now matched as a substring rather than equality, and the ordering trap is documented: the hard-coded name branches are still consulted first, so a test seeding an instance whose name they special-case will get the canned response.
Used black. No change in behaviour.
@apdavison
apdavison force-pushed the fix-exists-cache-empty-properties branch from b8302cf to d948846 Compare September 1, 2026 13:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

save() sets properties to null when the same object is saved twice in one session

1 participant