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 @@ -code issuescode issues22 \ No newline at end of file +code issuescode issues44 \ 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 @@ -teststests568 passed568 passed \ No newline at end of file +teststests711 passed711 passed \ 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