From 66262803ba4908fd4c008e2e19534d46dd4c30a8 Mon Sep 17 00:00:00 2001 From: ehennestad Date: Mon, 12 Oct 2026 23:49:07 +0200 Subject: [PATCH] fix(node): give every element of a struct-built array its own id The Node constructor preallocates the object array for a struct array with obj(1, n) = feval(class(obj)). MATLAB fills the elements between the first and the last with copies of one default object, so those copies carry the same generated blank-node id. fromStruct assigns an id only from at_id or x_id, which embedded values do not have, and the final loop regenerates only empty ids. From four embedded values of one type onwards, two or more therefore shared an id, the property holding them failed mustBeListOfUniqueItems, and the deserializer dropped the owning node with "Value must be an array of unique instances". Generate an id for every element right after preallocation. fromStruct still replaces it when the struct carries an identifier. Tests: a File with four embedded Hash values survives a JSON-LD round trip, and a Hash array built from four id-less structs has four distinct blank-node ids. Co-Authored-By: Claude Fable 5.1 --- code/+openminds/Node.m | 8 +++++++ tools/tests/unitTests/CollectionTest.m | 30 ++++++++++++++++++++++++++ tools/tests/unitTests/InstanceTest.m | 21 ++++++++++++++++++ 3 files changed, 59 insertions(+) diff --git a/code/+openminds/Node.m b/code/+openminds/Node.m index 1975c8dc0..f8809aefd 100644 --- a/code/+openminds/Node.m +++ b/code/+openminds/Node.m @@ -120,6 +120,14 @@ else if ~isscalar(instance) % Preallocate object array obj(1, numel(instance)) = feval( class(obj) ); + % Growing the array fills the elements in between with + % copies of one default object, so those copies share + % the id that object generated. Every element gets its + % own blank node id here; fromStruct replaces it when + % the struct carries an identifier. + for i = 1:numel(obj) + obj(i).id = obj(i).generateInstanceId(); + end end for i = 1:numel( instance ) % Todo: Separate method to handle reference structure. diff --git a/tools/tests/unitTests/CollectionTest.m b/tools/tests/unitTests/CollectionTest.m index 960eebb88..f87de75b7 100644 --- a/tools/tests/unitTests/CollectionTest.m +++ b/tools/tests/unitTests/CollectionTest.m @@ -269,6 +269,36 @@ function testSaveAndLoad(testCase) testCase.verifyTrue(newCollection.isKey(org.id)); end + function testEmbeddedArrayOfFourSurvivesRoundTrip(testCase) + % A multi-valued embedded property is saved without identifiers + % and read back as a struct array. Each value must come back as a + % node of its own, or the owning node fails the uniqueness check + % on that property and is dropped from the loaded collection. Four + % values, so that the array holds more than one element between + % the first and the last. + + hashes = openminds.core.data.Hash.empty; + for digest = ["1", "2", "3", "4"] + hashes(end+1) = openminds.core.data.Hash( ... + "algorithm", "SHA-1", "digest", digest); %#ok + end + file = openminds.core.data.File("name", "data.bin", ... + "IRI", "https://example.org/data.bin", "hash", hashes); + + filePath = 'embedded-array.jsonld'; + openminds.Collection(file).save(filePath); + reloaded = openminds.Collection(filePath); + + testCase.assertTrue(reloaded.isKey(file.id), ... + 'The node holding the embedded values was dropped on load.') + reloadedFile = reloaded.get(file.id); + testCase.assertNumElements(reloadedFile.hash, 4) + identifiers = arrayfun(@(h) string(h.id), reloadedFile.hash); + testCase.verifyEqual(numel(unique(identifiers)), 4, ... + 'The embedded values share an identifier after loading.') + testCase.verifyEqual([reloadedFile.hash.digest], ["1", "2", "3", "4"]) + end + function testSaveWithMetadataStoreOption(testCase) % A store passed to save by name-value must be the store used. % This went through obj.MetadataStore instead, which is empty diff --git a/tools/tests/unitTests/InstanceTest.m b/tools/tests/unitTests/InstanceTest.m index 020dd5397..d2f64764c 100644 --- a/tools/tests/unitTests/InstanceTest.m +++ b/tools/tests/unitTests/InstanceTest.m @@ -98,5 +98,26 @@ function testSaveLoadForTypedInstance(testCase, MetadataType) testCase.verifyEqual(S.itemPreSave, itemPreSave, ... sprintf('Loaded object does not match the original for %s', metadataType.ClassName)); end + + function testStructArrayWithoutIdsGetsDistinctIds(testCase) + % A multi-valued embedded property is read as a struct array with + % no identifiers, and each element must become a node of its own. + % Four elements, so that the array holds more than one element + % between the first and the last. + + structs = struct( ... + 'algorithm', {"SHA-1", "SHA-1", "SHA-1", "SHA-1"}, ... + 'digest', {"1", "2", "3", "4"}); + + hashes = openminds.core.data.Hash(structs); + + testCase.assertNumElements(hashes, 4) + identifiers = arrayfun(@(h) string(h.id), hashes); + testCase.verifyEqual(numel(unique(identifiers)), 4, ... + 'Elements built from one struct array share an identifier.') + testCase.verifyTrue(all(startsWith(identifiers, "_:")), ... + 'An element whose struct carries no identifier gets a blank node id.') + testCase.verifyEqual([hashes.digest], ["1", "2", "3", "4"]) + end end end