Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions code/internal/+openminds/+abstract/BaseVisitor.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
classdef (Abstract) BaseVisitor < openminds.internal.graph.TraversalCore
% BaseVisitor - Walk an instance graph and act on each node
%
% Subclass this for operations that inspect or modify an instance graph
% in place: resolving references, wiring up links after deserialization,
% validating. Each node is visited at most once per traversal, so a
% circular graph terminates.
%
% USAGE:
% ------
% Implement the two edge methods. Each receives the parent node, the
% name of the property the edge belongs to, and the child instances on
% it, and returns the child instances to keep:
%
% classdef MyVisitor < openminds.abstract.BaseVisitor
% methods (Access = protected)
% function children = doForLinkedEdge(obj, parentNode, propertyName, children)
% for i = 1:numel(children)
% children{i} = obj.visit(children{i});
% end
% end
% function children = doForEmbeddedEdge(obj, parentNode, propertyName, children)
% ...
% end
% end
% end
%
% Edges rather than nodes are the unit of work because a child cannot
% always be updated in place. A reference whose type is not known until
% it is probed has to be replaced by a new instance of the discovered
% type, and replacing it needs the parent, the property and the position
% in the property. Returning the child list also keeps the remaining
% children in their original positions.
%
% See also openminds.internal.graph.TraversalCore

methods (Sealed)
function node = visit(obj, node)
% visit - Traverse a node and everything reachable from it
%
% The node is returned because visiting it may replace it. A
% caller holding the node directly, rather than through a
% property of a parent, has to take the returned value.

arguments
obj (1,1) openminds.abstract.BaseVisitor
node (1,1) openminds.abstract.Schema
end

if obj.wasVisited(node)
return
end
obj.markVisited(node);

node = obj.onVisitNode(node);

obj.traverseEdges(node, obj.getLinkedEdges(node), @obj.doForLinkedEdge);
obj.traverseEdges(node, obj.getEmbeddedEdges(node), @obj.doForEmbeddedEdge);

obj.onLeaveNode(node);
end
end

methods (Abstract, Access = protected)
children = doForLinkedEdge(obj, parentNode, propertyName, children)
% doForLinkedEdge - Act on the children of a linked property

children = doForEmbeddedEdge(obj, parentNode, propertyName, children)
% doForEmbeddedEdge - Act on the children of an embedded property
end

methods (Access = protected) % Optional hooks
function node = onVisitNode(~, node)
% onVisitNode - Act on a node before its edges are traversed
%
% Return a different instance to replace the node.
end

function onLeaveNode(~, ~)
% onLeaveNode - Act on a node after its edges have been traversed
end
end

methods (Access = private)
function traverseEdges(obj, node, edges, edgeFunction)
% traverseEdges - Apply an edge method to each edge and store the result

for i = 1:numel(edges)
propertyName = edges(i).PropertyName;
children = edgeFunction(node, propertyName, edges(i).Children);
obj.setEdgeChildren(node, propertyName, children);
end
end
end
end
82 changes: 37 additions & 45 deletions code/internal/+openminds/+abstract/Schema.m
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@
% - instance (openminds.abstract.Schema) -
% an openMINDS typed metadata instance
%
% Output Arguments:
% - instance - the resolved instance or instances. Resolving a
% reference whose type was not known produces an instance of a
% different class, so the result is returned rather than
% assigned in place. If an array resolves to more than one
% class, the result is a cell array.
%
% - options (name-value pairs) -
% Optional name-value pairs. Available options:
%
Expand All @@ -117,36 +124,31 @@
% options.IsEmbedded = false - Todo?
end

visitorOptions = {"RemainingLinkDepth", options.NumLinksToResolve};
if isfield(options, 'LinkResolver')
visitorOptions = [visitorOptions, {"LinkResolver", options.LinkResolver}];
end

% Results are collected in a cell array rather than assigned
% back into obj, because resolving a reference whose type was
% unknown produces an instance of a different class, which
% cannot be stored in an array of the original class.
resolved = cell(1, numel(obj));
for i = 1:numel(obj)
if obj(i).IsReference
resolver = obj(i).selectLinkResolver(options);
obj(i) = resolver.resolve(obj(i), ...
"NumLinksToResolve", options.NumLinksToResolve);
obj(i).IsReference = false; % Update state: mark as resolved

elseif options.NumLinksToResolve > 0
% The instance itself is resolved, so spend one unit of
% depth following its links. The remaining depth is
% derived per element rather than by decrementing
% options, which would leak the budget already spent on
% one element into the next.
childOptions = options;
childOptions.NumLinksToResolve = options.NumLinksToResolve - 1;
nvPairs = namedargs2cell(childOptions);

linkedInstances = obj(i).getLinkedInstances();
for j = 1:numel(linkedInstances)
linkedInstances{j}.resolve(nvPairs{:});
end
embeddedInstances = obj(i).getEmbeddedInstances();
for j = 1:numel(embeddedInstances)
embeddedInstances{j}.resolve(nvPairs{:});
end
end
% An instance that is already resolved and has no depth left
% to spend needs no work.
% A fresh visitor per element, so the visited registry of
% one element does not stop a shared node from being
% resolved for the next.
visitor = openminds.internal.resolver.ResolvingVisitor(visitorOptions{:});
resolved{i} = visitor.visit(obj(i));
end

