Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
46fb12f
Improve type correction for datetime
ehennestad Jun 19, 2026
6513c2b
Undo parts of last commit: cell of datetime preserved
ehennestad Jun 19, 2026
f953063
Fix broken test
ehennestad Jun 19, 2026
fa6829f
Add method to validate properties on export plus tests
ehennestad Jun 18, 2026
a01e216
Ensure strict validation context on export
ehennestad Jun 18, 2026
74b2947
Potential fix for pull request finding
ehennestad Jun 18, 2026
5251ae5
Add coercingProperty test double and testCoercingValidatorRaisesError…
Copilot Jun 18, 2026
4540a58
Update MetaClass.m
ehennestad Jun 18, 2026
7582b11
fix: allow datetime validator normalization on export
ehennestad Jun 18, 2026
a35e1c4
fix: handle outputless validators during export validation
ehennestad Jun 19, 2026
0e34225
Merge branch 'main' into fix-scalar-datetime-export
bendichter Jun 30, 2026
f613569
Merge branch 'fix-scalar-datetime-export' into validate-properties-on…
bendichter Jun 30, 2026
c6a61f4
Merge branch 'main' into fix-scalar-datetime-export
ehennestad Jun 30, 2026
372dd63
Merge branch 'fix-scalar-datetime-export' into validate-properties-on…
ehennestad Jun 30, 2026
8f16218
Update nwbExportTest.m
ehennestad Jun 30, 2026
8f69030
Merge branch 'main' into fix-scalar-datetime-export
ehennestad Jun 30, 2026
b75eba6
Merge branch 'main' into fix-scalar-datetime-export
bendichter Jul 2, 2026
75242dd
Merge branch 'fix-scalar-datetime-export' into validate-properties-on…
bendichter Jul 2, 2026
1f93899
Merge branch 'main' into validate-properties-on-export
ehennestad Jul 2, 2026
4fe7721
Update MetaClass.m
ehennestad Jul 2, 2026
900f47d
Merge branch 'validate-properties-on-export' of https://github.com/Ne…
ehennestad Jul 2, 2026
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
47 changes: 47 additions & 0 deletions +tests/+unit/+types/+doubles/TypeWithFailingValidator.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
classdef TypeWithFailingValidator < types.untyped.MetaClass
% TypeWithFailingValidator - Test double for exercising MetaClass.validateProperties.
% Provides a property whose validator always fails and one whose validator
% always passes, plus a thin wrapper that invokes the protected
% validateProperties method from outside the class hierarchy.

properties
validProperty
invalidProperty
coercingProperty
datetimeProperty
end

methods
function value = validate_validProperty(~, value)
% Always valid; returns the value unchanged.
end

function validate_invalidProperty(~, ~)
error('NWB:Test:InvalidPropertyValue', ...
'This property value is never valid.')
end

function value = validate_coercingProperty(~, value)
% Simulates a validator that coerces the input (e.g., dtype
% conversion). Returns the value as double regardless of input type.
value = double(value);
end

function value = validate_datetimeProperty(~, value)
value = types.util.checkDtype('datetimeProperty', 'datetime', value);
end

function runValidateProperties(obj, fullpath)
obj.validateProperties(fullpath)
end
end

methods (Access = protected)
function str = getFooter(~)
% Override the inherited footer, which inspects required
% properties assuming a `types.` namespace this test double does
% not have.
str = '';
end
end
end
73 changes: 73 additions & 0 deletions +tests/+unit/+types/MetaClassValidatePropertiesTest.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
classdef MetaClassValidatePropertiesTest < matlab.unittest.TestCase
% MetaClassValidatePropertiesTest - Unit tests for MetaClass.validateProperties,
% the export-time guard that re-runs property validators so that values which
% bypassed strict validation cannot be written back out to a file.

methods (Test)
function testInvalidPropertyValueRaisesError(testCase)
testType = tests.unit.types.doubles.TypeWithFailingValidator();
testType.invalidProperty = 1;

testCase.verifyError( ...
@() testType.runValidateProperties('/some/path'), ...
'NWB:Export:InvalidPropertyValue')
end

function testErrorIncludesPropertyLocationAndCause(testCase)
testType = tests.unit.types.doubles.TypeWithFailingValidator();
testType.invalidProperty = 1;

