Summary
For an object whose id is missing or is not a full URI, exists() raises TypeError if one of the class's existence_query_properties holds a KGProxy. save() calls exists(), so saving the object fails too, and so does Collection.upload():
from fairgraph.kgproxy import KGProxy
import fairgraph.openminds.core as omcore
funding = omcore.Funding(
funder=KGProxy(omcore.Organization, "https://kg.ebrains.eu/api/instances/2cd6bfcd-6e3b-4b53-8b18-5a1eb6dc6f64")
)
funding.exists(client)
# TypeError: Filter specifications should be a single-level dict, without nesting
funding.save(client, space="myspace")
# TypeError: Filter specifications should be a single-level dict, without nesting
The same link given as a resolved object works:
funding = omcore.Funding(funder=omcore.Organization(id="https://kg.ebrains.eu/api/instances/2cd6bfcd-...", name="Acme"))
funding._build_existence_query()
# {'funder': 'https://kg.ebrains.eu/api/instances/2cd6bfcd-...'}
funding.exists(client) # runs the query as expected
Cause
_build_existence_query() (fairgraph/node.py:465) only takes the id of a linked value when the value is a KGNode (node.py:485). KGProxy is not a KGNode (its bases are Releasable, Resolvable and openMINDS Link), so it falls through to the final branch, where value_to_jsonld() (node.py:496) serializes it as a nested dict:
{'funder': {'@id': 'https://kg.ebrains.eu/api/instances/2cd6bfcd-...'}}
exists() passes this to generate_minimal_query(), and expand_filter() rejects the nested dict (fairgraph/utility.py:280). The TypeError isn't caught in exists() (only CannotBuildExistenceQuery is), so it propagates to save() and Collection.upload().
When it happens
A KGProxy ends up in an existence-query property of an object whose id is missing or is not a full URI in several common situations:
- Reusing a link from a fetched object. Links on objects retrieved with
from_id(), from_uri() or list() are KGProxy objects unless they were followed with follow_links. For example, building a new Comment whose about and commenter are taken from an existing Comment fails.
- Creating a proxy directly, to link to an existing KG instance without fetching it.
Collection.load() followed by Collection.upload(). When a JSON-LD node links to an instance that is already in the KG (not in the collection), deserialization turns the link into a KGProxy (node.py:393). If the node's own @id is not a full URI (e.g. a blank-node id such as _:funding1), upload() then saves the node and raises. Links between nodes within the collection are resolved to nodes by Collection._resolve_links() in openMINDS-Python (openminds/collection.py) and are not affected.
Objects whose id is a full URI (starting with http) are not affected, because exists() then looks them up by id and never builds an existence query (kgobject.py:588). Objects with any other id, such as a blank-node id, do build one.
There is also a silent side effect when such an object is created without error (e.g. one with a full-URI id that is not yet in the KG): after creating the instance, save() builds the existence query again to populate the save cache (kgobject.py:926). Here the nested dict does not raise, because generate_cache_key() converts dict values to strings, so the cache key is based on str({'@id': ...}) instead of the id itself.
A multi-valued link property that holds a list (of proxies or of objects) is not affected in the same way: _build_existence_query() raises CannotBuildExistenceQuery for any list, exists() returns False, and a new instance is created. That is a separate limitation.
Affected classes
Classes with a linked-object property in existence_query_properties, where the property holds a single KGProxy:
| Class |
Link properties in existence query |
v4 |
v5 |
Accessibility |
channel, eligibility, form, process |
|
✓ |
AccountInformation |
service |
✓ |
✓ |
AnatomicalAtlas |
digital_identifier |
|
✓ |
BrainAtlas |
digital_identifier |
✓ |
|
Chapter |
is_part_of |
✓ |
✓ |
ChemicalMixture |
type |
✓ |
✓ |
Comment |
about, commenter |
✓ |
✓ |
CommonCoordinateSpaceVersion |
accessibility, anatomical_axes_orientation, full_documentation, native_unit |
✓ |
|
Electrode, ElectrodeArray, MRIScanner, Pipette, SlicingDevice |
type |
|
✓ |
MRICoil |
mounting_type, type |
|
✓ |
HardwareProduct |
type |
|
✓ |
Environment |
hardware |
✓ |
✓ |
FileArchive |
format |
✓ |
✓ |
FileBundle |
is_part_of |
✓ |
✓ |
Funding |
funder |
✓ |
✓ |
GridImage, GridImageSequence, GridImageStack, GridVolume, GridVolumeSequence |
data_location |
|
✓ |
LivePaperResourceItem, LivePaperSection |
is_part_of |
✓ |
✓ |
Organization |
country_of_formation, type |
|
✓ |
ProductSource |
provider |
✓ |
✓ |
PublicationIssue, PublicationVolume |
is_part_of |
✓ |
✓ |
Recording |
data_location, recorded_with |
✓ |
|
RegularTimeSeries |
data_location |
|
✓ |
ServiceDeployment |
service |
|
✓ |
ServiceLink |
data_location (v4 and v5), service (v4) |
✓ |
✓ |
SoftwareAgent |
software |
✓ |
✓ |
Strain |
genetic_strain_type, species |
✓ |
✓ |
UsageAgreement |
jurisdiction, template |
|
✓ |
(Multi-valued link properties such as contributions, has_parts or stages are omitted; see above.)
Why no test catches it
test_build_existence_query in test/test_base.py uses plain values only, and no test calls exists() or save() on an object without an id that holds a KGProxy in an existence-query property.
Suggested fix
In _build_existence_query(), handle a KGProxy like a KGNode that has an id:
from .kgproxy import KGProxy # already imported in node.py
...
if isinstance(value, KGProxy):
query[query_property_name] = value.id
elif isinstance(value, KGNode):
...
Checked against the mock client by substituting a KGNode with the same id: the generated filter is correct both for a proxy with a full URI (a CONTAINS filter on the link's @id) and for one with a bare UUID (a CONTAINS filter on http://schema.org/identifier).
Tests:
_build_existence_query() returns the proxy's id for a KGProxy value.
exists() on a new object with a KGProxy in an existence-query property (e.g. Funding) runs the existence query, against the mock client with the query stubbed, instead of raising.
Collection.upload() of a JSON-LD node that links to an existing KG instance through an existence-query property.
Summary
For an object whose
idis missing or is not a full URI,exists()raisesTypeErrorif one of the class'sexistence_query_propertiesholds aKGProxy.save()callsexists(), so saving the object fails too, and so doesCollection.upload():The same link given as a resolved object works:
Cause
_build_existence_query()(fairgraph/node.py:465) only takes theidof a linked value when the value is aKGNode(node.py:485).KGProxyis not aKGNode(its bases areReleasable,Resolvableand openMINDSLink), so it falls through to the final branch, wherevalue_to_jsonld()(node.py:496) serializes it as a nested dict:{'funder': {'@id': 'https://kg.ebrains.eu/api/instances/2cd6bfcd-...'}}exists()passes this togenerate_minimal_query(), andexpand_filter()rejects the nested dict (fairgraph/utility.py:280). TheTypeErrorisn't caught inexists()(onlyCannotBuildExistenceQueryis), so it propagates tosave()andCollection.upload().When it happens
A
KGProxyends up in an existence-query property of an object whoseidis missing or is not a full URI in several common situations:from_id(),from_uri()orlist()areKGProxyobjects unless they were followed withfollow_links. For example, building a newCommentwhoseaboutandcommenterare taken from an existing Comment fails.Collection.load()followed byCollection.upload(). When a JSON-LD node links to an instance that is already in the KG (not in the collection), deserialization turns the link into aKGProxy(node.py:393). If the node's own@idis not a full URI (e.g. a blank-node id such as_:funding1),upload()then saves the node and raises. Links between nodes within the collection are resolved to nodes byCollection._resolve_links()in openMINDS-Python (openminds/collection.py) and are not affected.Objects whose
idis a full URI (starting withhttp) are not affected, becauseexists()then looks them up by id and never builds an existence query (kgobject.py:588). Objects with any other id, such as a blank-node id, do build one.There is also a silent side effect when such an object is created without error (e.g. one with a full-URI id that is not yet in the KG): after creating the instance,
save()builds the existence query again to populate the save cache (kgobject.py:926). Here the nested dict does not raise, becausegenerate_cache_key()converts dict values to strings, so the cache key is based onstr({'@id': ...})instead of the id itself.A multi-valued link property that holds a list (of proxies or of objects) is not affected in the same way:
_build_existence_query()raisesCannotBuildExistenceQueryfor any list,exists()returnsFalse, and a new instance is created. That is a separate limitation.Affected classes
Classes with a linked-object property in
existence_query_properties, where the property holds a singleKGProxy:AccessibilityAccountInformationAnatomicalAtlasBrainAtlasChapterChemicalMixtureCommentCommonCoordinateSpaceVersionElectrode,ElectrodeArray,MRIScanner,Pipette,SlicingDeviceMRICoilHardwareProductEnvironmentFileArchiveFileBundleFundingGridImage,GridImageSequence,GridImageStack,GridVolume,GridVolumeSequenceLivePaperResourceItem,LivePaperSectionOrganizationProductSourcePublicationIssue,PublicationVolumeRecordingRegularTimeSeriesServiceDeploymentServiceLinkSoftwareAgentStrainUsageAgreement(Multi-valued link properties such as
contributions,has_partsorstagesare omitted; see above.)Why no test catches it
test_build_existence_queryintest/test_base.pyuses plain values only, and no test callsexists()orsave()on an object without anidthat holds aKGProxyin an existence-query property.Suggested fix
In
_build_existence_query(), handle aKGProxylike aKGNodethat has anid:Checked against the mock client by substituting a
KGNodewith the same id: the generated filter is correct both for a proxy with a full URI (aCONTAINSfilter on the link's@id) and for one with a bare UUID (aCONTAINSfilter onhttp://schema.org/identifier).Tests:
_build_existence_query()returns the proxy's id for aKGProxyvalue.exists()on a new object with aKGProxyin an existence-query property (e.g.Funding) runs the existence query, against the mock client with the query stubbed, instead of raising.Collection.upload()of a JSON-LD node that links to an existing KG instance through an existence-query property.