resolvedClasses = cellfun(@class, resolved, 'UniformOutput', false);
if isscalar(unique(resolvedClasses))
instance = [resolved{:}];
else
% Instances of different types cannot form an object array
instance = resolved;
end
instance = obj; % Set output
end

function str = serialize(obj, options)
Expand Down Expand Up @@ -661,23 +663,6 @@
end
end

methods (Access = private)
function resolver = selectLinkResolver(obj, options)
% selectLinkResolver - Resolver for this instance, from options or registry

if isfield(options, 'LinkResolver')
resolver = options.LinkResolver;
else
resolver = openminds.internal.getLinkResolver(obj.id);
end

if isempty(resolver)
error('openMINDS:LinkResolver:NotFound', ...
'No link resolver found for object with id "%s".', obj.id);
end
end
end

methods (Access = private) % Introspective utility methods

function tf = isSubsForProperty(obj, subs)
Expand Down Expand Up @@ -847,6 +832,13 @@ function assignInstanceId(obj, id)
end
end

methods (Access = ?openminds.internal.resolver.ResolvingVisitor)
function markResolved(obj)
% markResolved - Record that this node is no longer a reference
obj.IsReference = false;
end
end

methods (Access = ?openminds.internal.mixin.CustomInstanceDisplay)
function semanticName = getSemanticName(obj)
% Using eval to ensure it also works for empty objects:
Expand Down
150 changes: 150 additions & 0 deletions code/internal/+openminds/+internal/+graph/TraversalCore.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
classdef (Abstract) TraversalCore < handle
% TraversalCore - Shared graph traversal primitives for openMINDS visitors
%
% Provides the machinery every traversal of an instance graph needs:
% enumerating the linked and embedded edges of a node, unwrapping mixed
% type values, writing child values back to a property, and tracking
% which nodes have been seen.
%
% This exists because the same traversal was written three times, in the
% serializer, in Schema.resolve and in loadInstances, each with its own
% handling of mixed types and only one of them with cycle detection.
%
% Subclasses define the traversal protocol on top of these primitives.
% See openminds.abstract.BaseVisitor for the side-effecting protocol and
% openminds.abstract.BaseTransformer for the accumulating one.

properties (Access = private)
% Identifiers of nodes seen so far. What "seen" means is decided by
% the protocol built on top: a visitor marks a node for the whole
% traversal, a transformer marks it only while its own subtree is
% being processed.
% Created in the constructor rather than as a default value,
% because a handle default would be shared by every instance.
VisitedNodeIds
end

methods
function obj = TraversalCore()
obj.reset()
end

function reset(obj)
% reset - Forget which nodes have been seen
%
% Call between independent traversals that share a visitor.

obj.VisitedNodeIds = containers.Map( ...
'KeyType', 'char', 'ValueType', 'logical');
end
end

methods (Access = protected)
function edges = getLinkedEdges(obj, node)
% getLinkedEdges - Edges of a node that point to linked instances
%
% Returns a struct array with fields PropertyName and Children,
% where Children is a cell array of openMINDS instances.

edges = obj.getEdges(node, fieldnames(node.LINKED_PROPERTIES));
end

function edges = getEmbeddedEdges(obj, node)
% getEmbeddedEdges - Edges of a node that point to embedded instances

edges = obj.getEdges(node, fieldnames(node.EMBEDDED_PROPERTIES));
end

function setEdgeChildren(~, node, propertyName, children)
% setEdgeChildren - Write child instances back to a property
%
% Assigning the property once, with the complete list, is what
% lets a traversal replace a child rather than only mutate it,
% and it keeps the remaining children in their original
% positions.

arguments
~
node (1,1) openminds.abstract.Schema
propertyName (1,1) string
children cell
end

if isempty(children)
node.(propertyName) = [];
return
end

% Instances of one type go back as an object array. A mix of
% types has to stay a cell array, which is what the mixed type
% wrapper for the property expects.
childClasses = cellfun(@class, children, 'UniformOutput', false);
if isscalar(unique(childClasses))
node.(propertyName) = [children{:}];
else
node.(propertyName) = children;
end
end

function tf = wasVisited(obj, node)
tf = obj.VisitedNodeIds.isKey(obj.nodeKey(node));
end

function markVisited(obj, node)
obj.VisitedNodeIds(obj.nodeKey(node)) = true;
end

function unmarkVisited(obj, node)
key = obj.nodeKey(node);
if obj.VisitedNodeIds.isKey(key)
obj.VisitedNodeIds.remove(key);
end
end
end

methods (Access = private)
function edges = getEdges(obj, node, propertyNames)
% getEdges - Collect the non-empty edges for a set of properties

edges = struct('PropertyName', {}, 'Children', {});

for i = 1:numel(propertyNames)
propertyName = string(propertyNames{i});
children = obj.getChildren(node, propertyName);
if isempty(children)
continue
end
edges(end+1) = struct( ...
'PropertyName', propertyName, ...
'Children', {children}); %#ok<AGROW>
end
end

function children = getChildren(~, node, propertyName)
% getChildren - Property value as a cell array of instances
%
% Mixed type values are containers rather than instances, so the
% instance is taken out of each element.

children = {};
value = node.(propertyName);

if isempty(value)
return
end

if openminds.utility.isMixedInstance(value)
children = arrayfun(@(element) element.Instance, value, ...
'UniformOutput', false);
elseif openminds.utility.isInstance(value)
children = num2cell(value);
end
end
end

methods (Static, Access = private)
function key = nodeKey(node)
key = char(node.id);
end
end
end
Loading
Loading