Integrate StructUtils.jl for typed struct round-tripping - #249
stephenberry wants to merge 1 commit into
Conversation
|
@stephenberry Is it possible to fix the failing invalidations CI? |
|
@mkitti Would you be the right person to review and merge? |
|
I can review |
|
I'm also suggesting @matthijscox for reviewer since they participated in the original issue. |
|
well this is quite a large PR, will have to schedule some review time soon |
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Three moderate issues remain in typed conversion and dispatch behavior.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (1)
What changed in this PR
Integrates StructUtils.jl to add opt-in typed MAT reads and improved struct round-tripping.
Changes:
- Adds typed
matread/readAPIs across MAT formats. - Adds MAT-specific StructUtils styles, conversions, and macros.
- Updates documentation, CI, dependencies, and tests.
| File | Summary |
|---|---|
test/structutils.jl |
Adds StructUtils integration tests. |
test/runtests.jl |
Includes the new test suite. |
src/MAT.jl |
Exposes typed reads and StructUtils APIs. |
src/MAT_v5.jl |
Adds typed v5 reads. |
src/MAT_v4.jl |
Adds typed v4 reads and lowering support. Moderate finding (1 vote): scalar targets do not normalize 1×1 numeric variables. |
src/MAT_types.jl |
Implements typed construction and MAT-specific hooks. Moderate findings: empty numeric arrays can lift to strings (2 votes); nested MatlabStructArray dispatch remains ambiguous for abstract targets (1 vote). |
src/MAT_HDF5.jl |
Adds typed reads and StructUtils-aware writing. |
README.md |
Documents typed struct round-tripping. |
Project.toml |
Adds StructUtils and updates Julia compatibility. |
docs/src/index.md |
Adds user-facing documentation. |
.github/workflows/CI.yml |
Updates the Julia CI matrix. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
I see the same number of failing invalidations on my simple PR #251 . I think it's due to the upgrade to Julia 1.13. Though I just ran it locally and it says 678 invalidations on 1.12, and here on 1.13 it says 161 invalidations. So not sure why it considers the PRs to increased the number of invalidations. Edit: it compares to default branch. It says our new PRs have 161 invalidations and the main branch has 160. I doubt my PR added an extra invalidation, so I'm tempted to ignore this jump in 1 invalidation. |
Add opt-in typed reads via matread(filename, varname, T) and read(handle, varname, T) on the HDF5, v5 and v4 backends. The generic struct write path now goes through StructUtils.applyeach, gaining @tags renaming and ignoring, lower value transforms, and field defaults. Array and struct-array targets are constructed by MAT rather than by StructUtils' generic source iteration, which does not fit MAT's data model: element type and shape are converted explicitly, and a MatlabStructArray fills any collection target. - Add StructUtils dependency (compat 2.5.1); bump Julia compat 1.6 -> 1.9 and update the CI matrix to match - Re-export the StructUtils macros, the MAT style types and MATConstructionError - Fix the isbits guard so structs with all-primitive fields can be written - Round-trip empty and one-character strings, and write nothing and missing as MATLAB's empty matrix - Read a one-element numeric array (a v4 scalar) into a numeric target - Document typed reads in the README and manual; add test/structutils.jl Closes JuliaIO#235
1a16e45 to
a0db0cd
Compare
|
@ViralBShah The Invalidations failure is a measurement artifact rather than a new invalidation; #251 shows the same 160 -> 161. Measuring both branches warm under equal conditions, the counts match (159 vs 159 on Julia 1.13, 429 vs 429 on 1.12). I've pushed an update (squashed) addressing the review:
|