try
testType.runValidateProperties('/some/path')
testCase.verifyFail('Expected an error for the invalid property value.')
catch exception
testCase.verifyEqual( ...
exception.identifier, 'NWB:Export:InvalidPropertyValue')
testCase.verifyTrue(contains(exception.message, 'invalidProperty'))
testCase.verifyTrue(contains(exception.message, '/some/path'))
% The original validator error is preserved as a cause.
testCase.verifyNotEmpty(exception.cause)
testCase.verifyEqual( ...
exception.cause{1}.identifier, 'NWB:Test:InvalidPropertyValue')
end
end

function testEmptyPropertyIsNotValidated(testCase)
% An unset (empty) property is skipped even though its validator
% would fail, because empty optional properties are not exported.
testType = tests.unit.types.doubles.TypeWithFailingValidator();

testCase.verifyWarningFree( ...
@() testType.runValidateProperties('/some/path'))
end

function testValidPropertyValuePasses(testCase)
testType = tests.unit.types.doubles.TypeWithFailingValidator();
testType.validProperty = 42;

testCase.verifyWarningFree( ...
@() testType.runValidateProperties('/some/path'))
end

function testCoercingValidatorRaisesError(testCase)
% A validator that changes the MATLAB class must produce an
% error, because the writer would receive a value whose type does
% not match what strict validation accepts.
testType = tests.unit.types.doubles.TypeWithFailingValidator();
testType.coercingProperty = int32(5); % int32 -> double on validate

testCase.verifyError( ...
@() testType.runValidateProperties('/some/path'), ...
'NWB:Export:InvalidPropertyValue')
end

function testDatetimeFormatterNormalizationPasses(testCase)
testType = tests.unit.types.doubles.TypeWithFailingValidator();
testType.datetimeProperty = {datetime(2020, 1, 1, ...
'Format', 'dd-MMM-uuuu HH:mm:ss')};

testCase.verifyWarningFree( ...
@() testType.runValidateProperties('/some/path'))
end
end
end
60 changes: 60 additions & 0 deletions +types/+untyped/MetaClass.m
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
writer = io.backend.base.Writer.ensure(writer);
obj.throwErrorIfCustomConstraintUnfulfilled(fullpath)
obj.throwErrorIfMissingRequiredProps(fullpath)
obj.validateProperties(fullpath)
obj.metaClass_fullPath = fullpath;
%find reference properties
propnames = properties(obj);
Expand Down Expand Up @@ -269,6 +270,65 @@ function throwErrorIfCustomConstraintUnfulfilled(obj, fullpath)
class(obj), fullpath, ME.message)
end
end

function validateProperties(obj, fullpath)
% validateProperties - Re-run property validators before writing to file.
% Ensures property values that bypassed strict validation (for
% example, values read permissively from a file that does not
% conform to the schema) are not written back out as a new, invalid
% file. Validators run in the default (strict) context here, so a
% schema violation raises an error rather than a warning.
previousValidationContext = matnwb.common.validation.internal.context('write');
cleanupValidationContext = onCleanup(@() matnwb.common.validation.internal.context(previousValidationContext));

if isempty(fullpath)
fullpath = 'root';
end

propertyNames = properties(obj);
for iProperty = 1:numel(propertyNames)
propertyName = propertyNames{iProperty};
propertyValue = obj.(propertyName);
validatorName = ['validate_' propertyName];

% Validate only set properties that have a generated
% validator. An empty value represents an unset optional
% property, which is not written on export.
if ~isempty(propertyValue) && ismethod(obj, validatorName)
warnState = warning('error', 'NWB:CheckDataType:NeedsManualConversion');
warnCleanupObj = onCleanup(@() warning(warnState));
try
try
validatedValue = feval(validatorName, obj, propertyValue);
if ~strcmp(class(validatedValue), class(propertyValue))
error('NWB:Export:PropertyValueRequiresNormalization', ...
['Property "%s" would be converted by its validator. ' ...
'Assign it via its setter (strict validation) before export.'], ...
propertyName);
end
catch MEValidator
if any(strcmp(MEValidator.identifier, ...
{'MATLAB:maxlhs', 'MATLAB:TooManyOutputs'}))
% Validator does not provide an output. Call
% again without requesting a normalized value.
feval(validatorName, obj, propertyValue);
else
rethrow(MEValidator)
end
end
catch ME
newException = MException( ...
'NWB:Export:InvalidPropertyValue', ...
['The value of property "%s" for type "%s" at ', ...
'file location "%s" is not valid according to ', ...
'the schema and cannot be exported:\n%s'], ...
propertyName, class(obj), fullpath, ME.message);
newException = newException.addCause(ME);
throw(newException)
end
end
end
end
end

methods
Expand Down