Skip to content
Merged
51 changes: 3 additions & 48 deletions +io/createParsedType.m
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,6 @@
% and a corresponding cell array of name-value pairs. It is typically used
% when parsing datasets or groups.
%
% Warnings with the ID "NWB:CheckUnset:InvalidProperties" are captured, and
% the warning message is enhanced with specific details about the dataset or
% group in the NWB file where the issue occurred.
%
% Inputs:
% typePath - (char) Path to the dataset or group in the NWB file where the
% neurodata type is parsed from.
Expand All @@ -19,28 +15,17 @@
% Outputs:
% typeInstance - The generated neurodata type instance.

warnState = warning('off', 'NWB:CheckUnset:InvalidProperties');
cleanupObj = onCleanup(@(s) warning(warnState)); % Make sure warning state is reset later

[lastWarningMessage, lastWarningID] = lastwarn('', ''); % Clear last warning

previousValidationContext = matnwb.common.validation.internal.context("read");
validationContextCleanupObj = onCleanup( ...
@() matnwb.common.validation.internal.context(previousValidationContext));
[~, contextCleanup] = matnwb.common.validation.internal.context("read"); %#ok<ASGLU>
[~, targetCleanup] = matnwb.common.validation.internal.validationTarget(...
"TypeName", typeName, "Path", typePath); %#ok<ASGLU>

try
typeInstance = feval(typeName, varargin{:}); % Create the type.
catch exception
lastwarn(lastWarningMessage, lastWarningID); % Reset last warning

% Add information about which data type failed, and where in the
% file it is located.
newException = MException('NWB:createParsedType:TypeCreationFailed', ...
'Failed to create object of type "%s" in file location "%s".', ...
typeName, typePath);

% Add full error stack to the exception's cause for easier
% debugging
extendedCause = MException(exception.identifier, ...
getReport(exception, "extended"));
newException = newException.addCause(extendedCause);
Expand All @@ -52,34 +37,4 @@
throw(newException)
end

clear validationContextCleanupObj

[warningMessage, warningID] = lastwarn();

% Handle any warnings if they occurred.
if ~isempty(warningMessage)
if strcmp( warningID, 'NWB:CheckUnset:InvalidProperties' )

clear cleanupObj % Reset last warning state

if endsWith(warningMessage, '.')
warningMessage = warningMessage(1:end-1);
end

updatedMessage = sprintf('%s at file location "%s"\n', warningMessage, typePath);

disclaimer = 'NB: The properties in question were dropped while reading the file.';

suggestion = [...
'Consider checking the schema version of the file with '...
'`util.getSchemaVersion(filename)` and comparing with the ' ...
'YAML namespace version present in nwb-schema/core/nwb.namespace.yaml' ];

warning(warningID, '%s\n%s\n\n%s', updatedMessage, disclaimer, suggestion)
else
% Pass, warning has already been displayed
end
else
lastwarn(lastWarningMessage, lastWarningID); % Reset last warning
end
end
12 changes: 11 additions & 1 deletion +matnwb/+common/+validation/+internal/context.m
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
function previousContext = context(newContext)
function [previousContext, cleanup] = context(newContext)
% context - Get or set the process-local schema validation context.
%
% [~, cleanup] = context(newContext) additionally returns an onCleanup
% handle that restores the prior context when it goes out of scope.
% cleanup must be assigned to a named variable — if ignored, it fires
% immediately and the state change is immediately undone.

arguments
newContext matnwb.common.validation.internal.ValidationContext = ...
Expand All @@ -20,4 +25,9 @@
'Validation context must be scalar.')
activeContext = newContext;
end

if nargout > 1
cleanup = onCleanup(@() ...
matnwb.common.validation.internal.context(previousContext));
end
end
65 changes: 65 additions & 0 deletions +matnwb/+common/+validation/+internal/validationTarget.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
function [previousTarget, cleanup] = validationTarget(newTarget, options)
% validationTarget - Get or set the current schema-validation target.
%
% The new target can be provided either as a scalar struct with TypeName
% and Path fields, or as name-value pairs (TypeName=..., Path=...).
% The two forms cannot be combined.
%
% [~, cleanup] = validationTarget(newTarget) additionally returns an onCleanup
% handle that restores the prior target when it goes out of scope.
% cleanup must be assigned to a named variable — if ignored, it fires
% immediately and the state change is immediately undone.

arguments
newTarget struct = struct.empty % Struct with fields TypeName and Path
options.TypeName (1,1) string
options.Path (1,1) string
end

persistent activeTarget

if isempty(activeTarget)
activeTarget = struct.empty;
end

previousTarget = activeTarget;

