Skip to content

fix(io): write compound datasets that have no rows - #876

Open
ehennestad wants to merge 4 commits into
mainfrom
fix-empty-compound-dataset-write
Open

fix(io): write compound datasets that have no rows#876
ehennestad wants to merge 4 commits into
mainfrom
fix-empty-compound-dataset-write

Conversation

@ehennestad

@ehennestad ehennestad commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Motivation

Background — This surfaced while adding a user-facing API for HERD, the type that records which external entities the terms in a file refer to. A HERD holds six compound datasets that the schema all marks required, so a file whose HERD has no external references yet holds six empty ones. That is a state a file legitimately reaches: asking a file for its external resources attaches a HERD before anything has been added to it, and PyNWB writes an empty HERD for the same sequence. The cross-reference columns of those tables are uint32, which is how the mistyping below surfaced. Writing and reading such a dataset were both affected; this PR is the write half, and #880 is the read half.

Problem — A compound dataset with no rows could not be written correctly. Every column was written as a string on disk regardless of what it held, so an integer column read back as text, and the same column changed member type depending on whether the table happened to be empty. A compound dataset holding a table with no rows also counted as a missing required property, so a file containing one could not be exported at all.

Separately, data carrying no fields at all reported an HDF5 error from several call layers down rather than saying what was wrong.

Solution — Take the row count from the input, and read the columns of a zero-row table directly so each keeps its own type. Treat a zero-row table as a value rather than an omission when checking required properties, and name the no-fields case in its own error.

What changed

  • A compound dataset written from a table with no rows gives each column its own member type, the same type that column gets when the table has rows.
  • A type whose required dataset holds a table with no rows exports instead of erroring.
  • Data with no fields raises NWB:WriteCompound:NoFields instead of MATLAB:imagesci:hdf5lib:libraryError / "H5Tcreate size must be positive". A field-less struct, a column-less table and an empty containers.Map all report it.

Text columns are unaffected: a text column is written as a variable length string whether or not the table has rows. This covers the write path only. Reading a zero-row compound dataset back returns an empty array without member names, which #880 fixes.

Implementation notes

The row count used to be read back off the normalized struct, which cannot express it: a scalar struct is written either as a single row of values or as one column per field, and the two were told apart by checking whether the first field held text. Normalizing a zero-row table into that same shape gave a scalar struct a third meaning. It is now taken from the input, and each input kind is normalized in its own branch, so the text check applies only to a scalar struct or containers.Map — the one input that really is ambiguous.

table2struct returns a 0x1 struct array for a zero-row table, and the per-column classes cannot be recovered from it, so the columns are read off the table directly. Numeric and logical columns keep their class; every other column is carried as an empty cellstr, which is the member type a text column already gets. Classes that cannot be typed from an empty value, such as object references, therefore also write as strings.

types.untyped.MetaClass/checkRequiredProps used a bare isempty test, which is true for a table with no rows. It now excludes tables, so an explicitly typed empty table counts as a value.

Examples

Member types of a zero-row compound dataset

fid = H5F.create('probe.h5');
emptyTable = table(uint32.empty(0, 1), cell(0, 1), ...
    'VariableNames', {'files_idx', 'object_id'});
io.writeCompound(fid, '/empty', emptyTable, 'forceArray');
H5F.close(fid);

info = h5info('probe.h5', '/empty');
for iMember = 1:numel(info.Datatype.Type.Member)
    member = info.Datatype.Type.Member(iMember);
    fprintf('%-12s %s\n', member.Name, member.Datatype.Class);
end

Before

files_idx    H5T_STRING
object_id    H5T_STRING

After

files_idx    H5T_INTEGER
object_id    H5T_STRING

The same column with rows already wrote as H5T_INTEGER, so the member type no longer depends on whether the table is empty.

Exporting a type whose compound datasets hold no rows

Building a types.hdmf_common.HERD whose six tables are all empty, assigning it to nwb.general_external_resources, then calling nwbExport(nwb, 'empty_herd.nwb'):

Before

Error using nwbExport
The following required properties are missing for instance for type "types.hdmf_common.Data" at file location "/general/external_resources/entities":
    data

After

nwbExport succeeded

Writing data that has no fields

fid = H5F.create('probe.h5');
io.writeCompound(fid, '/no_fields', struct);

Before

Error using matlab.internal.sci.hdf5lib2
The HDF5 library encountered an error and produced the following stack trace information:

 H5Tcreate    size must be positive

After

Cannot write the compound dataset "/no_fields" because the data has no fields, and a compound
type needs at least one member. Provide a table with at least one column, a struct with at least
one field, or a containers.Map with at least one key.

How to test

workingFolder = tempname; mkdir(workingFolder); cd(workingFolder);

% Member types survive for a table with no rows
fid = H5F.create('probe.h5');
emptyTable = table(uint32.empty(0, 1), cell(0, 1), ...
    'VariableNames', {'files_idx', 'object_id'});
