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 @@
-
\ No newline at end of file
+
\ 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