diff --git a/.github/badges/code_issues.svg b/.github/badges/code_issues.svg
index e9c29599..f7c6c0de 100644
--- a/.github/badges/code_issues.svg
+++ b/.github/badges/code_issues.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/.github/badges/tests.svg b/.github/badges/tests.svg
index 4e32f161..39a04e41 100644
--- a/.github/badges/tests.svg
+++ b/.github/badges/tests.svg
@@ -1 +1 @@
-
\ No newline at end of file
+
\ No newline at end of file
diff --git a/code/+openminds/@Collection/Collection.m b/code/+openminds/@Collection/Collection.m
index e7d3616a..115aec81 100644
--- a/code/+openminds/@Collection/Collection.m
+++ b/code/+openminds/@Collection/Collection.m
@@ -257,6 +257,11 @@ function remove(obj, instance)
function instances = getAll(obj)
% getAll - Get all instances of collection
+ if obj.NumNodes == 0
+ instances = {};
+ return
+ end
+
instances = obj.Nodes.values();
% For older MATLAB releases, the instances might be nested a
@@ -326,6 +331,10 @@ function remove(obj, instance)
end
function updateLinks(obj)
+ if obj.NumNodes == 0
+ return
+ end
+
allInstances = obj.Nodes.values;
if isa(obj.Nodes, 'containers.Map')
allInstances = [allInstances{:}];
@@ -385,7 +394,7 @@ function updateLinks(obj)
outputPaths = tempStore.save(instances);
elseif ~isempty(options.MetadataStore)
- outputPaths = obj.MetadataStore.save(instances);
+ outputPaths = options.MetadataStore.save(instances);
elseif ~isempty(obj.MetadataStore)
% Use configured store
@@ -584,11 +593,15 @@ function initializeFromInstances(obj, instance)
% Initialize from file(s)
if all( cellfun(isFilePath, instance) )
- obj.load(instance{:})
+ for i = 1:numel(instance)
+ obj.load(instance{i})
+ end
% Initialize from folder
elseif all( cellfun(isFolderPath, instance) )
- obj.load(instance{:})
+ for i = 1:numel(instance)
+ obj.load(instance{i})
+ end
% Initialize from instance(s)
elseif all( cellfun(isMetadata, instance) )
diff --git a/code/internal/+openminds/+internal/+serializer/jsonld2struct.m b/code/internal/+openminds/+internal/+serializer/jsonld2struct.m
index e8811a29..a214af54 100644
--- a/code/internal/+openminds/+internal/+serializer/jsonld2struct.m
+++ b/code/internal/+openminds/+internal/+serializer/jsonld2struct.m
@@ -1,9 +1,16 @@
function structInstance = jsonld2struct(jsonInstance)
%Convert metadata instance(s) from JSON-LD text strings to struct arrays
- vocabBaseUri = "https://openminds.ebrains.eu/vocab/";
+ vocabBaseUri = [
+ "https://openminds.ebrains.eu/vocab/"
+ "https://openminds.om-i.org/props/"
+ ];
- jsonInstance = strrep(jsonInstance, vocabBaseUri, '');
+ for i = 1:numel(vocabBaseUri)
+ propertyKeyPattern = sprintf('"%s([^"]+)"\\s*:', ...
+ regexptranslate('escape', vocabBaseUri(i)));
+ jsonInstance = regexprep(jsonInstance, propertyKeyPattern, '"$1":');
+ end
structInstance = openminds.internal.utility.json.decode(jsonInstance);
if isfield(structInstance, 'at_graph')
diff --git a/code/internal/+openminds/+internal/+store/loadInstances.m b/code/internal/+openminds/+internal/+store/loadInstances.m
index e347834f..2cd2a466 100644
--- a/code/internal/+openminds/+internal/+store/loadInstances.m
+++ b/code/internal/+openminds/+internal/+store/loadInstances.m
@@ -25,10 +25,10 @@
% Produce a cell array of instances represented as structs
if isscalar(str)
structInstances = jsonld2struct(str);
- if ~iscell(structInstances); structInstances={structInstances};end
else
structInstances = cellfun(@jsonld2struct, str, 'UniformOutput', false);
end
+ structInstances = normalizeStructInstances(structInstances);
% Create instance objects
instances = cell(size(structInstances));
@@ -75,6 +75,18 @@
end
end
+function structInstances = normalizeStructInstances(structInstances)
+%normalizeStructInstances Return one cell element per serialized instance.
+
+ if iscell(structInstances)
+ structInstances = cellfun(@normalizeStructInstances, ...
+ structInstances, 'UniformOutput', false);
+ structInstances = [structInstances{:}];
+ else
+ structInstances = num2cell(reshape(structInstances, 1, []));
+ end
+end
+
function resolveLinks(instance, instanceIds, instanceCollection)
%resolveLinks Resolve linked types, i.e replace an @id with the actual
% instance object.
@@ -112,6 +124,8 @@ function resolveLinks(instance, instanceIds, instanceCollection)
% Check if instance is a controlled instance
if startsWith(instanceId, "https://openminds.ebrains.eu/instances/")
resolvedInstances{j} = openminds.instanceFromIRI(instanceId);
+ else
+ resolvedInstances{j} = linkedInstances(j);
end
end
end
diff --git a/code/internal/+openminds/+internal/FolderMetadataStore.m b/code/internal/+openminds/+internal/FolderMetadataStore.m
index 64d918f5..18543ab1 100644
--- a/code/internal/+openminds/+internal/FolderMetadataStore.m
+++ b/code/internal/+openminds/+internal/FolderMetadataStore.m
@@ -94,14 +94,18 @@
% Serialize instances to individual documents
serializedDocuments = obj.Serializer.serialize(instances);
+ if ~iscell(serializedDocuments)
+ serializedDocuments = {serializedDocuments};
+ end
% Save each document to a separate file
outputPaths = cell(size(serializedDocuments));
for i = 1:numel(serializedDocuments)
- instance = instances{i};
+ instance = openminds.internal.serializer.jsonld2struct( ...
+ serializedDocuments{i});
% Build file path using unified method
- filePath = obj.buildFilepath(instance);
+ filePath = obj.buildFilepath(instance, i);
% Write to file
openminds.internal.utility.filewrite(filePath, serializedDocuments{i});
@@ -158,7 +162,7 @@
end
methods (Access = private)
- function instanceFilePath = buildFilepath(obj, instance)
+ function instanceFilePath = buildFilepath(obj, instance, documentIndex)
%buildFilepath Build complete filepath for an instance
%
% Creates the appropriate file path based on the store's Nested property.
@@ -180,14 +184,10 @@
% Flat: /root/Person_123.jsonld
% Nested: /root/person/123.jsonld
- % Get instance type and ID information
- className = class(instance);
- classNameParts = strsplit(className, '.');
- typeName = classNameParts{end};
-
- % Get instance ID and make it filesystem-safe
- instanceId = string(instance.id);
- if startsWith(instanceId, "http")
+ [typeName, instanceId] = getTypeNameAndId(instance);
+ if ismissing(instanceId) || instanceId == ""
+ safeId = sprintf('%04d', documentIndex);
+ elseif startsWith(instanceId, "http")
idParts = strsplit(instanceId, '/');
safeId = idParts{end};
else
@@ -212,3 +212,19 @@
end
end
end
+
+function [typeName, instanceId] = getTypeNameAndId(instance)
+ if isstruct(instance)
+ typeNameParts = strsplit(instance.at_type, '/');
+ typeName = typeNameParts{end};
+ if isfield(instance, 'at_id')
+ instanceId = string(instance.at_id);
+ else
+ instanceId = string(missing);
+ end
+ else
+ classNameParts = strsplit(class(instance), '.');
+ typeName = classNameParts{end};
+ instanceId = string(instance.id);
+ end
+end
diff --git a/tools/tests/unitTests/CollectionTest.m b/tools/tests/unitTests/CollectionTest.m
index e4c82691..67e59d7f 100644
--- a/tools/tests/unitTests/CollectionTest.m
+++ b/tools/tests/unitTests/CollectionTest.m
@@ -184,6 +184,24 @@ function testGet(testCase)
ommtest.oneoffs.organizationName(retrievedOrg), ...
ommtest.oneoffs.organizationName(org));
end
+
+ function testGetAllEmptyCollection(testCase)
+ collection = openminds.Collection();
+
+ instances = collection.getAll();
+
+ testCase.verifyEqual(instances, {});
+ end
+
+ function testSaveEmptyCollection(testCase)
+ collection = openminds.Collection();
+ filePath = "empty-collection.jsonld";
+
+ outputPath = collection.save(filePath);
+
+ testCase.verifyEqual(outputPath, filePath);
+ testCase.verifyTrue(isfile(filePath));
+ end
function testHasType(testCase)
% Test the hasType method
@@ -295,6 +313,73 @@ function testSaveToMultipleFiles(testCase)
testCase.verifyTrue(newCollection.isKey(person.id));
testCase.verifyTrue(newCollection.isKey(org.id));
end
+
+ function testFolderStoreSavesRecursiveLinkedDocuments(testCase)
+ identifier = openminds.core.ORCID( ...
+ "identifier", "https://orcid.org/0000-0000-0000-0000");
+ person = openminds.core.Person("digitalIdentifier", identifier);
+
+ folderPath = "recursive-folder-store";
+ metadataStore = openminds.internal.FolderMetadataStore( ...
+ folderPath, "RecursionDepth", 1);
+
+ outputPaths = metadataStore.save(person);
+
+ files = dir(fullfile(folderPath, "*.jsonld"));
+ testCase.verifyEqual(numel(outputPaths), 2);
+ testCase.verifyEqual(numel(files), 2);
+ testCase.verifyTrue(any(contains(string(outputPaths), "Person_")));
+ testCase.verifyTrue(any(contains(string(outputPaths), "ORCID_")));
+ end
+
+ function testFolderStoreSavesScalarInstance(testCase)
+ contact = openminds.core.ContactInformation( ...
+ "email", "contact@example.org");
+ folderPath = "scalar-folder-store";
+ metadataStore = openminds.internal.FolderMetadataStore(folderPath);
+
+ outputPaths = metadataStore.save(contact);
+
+ testCase.verifyEqual(numel(outputPaths), 1);
+ testCase.verifyTrue(isfile(outputPaths{1}));
+ testCase.verifyTrue(contains(string(outputPaths{1}), ...
+ "ContactInformation_"));
+ end
+
+ function testFolderStoreSavesInstanceWithoutIdentifier(testCase)
+ contact = openminds.core.ContactInformation( ...
+ "email", "contact@example.org");
+ folderPath = "identifier-free-folder-store";
+ metadataStore = openminds.internal.FolderMetadataStore( ...
+ folderPath, "IncludeIdentifier", false);
+
+ outputPaths = metadataStore.save(contact);
+ serializedDocument = fileread(outputPaths{1});
+
+ testCase.verifyEqual(numel(outputPaths), 1);
+ testCase.verifyTrue(isfile(outputPaths{1}));
+ testCase.verifyTrue(contains(string(outputPaths{1}), ...
+ "ContactInformation_0001"));
+ testCase.verifyFalse(contains(serializedDocument, '"@id"'));
+ end
+
+ function testCreateCollectionFromMultipleFiles(testCase)
+ firstContact = openminds.core.ContactInformation( ...
+ "email", "first@example.org");
+ secondContact = openminds.core.ContactInformation( ...
+ "email", "second@example.org");
+
+ firstFilePath = "first-contact.jsonld";
+ secondFilePath = "second-contact.jsonld";
+ openminds.internal.FileMetadataStore(firstFilePath).save(firstContact);
+ openminds.internal.FileMetadataStore(secondFilePath).save(secondContact);
+
+ collection = openminds.Collection(firstFilePath, secondFilePath);
+
+ testCase.verifyEqual(length(collection), 2);
+ testCase.verifyTrue(collection.isKey(firstContact.id));
+ testCase.verifyTrue(collection.isKey(secondContact.id));
+ end
function testLoadInstances(testCase)
% Test the loadInstances static method
@@ -317,6 +402,50 @@ function testLoadInstances(testCase)
% Verify that instances are loaded
testCase.verifyEqual(length(instances), expectedNumDocuments);
end
+
+ function testLoadHomogeneousGraphAsSeparateInstances(testCase)
+ firstContact = openminds.core.ContactInformation( ...
+ "email", "first@example.org");
+ secondContact = openminds.core.ContactInformation( ...
+ "email", "second@example.org");
+
+ filePath = "homogeneous-graph.jsonld";
+ openminds.internal.FileMetadataStore(filePath).save( ...
+ [firstContact, secondContact]);
+
+ newCollection = openminds.Collection();
+ newCollection.load(filePath);
+
+ testCase.verifyEqual(length(newCollection), 2);
+ testCase.verifyTrue(newCollection.isKey(firstContact.id));
+ testCase.verifyTrue(newCollection.isKey(secondContact.id));
+ end
+
+ function testLoadPreservesPartiallyUnresolvedLinks(testCase)
+ firstIdentifier = openminds.core.ORCID( ...
+ "identifier", "https://orcid.org/0000-0000-0000-0001");
+ secondIdentifier = openminds.core.ORCID( ...
+ "identifier", "https://orcid.org/0000-0000-0000-0002");
+ person = openminds.core.Person( ...
+ "digitalIdentifier", [firstIdentifier, secondIdentifier]);
+
+ serializer = openminds.internal.serializer.JsonLdSerializer( ...
+ "OutputMode", "single", ...
+ "RecursionDepth", 0);
+ filePath = "partial-graph.jsonld";
+ openminds.internal.utility.filewrite( ...
+ filePath, serializer.serialize({person, firstIdentifier}));
+
+ instances = openminds.internal.store.loadInstances(filePath);
+ loadedPerson = instances{1};
+ loadedIdentifiers = loadedPerson.digitalIdentifier;
+
+ testCase.verifyEqual(numel(loadedIdentifiers), 2);
+ testCase.verifyEqual(loadedIdentifiers(1).Instance.id, ...
+ firstIdentifier.id);
+ testCase.verifyEqual(loadedIdentifiers(2).Instance.id, ...
+ secondIdentifier.id);
+ end
function testSaveInstances(testCase)
% Tests saving instances with MetadataStore
@@ -337,6 +466,17 @@ function testSaveInstances(testCase)
% Verify that instances are loaded
testCase.verifyEqual(length(instances), expectedNumDocuments);
end
+
+ function testSaveUsesMethodMetadataStoreOption(testCase)
+ collection = openminds.Collection(organizationWithOneId());
+ filePath = "method-store-option.jsonld";
+ metadataStore = openminds.internal.FileMetadataStore(filePath);
+
+ outputPath = collection.save("", "MetadataStore", metadataStore);
+
+ testCase.verifyEqual(outputPath, filePath);
+ testCase.verifyTrue(isfile(filePath));
+ end
% % function testGetBlankNodeIdentifier(testCase)
% % % Test the getBlankNodeIdentifier method
diff --git a/tools/tests/unitTests/SerializationTest.m b/tools/tests/unitTests/SerializationTest.m
index da24a0f1..00360b96 100644
--- a/tools/tests/unitTests/SerializationTest.m
+++ b/tools/tests/unitTests/SerializationTest.m
@@ -96,5 +96,31 @@ function testInstanceWithLinkedArray(testCase)
testCase.verifyLength(str, 3)
testCase.verifyClass(str{1}, 'char')
end
+
+ function testExpandedJsonLdFileRoundTrip(testCase)
+ contact = openminds.core.ContactInformation( ...
+ "email", "contact@example.org");
+ filePath = "expanded-contact.jsonld";
+ metadataStore = openminds.internal.FileMetadataStore( ...
+ filePath, ...
+ "PropertyNameSyntax", "expanded");
+
+ metadataStore.save(contact);
+ loadedInstances = metadataStore.load();
+
+ testCase.verifyEqual(loadedInstances{1}.email, contact.email);
+ end
+
+ function testJsonLdCompactionPreservesLiteralValues(testCase)
+ contact = openminds.core.ContactInformation( ...
+ "email", "https://openminds.om-i.org/props/contact@example.org");
+ filePath = string(tempname) + ".jsonld";
+ metadataStore = openminds.internal.FileMetadataStore(filePath);
+
+ metadataStore.save(contact);
+ loadedInstances = metadataStore.load();
+
+ testCase.verifyEqual(loadedInstances{1}.email, contact.email);
+ end
end
end