% Any explicit argument is a set/reset request; calling with no arguments
% is a pure getter. nargin counts only positional arguments, so name-value
% input is detected via the options struct. An explicit empty target resets
% the state, which is how the onCleanup handler restores the prior (possibly
% empty) target when a scope exits.
if nargin >= 1 || ~isempty(fieldnames(options))
assert(isempty(newTarget) || isempty(fieldnames(options)), ...
'NWB:Validation:InvalidValidationTarget', ...
'Specify target as a struct or as name-value pairs, not both.')
if isempty(newTarget) && ~isempty(fieldnames(options))
newTarget = options;
end
validateTarget(newTarget)
activeTarget = newTarget;
end

if nargout > 1
cleanup = onCleanup(@() ...
matnwb.common.validation.internal.validationTarget(previousTarget));
end
end

function validateTarget(target)
if isempty(target); return; end

assert(isstruct(target) && isscalar(target), ...
'NWB:Validation:InvalidValidationTarget', ...
'Validation target must be a scalar struct.')
assert(isfield(target, 'TypeName') && isfield(target, 'Path'), ...
'NWB:Validation:InvalidValidationTarget', ...
'Validation target must have TypeName and Path fields.')
assert(isTextScalar(target.TypeName) && isTextScalar(target.Path), ...
'NWB:Validation:InvalidValidationTarget', ...
'Validation target TypeName and Path fields must be text scalars.')
end

function tf = isTextScalar(value)
tf = ischar(value) || (isstring(value) && isscalar(value));
end
17 changes: 14 additions & 3 deletions +matnwb/+common/+validation/reportSchemaViolation.m
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,24 @@ function reportSchemaViolation(errorId, message, causes, options)
isWriteContext = validationContext == ValidationContext.WRITE;

if ~isWriteContext && (isReadContext || options.WarnInsteadOfError)
lenientGuidance = ['The non-conforming value is kept. If you maintain ' ...
'this data, consider correcting it before export.'];
lenientGuidance = sprintf("\nNote: The non-conforming value is kept. If you maintain " + ...
"this data, consider correcting it before export.");

fullMessage = message;
fullMessage = "";

target = matnwb.common.validation.internal.validationTarget();
if isReadContext && ~isempty(target)
targetMessage = sprintf( ...
'Validation failed while reading object of type "%s" at file location "%s":', ...
target.TypeName, target.Path);
fullMessage = string(targetMessage) + newline;
end

fullMessage = fullMessage + message;
for iCause = 1:numel(causes)
fullMessage = fullMessage + " " + string(causes(iCause).message);
end

fullMessage = fullMessage + " " + lenientGuidance;

warning(errorId, '%s', fullMessage)
Expand Down
29 changes: 29 additions & 0 deletions +tests/+unit/+common/+validation/ReportSchemaViolationTest.m
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
methods (TestMethodTeardown)
function resetValidationContext(~)
matnwb.common.validation.internal.context("edit");
matnwb.common.validation.internal.validationTarget([]);
end
end

Expand Down Expand Up @@ -50,17 +51,45 @@ function testReadContextWarnsWithGuidanceAndCauseMessages(testCase)
warningMessage, 'The nested validation failed.')
testCase.verifySubstring( ...
warningMessage, 'The non-conforming value is kept.')
testCase.verifyFalse(contains(warningMessage, 'While reading object'))
end

function testReadContextWarnsWithValidationTarget(testCase)
matnwb.common.validation.internal.context("read");
source.TypeName = 'types.core.TimeSeries';
source.Path = '/acquisition/bad_ts';
matnwb.common.validation.internal.validationTarget(source);

lastwarn('')
testCase.verifyWarning( ...
@() matnwb.common.validation.reportSchemaViolation( ...
'NWB:Test:SchemaViolation', ...
"The value does not match the schema."), ...
'NWB:Test:SchemaViolation')

[warningMessage, warningId] = lastwarn;
testCase.verifyEqual(warningId, 'NWB:Test:SchemaViolation')
testCase.verifySubstring(warningMessage, ...
['Validation failed while reading object of type ' ...
'"types.core.TimeSeries" at file location "/acquisition/bad_ts":'])
end

function testWarnInsteadOfErrorWarnsInEditContext(testCase)
matnwb.common.validation.internal.context("edit");
source.TypeName = 'types.core.TimeSeries';
source.Path = '/acquisition/bad_ts';
matnwb.common.validation.internal.validationTarget(source);

testCase.verifyWarning( ...
@() matnwb.common.validation.reportSchemaViolation( ...
'NWB:Test:SchemaViolation', ...
"The value does not match the schema.", ...
WarnInsteadOfError=true), ...
'NWB:Test:SchemaViolation')

[warningMessage, warningId] = lastwarn;
testCase.verifyEqual(warningId, 'NWB:Test:SchemaViolation')
testCase.verifyFalse(contains(warningMessage, 'While reading object'))
end

