From 878cc9e635fb4f932c9f1b2e28aa83fd9767cd42 Mon Sep 17 00:00:00 2001 From: ehennestad Date: Sat, 19 Sep 2026 08:01:19 +0200 Subject: [PATCH 1/2] fix(collection): keep an untyped link to a controlled instance out of the nodes A link to a controlled instance that could not be taken from the instance library stays a MixedTypeReference. Collection.addNode tested for a controlled-instance identifier before it tested for a reference, so with AddControlledInstanceToCollection on, the untyped reference became a node, and saving the collection failed: the serializer has no type for it. kgpull leaves such links, for example to single colors of atlas annotations, so a pulled dataset version could not be saved. An untyped reference is now handled as any other reference: it stays a link in the linking document. A typed controlled instance with only an identifier is still a node when the preference is on. Co-Authored-By: Claude Opus 5 --- code/+openminds/@Collection/Collection.m | 7 ++++++- tools/tests/unitTests/CollectionTest.m | 25 ++++++++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/code/+openminds/@Collection/Collection.m b/code/+openminds/@Collection/Collection.m index b7cf54af2..787fb2b06 100644 --- a/code/+openminds/@Collection/Collection.m +++ b/code/+openminds/@Collection/Collection.m @@ -521,7 +521,12 @@ function load(obj, loadPath, options) startsWith(instance.id, "https://openminds.ebrains.eu/instances/") ... || startsWith(instance.id, "https://openminds.om-i.org/instances/"); - if isControlledInstance + % A link to a controlled instance that could not be taken from + % the instance library is an untyped reference. Without a type + % it cannot be a node, so it is handled as any other reference. + isUntypedReference = isa(instance, 'openminds.internal.MixedTypeReference'); + + if isControlledInstance && ~isUntypedReference % A controlled instance is a reference into the instance % library, which every reader has, so it can be a node of % the collection. Whether it is one is a preference. diff --git a/tools/tests/unitTests/CollectionTest.m b/tools/tests/unitTests/CollectionTest.m index 960eebb88..8ad28c9a9 100644 --- a/tools/tests/unitTests/CollectionTest.m +++ b/tools/tests/unitTests/CollectionTest.m @@ -528,6 +528,31 @@ function testUnresolvedLinkIsNotANode(testCase) testCase.verifyFalse(contains(document, "MixedTypeReference")); end + function testUnresolvedControlledInstanceLinkIsNotANode(testCase) + % A link to a controlled instance that could not be taken from + % the instance library stays a reference. With controlled + % instances added to collections, it is still not a node, so + % saving writes it as a reference instead of failing on it. + original = openminds.getpref('AddControlledInstanceToCollection'); + testCase.addTeardown(@() openminds.setpref( ... + 'AddControlledInstanceToCollection', original)); + openminds.setpref('AddControlledInstanceToCollection', true); + + referenceIRI = "https://openminds.om-i.org/instances/species/notInTheLibrary"; + subject = openminds.core.Subject("lookupLabel", "S"); + subject.species = openminds.internal.MixedTypeReference(referenceIRI); + + collection = openminds.Collection(subject); + testCase.verifyEqual(length(collection), 1); + + filePath = "unresolved-controlled-instance-collection.jsonld"; + collection.save(filePath); + + document = fileread(filePath); + testCase.verifyTrue(contains(document, referenceIRI)); + testCase.verifyFalse(contains(document, "MixedTypeReference")); + end + function testTypedReferenceSurvivesRoundTrip(testCase) % A reference whose type is known is still a reference, not a % node with no properties. It gets no file of its own, so From b27337d35001838dc3b6d3df04ca35df26f37d8f Mon Sep 17 00:00:00 2001 From: ehennestad Date: Sat, 19 Sep 2026 09:00:51 +0200 Subject: [PATCH 2/2] fix(collection): write a reference to a controlled instance as the library instance With AddControlledInstanceToCollection on, a typed reference to a controlled instance became a node with only @id and @type: nothing for a reader without the instance library, and only a link for a reader with it. An untyped reference was a link instead, so the two kinds of reference to one instance were saved differently. addNode now replaces a reference to a controlled instance, typed or not, with the instance from the library, so the node has the instance's properties. A reference to an instance the library does not have stays a link, including a controlled term with a name the library does not know, which is created with a warning rather than an error. With the preference off, references stay links as before. Saving, loading and saving again gives the same document, because both saves write the library instance. Co-Authored-By: Claude Opus 5 --- code/+openminds/@Collection/Collection.m | 49 ++++++++++--- tools/tests/unitTests/CollectionTest.m | 93 +++++++++++++++++++++++- 2 files changed, 131 insertions(+), 11 deletions(-) diff --git a/code/+openminds/@Collection/Collection.m b/code/+openminds/@Collection/Collection.m index 787fb2b06..c53fc20b3 100644 --- a/code/+openminds/@Collection/Collection.m +++ b/code/+openminds/@Collection/Collection.m @@ -521,18 +521,25 @@ function load(obj, loadPath, options) startsWith(instance.id, "https://openminds.ebrains.eu/instances/") ... || startsWith(instance.id, "https://openminds.om-i.org/instances/"); - % A link to a controlled instance that could not be taken from - % the instance library is an untyped reference. Without a type - % it cannot be a node, so it is handled as any other reference. - isUntypedReference = isa(instance, 'openminds.internal.MixedTypeReference'); - - if isControlledInstance && ~isUntypedReference - % A controlled instance is a reference into the instance - % library, which every reader has, so it can be a node of - % the collection. Whether it is one is a preference. + if isControlledInstance + % A controlled instance is in the instance library, which + % every reader has, so it can be a node of the collection. + % Whether it is one is a preference. if ~openminds.getpref('AddControlledInstanceToCollection') return end + + if instance.isReference() + % A reference, typed or not, is replaced by the instance + % from the library, so that the node has the properties + % of the controlled instance and not only its + % identifier. A reference to an instance the library + % does not have stays a link. + instance = getLibraryInstance(instance.id); + if isempty(instance) + return + end + end elseif instance.isReference() % Any other reference stands for a node that is not here, % whether its type is known or not. It is not a node @@ -684,3 +691,27 @@ function refreshTypeKeys(obj, instanceType) end end end + +function instance = getLibraryInstance(identifier) +%getLibraryInstance - The controlled instance with an identifier, taken from the instance library +% Returns [] when the library does not have the instance. The lookup +% can fail in several ways, for example when the name is not in the +% library or the type has no library instances, and all of them mean +% that the link stays a link. A controlled term whose name the library +% does not have is created with the warning +% openMINDS:ControlledTerm:UnknownInstanceName rather than an error, +% because a user may define a term of their own, so that warning is +% raised as an error here. + + unknownNameId = 'openMINDS:ControlledTerm:UnknownInstanceName'; + warningState = warning('error', unknownNameId); + restoreWarning = onCleanup(@() warning(warningState)); + + try + instance = openminds.instanceFromIRI(identifier); + catch + % The instance is not in the library; the caller keeps the link + instance = []; + end + clear restoreWarning +end diff --git a/tools/tests/unitTests/CollectionTest.m b/tools/tests/unitTests/CollectionTest.m index 8ad28c9a9..4135c842e 100644 --- a/tools/tests/unitTests/CollectionTest.m +++ b/tools/tests/unitTests/CollectionTest.m @@ -1,6 +1,10 @@ classdef CollectionTest < matlab.unittest.TestCase % CollectionTest - Unit tests for the openminds metadata collection class - + + properties (Constant, Access = private) + HomoSapiensIRI = "https://openminds.om-i.org/instances/species/homoSapiens" + end + methods (TestMethodSetup) function createTempDir(testCase) import matlab.unittest.fixtures.WorkingFolderFixture @@ -553,6 +557,76 @@ function testUnresolvedControlledInstanceLinkIsNotANode(testCase) testCase.verifyFalse(contains(document, "MixedTypeReference")); end + function testTypedControlledReferenceBecomesLibraryInstance(testCase) + % With controlled instances added to collections, a typed + % reference to one is replaced by the instance from the + % library, so the saved node has the instance's properties. + testCase.setControlledInstancePreference(true) + subject = openminds.core.Subject("lookupLabel", "S"); + subject.species = openminds.controlledterms.Species( ... + "id", testCase.HomoSapiensIRI, "IsReference", true); + + document = testCase.saveCollection(openminds.Collection(subject), "typed-reference.jsonld"); + + testCase.verifyTrue(contains(document, """name"": ""Homo sapiens""")); + end + + function testUntypedControlledReferenceBecomesLibraryInstance(testCase) + testCase.setControlledInstancePreference(true) + subject = openminds.core.Subject("lookupLabel", "S"); + subject.species = openminds.internal.MixedTypeReference(testCase.HomoSapiensIRI); + + collection = openminds.Collection(subject); + document = testCase.saveCollection(collection, "untyped-reference.jsonld"); + + testCase.verifyEqual(length(collection), 2); + testCase.verifyTrue(contains(document, """name"": ""Homo sapiens""")); + end + + function testTypedControlledReferenceNotInLibraryStaysLink(testCase) + testCase.setControlledInstancePreference(true) + referenceIRI = "https://openminds.om-i.org/instances/species/notInTheLibrary"; + subject = openminds.core.Subject("lookupLabel", "S"); + subject.species = openminds.controlledterms.Species( ... + "id", referenceIRI, "IsReference", true); + + collection = openminds.Collection(subject); + document = testCase.saveCollection(collection, "typed-reference-not-in-library.jsonld"); + + testCase.verifyEqual(length(collection), 1); + testCase.verifyTrue(contains(document, referenceIRI)); + end + + function testControlledReferenceStaysLinkWhenPreferenceIsOff(testCase) + testCase.setControlledInstancePreference(false) + subject = openminds.core.Subject("lookupLabel", "S"); + subject.species = openminds.controlledterms.Species( ... + "id", testCase.HomoSapiensIRI, "IsReference", true); + + collection = openminds.Collection(subject); + document = testCase.saveCollection(collection, "reference-preference-off.jsonld"); + + testCase.verifyEqual(length(collection), 1); + testCase.verifyTrue(contains(document, testCase.HomoSapiensIRI)); + testCase.verifyFalse(contains(document, "Homo sapiens")); + end + + function testControlledReferenceRoundTripIsStable(testCase) + % Saving writes the library instance; loading that file and + % saving again writes the same document. + testCase.setControlledInstancePreference(true) + subject = openminds.core.Subject("lookupLabel", "S"); + subject.species = openminds.controlledterms.Species( ... + "id", testCase.HomoSapiensIRI, "IsReference", true); + + firstDocument = testCase.saveCollection(openminds.Collection(subject), "first.jsonld"); + reloaded = openminds.Collection("first.jsonld"); + secondDocument = testCase.saveCollection(reloaded, "second.jsonld"); + + testCase.verifyEqual(sort(splitlines(string(secondDocument))), ... + sort(splitlines(string(firstDocument)))); + end + function testTypedReferenceSurvivesRoundTrip(testCase) % A reference whose type is known is still a reference, not a % node with no properties. It gets no file of its own, so @@ -720,7 +794,22 @@ function testSaveInstances(testCase) % % testCase.verifyTrue(startsWith(char(identifier), '_:')); % % end end - + + methods (Access = private) + function setControlledInstancePreference(testCase, value) + % Sets AddControlledInstanceToCollection for one test + original = openminds.getpref('AddControlledInstanceToCollection'); + testCase.addTeardown(@() openminds.setpref( ... + 'AddControlledInstanceToCollection', original)); + openminds.setpref('AddControlledInstanceToCollection', value); + end + + function document = saveCollection(~, collection, filePath) + collection.save(filePath); + document = fileread(filePath); + end + end + methods (Static, Access = private) function [dataset, affiliation] = datasetWithOneContributorAffiliation() [person, affiliation] = personWithOneAffiliation();