From 921883e33a81a17c3e9518e31f73224661b9a1eb Mon Sep 17 00:00:00 2001 From: ehennestad Date: Fri, 28 Aug 2026 03:07:53 +0200 Subject: [PATCH] feat: read documents written for any known openMINDS namespace A document written before openMINDS v4 uses the openminds.ebrains.eu namespace, and one written from v4 uses openminds.om-i.org. Reading either one required the active model to match the document, so a v3 file failed under a v4 model with an assertion about the expected prefix, and a v4 file failed under a v3 model. Types.fromAtType now accepts both namespaces. The type name is the last segment of the @type in either form, and the types that share a name are the same type, so the document maps onto the active model regardless of which version wrote it. A namespace that belongs to neither is still rejected, now with an error naming both accepted prefixes and the value that was given rather than an assertion. A type name that does not exist in the active model was previously an eval error naming a MATLAB enumeration. It now reports the type name and the model version that does not have it. jsonld2struct stripped the vocabulary prefix by replacing a hardcoded openminds.ebrains.eu vocabulary IRI. Documents in expanded form written under v4 use a different vocabulary IRI and were left with property names that no type class has. It now strips every known openMINDS vocabulary, listed in one place. Cross-namespace reads are not warned about. A property that a version does not have fails when it is assigned, and the deserializer already reports every node it could not read, so genuine incompatibilities surface on their own. A warning on every legacy document would fire on correct use. The fixture test for legacy documents changes from asserting a clear rejection to asserting the document loads, which is what its own comment anticipated. A test that a foreign namespace is still rejected takes its place. Co-Authored-By: Claude Opus 5 --- .../+openminds/+base/TypesEnumerationBase.m | 35 +++++++++--- .../+serializer/+jsonld/getVocabularyIRIs.m | 14 +++++ .../+internal/+serializer/BaseDeserializer.m | 53 ++++++++++++++++++- .../+internal/+serializer/jsonld2struct.m | 21 ++++++-- tools/tests/unitTests/DeserializerTest.m | 33 ++++++++++++ tools/tests/unitTests/FixtureTest.m | 41 +++++++++----- 6 files changed, 174 insertions(+), 23 deletions(-) create mode 100644 code/internal/+openminds/+internal/+serializer/+jsonld/getVocabularyIRIs.m diff --git a/code/internal/+openminds/+base/TypesEnumerationBase.m b/code/internal/+openminds/+base/TypesEnumerationBase.m index 489886082..416f69f28 100644 --- a/code/internal/+openminds/+base/TypesEnumerationBase.m +++ b/code/internal/+openminds/+base/TypesEnumerationBase.m @@ -137,18 +137,41 @@ arguments typeName (1,:) string end - - assert(all(startsWith(typeName, openminds.constant.BaseURI)), ... - 'OPENMINDS_MATLAB:Types:InvalidAtType', ... - 'Expected @type to start with "%s"', openminds.constant.BaseURI) - + + % Documents written for an older model use a different + % namespace. Both are accepted: the type name is the last + % segment either way, and it is resolved against the active + % model. That is a name match, not a migration. A type the + % active model does not have is rejected below; a property it + % does not have is dropped by fromStruct and reported by the + % deserializer; a property whose declaration changed fails on + % assignment and is reported as an unreadable node. + knownBaseURIs = openminds.constant.BaseURI("v1") + "/" | ... + openminds.constant.BaseURI("v4") + "/"; + + isKnownNamespace = startsWith(typeName, knownBaseURIs); + if ~all(isKnownNamespace) + error('OPENMINDS_MATLAB:Types:InvalidAtType', ... + 'Expected @type to start with "%s" or "%s". Got "%s".', ... + openminds.constant.BaseURI("v1"), ... + openminds.constant.BaseURI("v4"), ... + typeName(find(~isKnownNamespace, 1))) + end + if numel(typeName) > 1 typeEnum = arrayfun(@(str) openminds.enum.Types.fromAtType(str), typeName); return end splitName = strsplit(typeName, '/'); - typeEnum = eval(sprintf('openminds.enum.Types.%s', splitName{end})); + + try + typeEnum = openminds.enum.Types(splitName{end}); + catch + error('OPENMINDS_MATLAB:Types:UnknownAtType', ... + ['"%s" does not name a type in version "%s" of the ', ... + 'openMINDS model.'], splitName{end}, openminds.getModelVersion()) + end end end end diff --git a/code/internal/+openminds/+internal/+serializer/+jsonld/getVocabularyIRIs.m b/code/internal/+openminds/+internal/+serializer/+jsonld/getVocabularyIRIs.m new file mode 100644 index 000000000..f2f087570 --- /dev/null +++ b/code/internal/+openminds/+internal/+serializer/+jsonld/getVocabularyIRIs.m @@ -0,0 +1,14 @@ +function vocabularyIRIs = getVocabularyIRIs() +%getVocabularyIRIs Vocabulary IRIs used by any openMINDS model version +% +% vocabularyIRIs = getVocabularyIRIs() returns every vocabulary IRI an +% openMINDS document may use for property names in expanded form. +% +% Documents are read regardless of which model version wrote them, so +% all known vocabularies are needed, not only the one belonging to the +% active version. + + vocabularyIRIs = [ ... + openminds.constant.BaseURI("v1") + "/vocab/", ... + openminds.constant.BaseURI("v4") + "/props/"]; +end diff --git a/code/internal/+openminds/+internal/+serializer/BaseDeserializer.m b/code/internal/+openminds/+internal/+serializer/BaseDeserializer.m index cf4e093fa..763a12752 100644 --- a/code/internal/+openminds/+internal/+serializer/BaseDeserializer.m +++ b/code/internal/+openminds/+internal/+serializer/BaseDeserializer.m @@ -39,9 +39,10 @@ % be read are left out and reported. rawStructs = obj.parseToStructs(data); - [instances, unreadable] = obj.instantiateAll(rawStructs); + [instances, unreadable, dropped] = obj.instantiateAll(rawStructs); obj.reportUnreadableNodes(unreadable); + obj.reportDroppedProperties(dropped); if isempty(instances) return @@ -60,11 +61,15 @@ end methods (Access = protected) - function [instances, unreadable] = instantiateAll(~, rawStructs) + function [instances, unreadable, dropped] = instantiateAll(~, rawStructs) % instantiateAll - Build one instance per node of the document + % + % Also returns the nodes that could not be read, and the nodes + % that carried properties the active model does not have. instances = cell(1, numel(rawStructs)); unreadable = struct('Identifier', {}, 'Reason', {}); + dropped = struct('Identifier', {}, 'Properties', {}); for i = 1:numel(rawStructs) node = rawStructs{i}; @@ -86,6 +91,12 @@ try instances{i} = feval(typeEnum.ClassName, node); + droppedNames = droppedPropertyNames(node, instances{i}); + if ~isempty(droppedNames) + dropped(end+1) = struct( ... + 'Identifier', nodeIdentifier(node), ... + 'Properties', {droppedNames}); %#ok + end catch ME unreadable(end+1) = struct( ... 'Identifier', nodeIdentifier(node), ... @@ -120,9 +131,47 @@ function reportUnreadableNodes(obj, unreadable) warning('openMINDS:Deserializer:UnreadableNodes', '%s', message) end end + + function reportDroppedProperties(~, dropped) + % reportDroppedProperties - Report properties the active model does not have + % + % A document written for another model version can carry + % properties this version lacks. They cannot be kept, and losing + % them silently would hide that the document was not read in + % full, so they are reported once, by node. This is a warning + % whatever the unreadable-node policy: the nodes themselves were + % read. + + if isempty(dropped) + return + end + + details = arrayfun( ... + @(entry) sprintf(' %s: %s', entry.Identifier, strjoin(entry.Properties, ', ')), ... + dropped, 'UniformOutput', false); + + warning('openMINDS:Deserializer:DroppedProperties', ... + ['%d of the nodes in the data carry properties that version "%s" ', ... + 'of the openMINDS model does not have. They were dropped:\n%s'], ... + numel(dropped), openminds.getModelVersion(), strjoin(details, newline)) + end end end +function names = droppedPropertyNames(node, instance) +% Fields of a decoded node that the instance's type does not declare. +% +% fromStruct keeps the fields that name a property of the type and +% drops the rest without a report. This mirrors that filter so the +% deserializer can say what was lost. Only the node itself is checked; +% fields dropped inside embedded values are not detected. + + fields = string(fieldnames(node))'; + known = [string(instance.PropertyNames), ... + openminds.internal.utility.jsonLdKeywordFields()]; + names = setdiff(fields, known, 'stable'); +end + function identifier = nodeIdentifier(node) % Best available name for a node that could not be read. diff --git a/code/internal/+openminds/+internal/+serializer/jsonld2struct.m b/code/internal/+openminds/+internal/+serializer/jsonld2struct.m index e8811a298..45bcd0a91 100644 --- a/code/internal/+openminds/+internal/+serializer/jsonld2struct.m +++ b/code/internal/+openminds/+internal/+serializer/jsonld2struct.m @@ -1,9 +1,24 @@ function structInstance = jsonld2struct(jsonInstance) -%Convert metadata instance(s) from JSON-LD text strings to struct arrays +%jsonld2struct Convert JSON-LD text into struct form +% +% structInstance = jsonld2struct(jsonInstance) decodes one JSON-LD +% document. A collection document is returned as its @graph, so the +% result is always the nodes rather than the wrapper. +% +% Property names written in expanded form carry a vocabulary IRI. That +% prefix is removed so the field names match the property names of the +% generated type classes. Every openMINDS vocabulary is removed, not +% just the one belonging to the active model version, so a document +% written for an older model can still be read. - vocabBaseUri = "https://openminds.ebrains.eu/vocab/"; + arguments + jsonInstance (1,1) string + end + + for vocabularyIRI = openminds.internal.serializer.jsonld.getVocabularyIRIs() + jsonInstance = strrep(jsonInstance, vocabularyIRI, ""); + end - jsonInstance = strrep(jsonInstance, vocabBaseUri, ''); structInstance = openminds.internal.utility.json.decode(jsonInstance); if isfield(structInstance, 'at_graph') diff --git a/tools/tests/unitTests/DeserializerTest.m b/tools/tests/unitTests/DeserializerTest.m index 1221e39c3..78d7ab8a0 100644 --- a/tools/tests/unitTests/DeserializerTest.m +++ b/tools/tests/unitTests/DeserializerTest.m @@ -104,6 +104,39 @@ function testUnreadableNodesCanBeAnError(testCase) end end + methods (Test) % Properties the active model does not have + + function testPropertiesUnknownToTheModelAreReported(testCase) + % A document written for another model version can carry + % properties the active model does not have. They cannot be kept, + % but losing them silently would hide that the document was not + % read in full, so they are reported once, by node, and the + % known properties are still read. + + document = DeserializerTest.collectionDocument(sprintf( ... + ['{"@id": "_:person-1", "@type": "%sPerson", "givenName": "Ada", ', ... + '"interlexIdentifier": "http://uri.interlex.org/base/ilx_0000000"}'], ... + DeserializerTest.TypeIRI)); + + instances = testCase.verifyWarning(@() testCase.deserialize(document), ... + 'openMINDS:Deserializer:DroppedProperties'); + + testCase.assertNumElements(instances, 1) + testCase.verifyEqual(instances{1}.givenName, "Ada", ... + 'The properties the model does have are still read.') + end + + function testDocumentWithinTheModelIsReadWithoutReport(testCase) + % The report fires only when something is lost. A document that + % uses only properties the active model has produces none. + + document = DeserializerTest.collectionDocument( ... + DeserializerTest.personNode("_:person-1", "Ada")); + + testCase.verifyWarningFree(@() testCase.deserialize(document)); + end + end + methods (Access = private) function instances = deserialize(~, documents) deserializer = openminds.internal.serializer.JsonLdDeserializer(); diff --git a/tools/tests/unitTests/FixtureTest.m b/tools/tests/unitTests/FixtureTest.m index eb016a324..9ad2d036f 100644 --- a/tools/tests/unitTests/FixtureTest.m +++ b/tools/tests/unitTests/FixtureTest.m @@ -98,14 +98,10 @@ function testGoldenFixtureLoadsWithValuesIntact(testCase) "https://openminds.om-i.org/instances/species/homoSapiens") end - function testLegacyNamespaceDocumentIsRejectedClearly(testCase) - % A document written with the pre-v4 EBRAINS namespace cannot be - % loaded while a v4 model is active. - % - % This pins current behaviour: the failure is a clear, identified - % error rather than silent data loss. Supporting cross-namespace - % loading would be an improvement, and this test must then be - % changed to assert that the document loads. + function testLegacyNamespaceDocumentIsRead(testCase) + % A document written with the pre-v4 EBRAINS namespace must load + % under a v4 model. The type name is the last segment of the @type + % either way, and the types that share a name are the same type. legacyPath = fullfile(ommtest.helper.fixturePath(), ... "collection_ebrains_legacy.jsonld"); @@ -113,10 +109,31 @@ function testLegacyNamespaceDocumentIsRejectedClearly(testCase) testCase.assumeEqual(ommtest.helper.fixtureNamespaceTag(), "omi", ... 'This test only applies while a v4 or later model is active.') - testCase.verifyError(@() openminds.Collection(legacyPath), ... - 'OPENMINDS_MATLAB:Types:InvalidAtType', ... - ['Loading a legacy namespace document should fail with a ', ... - 'clear error identifying the namespace mismatch.']) + collection = openminds.Collection(legacyPath); + + person = collection.list(openminds.enum.Types("Person")); + testCase.assertNumElements(person, 1, ... + 'The legacy document should produce one Person.') + testCase.verifyEqual(person.givenName, "Ada") + testCase.verifyEqual(person.familyName, "Lovelace") + end + + function testUnknownNamespaceIsRejectedClearly(testCase) + % A document from something that is not openMINDS at all must fail + % with an identified error rather than being partly read. + + documentPath = fullfile(testCase.TemporaryFolder, "foreign.jsonld"); + document = [ ... + '{"@context": {"@vocab": "https://example.org/props/"},' ... + ' "@graph": [{"@id": "_:x",' ... + ' "@type": "https://example.org/types/Person",' ... + ' "givenName": "Ada"}]}']; + fileIdentifier = fopen(documentPath, 'w'); + fwrite(fileIdentifier, document); + fclose(fileIdentifier); + + testCase.verifyError(@() openminds.Collection(documentPath), ... + 'OPENMINDS_MATLAB:Types:InvalidAtType') end end