diff --git a/.github/badges/tests.svg b/.github/badges/tests.svg index a3155a35..b85b29fa 100644 --- a/.github/badges/tests.svg +++ b/.github/badges/tests.svg @@ -1 +1 @@ -teststests700 passed700 passed \ No newline at end of file +teststests758 passed758 passed \ No newline at end of file diff --git a/code/internal/+openminds/+abstract/BaseTransformer.m b/code/internal/+openminds/+abstract/BaseTransformer.m new file mode 100644 index 00000000..2e91bee6 --- /dev/null +++ b/code/internal/+openminds/+abstract/BaseTransformer.m @@ -0,0 +1,113 @@ +classdef (Abstract) BaseTransformer < openminds.internal.graph.TraversalCore +% BaseTransformer - Map each node of an instance graph to an output value +% +% Subclass this for operations that build something from a graph rather +% than modify it: serializing, exporting, converting between formats. +% Each node is mapped to a representation, and the representations of +% its children are composed into it. +% +% USAGE: +% ------ +% Implement how a node begins and what its children contribute: +% +% classdef MyTransformer < openminds.abstract.BaseTransformer +% methods (Access = protected) +% function result = beginNode(obj, node) +% result = struct('type', node.X_TYPE); +% end +% function values = doForLinkedEdge(obj, parentNode, propertyName, children) +% values = cellfun(@(c) c.id, children, 'UniformOutput', false); +% end +% function values = doForEmbeddedEdge(obj, parentNode, propertyName, children) +% values = cellfun(@(c) obj.transform(c), children, 'UniformOutput', false); +% end +% end +% end +% +% CYCLE SEMANTICS: +% ---------------- +% A node is marked while its own subtree is being built and unmarked +% afterwards, so meeting it again during that subtree is a cycle and is +% represented by representRevisit. Meeting it again later, in a +% different subtree, is not a cycle and is transformed normally. +% +% This differs from openminds.abstract.BaseVisitor, where a node is +% visited at most once for the whole traversal. A visitor acts on each +% node once; a transformer has to produce a value everywhere a node +% appears. +% +% See also openminds.abstract.BaseVisitor, openminds.internal.graph.TraversalCore + + methods (Sealed) + function result = transform(obj, node) + % transform - Build the representation of a node and its children + + arguments + obj (1,1) openminds.abstract.BaseTransformer + node (1,1) openminds.abstract.Schema + end + + if obj.wasVisited(node) + % Already on the current path, so this closes a cycle + result = obj.representRevisit(node); + return + end + obj.markVisited(node); + visitCleanup = onCleanup(@() obj.unmarkVisited(node)); + + result = obj.beginNode(node); + + result = obj.composeEdges(result, node, ... + obj.getLinkedEdges(node), @obj.doForLinkedEdge); + result = obj.composeEdges(result, node, ... + obj.getEmbeddedEdges(node), @obj.doForEmbeddedEdge); + + result = obj.endNode(node, result); + end + end + + methods (Abstract, Access = protected) + result = beginNode(obj, node) + % beginNode - Representation of a node before its children + + values = doForLinkedEdge(obj, parentNode, propertyName, children) + % doForLinkedEdge - Representations of the children of a linked property + + values = doForEmbeddedEdge(obj, parentNode, propertyName, children) + % doForEmbeddedEdge - Representations of the children of an embedded property + end + + methods (Access = protected) % Overridable defaults + function result = endNode(~, ~, result) + % endNode - Finalize a node's representation after its children + end + + function result = representRevisit(~, node) + % representRevisit - Representation of a node already on the path + result = struct('at_id', node.id); + end + + function result = setPropertyValue(~, result, ~, propertyName, values) + % setPropertyValue - Place child representations into the parent + % + % Children are always written as a list, including when a + % property holds a single value. JSON-LD treats a lone object + % and a one-element array as equivalent, but this is the shape + % the library has always written, and changing it would change + % every document it produces. Subclasses that need a different + % shape can override this. + + result.(propertyName) = values; + end + end + + methods (Access = private) + function result = composeEdges(obj, result, node, edges, edgeFunction) + for i = 1:numel(edges) + propertyName = edges(i).PropertyName; + values = edgeFunction(node, propertyName, edges(i).Children); + result = obj.setPropertyValue(result, node, propertyName, values); + end + end + end +end diff --git a/code/internal/+openminds/+internal/+serializer/AbstractSerializer.m b/code/internal/+openminds/+internal/+serializer/AbstractSerializer.m deleted file mode 100644 index 6d51e4b8..00000000 --- a/code/internal/+openminds/+internal/+serializer/AbstractSerializer.m +++ /dev/null @@ -1,10 +0,0 @@ -classdef AbstractSerializer < handle - - properties - end - - methods (Abstract) - result = serialize(obj, instances, options) - instances = deserialize(obj, data, options) - end -end diff --git a/code/internal/+openminds/+internal/+serializer/BaseSerializer.m b/code/internal/+openminds/+internal/+serializer/BaseSerializer.m index a273e166..d6143352 100644 --- a/code/internal/+openminds/+internal/+serializer/BaseSerializer.m +++ b/code/internal/+openminds/+internal/+serializer/BaseSerializer.m @@ -1,13 +1,19 @@ -classdef (Abstract) BaseSerializer < handle +classdef (Abstract) BaseSerializer < openminds.abstract.BaseTransformer % BaseSerializer - Abstract base class for openMINDS serialization % % This class provides the core serialization logic for openMINDS % instances, handling linked and embedded types according to openMINDS % specifications. Concrete subclasses implement format-specific output. % -% An instance of this class will act as a visitor for a metadata instance -% via its `serialize` method, in accordance with the Visitor design pattern: -% https://refactoring.guru/design-patterns/visitor +% Serialization is a fold over the instance graph: each instance is +% mapped to a document representation, and the representations of its +% children are composed into it. That protocol lives in +% openminds.abstract.BaseTransformer; this class adds the openMINDS +% rules on top of it. +% +% Linked instances are always written as references and queued to be +% emitted as documents of their own, subject to the configured recursion +% depth. Embedded instances are written inline and carry no identifier. % % USAGE: % ------ @@ -189,333 +195,160 @@ end end - methods (Access = private) - function processedStructs = processInstances(obj, instances) - %processInstances Process instances and add openMINDS-specific fields - % - % processedStructs = processInstances(obj, instances) - % converts instances to structs and adds openMINDS-specific - % fields like @type, @id, @context, and processes linked/embedded types. - % Returns both the main processed structs and any linked instances found. - - arguments - obj (1,1) openminds.internal.serializer.BaseSerializer - instances % openminds.abstract.Schema or cell array - end - - % Ensure instances is a cell array - if ~iscell(instances) - instances = num2cell(instances); - end + properties (Access = private) + % Instances that were referenced and still have to be emitted as + % documents of their own, with the depth at which they were found. + PendingDocuments cell = {} - config = obj.SerializationConfiguration; - - % Create serialization context with linked instance collection - context = openminds.internal.serializer.SerializationContext(config); - - % Process each instance - processedStructs = cell(size(instances)); - for i = 1:numel(instances) - processedStructs{i} = obj.processInstance(instances{i}, context); - end - - % Extract linked instances from context - linkedInstances = context.getLinkedInstances(); + % Identifiers of instances already emitted, so an instance + % referenced from several places is written once. Created when + % serialization starts rather than as a default value, because a + % handle default would be shared by every instance. + EmittedIdentifiers + + % Depth of the document currently being built. + CurrentDepth (1,1) double = 0 + end + + methods (Access = protected) % BaseTransformer implementation + function result = setPropertyValue(~, result, node, propertyName, values) + % openMINDS documents write an embedded value that can occur only + % once as a single object, and everything else as a list, linked + % values included. The asymmetry is reproduced here so the + % documents this library writes keep their shape. - % Combine main instances with linked instances for output - if ~isempty(linkedInstances) - processedStructs = [processedStructs, linkedInstances]; + metaType = openminds.internal.meta.fromInstance(node); + + isSingleEmbeddedValue = ... + metaType.isPropertyWithEmbeddedType(propertyName) && ... + metaType.isPropertyValueScalar(propertyName) && ... + isscalar(values); + + if isSingleEmbeddedValue + result.(propertyName) = values{1}; + else + result.(propertyName) = values; end end - - function processedStruct = processInstance(obj, instance, context) - %processInstance Process a single instance - % - % processedStruct = processInstance(obj, instance, context) - % converts a single instance to a struct with openMINDS fields - - arguments - obj (1,1) openminds.internal.serializer.BaseSerializer - instance (1,1) openminds.abstract.Schema - context (1,1) openminds.internal.serializer.SerializationContext - end - - % Check for circular reference - if context.isVisited(string(instance.id)) - % Return just a reference for circular dependencies - processedStruct = struct('at_id', instance.id); - return - end - - % Mark this instance as being processed - context.markVisited(string(instance.id)); - - try - % Get basic struct from StructAdapter - S = instance.toStruct(); - - if ~obj.SerializationConfiguration.IncludeEmptyProperties - S = obj.removeEmptyProperties(S); - end - % Add openMINDS-specific fields - S = obj.addOpenMindsType(S, instance); + function S = beginNode(obj, instance) + % Start from the instance's own property values. - % Add @id if requested (todo: and not embedded) - if context.Config.IncludeIdentifier - S = obj.addInstanceIdentifier(S, instance); - end + S = instance.toStruct(); - % Process linked properties (respect recursion depth) - S = obj.processLinkedProperties(S, instance, context); - - % Process embedded properties (always inline, no @id) - S = obj.processEmbeddedProperties(S, instance, context); - - processedStruct = S; - - catch ME - % Unmark visited on error - context.unmarkVisited(string(instance.id)); - rethrow(ME); + if ~obj.SerializationConfiguration.IncludeEmptyProperties + S = obj.removeEmptyProperties(S); end - - % Unmark visited after successful processing - context.unmarkVisited(string(instance.id)); - end - - function S = removeEmptyProperties(obj, S) - propNames = fieldnames(S); - propValues = struct2cell(S); - propNamesIgnore = false(size(propNames)); - for i = 1:numel(propValues) - iPropertyValue = propValues{i}; - if obj.isEmptyPropertyValue(iPropertyValue) - propNamesIgnore(i) = true; - end + S = obj.addOpenMindsType(S, instance); + + if obj.SerializationConfiguration.IncludeIdentifier + S = obj.addInstanceIdentifier(S, instance); end - S = rmfield(S, propNames(propNamesIgnore)); end - function S = processLinkedProperties(obj, S, instance, context) - %processLinkedProperties Process properties with linked types - % - % S = processLinkedProperties(obj, S, instance, context) - % processes properties that contain linked instances. Linked instances - % are ALWAYS represented as references (@id only) in the property, - % and the actual instances are collected separately for processing. - - arguments - obj (1,1) openminds.internal.serializer.BaseSerializer - S (1,1) struct - instance (1,1) openminds.abstract.Schema - context (1,1) openminds.internal.serializer.SerializationContext - end - - % Get metadata about the instance type - metaType = openminds.internal.meta.fromInstance(instance); - - % Get linked property names - linkedPropertyNames = fieldnames(instance.LINKED_PROPERTIES); - - for i = 1:numel(linkedPropertyNames) - propName = linkedPropertyNames{i}; - - % Skip if property is not set or empty - if ~isfield(S, propName) || isempty(S.(propName)) - continue - end - - % Get the linked instances - linkedInstances = instance.(propName); - - % ALWAYS create references for linked properties - S.(propName) = obj.createReferences(linkedInstances); - - % Collect linked instances for separate processing if recursion is enabled - if context.canRecurse() - obj.collectLinkedInstances(linkedInstances, context); - end - - % Ensure array format if property allows multiple values - if ~metaType.isPropertyValueScalar(propName) && ~iscell(S.(propName)) - S.(propName) = {S.(propName)}; - end + function values = doForLinkedEdge(obj, ~, ~, children) + % A linked instance is always a reference. The instance itself is + % queued so it can be emitted as a document of its own. + + % Each child is referenced on its own rather than through one + % concatenated array, because a property may hold instances of + % several types and those cannot be concatenated. + values = cell(1, numel(children)); + + for i = 1:numel(children) + reference = obj.createReferences(children{i}); + values{i} = reference{1}; + obj.enqueueDocument(children{i}); end end - - function S = processEmbeddedProperties(obj, S, instance, context) - %processEmbeddedProperties Process properties with embedded types - % - % S = processEmbeddedProperties(obj, S, instance, context) - % processes properties that contain embedded instances. Embedded - % instances are always serialized inline regardless of recursion depth - - arguments - obj (1,1) openminds.internal.serializer.BaseSerializer - S (1,1) struct - instance (1,1) openminds.abstract.Schema - context (1,1) openminds.internal.serializer.SerializationContext - end - - % Get metadata about the instance type - metaType = openminds.internal.meta.fromInstance(instance); - - % Get embedded property names - embeddedPropertyNames = fieldnames(instance.EMBEDDED_PROPERTIES); - - for i = 1:numel(embeddedPropertyNames) - propName = embeddedPropertyNames{i}; - - % Skip if property is not set or empty - if ~isfield(S, propName) || isempty(S.(propName)) - continue - end - - % Get the embedded instances - embeddedInstances = instance.(propName); - - % Always serialize embedded instances inline (no recursion depth limit) - S.(propName) = obj.processEmbeddedInstanceArray(embeddedInstances, context); - - % Ensure array format if property allows multiple values - if ~metaType.isPropertyValueScalar(propName) && ~iscell(S.(propName)) - S.(propName) = {S.(propName)}; + + function values = doForEmbeddedEdge(obj, ~, ~, children) + % An embedded instance is written inline and has no identifier of + % its own, because it is part of its parent rather than a node. + + values = cell(1, numel(children)); + for i = 1:numel(children) + values{i} = obj.transform(children{i}); + if isfield(values{i}, 'at_id') + values{i} = rmfield(values{i}, 'at_id'); end end end - - function collectLinkedInstances(obj, linkedInstances, context) - %collectLinkedInstances Collect linked instances for separate processing - % - % collectLinkedInstances(obj, linkedInstances, context) - % adds linked instances to the context for separate processing. - % This ensures linked instances become separate documents. - - arguments - obj (1,1) openminds.internal.serializer.BaseSerializer - linkedInstances % Array of linked instances - context (1,1) openminds.internal.serializer.SerializationContext - end - - if isempty(linkedInstances) - return + end + + methods (Access = private) + function processedStructs = processInstances(obj, instances) + % Build a document for each instance, then for everything they + % reference, as far as the configured recursion depth allows. + + if ~iscell(instances) + instances = num2cell(instances); end - - % Process each linked instance - for i = 1:numel(linkedInstances) - obj.collectLinkedInstance(linkedInstances(i), context); + + obj.reset() + obj.PendingDocuments = {}; + obj.EmittedIdentifiers = containers.Map( ... + 'KeyType', 'char', 'ValueType', 'logical'); + + processedStructs = cell(1, numel(instances)); + for i = 1:numel(instances) + obj.CurrentDepth = 0; + obj.EmittedIdentifiers(char(instances{i}.id)) = true; + processedStructs{i} = obj.transform(instances{i}); end + + processedStructs = [processedStructs, obj.drainPendingDocuments()]; end - - function collectLinkedInstance(obj, linkedInstance, context) - %collectLinkedInstance Collect a single linked instance - % - % collectLinkedInstance(obj, linkedInstance, context) - % adds a single linked instance to the context for processing - - arguments - obj (1,1) openminds.internal.serializer.BaseSerializer - linkedInstance % Single linked instance - context (1,1) openminds.internal.serializer.SerializationContext - end - - % Handle mixed type instances - if openminds.utility.isMixedInstance(linkedInstance) - actualInstance = linkedInstance.Instance; - else - actualInstance = linkedInstance; - end - - % Skip struct instances (already processed) - if isstruct(actualInstance) - return - end - - % Process openMINDS instance - if openminds.utility.isInstance(actualInstance) - instanceId = string(actualInstance.id); - - % Only process if not already collected and not currently being processed - if ~context.LinkedInstances.isKey(char(instanceId)) && ~context.isVisited(instanceId) - % Create child context for processing linked instance - % Child context shares the same LinkedInstances and VisitedInstances maps - % but has incremented recursion depth - childContext = context.createChildContext(); - processedInstance = obj.processInstance(actualInstance, childContext); - - % Store in linked instances collection (shared with parent context) - context.LinkedInstances(char(instanceId)) = processedInstance; + + function linkedStructs = drainPendingDocuments(obj) + % Emit a document for each queued instance. Building one may queue + % more, so the queue is drained rather than iterated. + + linkedStructs = {}; + + while ~isempty(obj.PendingDocuments) + pending = obj.PendingDocuments{1}; + obj.PendingDocuments(1) = []; + + identifier = char(pending.Instance.id); + if obj.EmittedIdentifiers.isKey(identifier) + continue end - else - error('Unknown linked instance type: %s', class(actualInstance)); + obj.EmittedIdentifiers(identifier) = true; + + obj.CurrentDepth = pending.Depth; + linkedStructs{end+1} = obj.transform(pending.Instance); %#ok end end - - function result = processEmbeddedInstanceArray(obj, embeddedInstances, context) - %processEmbeddedInstanceArray Process an array of embedded instances - % - % result = processEmbeddedInstanceArray(obj, embeddedInstances, context) - % processes multiple embedded instances, always inline without @id - - arguments - obj (1,1) openminds.internal.serializer.BaseSerializer - embeddedInstances % Array of embedded instances - context (1,1) openminds.internal.serializer.SerializationContext - end - - if isempty(embeddedInstances) - result = {}; + + function enqueueDocument(obj, instance) + % Queue a referenced instance for emission as its own document. + + childDepth = obj.CurrentDepth + 1; + if childDepth > obj.SerializationConfiguration.RecursionDepth return end - - % Handle single instance - if isscalar(embeddedInstances) - result = obj.processEmbeddedInstance(embeddedInstances, context); + + if obj.EmittedIdentifiers.isKey(char(instance.id)) return end - - % Handle multiple instances - result = cell(size(embeddedInstances)); - for i = 1:numel(embeddedInstances) - result{i} = obj.processEmbeddedInstance(embeddedInstances(i), context); - end + + obj.PendingDocuments{end+1} = struct( ... + 'Instance', instance, 'Depth', childDepth); end - - function result = processEmbeddedInstance(obj, embeddedInstance, context) - %processEmbeddedInstance Process a single embedded instance - % - % result = processEmbeddedInstance(obj, embeddedInstance, context) - % processes a single embedded instance, always inline without @id - - arguments - obj (1,1) openminds.internal.serializer.BaseSerializer - embeddedInstance % Single embedded instance - context (1,1) openminds.internal.serializer.SerializationContext - end - - % Handle mixed type instances - if openminds.utility.isMixedInstance(embeddedInstance) - actualInstance = embeddedInstance.Instance; - else - actualInstance = embeddedInstance; - end - - % Process openMINDS instance - if openminds.utility.isInstance(actualInstance) - - % Process using the same context to ensure linked instances are collected - result = obj.processInstance(actualInstance, context); - - % Remove @id if it was added. Embedded nodes do not have - % their own identifiers. - if isfield(result, 'at_id') - result = rmfield(result, 'at_id'); + + function S = removeEmptyProperties(obj, S) + propNames = fieldnames(S); + propValues = struct2cell(S); + + propNamesIgnore = false(size(propNames)); + for i = 1:numel(propValues) + if obj.isEmptyPropertyValue(propValues{i}) + propNamesIgnore(i) = true; end - else - error('Unknown embedded instance type: %s', class(actualInstance)); end + S = rmfield(S, propNames(propNamesIgnore)); end end diff --git a/code/internal/+openminds/+internal/+serializer/SerializationContext.m b/code/internal/+openminds/+internal/+serializer/SerializationContext.m deleted file mode 100644 index e61d957e..00000000 --- a/code/internal/+openminds/+internal/+serializer/SerializationContext.m +++ /dev/null @@ -1,161 +0,0 @@ -classdef SerializationContext < handle -%SerializationContext Manages state during serialization of openMINDS instances -% -% This class tracks the serialization state to handle recursion depth -% for linked types and prevent infinite loops from circular references. -% -% USAGE: -% ------ -% context = openminds.internal.serializer.SerializationContext(config) -% context = openminds.internal.serializer.SerializationContext(config, 'MaxRecursionDepth', 3) -% -% PROPERTIES: -% ----------- -% Config - SerializationConfig object -% CurrentDepth - Current recursion depth for linked types -% MaxRecursionDepth - Maximum allowed recursion depth -% VisitedInstances - Set of instance IDs already being processed - -% Note: recursion depth only applies to linked properties, not embedded. - - properties (SetAccess = private) - Config % SerializationConfig object - CurrentDepth (1,1) {mustBeInteger, mustBeNonnegative} = 0 - MaxRecursionDepth (1,1) {mustBeInteger, mustBeNonnegative} = 0 - end - - properties (SetAccess = {?openminds.internal.serializer.SerializationContext}) - VisitedInstances containers.Map - LinkedInstances containers.Map - end - - methods - function obj = SerializationContext(config, options) - %SerializationContext Constructor for serialization context - % - % context = openminds.internal.serializer.SerializationContext(config) creates a context - % with the provided configuration - % - % context = openminds.internal.serializer.SerializationContext(config, Name, Value, ...) - % creates a context with additional options - % - % PARAMETERS: - % ----------- - % config : SerializationConfig - % Configuration object for serialization - % - % MaxRecursionDepth : integer (optional) - % Override the recursion depth from config - - arguments - config % SerializationConfig object - options.CurrentDepth {mustBeInteger, mustBeNonnegative} = 0 - options.MaxRecursionDepth {mustBeInteger, mustBeNonnegative} = [] - end - - obj.Config = config; - - if ~isempty(options.MaxRecursionDepth) - obj.MaxRecursionDepth = options.MaxRecursionDepth; - else - obj.MaxRecursionDepth = config.RecursionDepth; - end - if ~isempty(options.CurrentDepth) - obj.CurrentDepth = options.CurrentDepth; - end - - obj.VisitedInstances = containers.Map(); - obj.LinkedInstances = containers.Map(); - end - - function tf = canRecurse(obj) - %canRecurse Check if recursion is allowed at current depth - % - % tf = context.canRecurse() returns true if the current - % recursion depth is less than the maximum allowed depth - - tf = obj.CurrentDepth < obj.MaxRecursionDepth; - end - - function tf = isVisited(obj, instanceId) - %isVisited Check if an instance is currently being processed - % - % tf = context.isVisited(instanceId) returns true if the - % instance with the given ID is already in the processing stack - % - % This helps prevent infinite loops from circular references. - - arguments - obj (1,1) openminds.internal.serializer.SerializationContext - instanceId (1,1) string - end - - tf = obj.VisitedInstances.isKey(char(instanceId)); - end - - function markVisited(obj, instanceId) - %markVisited Mark an instance as currently being processed - % - % context.markVisited(instanceId) adds the instance ID to - % the set of currently visited instances - - arguments - obj (1,1) openminds.internal.serializer.SerializationContext - instanceId (1,1) string - end - - obj.VisitedInstances(char(instanceId)) = true; - end - - function unmarkVisited(obj, instanceId) - %unmarkVisited Remove an instance from the visited set - % - % context.unmarkVisited(instanceId) removes the instance ID - % from the set of currently visited instances - - arguments - obj (1,1) openminds.internal.serializer.SerializationContext - instanceId (1,1) string - end - - if obj.VisitedInstances.isKey(char(instanceId)) - obj.VisitedInstances.remove(char(instanceId)); - end - end - - function newContext = createChildContext(obj) - %createChildContext Create a child context with incremented depth - % - % childContext = context.createChildContext() creates a new - % context with the same configuration but incremented recursion - % depth and shared visited instances set - - newContext = openminds.internal.serializer.SerializationContext(obj.Config, ... - 'MaxRecursionDepth', obj.MaxRecursionDepth, ... - 'CurrentDepth', obj.CurrentDepth + 1); - newContext.VisitedInstances = obj.VisitedInstances; % Share the same map - newContext.LinkedInstances = obj.LinkedInstances; % Share the same map - end - - function reset(obj) - %reset Reset the context to initial state - % - % context.reset() clears the visited instances and resets - % the current depth to 0 - - obj.CurrentDepth = 0; - obj.VisitedInstances = containers.Map(); - obj.LinkedInstances = containers.Map(); - end - - function linkedInstances = getLinkedInstances(obj) - linkedInstances = obj.LinkedInstances.values(); - end - end - - methods - function depth = get.CurrentDepth(obj) - depth = obj.CurrentDepth; - end - end -end diff --git a/tools/tests/unitTests/SerializationTest.m b/tools/tests/unitTests/SerializationTest.m index da24a0f1..1bf798da 100644 --- a/tools/tests/unitTests/SerializationTest.m +++ b/tools/tests/unitTests/SerializationTest.m @@ -96,5 +96,80 @@ function testInstanceWithLinkedArray(testCase) testCase.verifyLength(str, 3) testCase.verifyClass(str{1}, 'char') end + + function testCircularGraphSerializesAsReference(testCase) + % A cycle must close with a reference rather than being followed + % forever. Two content types referring to each other are the + % smallest case. + + firstType = openminds.core.data.ContentType(); + firstType.name = "first/type"; + secondType = openminds.core.data.ContentType(); + secondType.name = "second/type"; + + firstType.isBasedOn = secondType; + secondType.isBasedOn = firstType; + + serializer = openminds.internal.serializer.JsonLdSerializer( ... + 'RecursionDepth', 5); + documents = serializer.serialize(firstType); + + % Each node becomes its own document holding a reference to + % the other, rather than one being inlined into the other + % without end. + testCase.assertNumElements(documents, 2) + combined = strjoin(documents, newline); + testCase.verifySubstring(combined, 'first/type') + testCase.verifySubstring(combined, 'second/type') + testCase.verifyEqual(count(combined, '"@type"'), 2, ... + 'Each node should appear exactly once, as its own document.') + end + + function testPropertyHoldingSeveralTypesSerializes(testCase) + % A property that accepts several types may hold instances of more + % than one of them at once. Those instances cannot be concatenated + % into one array, so anything that gathers them has to keep them + % apart. + % + % The round-trip suite does not cover this, because the synthesizer + % populates such a property with instances of a single allowed + % type. + + dataset = openminds.core.Dataset(); + dataset.fullName = "Mixed keyword dataset"; + dataset.keyword = { ... + openminds.controlledterms.AccessChannel("hybridAccess"), ... + openminds.controlledterms.DataType("associativeArray")}; + + documents = openminds.internal.serializer.JsonLdSerializer.serializeToJsonLd( ... + dataset, 'PrettyPrint', false); + + combined = strjoin(string(documents), newline); + testCase.verifySubstring(combined, 'instances/accessChannel/hybridAccess') + testCase.verifySubstring(combined, 'instances/dataType/associativeArray') + end + + function testEmbeddedScalarIsNotWrappedInAList(testCase) + % openMINDS documents write an embedded value that can occur only + % once as a single object. A linked value is written as a list even + % when there is one of them. Both shapes are pinned here because + % they are easy to change by accident. + + quantitativeValue = openminds.core.QuantitativeValue(); + quantitativeValue.value = 42; + + specimenAge = openminds.core.SpecimenAge(); + specimenAge.age = quantitativeValue; + + subjectState = openminds.core.SubjectState(); + subjectState.age = specimenAge; + + jsonText = openminds.internal.serializer.JsonLdSerializer.serializeToJsonLd( ... + subjectState, 'PrettyPrint', false); + + testCase.verifySubstring(jsonText, '"age":{') + testCase.verifyEmpty(strfind(jsonText, '"age":['), ... + 'An embedded scalar should not be written as a list.') + end end end