StructUtils.jl Integration for Round-Tripping Julia Structs
Closes #235
Summary
MAT.jl can write arbitrary Julia structs to
.matfiles, but always reads them back asDict{String, Any}. This PR integrates StructUtils.jl (the same engine behind JSON.jl's typed parsing) to enable opt-in typed reads and improved writes. The untypedmatread(filename)behavior is unchanged.New API
A typed
read(handle, varname, T)is available onmatopenhandles for all three formats.Because the struct write path now goes through StructUtils, its macros are re-exported and work with
using MAT:@defaults(default field values),@tags(renaming and ignoring fields),@choosetype(abstract type dispatch),@noargand@kwarg. Types MATLAB has no representation for are handled by extendingStructUtils.loweron write andStructUtils.lifton read. The README and manual document all of this with examples.Design Notes
These are the decisions a reader cannot get from the diff.
Array targets are constructed by MAT.jl, not by StructUtils' generic source iteration, which does not fit MAT data: a value read from a file is already a fully-typed Julia object, MATLAB has no scalars (a 1x1 array reads back as a scalar), MAT arrays are flat and carry their own shape (StructUtils infers dimensions by descending into nested sources), and the stored element type rarely matches the target.
make(::MATReadStyle, ::Type{<:AbstractArray}, x)therefore converts element type and shape explicitly: a scalar fills a one-element array target,single/int32/logical/cell sources fill aMatrix{Float64}, trailing singleton dimensions are padded or dropped, a row or column vector fills aVector, and a shape that cannot be matched raisesDimensionMismatchrather than being silently flattened.A one-element
MatlabStructArrayis unwrapped inprepare_source, ahead of dispatch, for a whole variable and for each element of an array being built, rather than only by amakemethod general in the target type: such a method is ambiguous with the per-type methods@choosetypegenerates, so unwrapping first is what keepsmatread(file, var, SomeAbstractType)andVector{SomeAbstractType}targets working against struct arrays. Two cases remain that MAT.jl cannot intercept, because StructUtils dispatches to@choosetype's method first; both are documented: a struct field of the abstract type reaches the chooser as a rawMatlabStructArray, and struct arrays stored as struct values cannot be read intoDict{String,SomeAbstractType}. Struct arrays are otherwise expanded to the equivalent array ofDicts at the point of construction, so they work as a nested field as well as a top-level variable, and can fill any collection target (Vector{T},Matrix{T},Set{T},Tuple{...}, a narrowedUnion); a struct array fills a single struct only when it holds exactly one element.Four MAT-specific StructUtils hooks:
structlike(::MATStyle, ::Type{<:Complex}) = false, because MATLAB stores complex numbers natively: they are leaf values, not two-field structs to rebuild fromre/im.nulllike(::MATStyle, x::AbstractArray), because MATLAB spells "no value" as the empty matrix[], so an empty array narrows aUnion{Nothing,T}/Union{Missing,T}field, andnothing/missingare written back as[]. The ambiguity is inherent to the format: a genuinely empty numeric array in such a field is indistinguishable from a missing value. Empty character data is excluded, since that is how""is stored and narrowing it would discard a value the file holds. v4 files store''and[]identically, with element typeUnion{}, so there both narrow.liftfrom an empty character array to""forAbstractStringtargets, the counterpart of theAbstractCharlift for one-character strings. Without this aStringfield holding""cannot be read back at all. Empty numeric data is rejected rather than read as"".liftfrom a one-element numeric array forNumbertargets, the counterpart of a scalar filling a one-element array target. v4 files read a scalar back as a 1x1 matrix, so without thismatread(file, "x", Float64)fails on v4.On write,
loweris applied at a single choke point (m_write_value), which every top-level variable,Dictvalue and cell or struct-array element passes through, so one overload covers a type wherever it appears. Struct fields are lowered byapplyeachinstead and so never pass through it, which is what keeps them from being lowered twice.NamedTuplekeeps its own write method rather than falling into the generic struct path, because aNamedTupleof vectors is a Tables.jl table and would otherwise be rejected by that path's "writing tables is not yet supported" check.MATStyle <: StructUtils.StructStylesetsfieldtagkey = :mat, following JSON.jl's pattern, so tags can be namespaced per library:Exports: the StructUtils macros above, the
StructUtilsmodule itself (@choosetypegenerates code referencingStructUtils.make, and extensions are written as qualified method definitions), the style typesMATStyle/MATReadStyle/MATWriteStyle, andMATConstructionError.lowerandliftare deliberately not exported: a function cannot be extended through an unqualified imported name, so an exported binding would not help.Files Changed
Project.toml2.5.1), bumped Julia compat 1.6 -> 1.9.github/workflows/CI.ymlsrc/MAT_types.jlMATStyle,MATReadStyle,MATWriteStyle,MATConstructionError;construct_from_rawhelper; MAT-aware array construction;structlike/nulllike/lifthookssrc/MAT.jlmatread(filename, varname, T), re-exported StructUtils macros, styles and error typesrc/MAT_HDF5.jlread(f, name, T); CompositeKind write viaapplyeach;lowerapplied at every value entry point;nothing/missingwritten as[]src/MAT_v5.jlread(matfile, varname, T)src/MAT_v4.jlread(matfile, varname, T);lowerapplied on write;nothing/missingwritten as[]README.md,docs/src/index.mdlower/liftextensiontest/structutils.jltest/runtests.jlinclude("structutils.jl")Breaking Changes
Julia compat bumped from 1.6 to 1.9, required by StructUtils.jl. Julia 1.6 is past its support lifecycle. This warrants a minor version bump; the PR leaves
versionuntouched so the release is yours to time.Bug Fixes
isbits(s)guard in the CompositeKind write path rejected valid user structs whose fields happen to all be primitive (e.g.struct Point; x::Float64; y::Float64; end) with "This is the write function for CompositeKind, but the input doesn't fit". The guard is now!StructUtils.structlike(MATWriteStyle(), T), which accepts user structs and rejects non-struct types. No existing type changes dispatch path: the specificm_writemethods (Symbol,NamedTuple,Dict,MatlabOpaque,FunctionHandle, ...) are more specific and still match first.Char, since MATLAB has no scalar char type distinct from a 1x1 char array; it is now lifted forAbstractStringfields.""back as an empty array, which noStringfield could previously accept.nothingandmissingcan be written, as MATLAB's[].cannot write a value of type `T` to a MAT file.Future Work
Dictfirst, then construct the target type. A future optimization could construct directly from HDF5.Array{Dict{String,Any}}before constructing typed elements. DefiningStructUtils.applyeachforMatlabStructArraywould constructTelements from columns without the intermediate Dicts.Test Plan
test/structutils.jladds 45 testsets covering typed reads of structs, nested structs and struct arrays; each StructUtils macro; array element-type and shape conversion, including the mismatch errors;Unionnarrowing on[]and the empty-string cases above; thelower/liftextension pattern; write-path type handling; and the v4, v5, v7.3 and compressed formats. Backward compatibility is covered by the pre-existing suite, which is unchanged.The full suite (768 tests) passes on Julia 1.9.4 with StructUtils 2.5.1 (the declared minimums) and on Julia 1.12.7 with StructUtils 2.9.1 (current). 2.5.1 is the compat floor because
Settargets fail on 2.5.0, whereStructUtils.initializeforAbstractSetis missing.