function testWriteContextRaisesErrorWithWarnInsteadOfError(testCase)
Expand Down
49 changes: 49 additions & 0 deletions +tests/+unit/+common/+validation/ValidationTargetTest.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
classdef ValidationTargetTest < matlab.unittest.TestCase
% ValidationTargetTest - Unit tests for the schema-validation target state.

methods (TestMethodTeardown)
function resetValidationTarget(~)
matnwb.common.validation.internal.validationTarget([]);
end
end

methods (Test)
function testExplicitEmptyResetsState(testCase)
% Passing an explicit empty target must clear the stored state,
% not be treated as a no-op getter.
target.TypeName = 'types.core.TimeSeries';
target.Path = '/acquisition/bad_ts';
matnwb.common.validation.internal.validationTarget(target);
testCase.verifyNotEmpty( ...
matnwb.common.validation.internal.validationTarget())

matnwb.common.validation.internal.validationTarget([]);
testCase.verifyEmpty( ...
matnwb.common.validation.internal.validationTarget())
end

function testCleanupRestoresEmptyTarget(testCase)
% When a scope sets a target from the initial empty state, its
% cleanup handle must restore the state back to empty on exit.
target.TypeName = 'types.core.TimeSeries';
target.Path = '/acquisition/bad_ts';

% The cleanup handle is scoped to the helper, so it fires when the
% helper returns, mimicking how a read scope exits.
testCase.setScopedTarget(target)

testCase.verifyEmpty( ...
matnwb.common.validation.internal.validationTarget())
end
end

methods (Access = private)
function setScopedTarget(testCase, target)
[~, cleanup] = matnwb.common.validation.internal ...
.validationTarget(target); %#ok<NASGU>
testCase.verifyEqual( ...
matnwb.common.validation.internal.validationTarget().TypeName, ...
target.TypeName)
end
end
end
5 changes: 5 additions & 0 deletions +tests/+unit/+io/testCreateParsedType.m
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
function setupMethod(testCase)
% Use a fixture to create a temporary working directory
testCase.applyFixture(matlab.unittest.fixtures.WorkingFolderFixture);
previousValidationTarget = matnwb.common.validation.internal.validationTarget([]);
testCase.addTeardown( ...
@() matnwb.common.validation.internal.validationTarget(previousValidationTarget));
end
end

Expand Down Expand Up @@ -63,6 +66,8 @@ function testCreateDynamicTableWithDuplicateColnamesWarns(testCase)
dynamicTable = testCase.verifyWarning( ...
@() io.createParsedType(testPath, testType, kwargs{:}), ...
'NWB:DynamicTable:DuplicateColumnNames');
testCase.verifyEmpty( ...
matnwb.common.validation.internal.validationTarget())

testCase.verifyClass(dynamicTable, testType)
[warningMessage, warningIdentifier] = lastwarn();
Expand Down
22 changes: 19 additions & 3 deletions +types/+util/checkUnset.m
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,24 @@ function checkUnset(obj, argin)
end
dropped = setdiff(argin, union(allProperties, anonNames));
if ~isempty(dropped)
warning('NWB:CheckUnset:InvalidProperties', ...
'Unexpected properties {%s} for instance of type "%s".', ...
message = sprintf('Unexpected properties {%s} for instance of type "%s".', ...
misc.cellPrettyPrint(dropped), class(obj));

target = matnwb.common.validation.internal.validationTarget();
if ~isempty(target)
message = sprintf('%s at file location "%s".', ...
message(1:end-1), target.Path);
end

message = sprintf('%s\nNB: The properties in question were dropped.', message);

if matnwb.common.validation.isReadContext()
message = sprintf(['%s\nConsider checking the schema version of the file ' ...
'with `util.getSchemaVersion(filename)` and comparing with the ' ...
'YAML namespace version present in nwb-schema/core/nwb.namespace.yaml'], ...
message);
end

warning('NWB:CheckUnset:InvalidProperties', '%s', message)
end
end
end
5 changes: 1 addition & 4 deletions NwbFile.m
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,7 @@ function export(obj, filename, mode, options)
options.StorageBackend (1,1) string = "hdf5"
end

previousSchemaValidationContext = matnwb.common.validation.internal.context("write");
schemaValidationContextCleanupObj = onCleanup( ...
@() matnwb.common.validation.internal.context(previousSchemaValidationContext));
[~, contextCleanup] = matnwb.common.validation.internal.context("write");

% add to file create date
if isa(obj.file_create_date, 'types.untyped.DataStub')
Expand Down Expand Up @@ -82,7 +80,6 @@ function export(obj, filename, mode, options)
rethrow(ME);
end

clear schemaValidationContextCleanupObj
end

function datasetConfig = applyDatasetSettingsProfile(obj, profile, options)
Expand Down