io.writeCompound(fid, '/empty', emptyTable, 'forceArray');
H5F.close(fid);
info = h5info('probe.h5', '/empty');
disp({info.Datatype.Type.Member.Name})
disp(cellfun(@(x) x.Class, {info.Datatype.Type.Member.Datatype}, 'UniformOutput', false))

% Data with no fields names the problem
fid2 = H5F.create('nofields.h5');
try
    io.writeCompound(fid2, '/no_fields', struct);
catch ME
    disp(ME.identifier)
end
H5F.close(fid2);

% And a zero-row compound dataset can be exported
nwb = NwbFile('session_description', 'demo', 'identifier', 'DEMO', ...
    'session_start_time', '2018-12-02T12:57:27.371444-08:00');
herd = types.hdmf_common.HERD();
herd.keys = types.hdmf_common.Data('data', table(cell(0,1), 'VariableNames', {'key'}));
herd.files = types.hdmf_common.Data('data', table(cell(0,1), 'VariableNames', {'file_object_id'}));
herd.entities = types.hdmf_common.Data('data', table(cell(0,1), cell(0,1), ...
    'VariableNames', {'entity_id', 'entity_uri'}));
herd.objects = types.hdmf_common.Data('data', table(uint32.empty(0,1), cell(0,1), cell(0,1), ...
    cell(0,1), cell(0,1), 'VariableNames', ...
    {'files_idx', 'object_id', 'object_type', 'relative_path', 'field'}));
herd.object_keys = types.hdmf_common.Data('data', table(uint32.empty(0,1), uint32.empty(0,1), ...
    'VariableNames', {'objects_idx', 'keys_idx'}));
herd.entity_keys = types.hdmf_common.Data('data', table(uint32.empty(0,1), uint32.empty(0,1), ...
    'VariableNames', {'entities_idx', 'keys_idx'}));
nwb.general_external_resources = herd;
nwbExport(nwb, 'empty_herd.nwb');

Unit test:

nwbtest('Name', 'tests.unit.io.WriteTest')

Checklist

  • Have you ensured the PR description clearly describes the problem and solutions?
  • Have you checked to ensure that there aren't other open or previously closed Pull Requests for the same change?
  • If this PR fixes an issue, is the first line of the PR description fix #XX where XX is the issue number?

🤖 Generated with Claude Code

@codecov

codecov Bot commented Aug 26, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.33%. Comparing base (44aa826) to head (46cf771).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #876      +/-   ##
==========================================
+ Coverage   95.31%   95.33%   +0.01%     
==========================================
  Files         234      234              
  Lines        8329     8352      +23     
==========================================
+ Hits         7939     7962      +23     
  Misses        390      390              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ehennestad
ehennestad force-pushed the fix-empty-compound-dataset-write branch from 0d9ca4c to 0682abd Compare August 27, 2026 09:02
@ehennestad ehennestad changed the title fix(io): keep column types and allow export when a compound dataset has no rows fix(io): write compound datasets that have no rows Aug 27, 2026
@ehennestad
ehennestad requested a review from bendichter August 27, 2026 09:42
@ehennestad
ehennestad force-pushed the fix-empty-compound-dataset-write branch 2 times, most recently from 0956f02 to 5ce3703 Compare August 31, 2026 14:25
ehennestad and others added 4 commits September 1, 2026 13:40
A compound dataset with no rows could not be written correctly. Every
column was written as a string on disk regardless of what it held, so an
integer column read back as text, and the same column changed member type
depending on whether the table happened to be empty. A compound dataset
holding a table with no rows also counted as a missing required property,
so a file containing one could not be exported at all.

Take the row count from the input, which knows it, rather than reading it
back off the normalized struct, where a scalar struct means either a
single row of values or one column per field. Read the columns of a
zero-row table directly so numeric and logical columns keep their class,
and carry every other column as an empty cellstr so it keeps the variable
length string member type a text column already gets. Treat a zero-row
table as a value rather than an omission when checking required
properties.

Raise NWB:WriteCompound:NoFields when the data carries no fields at all.
A compound type needs at least one member, so HDF5 refused with
"H5Tcreate size must be positive" from several call layers down. All
three input kinds are normalized before the check, so a field-less
struct, a column-less table and an empty containers.Map report the same
error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Assert that a zero-row table gives each column its own member type, and
that every column class writes without erroring. A member type can only
be derived from an empty column for some classes, and each column is
placed first because that is the field the scalar-struct row count is
read from.

Cover the no-fields error for all three input kinds. Verified to fail
without the fix: a uint32 column comes back as a cell array, i.e. written
as a string.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Use camelCase variable name
@ehennestad
ehennestad force-pushed the fix-empty-compound-dataset-write branch from 5ce3703 to 46cf771 Compare September 1, 2026 11:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant