From 4e9ede27fc52a64a975d96a9e52864db2dafdf5f Mon Sep 17 00:00:00 2001 From: ehennestad Date: Fri, 28 Aug 2026 02:59:41 +0200 Subject: [PATCH] refactor: move serialization onto the shared traversal core BaseSerializer walked the instance graph itself, with its own visited registry, its own recursion accounting and its own mixed type unwrapping, none of it shared with the other two traversals in the codebase. BaseTransformer is the accumulating counterpart to BaseVisitor: each node is mapped to a representation and the representations of its children are composed into it. Its cycle semantics differ from the visitor on purpose. A visitor acts on each node once for the whole traversal; a transformer has to produce a value everywhere a node appears, so a node is marked only while its own subtree is being built. Meeting it again inside that subtree is a cycle and yields a reference; meeting it again elsewhere is not. BaseSerializer becomes a BaseTransformer subclass carrying only the openMINDS rules: an instance's own properties, a reference for every linked value, an inline representation without an identifier for every embedded value, and a queue of referenced instances to emit as documents of their own. SerializationContext is retired. Its visited registry comes from the traversal core, and its map of linked instances plus threaded recursion depth are replaced by that explicit queue, which also stops an instance referenced from several places being emitted more than once. AbstractSerializer is deleted. It was a two-method stub with no subclasses, superseded by BaseSerializer and now by BaseDeserializer. The output format is unchanged, which the golden fixture checks. That required reproducing one asymmetry: an embedded value that can occur only once is written as a single object, while everything else, including a lone linked value, is written as a list. Centralizing the shape rule made that asymmetry easy to lose, and the fixture caught it twice while this change was being written. Co-Authored-By: Claude Opus 5 --- .../+openminds/+abstract/BaseTransformer.m | 113 +++++ .../+serializer/AbstractSerializer.m | 10 - .../+internal/+serializer/BaseSerializer.m | 435 ++++++------------ .../+serializer/SerializationContext.m | 161 ------- tools/tests/unitTests/SerializationTest.m | 75 +++ 5 files changed, 322 insertions(+), 472 deletions(-) create mode 100644 code/internal/+openminds/+abstract/BaseTransformer.m delete mode 100644 code/internal/+openminds/+internal/+serializer/AbstractSerializer.m delete mode 100644 code/internal/+openminds/+internal/+serializer/SerializationContext.m 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