From c04d7a4d5d78569dc6a04ab0117444e2eca4a603 Mon Sep 17 00:00:00 2001 From: ehennestad Date: Wed, 1 Jul 2026 16:50:56 +0200 Subject: [PATCH 1/9] Add AlignedDynamicTableBase class extending DynamicTableBase Introduce matnwb.neurodata.AlignedDynamicTableBase, the non-generated base class that owns aligned-table behavior the generated schema class cannot express: category table registration and lookup, category-name validation, and row-height consistency between the parent table and its category tables. It extends DynamicTableBase so aligned tables inherit the shared DynamicTable behavior. Add matnwb.neurodata.internal.collectConstantPropertiesAcrossHierarchy, used to gather schema-declared categories across the class hierarchy. Co-Authored-By: Claude Opus 4.8 --- ...collectConstantPropertiesAcrossHierarchy.m | 57 +++ +matnwb/+neurodata/AlignedDynamicTableBase.m | 410 ++++++++++++++++++ 2 files changed, 467 insertions(+) create mode 100644 +matnwb/+neurodata/+internal/collectConstantPropertiesAcrossHierarchy.m create mode 100644 +matnwb/+neurodata/AlignedDynamicTableBase.m diff --git a/+matnwb/+neurodata/+internal/collectConstantPropertiesAcrossHierarchy.m b/+matnwb/+neurodata/+internal/collectConstantPropertiesAcrossHierarchy.m new file mode 100644 index 00000000..2d6e6537 --- /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 00000000..290295c1 --- /dev/null +++ b/+matnwb/+neurodata/AlignedDynamicTableBase.m @@ -0,0 +1,410 @@ +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 + obj.handleCategoryNamesMismatch( ... + ['The `categories` property lists category table(s) that have not ', ... + 'been added to the AlignedDynamicTable: %s.\nAdd the missing ', ... + 'table(s) with the addCategory method (or by setting the ', ... + 'corresponding schema category property), or list categories only ', ... + 'before the table has rows.'], ... + 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 From c2cdda7735c452df3ee1a85c9697d3434b991a2f Mon Sep 17 00:00:00 2001 From: ehennestad Date: Wed, 1 Jul 2026 16:52:03 +0200 Subject: [PATCH 2/9] Refine internal dynamic table height and id helpers getTableHeight now also reports whether the table height is established, getColumnHeight accounts for unbound DataPipe offsets, and initDynamicTableId fills an existing but empty id column. These helpers back the row-height consistency checks used by both DynamicTable and AlignedDynamicTable. Co-Authored-By: Claude Opus 4.8 --- .../+dynamictable/+internal/getColumnHeight.m | 41 +++++++++++++++---- .../+internal/getColumnRowHeight.m | 6 ++- .../+dynamictable/+internal/getTableHeight.m | 17 +++++--- .../+internal/initDynamicTableId.m | 30 ++++++++++---- 4 files changed, 70 insertions(+), 24 deletions(-) diff --git a/+types/+util/+dynamictable/+internal/getColumnHeight.m b/+types/+util/+dynamictable/+internal/getColumnHeight.m index 68bf0b7a..cac0fd48 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 2f6adda5..06238fd5 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 caf0e1a0..18f4ce8c 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 798cda8f..06007560 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 From f7ceb6aa28b625ea364a7a55ab96cec76d430ca7 Mon Sep 17 00:00:00 2001 From: ehennestad Date: Wed, 1 Jul 2026 16:52:03 +0200 Subject: [PATCH 3/9] Generate AlignedDynamicTable with its custom base class Attach matnwb.neurodata.AlignedDynamicTableBase to AlignedDynamicTable via the customBaseClasses map and emit its schema-declared category constant block. Route aligned tables through obj.ensureAlignedTableConsistency() in the constructor and custom-constraint hooks, and register schema-defined category properties with post-set hooks. Replace file.isDynamicTableDescendant with the more general file.internal.isDescendantOf. Co-Authored-By: Claude Opus 4.8 --- +file/+internal/isDescendantOf.m | 18 ++++++++++++ +file/fillClass.m | 49 ++++++++++++++++++++++++++++++-- +file/fillConstructor.m | 4 ++- +file/fillCustomConstraint.m | 7 +++++ +file/getPropertyHooks.m | 21 +++++++++++++- +file/isDynamicTableDescendant.m | 18 ------------ 6 files changed, 95 insertions(+), 22 deletions(-) create mode 100644 +file/+internal/isDescendantOf.m delete mode 100644 +file/isDynamicTableDescendant.m diff --git a/+file/+internal/isDescendantOf.m b/+file/+internal/isDescendantOf.m new file mode 100644 index 00000000..4c6d40a6 --- /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 19320c44..127bb6ec 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 @@ -182,8 +183,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 @@ -235,3 +242,41 @@ sprintf(' GroupPropertyNames = {%s}', strjoin(strcat('''', anonNames, ''''), ', ') ), ... '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 diff --git a/+file/fillConstructor.m b/+file/fillConstructor.m index aa1e1dce..df1e33d3 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 8072a9a6..71d23776 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 534eddd6..0ca72a8d 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 3625344a..00000000 --- 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 From 409ee0c2e0a8cddfae78f3e7fbc2b546c93180fc Mon Sep 17 00:00:00 2001 From: ehennestad Date: Wed, 1 Jul 2026 16:52:25 +0200 Subject: [PATCH 4/9] Regenerate types against AlignedDynamicTableBase AlignedDynamicTable inherits matnwb.neurodata.AlignedDynamicTableBase and gains its DeclaredSchemaCategories constant and category post-set hooks; IntracellularRecordingsTable, an AlignedDynamicTable descendant, is regenerated accordingly. Co-Authored-By: Claude Opus 4.8 --- +types/+core/IntracellularRecordingsTable.m | 18 +++++++++++++++++- +types/+hdmf_common/AlignedDynamicTable.m | 14 ++++++++++++-- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/+types/+core/IntracellularRecordingsTable.m b/+types/+core/IntracellularRecordingsTable.m index 3a4a6ed1..f2ba7b97 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 @@ -62,18 +66,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 63599201..2c82e02d 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 @@ -60,7 +64,7 @@ cellStringArguments = convertContainedStringsToChars(varargin(1:2:end)); types.util.checkUnset(obj, unique(cellStringArguments)); obj.setupHasUnnamedGroupsMixin(); - obj.ensureDynamicTableConsistency(); + obj.ensureAlignedTableConsistency(); end end %% SETTERS @@ -75,6 +79,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(); @@ -92,6 +97,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 From 9b7358ae3e44018e6501f154bf62cf8692ad33cb Mon Sep 17 00:00:00 2001 From: ehennestad Date: Wed, 1 Jul 2026 16:52:25 +0200 Subject: [PATCH 5/9] Add AlignedDynamicTable tests Add system tests for AlignedDynamicTable category construction and a DynamicTable test factory double, plus unit coverage for the refined height and id helpers. Co-Authored-By: Claude Opus 4.8 --- +tests/+factory/DynamicTable.m | 35 +++ +tests/+system/AlignedDynamicTableTest.m | 316 +++++++++++++++++++++++ +tests/+unit/dynamicTableTest.m | 42 +++ 3 files changed, 393 insertions(+) create mode 100644 +tests/+factory/DynamicTable.m create mode 100644 +tests/+system/AlignedDynamicTableTest.m diff --git a/+tests/+factory/DynamicTable.m b/+tests/+factory/DynamicTable.m new file mode 100644 index 00000000..f9eae36c --- /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 00000000..b8fdb987 --- /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 14a64dff..cef25844 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(); From 54a43a3b64a55c8790fc697ce293cf173b6b595a Mon Sep 17 00:00:00 2001 From: ehennestad Date: Wed, 1 Jul 2026 17:03:51 +0200 Subject: [PATCH 6/9] Improve warning message in AlignedDynamicTable validator --- +matnwb/+neurodata/AlignedDynamicTableBase.m | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/+matnwb/+neurodata/AlignedDynamicTableBase.m b/+matnwb/+neurodata/AlignedDynamicTableBase.m index 290295c1..31b82f64 100644 --- a/+matnwb/+neurodata/AlignedDynamicTableBase.m +++ b/+matnwb/+neurodata/AlignedDynamicTableBase.m @@ -141,13 +141,15 @@ function ensureAlignedTableConsistency(obj) 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 AlignedDynamicTable: %s.\nAdd the missing ', ... - 'table(s) with the addCategory method (or by setting the ', ... - 'corresponding schema category property), or list categories only ', ... - 'before the table has rows.'], ... - strjoin(unmaterializedCategoryNames, ', ')); + 'been added to the %s: %s.\n', ... + 'The table already has %d rows, and all category tables ', ... + 'must have the same height as the parent table. ', ... + 'Add the missing table(s) with the addCategory method, ', ... + 'or list categories only before the table has rows.'], ... + classShortName, strjoin(unmaterializedCategoryNames, ', '), parentHeight); end assert(isempty(categoryHeights) || all(categoryHeights == parentHeight), ... From e5b48fd70e8b093f5a0b2b57036c3b04a813a259 Mon Sep 17 00:00:00 2001 From: ehennestad Date: Wed, 1 Jul 2026 17:09:53 +0200 Subject: [PATCH 7/9] Update icephys tutorial Use addCategory to add custom category table --- .../_static/html/tutorials/icephys.html | 24 ++++++++---------- tutorials/icephys.mlx | Bin 708877 -> 708888 bytes tutorials/private/mcode/icephys.m | 4 +-- 3 files changed, 11 insertions(+), 17 deletions(-) diff --git a/docs/source/_static/html/tutorials/icephys.html b/docs/source/_static/html/tutorials/icephys.html index ae9c9179..5b586cfd 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