Route links through the backend reader and writer interfaces - #866
Merged
Conversation
types.untyped.SoftLink and types.untyped.ExternalLink reached past the
backend abstraction and called HDF5 directly: both export methods took
writer.FileId and called H5L.create_soft/H5L.create_external, and
ExternalLink.deref opened the target itself with H5F.open, h5info and
H5L.get_val. The Writer interface had no link API at all, which is why
they had to.
Add writeSoftLink and writeExternalLink to the writer interface and
readLinkInfo to the reader interface, implement them for HDF5, and
route the two link classes through them. Neither class makes an H5
call any more, so a second backend can support links by implementing
three methods rather than by special-casing these classes.
Behaviour is unchanged for HDF5. Two details are worth noting:
- The retry that SoftLink performed by catching "name already exists"
is now an explicit check in the HDF5 writer, which leaves an
identical link alone and replaces a differing one. Rewriting a link
is routine rather than exceptional: an object whose target is not
resolvable on the first pass is exported again by
NwbFile.resolveReferences.
- ExternalLink.export never assigned its refs output, so requesting it
errored. It now returns {} like SoftLink.export does.
deref checks for its target with isfile or isfolder, since a store is
a single file for some backends and a directory for others. The node
classification still reads the h5info-style struct that readNodeInfo
returns by contract for every backend, and one HDF5 term remains in
it -- the H5T_REFERENCE dataset check -- which the backend adding
support for it should generalise.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ExternalLink.deref decided how to handle a linked dataset by testing LinkedInfo.Datatype.Class against 'H5T_REFERENCE'. That was the last piece of HDF5 vocabulary in backend-neutral code: every other mention of the constant lives in the HDF5 backend or the h5 internals. The knowledge was already behind the interface -- HDF5Reader made the same test inside readDatasetValue -- so deref was reaching around an abstraction rather than filling a gap in one. Promote it to a reader method, isReferenceDataset, and have readDatasetValue use it too so there is a single place that decides. How a dataset is marked as holding references is backend specific: a reference datatype class in HDF5, a "zarr_dtype" attribute of "object" in hdmf-zarr. Callers need the answer, not the encoding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Apply MATLAB naming conventions to the code this branch introduces rather than following the surrounding file. - The struct returned by readLinkInfo uses lowerCamelCase fields (type, targetPath, targetFilename). The h5info-derived structs keep their PascalCase fields, since those names are the contract that readNodeInfo mirrors for every backend. - ExternalLink.deref uses lowerCamelCase locals and nested functions (linkedInfo, isTyped, isDataset, scalarDeref, derefLink) in place of the mixed PascalCase and snake_case it had. - The abbreviated `plist` is spelled propertyListId. No behaviour change. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ehennestad
marked this pull request as ready for review
August 24, 2026 18:52
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #866 +/- ##
==========================================
+ Coverage 95.21% 95.27% +0.05%
==========================================
Files 231 232 +1
Lines 8252 8292 +40
==========================================
+ Hits 7857 7900 +43
+ Misses 395 392 -3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Writing a link over a path already held by a group or dataset silently replaced the node. That was a side effect of the link-type check added when the retry logic moved into the HDF5 writer: a hard link matched neither the soft nor the external case, so it fell through to the delete-and-recreate branch. Before this branch, SoftLink reached H5L.get_val on such a node and failed with the HDF5 library's own error, which named neither the path nor the conflict. Report it directly instead, naming both. This is stricter than ExternalLink's previous behaviour, which deleted the node unconditionally. Replacing a link with a link is unchanged, including replacing one kind with the other, since both are links rather than nodes standing in the way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ehennestad
force-pushed
the
backend-agnostic-links
branch
from
August 25, 2026 08:03
7e62552 to
c359d06
Compare
ExternalLink.deref told a group, a dataset and a link apart by which fields the node info carried, and its group test required 'Datatypes'. That field holds HDF5 named datatypes, a concept no other backend has, so a group read through another backend matched none of the three and deref raised NWB:ExternalLink:UnknownNodeType. Nothing anywhere reads 'Datatypes' -- this test was its only mention in the codebase -- and Groups, Datasets and Links already distinguish a group from a dataset or a link. Drop it from the test. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two paths added by this branch had no test. The existing-link comparison was only exercised for soft links. Add the external cases: re-writing an identical link, and replacing one that differs by file or by path. The replacement test is the one that discriminates -- a comparison that wrongly reports a match leaves the old link in place and fails it on both halves. Re-writing an identical link cannot be told apart from deleting and recreating it by looking at the result, so that test only shows the path is taken without error; the same is true of the soft-link case beside it. ExternalLink.deref routes a dataset either through io.parseDataset or into a bare stub, and only the stub branch was covered: testExternalResolution links to a plain dataset. Add a link to a typed dataset, which comes back as its neurodata type, and one to a dataset of object references, whose references are resolved rather than handed back raw. Bypassing io.parseDataset returns a DataStub and fails both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ehennestad
enabled auto-merge
August 25, 2026 09:18
This was referenced Aug 25, 2026
bendichter
approved these changes
Aug 25, 2026
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
Background — MatNWB's storage backends are described by
io.backend.base.Readerandio.backend.base.Writer, with HDF5 as the only implementation today, but a Zarr reader in development. This PR closes a hole where the abstraction did not fully cover link types (SoftLink, ExternalLink).Problem —
types.untyped.SoftLinkandtypes.untyped.ExternalLinkreached past the abstraction and called HDF5 directly: both export methods took the writer's raw file id and calledH5L.create_soft/H5L.create_external, andExternalLink.derefopened the target file itself withH5F.open,h5infoandH5L.get_val. TheWriterinterface had no link API at all, so there was nothing for a new backend to implement — supporting links would have meant special-casing these two classes.Solution — Links are now expressed as capabilities of the backend. Writing a link asks the writer; reading one asks the reader. A new backend supports links by implementing interface methods, and neither link class contains a storage-specific call any more.
What changed
WritergainswriteSoftLinkandwriteExternalLink;ReadergainsreadLinkInfoandisReferenceDataset. All four are implemented for HDF5.SoftLinkandExternalLinkno longer contain anyH5F.,H5L.,h5infoorH5T_REFERENCEreference. A backend that has not implemented link support now fails naming the method it is missing, rather than failing inside HDF5.SoftLink.exportScalarcontained an unreachable secondif isempty(obj.path)branch, dead after the early return above it.ExternalLink.exportdeclared arefsoutput that it never assigned, so requesting that output errored. It now returns{}, matchingSoftLink.export.Implementation notes
ExternalLink.derefpreviously opened the target file withH5F.openandh5info. It now resolves a reader throughio.backend.BackendFactoryand callsreadNodeInfo, which returns the sameh5info-style struct for any backend, so the group/dataset/link classification is unchanged.The retry that
SoftLinkperformed by catching an"name already exists"message is now an explicit check in the HDF5 writer: an identical link is left alone, a differing one is replaced. Rewriting a link is routine rather than exceptional, since an object whose link target is not resolvable on the first pass is exported again byNwbFile.resolveReferences.isReferenceDatasetwas added becausederefdecided how to handle a linked dataset by testingLinkedInfo.Datatype.Classagainst'H5T_REFERENCE'.HDF5Readeralready made the same test insidereadDatasetValue, so this was reaching around an existing abstraction rather than filling a gap in one;readDatasetValuenow calls the new method too, so a single place decides. How a dataset is marked as holding references is backend specific — a reference datatype class in HDF5, azarr_dtypeattribute of"object"in hdmf-zarr.derefchecks for its target withisfileorisfolder, since a store is a single file for some backends and a directory for others.How to test
Soft and external links round-trip unchanged:
Not included
ExternalLinkresolves a relative target filename against the current working directory rather than against the file containing the link, which can silently dereference the wrong file. That is reported separately in #865 and deliberately left alone here, since fixing it changes whatderefreturns rather than where the call is routed.Checklist
fix #XXwhereXXis the issue number?🤖 Generated with Claude Code