diff --git a/+file/+internal/isDescendantOf.m b/+file/+internal/isDescendantOf.m new file mode 100644 index 000000000..4c6d40a61 --- /dev/null +++ b/+file/+internal/isDescendantOf.m @@ -0,0 +1,18 @@ +function tf = isDescendantOf(name, namespace, targetAncestorName) +%isDescendantOf Check if a type inherits from an ancestor type. + + tf = false; + + if strcmp(name, targetAncestorName) + tf = true; + return + end + + ancestry = namespace.getRootBranch(name); + for iAncestor = 1:length(ancestry) + parentRaw = ancestry{iAncestor}; + typeDefIndex = isKey(parentRaw, namespace.TYPEDEF_KEYS); + currentAncestorName = parentRaw(namespace.TYPEDEF_KEYS{typeDefIndex}); + tf = tf || strcmp(currentAncestorName, targetAncestorName); + end +end diff --git a/+file/fillClass.m b/+file/fillClass.m index 96b9899da..26332f877 100644 --- a/+file/fillClass.m +++ b/+file/fillClass.m @@ -3,7 +3,8 @@ %namespace is the namespace context for this class customBaseClasses = struct( ... - 'DynamicTable', 'matnwb.neurodata.DynamicTableBase' ... + 'DynamicTable', 'matnwb.neurodata.DynamicTableBase', ... + 'AlignedDynamicTable', 'matnwb.neurodata.AlignedDynamicTableBase' ... ); %% PROCESSING @@ -191,8 +192,14 @@ fullMethodBody = strjoin({'methods' ... file.addSpaces(methodBody, 4) 'end'}, newline); + schemaCategoryPropertyBlock = createPropertyBlockForAlignedDynamicTableCategories( ... + classprops, nonInherited, name, namespace); readPolicyMethodBlock = fillReadPolicy(class, classprops); - classSections = {classDefinitionHeader, fullPropertyDefinition, fullMethodBody}; + classSections = {classDefinitionHeader, fullPropertyDefinition}; + if ~isempty(schemaCategoryPropertyBlock) + classSections{end+1} = schemaCategoryPropertyBlock; + end + classSections{end+1} = fullMethodBody; if ~isempty(readPolicyMethodBlock) classSections{end+1} = readPolicyMethodBlock; end @@ -245,6 +252,44 @@ 'end'}, newline); end +function propertyBlockStr = createPropertyBlockForAlignedDynamicTableCategories( ... + classProps, propertyNames, className, namespace) + + propertyBlockStr = ''; + if ~file.internal.isDescendantOf(className, namespace, 'AlignedDynamicTable') + return + end + + categoryNames = string.empty(1, 0); + for iProperty = 1:length(propertyNames) + propertyName = propertyNames{iProperty}; + propertyInfo = classProps(propertyName); + if isSchemaDefinedAlignedDynamicTableCategory(propertyInfo, namespace) + categoryNames(end+1) = string(propertyName); %#ok + end + end + + formattedNames = """" + categoryNames + """"; + if isempty(formattedNames) + propertyLine = 'DeclaredSchemaCategories = string.empty(1, 0);'; + else + propertyLine = sprintf( ... + 'DeclaredSchemaCategories = [%s];', strjoin(formattedNames, ', ')); + end + + propertyBlockStr = strjoin({ ... + 'properties (Constant, Access = private)', ... + file.addSpaces(propertyLine, 4), ... + 'end'}, newline); +end + +function tf = isSchemaDefinedAlignedDynamicTableCategory(propertyInfo, namespace) + tf = isa(propertyInfo, 'file.Group') ... + && ~propertyInfo.isConstrainedSet ... + && ~isempty(propertyInfo.type) ... + && file.internal.isDescendantOf(propertyInfo.type, namespace, 'DynamicTable'); +end + function propertyBlockStr = createSchemaNameMappingBlock(schemaNames) % Build a (Constant, Hidden) struct mapping the valid property identifier % to its schema name for each reserved-keyword schema name. diff --git a/+file/fillConstructor.m b/+file/fillConstructor.m index 1756976fa..c76c90879 100644 --- a/+file/fillConstructor.m +++ b/+file/fillConstructor.m @@ -28,7 +28,9 @@ end % Add custom validation for DynamicTable and its descendant classes - if file.isDynamicTableDescendant(name, namespace) + if file.internal.isDescendantOf(name, namespace, 'AlignedDynamicTable') + constructorElements{end+1} = ' obj.ensureAlignedTableConsistency();'; + elseif file.internal.isDescendantOf(name, namespace, 'DynamicTable') constructorElements{end+1} = ' obj.ensureDynamicTableConsistency();'; end diff --git a/+file/fillCustomConstraint.m b/+file/fillCustomConstraint.m index 8072a9a6c..71d237768 100644 --- a/+file/fillCustomConstraint.m +++ b/+file/fillCustomConstraint.m @@ -41,6 +41,13 @@ ' obj.ensureDynamicTableConsistency()\n', ... 'end'] ); + case "AlignedDynamicTable" + customConstraintStr = sprintf( [... + 'function checkCustomConstraint(obj)\n', ... + ' checkCustomConstraint@types.untyped.MetaClass(obj)\n', ... + ' obj.ensureAlignedTableConsistency()\n', ... + 'end'] ); + otherwise customConstraintStr = ''; end diff --git a/+file/getPropertyHooks.m b/+file/getPropertyHooks.m index 534eddd6b..0ca72a8d8 100644 --- a/+file/getPropertyHooks.m +++ b/+file/getPropertyHooks.m @@ -13,11 +13,23 @@ 'val = types.util.dynamictable.validateColnames(val);' }; end - if file.isDynamicTableDescendant(typeName, namespace) ... + if strcmp(fullClassName, 'types.hdmf_common.AlignedDynamicTable') ... + && strcmp(propName, 'categories') + hooks.ValidatorLines = { ... + 'val = obj.validateCategoryNames(val);' }; + end + + if file.internal.isDescendantOf(typeName, namespace, 'DynamicTable') ... && isSchemaDefinedDynamicTableColumn(propName, prop) hooks.PostsetStatements = { ... sprintf('types.util.dynamictable.syncNamedColumn(obj, ''%s'');', propName) }; end + + if file.internal.isDescendantOf(typeName, namespace, 'AlignedDynamicTable') ... + && isSchemaDefinedAlignedDynamicTableCategory(prop, namespace) + hooks.PostsetStatements = [hooks.PostsetStatements, { ... + sprintf('obj.ensureCategoryNameRegistered(''%s'');', propName) }]; + end end function tf = isSchemaDefinedDynamicTableColumn(propName, prop) @@ -26,3 +38,10 @@ && ~strcmp(propName, 'id') ... && ~endsWith(propName, '_index'); end + +function tf = isSchemaDefinedAlignedDynamicTableCategory(prop, namespace) + tf = isa(prop, 'file.Group') ... + && ~prop.isConstrainedSet ... + && ~isempty(prop.type) ... + && file.internal.isDescendantOf(prop.type, namespace, 'DynamicTable'); +end diff --git a/+file/isDynamicTableDescendant.m b/+file/isDynamicTableDescendant.m deleted file mode 100644 index 3625344a2..000000000 --- a/+file/isDynamicTableDescendant.m +++ /dev/null @@ -1,18 +0,0 @@ -function tf = isDynamicTableDescendant(name, namespace) -%ISDYNAMICTABLEDESCENDANT Check if a type inherits from DynamicTable. - - tf = false; - - if strcmp(name, 'DynamicTable') - tf = true; - return - end - - ancestry = namespace.getRootBranch(name); - for iAncestor = 1:length(ancestry) - parentRaw = ancestry{iAncestor}; - typeDefIndex = isKey(parentRaw, namespace.TYPEDEF_KEYS); - ancestorName = parentRaw(namespace.TYPEDEF_KEYS{typeDefIndex}); - tf = tf || strcmp(ancestorName, 'DynamicTable'); - end -end diff --git a/+matnwb/+neurodata/+internal/collectConstantPropertiesAcrossHierarchy.m b/+matnwb/+neurodata/+internal/collectConstantPropertiesAcrossHierarchy.m new file mode 100644 index 000000000..2d6e65379 --- /dev/null +++ b/+matnwb/+neurodata/+internal/collectConstantPropertiesAcrossHierarchy.m @@ -0,0 +1,57 @@ +function result = collectConstantPropertiesAcrossHierarchy(className, propertyName) +% collectConstantPropertyAcrossHierarchy - Collect constant property values +% across class hierarchy +% +% Syntax: +% groupTypes = collectConstantPropertiesAcrossHierarchy(nwbTypeName) +% This function retrieves property names of unnamed groups associated with +% the specified NWB type name, traversing the class hierarchy to also include +% property names of unnamed groups for parent types. +% +% Input Arguments: +% nwbTypeName (1,1) string - The name of the NWB type for which property +% names of unnamed groups are to be retrieved. +% +% Output Arguments: +% groupPropertyNames - An array of property names of unnamed groups +% associated with the specified NWB type. +% +% Assumptions: +% 1. Class name is the name of a generated neurodata type +% 2. A parent neurodata type (superclass) is always defined as the first +% superclass if a class inherits from multiple classes. + + arguments + className (1,1) string + propertyName (1,1) string + end + + result = string.empty; % Initialize an empty cell array + currentType = className; % Start with the specific type + + % Iterate over class and superclasses to detect property names for + % unnamed groups across the type hierarchy. + while ~strcmp(currentType, 'types.untyped.MetaClass') + + % Use MetaClass information to get class information + metaClass = meta.class.fromName(currentType); + + % Get value of GroupPropertyNames if this class is a subclass of + % the HasUnnamedGroups subclass. + isProp = strcmp({metaClass.PropertyList.Name}, propertyName); + if any(isProp) + result = [result, ... + string(metaClass.PropertyList(isProp).DefaultValue)]; %#ok + end + + if isempty(metaClass.SuperclassList) + break % Reached the base type + end + + % Get superclass for next iteration. NWB parent type should + % always be the first superclass in the list + currentType = metaClass.SuperclassList(1).Name; + end + + result = unique(result, 'stable'); +end diff --git a/+matnwb/+neurodata/AlignedDynamicTableBase.m b/+matnwb/+neurodata/AlignedDynamicTableBase.m new file mode 100644 index 000000000..d0e4a04a9 --- /dev/null +++ b/+matnwb/+neurodata/AlignedDynamicTableBase.m @@ -0,0 +1,409 @@ +classdef (Abstract) AlignedDynamicTableBase < matnwb.neurodata.DynamicTableBase +% AlignedDynamicTableBase - Non-generated base class for AlignedDynamicTable. +% +% This class owns custom behavior that the generated schema class +% cannot express: category table registration and lookup, category name +% validation, and row-height consistency between the parent table and all +% category tables. + + properties (Abstract) + categories + dynamictable + end + + methods + function addCategory(obj, categoryName, categoryTable) + % addCategory - Add one or more category tables. + % + % Syntax: + % alignedDynamicTable.addCategory(categoryName, categoryTable) + % alignedDynamicTable.addCategory(categoryNameA, categoryTableA, ...) + % + % This method assigns each category table, registers the category + % name, and ensures the category table height matches the parent + % AlignedDynamicTable height. + % + % Input Arguments: + % - categoryName (string) - + % A name for the category. + % + % - categoryTable (types.hdmf_common.DynamicTable) - + % A dynamic table to add as a category table. Note: Nested + % AlignedDynamicTables are not supported. + + arguments + obj (1,1) matnwb.neurodata.AlignedDynamicTableBase + end + arguments (Repeating) + categoryName (1,1) string + categoryTable (1,1) {matnwb.common.validation.mustBeDynamicTable, mustNotBeAlignedDynamicTable} + end + + categoryNames = [categoryName{:}]; + + assert(~isempty(categoryName), ... + 'NWB:AlignedDynamicTable:AddCategory:NoData', ... + 'Provide at least one category name and DynamicTable pair.') + obj.assertUniqueCategoryNames(categoryNames) + + [parentHeight, parentHasHeight] = types.util.dynamictable.internal.getTableHeight(obj); + + for iCategory = 1:numel(categoryNames) + currentName = categoryNames(iCategory); + currentTable = categoryTable{iCategory}; + + [parentHeight, parentHasHeight, categoryHeight] = ... + obj.establishAlignedTableHeight( ... + currentTable, parentHeight, parentHasHeight); + + obj.assertCategoryHeightMatchesParent(currentName, categoryHeight, parentHeight) + obj.assignCategoryTable(currentName, currentTable) + obj.registerCategoryName(currentName) + end + end + + function categoryTable = getCategory(obj, categoryName) + % getCategory - Return a schema-defined or custom category table. + % + % Syntax: + % categoryTable = alignedDynamicTable.getCategory(categoryName) + % + % This method resolves schema-defined categories stored as object + % properties and custom categories stored in the constrained + % dynamictable group. + % + % Input Arguments: + % - categoryName (string) - + % A name for the category. + + arguments + obj (1,1) matnwb.neurodata.AlignedDynamicTableBase + categoryName (1,1) string + end + + categoryTable = obj.getCategoryTable(categoryName); + end + end + + % Hidden because this method is normally called by generated + % validation/export flow, but remains callable for explicit consistency + % checks. + methods (Hidden) + function ensureAlignedTableConsistency(obj) + % ensureAlignedTableConsistency - Ensure category and height consistency. + % + % This method delegates ordinary DynamicTable validation to + % types.util.dynamictable.checkConfig, then validates category + % registration and category table heights. It may also initialize + % missing id datasets when the table height can be inferred from the + % parent table or a category table. Category registry mismatches are + % warnings while reading existing files, but errors during normal + % validation/export. + + arguments + obj (1,1) matnwb.neurodata.AlignedDynamicTableBase + end + + types.util.dynamictable.checkConfig(obj); + + categoryTableNames = obj.getMaterializedCategoryNames(); + + obj.assertNoNestedAlignedDynamicTable(categoryTableNames); + + categoryNames = obj.validateCategoryNames(obj.categories); + missingCategoryNames = setdiff(categoryTableNames, categoryNames, 'stable'); + if ~isempty(missingCategoryNames) + obj.handleCategoryNamesMismatch( ... + ['All materialized AlignedDynamicTable category tables must be listed ', ... + 'in `categories`.\nMissing from `categories`: %s'], ... + strjoin(missingCategoryNames, ', ')); + end + + if isempty(categoryNames); return; end + + [parentHeight, parentHasHeight] = types.util.dynamictable.internal.getTableHeight(obj); + materializedRegisteredNames = intersect(categoryNames, categoryTableNames, 'stable'); + categoryHeights = zeros(size(materializedRegisteredNames)); + + for iCategory = 1:numel(materializedRegisteredNames) + categoryName = materializedRegisteredNames{iCategory}; + categoryTable = obj.getCategoryTable(categoryName); + types.util.dynamictable.checkConfig(categoryTable); + + [parentHeight, parentHasHeight, categoryHeight, categoryHasHeight] = ... + obj.establishAlignedTableHeight( ... + categoryTable, parentHeight, parentHasHeight); + + if categoryHasHeight + categoryHeights(iCategory) = categoryHeight; + end + end + + unmaterializedCategoryNames = setdiff(categoryNames, categoryTableNames, 'stable'); + if ~isempty(unmaterializedCategoryNames) && parentHasHeight && parentHeight > 0 + classShortName = obj.TypeName; + obj.handleCategoryNamesMismatch( ... + ['The `categories` property lists category table(s) that have not ', ... + 'been added to the %s: %s.\n', ... + 'Add the missing table(s) with the addCategory method.'], ... + classShortName, strjoin(unmaterializedCategoryNames, ', ')); + end + + assert(isempty(categoryHeights) || all(categoryHeights == parentHeight), ... + 'NWB:AlignedDynamicTable:ValidateAlignedTableConsistency:InvalidCategoryShape', ... + ['Invalid AlignedDynamicTable: all category tables must have the ', ... + 'same height as the parent table.']) + end + end + + methods (Access = protected, Hidden) + function categoryNames = getSchemaDefinedCategories(obj) + % getSchemaDefinedCategories - Return schema-defined category names. + % + % The class generation pipeline declares local schema category names + % as private constant properties on each generated aligned table + % class. This method aggregates those declarations across the class + % hierarchy. + + import matnwb.neurodata.internal.collectConstantPropertiesAcrossHierarchy + + className = class(obj); + categoryNames = collectConstantPropertiesAcrossHierarchy(... + className, 'DeclaredSchemaCategories'); + end + + function ensureCategoryNameRegistered(obj, categoryName) + % ensureCategoryNameRegistered - Register a populated schema category. + % + % This method is added as a generated property post-set hook for any + % schema-defined category property by the class generation pipeline. + % + % Generated property setters call this after assigning schema-defined + % category table properties, for example: + % intracellularRecordingsTable.electrodes = electrodesTable; + % + % This ensures "electrodes" is added to the categories property if + % it was not assigned during table construction. + % + % During file read, this method does not mutate categories. + + arguments + obj (1,1) matnwb.neurodata.AlignedDynamicTableBase + categoryName (1,:) char + end + + if isempty(obj.(categoryName)) + return + end + + if matnwb.common.validation.isReadContext() + return % No mutation on read + end + + obj.registerCategoryName(categoryName) + end + + function categoryNames = validateCategoryNames(~, categoryNames) + % validateCategoryNames - Normalize category names and reject duplicates. + + categoryNames = types.util.dynamictable.normalizeColnames(categoryNames); + validateUniqueCategoryNames(categoryNames) + end + end + + methods (Access = private) + function tf = isSchemaDefinedCategory(obj, categoryName) + arguments + obj (1,1) matnwb.neurodata.AlignedDynamicTableBase + categoryName (1,1) string + end + + schemaCategoryNames = obj.getSchemaDefinedCategories(); + tf = any(schemaCategoryNames == categoryName); + end + + function tf = categoryExists(obj, categoryName) + arguments + obj (1,1) matnwb.neurodata.AlignedDynamicTableBase + categoryName (1,1) string + end + + if obj.isSchemaDefinedCategory(categoryName) + tf = ~isempty(obj.(categoryName)); + else + tf = obj.dynamictable.Count ~= 0 && obj.dynamictable.isKey(categoryName); + end + end + + function categoryTable = getCategoryTable(obj, categoryName) + arguments + obj (1,1) matnwb.neurodata.AlignedDynamicTableBase + categoryName (1,1) string + end + + if obj.isSchemaDefinedCategory(categoryName) + if isempty(obj.(categoryName)) + error('NWB:AlignedDynamicTable:CategoryNotFound', ... + 'Category `%s` has not been added to the table.', categoryName) + end + categoryTable = obj.(categoryName); + elseif obj.dynamictable.Count == 0 || ~obj.dynamictable.isKey(categoryName) + error('NWB:AlignedDynamicTable:CategoryNotFound', ... + 'Category `%s` has not been added to the table.', categoryName) + else + categoryTable = obj.dynamictable.get(categoryName); + end + end + + function categoryNames = getMaterializedCategoryNames(obj) + arguments + obj (1,1) matnwb.neurodata.AlignedDynamicTableBase + end + + categoryNames = string.empty(1, 0); + schemaCategoryNames = obj.getSchemaDefinedCategories(); + + for iCategory = 1:numel(schemaCategoryNames) + categoryName = schemaCategoryNames(iCategory); + if isprop(obj, categoryName) && ~isempty(obj.(categoryName)) + categoryNames(end+1) = categoryName; %#ok + end + end + + if obj.dynamictable.Count ~= 0 + customCategoryNames = string(obj.dynamictable.keys()); + categoryNames = [categoryNames, customCategoryNames]; + end + + categoryNames = cellstr(unique(categoryNames, 'stable')); + end + + function [parentHeight, parentHasHeight, categoryHeight, categoryHasHeight] = ... + establishAlignedTableHeight(obj, categoryTable, parentHeight, parentHasHeight) + % establishAlignedTableHeight - Establish compatible parent/category heights. + % + % If exactly one table has an established height, this method + % initializes ids for the other table to that height. If neither + % table has a height, it initializes both as empty tables so later + % checks can treat height as established. + + [categoryHeight, categoryHasHeight] = types.util.dynamictable.internal.getTableHeight(categoryTable); + + if parentHasHeight && ~categoryHasHeight + types.util.dynamictable.internal.initDynamicTableId(categoryTable, parentHeight); + categoryHeight = parentHeight; + categoryHasHeight = true; + elseif ~parentHasHeight && categoryHasHeight + types.util.dynamictable.internal.initDynamicTableId(obj, categoryHeight); + parentHeight = categoryHeight; + parentHasHeight = true; + elseif ~parentHasHeight && ~categoryHasHeight + types.util.dynamictable.internal.initDynamicTableId(categoryTable, 0); + types.util.dynamictable.internal.initDynamicTableId(obj, 0); + [categoryHeight, parentHeight] = deal(0); + [categoryHasHeight, parentHasHeight] = deal(true); + end + end + + function assertNoNestedAlignedDynamicTable(obj, categoryNames) + for iCategory = 1:numel(categoryNames) + categoryTable = obj.getCategoryTable(string(categoryNames{iCategory})); + mustNotBeAlignedDynamicTable(categoryTable) + end + end + + function assignCategoryTable(obj, categoryName, categoryTable) + arguments + obj (1,1) matnwb.neurodata.AlignedDynamicTableBase + categoryName (1,1) string + categoryTable (1,1) {matnwb.common.validation.mustBeDynamicTable} + end + + if obj.categoryExists(categoryName) + error('NWB:AlignedDynamicTable:AddCategory:CategoryExists', ... + 'Category `%s` already exists in the table.', categoryName) + end + + if obj.isSchemaDefinedCategory(categoryName) + obj.(categoryName) = categoryTable; + else + obj.dynamictable.set( ... + categoryName, categoryTable, ... + FailIfKeyExists=true, ... + FailOnInvalidType=true); + end + end + + function registerCategoryName(obj, categoryName) + categoryNames = obj.validateCategoryNames(obj.categories); + if isempty(categoryNames) + obj.categories = {char(categoryName)}; + return + end + + if ~any(strcmp(categoryNames, categoryName)) + categoryNames{end+1} = char(categoryName); + obj.categories = categoryNames; + end + end + end + + methods (Static, Access = private) + function assertUniqueCategoryNames(categoryNames) + uniqueNames = unique(categoryNames, 'stable'); + hasDuplicateNames = numel(uniqueNames) ~= numel(categoryNames); + + assert(~hasDuplicateNames, ... + 'NWB:AlignedDynamicTable:AddCategory:DuplicateInputNames', ... + 'Each category name can only be specified once.') + end + + function assertCategoryHeightMatchesParent(categoryName, categoryHeight, parentHeight) + if categoryHeight ~= parentHeight + error('NWB:AlignedDynamicTable:AddCategory:MissingRows', ... + 'Category `%s` has detected height %d, but the parent table height is %d.', ... + categoryName, categoryHeight, parentHeight) + end + end + + function handleCategoryNamesMismatch(message, varargin) + matnwb.common.validation.reportSchemaViolation(... + 'NWB:AlignedDynamicTable:ValidateAlignedTableConsistency:CategoryNamesMismatch', ... + sprintf(message, varargin{:}), ... + "WarnInsteadOfError", true) + end + end +end + +function validateUniqueCategoryNames(categories) + uniqueNames = unique(categories, 'stable'); + hasDuplicateNames = numel(uniqueNames) ~= numel(categories); + if ~hasDuplicateNames + return + end + + isDuplicateName = cellfun(@(name) sum(strcmp(categories, name)) > 1, uniqueNames); + duplicateNames = uniqueNames(isDuplicateName); + duplicateNameLabels = strcat('`', duplicateNames, '`'); + duplicateNamesText = strjoin(duplicateNameLabels, ', '); + + if isscalar(duplicateNames) + categoryLabel = 'name'; + else + categoryLabel = 'names'; + end + + message = sprintf( ... + 'Category names in `categories` must be unique. Duplicate category %s: %s.', ... + categoryLabel, duplicateNamesText); + + matnwb.common.validation.reportSchemaViolation(... + 'NWB:AlignedDynamicTable:DuplicateCategoryNames', message) +end + +function mustNotBeAlignedDynamicTable(value) + assert(~isa(value, 'matnwb.neurodata.AlignedDynamicTableBase'), ... + 'NWB:AlignedDynamicTable:NestedAlignedTable', ... + ['Category tables of AlignedDynamicTable cannot themselves be ', ... + 'AlignedDynamicTable instances.']) +end diff --git a/+tests/+factory/DynamicTable.m b/+tests/+factory/DynamicTable.m new file mode 100644 index 000000000..f9eae36cd --- /dev/null +++ b/+tests/+factory/DynamicTable.m @@ -0,0 +1,35 @@ +function dynamicTable = DynamicTable(options) + + arguments + options.NumRows = 1 + options.NumColumns {mustBeLessThanOrEqual(options.NumColumns, 26)} = 1 + options.ColumnNames (1,:) string = missing + end + + if ~ismissing(options.ColumnNames) + assert(numel(options.ColumnNames) == options.NumColumns) + else + columnNames = strings(1, options.NumColumns); + for i = 1:options.NumColumns + columnNames(i) = sprintf("Column%s", char(i+64)); + end + end + + columnData = cell(1, options.NumColumns); + for i = 1:options.NumColumns + columnData{i} = types.hdmf_common.VectorData(... + 'description', sprintf('column #%d', i), ... + 'data', randi(10, [1, options.NumRows])); + end + + columnNvPairs = cat(1, cellstr(columnNames), columnData); + + idColumn = types.hdmf_common.ElementIdentifiers(... + 'data', (0:options.NumRows-1)' ); + + dynamicTable = types.hdmf_common.DynamicTable( ... + 'description', 'test table with columns and rows', ... + 'colnames', columnNames, ... + columnNvPairs{:}, ... + 'id', idColumn); +end diff --git a/+tests/+system/AlignedDynamicTableTest.m b/+tests/+system/AlignedDynamicTableTest.m new file mode 100644 index 000000000..b8fdb9877 --- /dev/null +++ b/+tests/+system/AlignedDynamicTableTest.m @@ -0,0 +1,316 @@ +classdef (SharedTestFixtures = {tests.fixtures.GenerateCoreFixture}) ... + AlignedDynamicTableTest < matlab.unittest.TestCase +% AlignedDynamicTableTest - System tests for generated AlignedDynamicTable classes. + + methods (Test) + function testAddCustomCategoryInitializesParentId(testCase) + alignedTable = tests.system.AlignedDynamicTableTest.createAlignedTable(); + categoryTable = tests.system.AlignedDynamicTableTest.createTableWithHeight(3); + + alignedTable.addCategory("custom", categoryTable) + + testCase.verifyTrue(alignedTable.dynamictable.isKey("custom")) + testCase.verifyEqual(alignedTable.categories, {'custom'}) + testCase.verifyEqual(alignedTable.id.data, int64((0:2)')) + end + + function testAddEmptyTableInitializesParentAndCategoryId(testCase) + alignedTable = tests.system.AlignedDynamicTableTest.createAlignedTable(); + categoryTable = tests.system.AlignedDynamicTableTest.createEmptyTable(); + + testCase.verifyEmpty(alignedTable.id) + testCase.verifyEmpty(categoryTable.id) + + alignedTable.addCategory("custom", categoryTable) + + testCase.verifyNotEmpty(alignedTable.id) + testCase.verifyNotEmpty(categoryTable.id) + + testCase.verifyEmpty(alignedTable.id.data) + testCase.verifyEmpty(categoryTable.id.data) + end + + function testAddCategoryInitializesEmptyIdDataPipe(testCase) + parent = types.hdmf_common.AlignedDynamicTable( ... + 'description', 'parent', ... + 'id', types.hdmf_common.ElementIdentifiers('data', int64((0:9)'))); + + idDataPipe = types.untyped.DataPipe( ... + 'maxSize', Inf, ... + 'dataType', 'int64'); + + category = types.hdmf_common.DynamicTable( ... + 'description', 'category'); + category.id = types.hdmf_common.ElementIdentifiers('data', idDataPipe); + + parent.addCategory("category", category) + + testCase.verifyEqual(idDataPipe.internal.data, int64((0:9)')) + types.util.dynamictable.checkConfig(category) + end + + function testGetCustomCategory(testCase) + alignedTable = tests.system.AlignedDynamicTableTest.createAlignedTable(); + categoryTable = tests.system.AlignedDynamicTableTest.createTableWithHeight(3); + + alignedTable.addCategory("custom", categoryTable) + + testCase.verifyTrue(alignedTable.getCategory("custom") == categoryTable) + end + + function testAddCustomCategoryRejectsExistingCategory(testCase) + alignedTable = tests.system.AlignedDynamicTableTest.createAlignedTable(); + categoryTable = tests.system.AlignedDynamicTableTest.createTableWithHeight(2); + replacementTable = tests.system.AlignedDynamicTableTest.createTableWithHeight(2); + alignedTable.addCategory("custom", categoryTable) + + testCase.verifyError( ... + @() alignedTable.addCategory("custom", replacementTable), ... + 'NWB:AlignedDynamicTable:AddCategory:CategoryExists') + end + + function testAddCategoryInitializesEmptyCategoryId(testCase) + alignedTable = tests.system.AlignedDynamicTableTest.createAlignedTableWithId( ... + int64((0:2)')); + categoryTable = tests.system.AlignedDynamicTableTest.createEmptyTable(); + + alignedTable.addCategory("custom", categoryTable) + + testCase.verifyEqual(categoryTable.id.data, int64((0:2)')) + end + + function testAddCategoryRejectsHeightMismatch(testCase) + alignedTable = tests.system.AlignedDynamicTableTest.createAlignedTableWithId( ... + int64((0:2)')); + categoryTable = tests.system.AlignedDynamicTableTest.createTableWithHeight(2); + + testCase.verifyError( ... + @() alignedTable.addCategory("custom", categoryTable), ... + 'NWB:AlignedDynamicTable:AddCategory:MissingRows') + end + + function testAddCategoryRejectsNestedAlignedTable(testCase) + alignedTable = tests.system.AlignedDynamicTableTest.createAlignedTable(); + nestedTable = tests.system.AlignedDynamicTableTest.createAlignedTable(); + + testCase.verifyError( ... + @() alignedTable.addCategory("nested", nestedTable), ... + 'NWB:AlignedDynamicTable:NestedAlignedTable') + end + + function testAddSchemaCategoryUsesNamedProperty(testCase) + alignedTable = tests.system.AlignedDynamicTableTest.createSchemaAlignedTable(); + categoryTable = tests.system.AlignedDynamicTableTest.createElectrodesTableWithHeight(2); + + alignedTable.addCategory("electrodes", categoryTable) + + testCase.verifyTrue(alignedTable.electrodes == categoryTable) + testCase.verifyFalse(alignedTable.dynamictable.isKey("electrodes")) + testCase.verifyEqual(alignedTable.categories, {'electrodes'}) + end + + function testGetSchemaCategory(testCase) + alignedTable = tests.system.AlignedDynamicTableTest.createSchemaAlignedTable(); + categoryTable = tests.system.AlignedDynamicTableTest.createElectrodesTableWithHeight(2); + + alignedTable.addCategory("electrodes", categoryTable) + + testCase.verifyTrue(alignedTable.getCategory("electrodes") == categoryTable) + end + + function testAddSchemaCategoryRejectsExistingCategory(testCase) + alignedTable = tests.system.AlignedDynamicTableTest.createSchemaAlignedTable(); + categoryTable = tests.system.AlignedDynamicTableTest.createElectrodesTableWithHeight(2); + replacementTable = tests.system.AlignedDynamicTableTest.createElectrodesTableWithHeight(2); + alignedTable.addCategory("electrodes", categoryTable) + + testCase.verifyError( ... + @() alignedTable.addCategory("electrodes", replacementTable), ... + 'NWB:AlignedDynamicTable:AddCategory:CategoryExists') + end + + function testGetCategoryRejectsMissingCategory(testCase) + alignedTable = tests.system.AlignedDynamicTableTest.createSchemaAlignedTableWithCategories( ... + {'electrodes'}); + + testCase.verifyError( ... + @() alignedTable.getCategory("electrodes"), ... + 'NWB:AlignedDynamicTable:CategoryNotFound') + end + + + function testGetCategoryRejectsMissingCustomCategory(testCase) + alignedTable = tests.system.AlignedDynamicTableTest.createSchemaAlignedTable(); + + testCase.verifyError( ... + @() alignedTable.getCategory("nonExistingCategory"), ... + 'NWB:AlignedDynamicTable:CategoryNotFound') + end + + function testMissingCategoryNameWarns(testCase) + alignedTable = tests.system.AlignedDynamicTableTest.createAlignedTable(); + categoryTable = tests.factory.DynamicTable('NumColumns', 1, 'NumRows', 1); + + % Add category (bypassing category registration) + alignedTable.dynamictable.set('category', categoryTable); + + testCase.verifyWarning(... + @alignedTable.ensureAlignedTableConsistency, ... + 'NWB:AlignedDynamicTable:ValidateAlignedTableConsistency:CategoryNamesMismatch' ... + ) + end + + function testDirectSchemaCategoryAssignmentSyncsCategories(testCase) + alignedTable = tests.system.AlignedDynamicTableTest.createSchemaAlignedTable(); + categoryTable = tests.system.AlignedDynamicTableTest.createElectrodesTableWithHeight(2); + + alignedTable.electrodes = categoryTable; + + testCase.verifyEqual(alignedTable.categories, {'electrodes'}) + end + + function testAddDuplicateCategoryNameFails(testCase) + alignedTable = tests.system.AlignedDynamicTableTest.createAlignedTable(); + categoryTable = tests.system.AlignedDynamicTableTest.createTableWithHeight(1); + + alignedTable.addCategory("custom", categoryTable) + + testCase.verifyError(... + @() assignCategoryNameToCategories(alignedTable, 'custom'), ... + 'NWB:AlignedDynamicTable:DuplicateCategoryNames') + + function assignCategoryNameToCategories(alignedTable, name) + alignedTable.categories{end+1} = name; + end + end + + function testConstructorAllowsSchemaCategoriesBeforeTables(testCase) + alignedTable = tests.system.AlignedDynamicTableTest.createSchemaAlignedTableWithCategories( ... + {'electrodes'}); + + testCase.verifyEqual(alignedTable.categories, {'electrodes'}) + testCase.verifyEmpty(alignedTable.electrodes) + + categoryTable = tests.system.AlignedDynamicTableTest.createElectrodesTableWithHeight(2); + alignedTable.addCategory("electrodes", categoryTable) + + testCase.verifyTrue(alignedTable.electrodes == categoryTable) + testCase.verifyEqual(alignedTable.categories, {'electrodes'}) + end + + function testDeclaredMissingCategoryWarnsWhenHeightExists(testCase) + testCase.verifyWarning( ... + @() tests.system.AlignedDynamicTableTest.createSchemaAlignedTable( ... + Categories={'electrodes'}, ... + IdData=int64((0:1)')), ... + 'NWB:AlignedDynamicTable:ValidateAlignedTableConsistency:CategoryNamesMismatch') + end + + function testValidateAlignedTableConsistencyDetectsUnregisteredCategory(testCase) + alignedTable = tests.system.AlignedDynamicTableTest.createAlignedTable(); + categoryTable = tests.system.AlignedDynamicTableTest.createTableWithHeight(2); + alignedTable.dynamictable.set("custom", categoryTable); + + testCase.verifyWarning( ... + @() alignedTable.ensureAlignedTableConsistency(), ... + 'NWB:AlignedDynamicTable:ValidateAlignedTableConsistency:CategoryNamesMismatch') + end + + function testValidateAlignedTableConsistencyRejectsNestedAlignedTable(testCase) + alignedTable = tests.system.AlignedDynamicTableTest.createAlignedTableWithCategories( ... + {'nested'}); + nestedTable = tests.system.AlignedDynamicTableTest.createAlignedTable(); + alignedTable.dynamictable.set("nested", nestedTable); + + testCase.verifyError( ... + @() alignedTable.ensureAlignedTableConsistency(), ... + 'NWB:AlignedDynamicTable:NestedAlignedTable') + end + + function testValidateAlignedTableConsistencyRejectsUnlistedNestedTable(testCase) + alignedTable = tests.system.AlignedDynamicTableTest.createAlignedTable(); + nestedTable = tests.system.AlignedDynamicTableTest.createAlignedTable(); + alignedTable.dynamictable.set("nested", nestedTable); + + testCase.verifyError( ... + @() alignedTable.ensureAlignedTableConsistency(), ... + 'NWB:AlignedDynamicTable:NestedAlignedTable') + end + + function testUnboundDataPipeIdHeightIsOffset(testCase) + idDataPipe = types.untyped.DataPipe( ... + 'maxSize', Inf, ... + 'axis', 1, ... + 'offset', 3, ... + 'dataType', 'int64'); + alignedTable = tests.system.AlignedDynamicTableTest.createAlignedTableWithId(idDataPipe); + categoryTable = tests.system.AlignedDynamicTableTest.createTableWithHeight(3); + + alignedTable.addCategory("custom", categoryTable) + + testCase.verifyEqual(alignedTable.categories, {'custom'}) + end + end + + methods (Static, Access = private) + function alignedTable = createAlignedTable() + alignedTable = types.hdmf_common.AlignedDynamicTable( ... + 'description', 'parent table'); + end + + function alignedTable = createAlignedTableWithId(idData) + alignedTable = types.hdmf_common.AlignedDynamicTable( ... + 'description', 'parent table', ... + 'id', tests.system.AlignedDynamicTableTest.createId(idData)); + end + + function alignedTable = createAlignedTableWithCategories(categories) + alignedTable = types.hdmf_common.AlignedDynamicTable( ... + 'description', 'parent table', ... + 'categories', categories); + end + + function alignedTable = createSchemaAlignedTable(options) + arguments + options.Categories = [] + options.IdData = [] + end + + constructorArguments = {}; + if ~isempty(options.Categories) + constructorArguments = [constructorArguments, {'categories', options.Categories}]; + end + if ~isempty(options.IdData) + constructorArguments = [constructorArguments, { ... + 'id', tests.system.AlignedDynamicTableTest.createId(options.IdData)}]; + end + + alignedTable = types.core.IntracellularRecordingsTable(constructorArguments{:}); + end + + function alignedTable = createSchemaAlignedTableWithCategories(categories) + alignedTable = tests.system.AlignedDynamicTableTest.createSchemaAlignedTable( ... + Categories=categories); + end + + function dynamicTable = createEmptyTable() + dynamicTable = types.hdmf_common.DynamicTable( ... + 'description', 'category table'); + end + + function dynamicTable = createTableWithHeight(tableHeight) + dynamicTable = types.hdmf_common.DynamicTable( ... + 'description', 'category table', ... + 'id', tests.system.AlignedDynamicTableTest.createId(int64((0:tableHeight-1)'))); + end + + function dynamicTable = createElectrodesTableWithHeight(tableHeight) + dynamicTable = types.core.IntracellularElectrodesTable( ... + 'id', tests.system.AlignedDynamicTableTest.createId(int64((0:tableHeight-1)'))); + end + + function id = createId(idData) + id = types.hdmf_common.ElementIdentifiers('data', idData); + end + end +end diff --git a/+tests/+unit/dynamicTableTest.m b/+tests/+unit/dynamicTableTest.m index 14a64dff3..cef258442 100644 --- a/+tests/+unit/dynamicTableTest.m +++ b/+tests/+unit/dynamicTableTest.m @@ -486,6 +486,48 @@ function testCheckConfigDetectsColumnsMissingFromColnames(testCase) 'NWB:DynamicTable:CheckConfig:ColumnNamesMismatch'); end + function testGetTableHeightReportsUnestablishedEmptyTable(testCase) + dynamicTable = types.hdmf_common.DynamicTable( ... + 'description', 'empty table'); + + [tableHeight, hasEstablishedHeight] = ... + types.util.dynamictable.internal.getTableHeight(dynamicTable); + + testCase.verifyEqual(tableHeight, 0) + testCase.verifyFalse(hasEstablishedHeight) + end + + function testUnboundDataPipeOffsetContributesToColumnHeight(testCase) + idDataPipe = types.untyped.DataPipe( ... + 'maxSize', Inf, ... + 'axis', 1, ... + 'offset', 3, ... + 'dataType', 'int64'); + idColumn = types.hdmf_common.ElementIdentifiers( ... + 'data', idDataPipe); + dynamicTable = types.hdmf_common.DynamicTable( ... + 'description', 'table with unbound id DataPipe'); + dynamicTable.id = idColumn; + + columnHeight = types.util.dynamictable.internal.getColumnHeight(idColumn); + [tableHeight, hasEstablishedHeight] = ... + types.util.dynamictable.internal.getTableHeight(dynamicTable); + + testCase.verifyEqual(columnHeight, 3) + testCase.verifyEqual(tableHeight, 3) + testCase.verifyTrue(hasEstablishedHeight) + end + + function testInitDynamicTableIdFillsExistingEmptyId(testCase) + dynamicTable = types.hdmf_common.DynamicTable( ... + 'description', 'table with empty id dataset'); + dynamicTable.id = types.hdmf_common.ElementIdentifiers('data', []); + + types.util.dynamictable.internal.initDynamicTableId(dynamicTable, 3); + + testCase.verifyEqual(dynamicTable.id.data, int64((0:2)')) + end + function testExportDetectsColumnsMissingFromColnames(testCase) fileName = testCase.getRandomFilename(); nwb = tests.factory.NWBFile(); diff --git a/+types/+core/IntracellularRecordingsTable.m b/+types/+core/IntracellularRecordingsTable.m index 5c234be4f..8a1be0dc5 100644 --- a/+types/+core/IntracellularRecordingsTable.m +++ b/+types/+core/IntracellularRecordingsTable.m @@ -12,6 +12,10 @@ stimuli; % REQUIRED (IntracellularStimuliTable) Table for storing intracellular stimulus related metadata. end +properties (Constant, Access = private) + DeclaredSchemaCategories = ["electrodes", "responses", "stimuli"]; +end + methods function obj = IntracellularRecordingsTable(varargin) % INTRACELLULARRECORDINGSTABLE - Constructor for IntracellularRecordingsTable @@ -64,18 +68,30 @@ if strcmp(class(obj), 'types.core.IntracellularRecordingsTable') %#ok cellStringArguments = convertContainedStringsToChars(varargin(1:2:end)); types.util.checkUnset(obj, unique(cellStringArguments)); - obj.ensureDynamicTableConsistency(); + obj.ensureAlignedTableConsistency(); end end %% SETTERS function set.electrodes(obj, val) obj.electrodes = obj.validate_electrodes(val); + obj.postset_electrodes() + end + function postset_electrodes(obj) + obj.ensureCategoryNameRegistered('electrodes'); end function set.responses(obj, val) obj.responses = obj.validate_responses(val); + obj.postset_responses() + end + function postset_responses(obj) + obj.ensureCategoryNameRegistered('responses'); end function set.stimuli(obj, val) obj.stimuli = obj.validate_stimuli(val); + obj.postset_stimuli() + end + function postset_stimuli(obj) + obj.ensureCategoryNameRegistered('stimuli'); end %% VALIDATORS diff --git a/+types/+hdmf_common/AlignedDynamicTable.m b/+types/+hdmf_common/AlignedDynamicTable.m index 689f0197a..ef68dde43 100644 --- a/+types/+hdmf_common/AlignedDynamicTable.m +++ b/+types/+hdmf_common/AlignedDynamicTable.m @@ -1,4 +1,4 @@ -classdef AlignedDynamicTable < types.hdmf_common.DynamicTable & types.untyped.GroupClass & matnwb.mixin.HasUnnamedGroups +classdef AlignedDynamicTable < types.hdmf_common.DynamicTable & types.untyped.GroupClass & matnwb.mixin.HasUnnamedGroups & matnwb.neurodata.AlignedDynamicTableBase % ALIGNEDDYNAMICTABLE - DynamicTable container that supports storing a collection of sub-tables. Each sub-table is a DynamicTable itself that is aligned with the main table by row index. I.e., all DynamicTables stored in this group MUST have the same number of rows. This type effectively defines a 2-level table in which the main data is stored in the main table implemented by this type and additional columns of the table are grouped into categories, with each category being represented by a separate DynamicTable stored within the group. % % Required Properties: @@ -17,6 +17,10 @@ GroupPropertyNames = {'dynamictable'} end +properties (Constant, Access = private) + DeclaredSchemaCategories = string.empty(1, 0); +end + methods function obj = AlignedDynamicTable(varargin) % ALIGNEDDYNAMICTABLE - Constructor for AlignedDynamicTable @@ -62,7 +66,7 @@ cellStringArguments = convertContainedStringsToChars(varargin(1:2:end)); types.util.checkUnset(obj, unique(cellStringArguments)); obj.setupHasUnnamedGroupsMixin(); - obj.ensureDynamicTableConsistency(); + obj.ensureAlignedTableConsistency(); end end %% SETTERS @@ -77,6 +81,7 @@ function val = validate_categories(obj, val) val = types.util.checkDtype('categories', 'char', val); types.util.validateShape('categories', {[Inf]}, val) + val = obj.validateCategoryNames(val); end function val = validate_dynamictable(obj, val) namedprops = struct(); @@ -94,6 +99,11 @@ refs = obj.dynamictable.export(writer, fullpath, refs); end end + %% CUSTOM CONSTRAINTS + function checkCustomConstraint(obj) + checkCustomConstraint@types.untyped.MetaClass(obj) + obj.ensureAlignedTableConsistency() + end end end \ No newline at end of file diff --git a/+types/+util/+dynamictable/+internal/getColumnHeight.m b/+types/+util/+dynamictable/+internal/getColumnHeight.m index 68bf0b7ae..cac0fd482 100644 --- a/+types/+util/+dynamictable/+internal/getColumnHeight.m +++ b/+types/+util/+dynamictable/+internal/getColumnHeight.m @@ -1,4 +1,4 @@ -function columnHeight = getColumnHeight(column) +function [columnHeight, hasEstablishedHeight] = getColumnHeight(column) % getColumnHeight - Return the stored height of a DynamicTable column object. % % This helper inspects the underlying stored data shape only. It does not @@ -6,14 +6,16 @@ if isempty(column) columnHeight = 0; + hasEstablishedHeight = false; else - columnHeight = getDataHeight(column.data); + [columnHeight, hasEstablishedHeight] = getDataHeight(column.data); end end -function columnHeight = getDataHeight(data) +function [columnHeight, hasEstablishedHeight] = getDataHeight(data) if isempty(data) columnHeight = 0; + hasEstablishedHeight = false; elseif isa(data, 'types.untyped.DataPipe') if data.isBound % Bound DataPipes can have an ambiguous inferred axis when a dataset @@ -21,31 +23,52 @@ % instead, where DynamicTable row dimension maps to the last axis. dataDims = size(data); columnHeight = dataDims(end); - elseif isempty(data.internal.data) - columnHeight = 0; - elseif ~isscalar(data.internal.data) && isvector(data.internal.data) - columnHeight = length(data.internal.data); % datapipe axis can be misleading if vector. + hasEstablishedHeight = true; else - columnHeight = size(data.internal.data, data.axis); + [columnHeight, hasEstablishedHeight] = getUnboundDataPipeHeight(data); end elseif isa(data, 'types.untyped.DataStub') columnHeight = data.dims(end); + hasEstablishedHeight = true; elseif isscalar(data) && isstruct(data) % compound type (struct) dataFieldNames = fieldnames(data); if isempty(dataFieldNames) columnHeight = 0; + hasEstablishedHeight = false; else columnHeight = zeros(size(dataFieldNames)); + hasEstablishedHeight = false(size(dataFieldNames)); for iField = 1:length(dataFieldNames) field = dataFieldNames{iField}; - columnHeight(iField) = getDataHeight(data.(field)); + [columnHeight(iField), hasEstablishedHeight(iField)] = getDataHeight(data.(field)); end end elseif istable(data) % compound type (table) columnHeight = height(data); + hasEstablishedHeight = true; elseif isscalar(data) || ~isvector(data) columnHeight = size(data, ndims(data)); + hasEstablishedHeight = true; else columnHeight = size(data, find(1 < size(data))); + hasEstablishedHeight = true; + end +end + +function [columnHeight, hasEstablishedHeight] = getUnboundDataPipeHeight(dataPipe) + dataHeight = getQueuedDataHeight(dataPipe); + columnHeight = dataPipe.offset + dataHeight; + hasEstablishedHeight = columnHeight > 0; +end + +function dataHeight = getQueuedDataHeight(dataPipe) + if isempty(dataPipe.internal.data) + dataHeight = 0; + elseif ~isscalar(dataPipe.internal.data) && isvector(dataPipe.internal.data) + % DataPipe axis can be misleading for vectors because vectors are + % coerced to vertical arrays when bound to file. + dataHeight = length(dataPipe.internal.data); + else + dataHeight = size(dataPipe.internal.data, dataPipe.axis); end end diff --git a/+types/+util/+dynamictable/+internal/getColumnRowHeight.m b/+types/+util/+dynamictable/+internal/getColumnRowHeight.m index 2f6adda52..06238fd5a 100644 --- a/+types/+util/+dynamictable/+internal/getColumnRowHeight.m +++ b/+types/+util/+dynamictable/+internal/getColumnRowHeight.m @@ -1,4 +1,4 @@ -function [columnRowHeight, resolvedColumnName] = getColumnRowHeight(dynamicTable, columnName) +function [columnRowHeight, resolvedColumnName, hasEstablishedHeight] = getColumnRowHeight(dynamicTable, columnName) % getColumnRowHeight - Return the row height for a DynamicTable column. % % For ragged columns, this follows VectorIndex links to the outermost index @@ -13,7 +13,9 @@ resolvedColumnName = types.util.dynamictable.internal.getOutermostIndexColumnName( ... dynamicTable, columnName); vector = getVector(dynamicTable, resolvedColumnName); - columnRowHeight = types.util.dynamictable.internal.getColumnHeight(vector); + [columnRowHeight, hasEstablishedHeight] = ... + types.util.dynamictable.internal.getColumnHeight(vector); + hasEstablishedHeight = any(hasEstablishedHeight) || ~isempty(vector); end function vector = getVector(dynamicTable, columnName) diff --git a/+types/+util/+dynamictable/+internal/getTableHeight.m b/+types/+util/+dynamictable/+internal/getTableHeight.m index caf0e1a04..18f4ce8c3 100644 --- a/+types/+util/+dynamictable/+internal/getTableHeight.m +++ b/+types/+util/+dynamictable/+internal/getTableHeight.m @@ -1,27 +1,34 @@ -function tableHeight = getTableHeight(dynamicTable) +function [tableHeight, hasEstablishedHeight] = getTableHeight(dynamicTable) % getTableHeight - Return the inferred row height of a DynamicTable. % % This helper returns the effective table height. It differs from % `getColumnHeight`, which only inspects the stored height of a single -% column object. +% column object. A table has an established height when it has id data, a +% bound or offset id DataPipe, or at least one data column. A table with no +% id data and no columns returns height 0 with hasEstablishedHeight=false. arguments dynamicTable end if ~isempty(dynamicTable.id) - tableHeight = types.util.dynamictable.internal.getColumnHeight(dynamicTable.id); - return; + [tableHeight, hasEstablishedHeight] = ... + types.util.dynamictable.internal.getColumnHeight(dynamicTable.id); + if hasEstablishedHeight + return + end end if isempty(dynamicTable.colnames) tableHeight = 0; + hasEstablishedHeight = false; return; end - tableHeight = types.util.dynamictable.internal.getColumnRowHeight( ... + [tableHeight, ~, hasEstablishedHeight] = types.util.dynamictable.internal.getColumnRowHeight( ... dynamicTable, dynamicTable.colnames{1}); tableHeight = unique(tableHeight); + hasEstablishedHeight = any(hasEstablishedHeight); assert(isscalar(tableHeight), ... 'NWB:DynamicTable:GetRow:InvalidShape', ... diff --git a/+types/+util/+dynamictable/+internal/initDynamicTableId.m b/+types/+util/+dynamictable/+internal/initDynamicTableId.m index 798cda8f9..060075608 100644 --- a/+types/+util/+dynamictable/+internal/initDynamicTableId.m +++ b/+types/+util/+dynamictable/+internal/initDynamicTableId.m @@ -10,15 +10,29 @@ function initDynamicTableId(dynamicTable, tableHeight) tableHeight = [] end - if ~isempty(tableHeight) - idData = int64(1:tableHeight) .' - 1; - else + if isempty(tableHeight) idData = []; + else + idData = int64(0:tableHeight-1).'; end - - if exist('types.hdmf_common.ElementIdentifiers', 'class') == 8 - dynamicTable.id = types.hdmf_common.ElementIdentifiers('data', idData); - else % legacy ElementIdentifiers - dynamicTable.id = types.core.ElementIdentifiers('data', idData); + + if isempty(dynamicTable.id) + if exist('types.hdmf_common.ElementIdentifiers', 'class') == 8 + dynamicTable.id = types.hdmf_common.ElementIdentifiers('data', idData); + else % legacy ElementIdentifiers + dynamicTable.id = types.core.ElementIdentifiers('data', idData); + end + elseif isempty(dynamicTable.id.data) + dynamicTable.id.data = idData; + elseif isa(dynamicTable.id.data, 'types.untyped.DataPipe') && ~dynamicTable.id.data.isBound + idDataPipe = dynamicTable.id.data; + assert(idDataPipe.offset == 0 && isempty(idDataPipe.internal.data), ... + 'NWB:DynamicTable:CannotInitializeId', ... + ['Cannot initialize ids for table `%s` because its id DataPipe ', ... + 'already has queued data or a nonzero offset.'], class(dynamicTable)) + + if ~isempty(idData) + idDataPipe.append(idData); + end end end diff --git a/docs/source/_static/html/tutorials/icephys.html b/docs/source/_static/html/tutorials/icephys.html index ae9c91794..5b586cfda 100644 --- a/docs/source/_static/html/tutorials/icephys.html +++ b/docs/source/_static/html/tutorials/icephys.html @@ -1,17 +1,17 @@ -Intracellular electrophysiology +Intracellular electrophysiology