From 97c8c474c4496c4f59ef366199a64ca742e2dfab Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Wed, 5 Aug 2026 07:04:11 -0600 Subject: [PATCH] perf(make): add trim-friendly field sink Replace the closure-based field loop with linear generated match and assignment ladders. Preserve constructors, custom make dispatch, tags, aliases, defaults, and inbound ignore semantics. Add focused regression and trim coverage. --- Project.toml | 2 +- src/StructUtils.jl | 285 ++++++++++++++++++++++++++++++----------- test/construction.jl | 245 +++++++++++++++++++++++++++++++++++ test/ignore_inbound.jl | 91 +++++++++++++ test/runtests.jl | 2 + 5 files changed, 546 insertions(+), 79 deletions(-) create mode 100644 test/construction.jl create mode 100644 test/ignore_inbound.jl diff --git a/Project.toml b/Project.toml index dea4f40..f90f47e 100644 --- a/Project.toml +++ b/Project.toml @@ -1,6 +1,6 @@ name = "StructUtils" uuid = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42" -version = "2.8.2" +version = "2.8.3" [deps] Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" diff --git a/src/StructUtils.jl b/src/StructUtils.jl index 8e9bd84..cf92b8b 100644 --- a/src/StructUtils.jl +++ b/src/StructUtils.jl @@ -156,26 +156,27 @@ function _fieldtag(st::StructStyle, ft, field) end @generated function _fieldtagtuple(st::StructStyle, ::Type{T}, fsyms) where {T} - n = fieldcount(T) - vals = [:(_fieldtag(st, ft, $(QuoteNode(fieldname(T, i))))) for i = 1:n] + t = Expr(:tuple) + for i = 1:fieldcount(T) + push!(t.args, :(_fieldtag(st, ft, $(QuoteNode(fieldname(T, i)))))) + end return quote Base.@_inline_meta ft = fieldtags(st, T) if isempty(ft) return _fieldtagtuple_public(st, T, fsyms) else - return ($(vals...),) + return $t end end end @generated function _fieldtagtuple_public(st::StructStyle, ::Type{T}, fsyms) where {T} - n = fieldcount(T) - vals = [:(fieldtags(st, T, $(QuoteNode(fieldname(T, i))))) for i = 1:n] - return quote - Base.@_inline_meta - return ($(vals...),) + t = Expr(:tuple) + for i = 1:fieldcount(T) + push!(t.args, :(fieldtags(st, T, $(QuoteNode(fieldname(T, i)))))) end + return Expr(:block, :(Base.@_inline_meta), :(return $t)) end """ @@ -846,7 +847,15 @@ end @inline abstractcollectionpassthrough(style::StructStyle, ::Type{T}, source) where {T} = isabstracttype(T) && source isa T && (dictlike(style, T) || arraylike(style, T)) -function make(style::StructStyle, T::Type, source, tags) +# Keep normal `make` dispatch at the public boundary so exact custom methods +# and `@choosetype` methods win first. The concrete `Val{T}` token then gives +# the default implementation a specialized signature even when `T` is a +# Union, which avoids duplicating Union behavior in generated field code. +function make(style::StructStyle, ::Type{T}, source, tags) where {T} + return _make(style, Val{T}(), source, tags) +end + +function _make(style::StructStyle, ::Val{T}, source, tags) where {T} if haskey(tags, :choosetype) return make(style, tags.choosetype(source), source, _delete(tags, :choosetype)) end @@ -904,7 +913,7 @@ function make(style::StructStyle, T::Type, source, tags) end end -function make(style::StructStyle, T::Type, source) +function make(style::StructStyle, ::Type{T}, source) where {T} if abstractcollectionpassthrough(style, T, source) return source, defaultstate(style) end @@ -990,11 +999,11 @@ macro _t(i) end @generated function _tuple(::Type{T}, vals, style) where {T} - n = fieldcount(T) - ex = Expr(:block) - push!(ex.args, :(Base.@_inline_meta)) - push!(ex.args, Expr(:tuple, [:(@_t($i)) for i = 1:n]...)) - return ex + t = Expr(:tuple) + for i = 1:fieldcount(T) + push!(t.args, :(@_t($i))) + end + return Expr(:block, :(Base.@_inline_meta), t) end struct TupleClosure{T,A,S} @@ -1112,28 +1121,27 @@ function makearray(style, x::T, source) where {T} end end +# NOTE for all @generated functions in this file: generator bodies avoid +# comprehensions/generators that capture `T` — each such closure type is +# specific to `Type{T}`, so running the generator would trigger fresh +# inference of `collect(Generator{...})` for every target type (measured at +# ~5-10ms per closure per type) @generated function fieldnamestrings(::Type{T}) where {T} - :($(Tuple(String(fieldname(T, i)) for i in 1:fieldcount(T)))) + t = Expr(:tuple) + for i = 1:fieldcount(T) + push!(t.args, String(fieldname(T, i))) + end + return t end @generated function fieldnamesymbols(::Type{T}) where {T} - :($(Tuple(fieldname(T, i) for i in 1:fieldcount(T)))) -end - -struct StructClosure{T,A,S,FS,FSS,FT} - vals::A # Memory{Any} for structs, T for mutable structs - style::S - fsyms::FS - fstrs::FSS - ftags::FT + t = Expr(:tuple) + for i = 1:fieldcount(T) + push!(t.args, QuoteNode(fieldname(T, i))) + end + return t end -StructClosure{T}(vals::A, style::S, fsyms::FS, fstrs::FSS) where {T,A,S,FS,FSS} = - StructClosure{T}(vals, style, fsyms, fstrs, _fieldtagtuple(style, T, fsyms)) - -StructClosure{T}(vals::A, style::S, fsyms::FS, fstrs::FSS, ftags::FT) where {T,A,S,FS,FSS,FT} = - StructClosure{T,A,S,FS,FSS,FT}(vals, style, fsyms, fstrs, ftags) - if VERSION < v"1.11" setval!(vals::Vector{Any}, x, i) = @inbounds vals[i] = x else @@ -1142,47 +1150,167 @@ end setval!(vals::T, x, i) where {T} = _setfield!(vals, i, x) -function findfield(::Type{T}, k, v, f) where {T} - st = _foreach(T) do i - if typeof(k) == Symbol - fn = f.fsyms[i] - ftags = f.ftags[i] - field = get(ftags, :name, fn) - if keyeq(k, field) || keyeq(k, fn) - symval, symst = make(f.style, fieldtype(T, i), v, ftags) - setval!(f.vals, symval, i) - return EarlyReturn(_MatchedState(symst)) - end - elseif typeof(k) == Int - if k == i - ftags = f.ftags[i] - intval, intst = make(f.style, fieldtype(T, i), v, ftags) - setval!(f.vals, intval, i) - return EarlyReturn(_MatchedState(intst)) - end - else - fn = f.fsyms[i] - fstr = f.fstrs[i] - ftags = f.ftags[i] - field = get(ftags, :name, fstr) - if keyeq(k, field) - strval, strst = make(f.style, fieldtype(T, i), v, ftags) - setval!(f.vals, strval, i) - return EarlyReturn(_MatchedState(strst)) +# Struct-shaped targets are filled by a FieldSink: a source key is matched to +# a field index (see `cursorhit`/`matchscan` below), then the generated +# `applyfield!` ladder dispatches the index to a `make` call on that field's +# concrete type. + +struct NoFieldMetadata end + +struct FieldMetadata{FT} + tags::FT +end + +struct NoCursor end + +@inline fieldmetadata(tags::Tuple{Vararg{@NamedTuple{}}}) = NoFieldMetadata() +@inline fieldmetadata(tags) = FieldMetadata(tags) + +@inline sinktag(::NoFieldMetadata, ::Int) = (;) +@inline sinktag(metadata::FieldMetadata, i::Int) = @inbounds metadata.tags[i] + +@inline ignoredfield(tags::NamedTuple{names}) where {names} = + :ignore in names && tags.ignore + +""" + StructUtils.orderedfields(::StructStyle) -> Bool + +Return `true` only when a style consumes source keys in field order and its +owned key type implements [`StructUtils.orderedfieldmatch`](@ref). This is an +internal, experimental integration hook. The source integration must own the +only key type for which `orderedfieldmatch` can return `true`; generic sources +must keep declaration-order matching. +""" +orderedfields(::StructStyle) = false + +@inline fieldcursor(style, ::NoFieldMetadata) = + orderedfields(style) ? Ref(1) : NoCursor() +@inline fieldcursor(style, ::FieldMetadata) = NoCursor() + +struct FieldSink{T,S,V,M,C} + vals::V # Memory{Any} (immutable/NamedTuple targets) or the instance itself (noarg) + style::S + metadata::M # empty marker or per-field tag NamedTuples, fetched once per `make` + cursor::C # NoCursor, or source-owned ordered-key cursor storage +end + +FieldSink{T}(vals::V, style::S, metadata::M, cursor::C) where {T,S,V,M,C} = + FieldSink{T,S,V,M,C}(vals, style, metadata, cursor) + +# Key matching is two-phase. Phase 1 (`cursorhit`) is an internal opt-in for +# source-owned key types that can prove the next raw field-name match without +# changing `keyeq` semantics. Generic sources skip it: a custom key may match +# several fields and must always select the first one in declaration order. +# Phase 2 (`matchscan`) is a per-type generated scan with field-name literals. +@inline function matchone(k, ::NoFieldMetadata, i, fn, fstr) + _ = i + if typeof(k) == Symbol + return keyeq(k, fn) + else + return keyeq(k, fstr) + end +end + +@inline function matchone(k, metadata::FieldMetadata, i, fn, fstr) + tags = sinktag(metadata, i) + if typeof(k) == Symbol + name = get(tags, :name, fn) + return keyeq(k, name) || keyeq(k, fn) + else + return keyeq(k, get(tags, :name, fstr)) + end +end + +""" + StructUtils.orderedfieldmatch(key, field::String) -> Bool + +Return `true` when an integration-owned source-key type proves an exact raw +field-name match. This is an internal, experimental hook. Styles must also opt +in through [`StructUtils.orderedfields`](@ref), and the generic fallback must +remain `false`. +""" +@inline orderedfieldmatch(key, field::String) = false +@inline cursorhit(k, ::NoCursor, metadata, fstrs) = 0 +@inline advancecursor!(::NoCursor, i::Int, n::Int) = nothing +@inline advancecursor!(cursor::Base.RefValue{Int}, i::Int, n::Int) = + cursor[] = i == n ? 1 : i + 1 + +function cursorhit(k, cursor::Base.RefValue{Int}, metadata, fstrs) + N = length(fstrs) + # Tagged names can overlap. Preserve first-field scan order by using the + # cursor only when every field has the default empty metadata. + metadata isa NoFieldMetadata || return 0 + N == 0 && return 0 + i = cursor[] + i > N && (i = 1) + if orderedfieldmatch(k, @inbounds(fstrs[i])) + cursor[] = i == N ? 1 : i + 1 + return i + end + return 0 +end + +@generated function matchscan(::Type{T}, k, metadata) where {T} + ex = Expr(:block) + for i = 1:fieldcount(T) + fn = QuoteNode(fieldname(T, i)) + fstr = String(fieldname(T, i)) + push!(ex.args, :(matchone(k, metadata, $i, $fn, $fstr) && return $i)) + end + push!(ex.args, :(return 0)) + return ex +end + +# Splice each field type as a literal. Normal `make` dispatch remains visible, +# including exact custom methods; its default path uses the concrete Val token +# above for Union targets. +function _fieldmake(j::Int, @nospecialize(ft)) + return :(make(f.style, $ft, v, sinktag(f.metadata, $j))) +end + +@generated function applyfield!(f::FieldSink{T}, i::Int, v) where {T} + ex = Expr(:block) + for j = 1:fieldcount(T) + push!(ex.args, quote + if i == $j + ignoredfield(sinktag(f.metadata, $j)) && return defaultstate(f.style) + val, st = $(_fieldmake(j, fieldtype(T, j))) + setval!(f.vals, val, $j) + return st end - end + end) end - return st isa _MatchedState ? st.value : unknownfield(f.style, T, k, v) + push!(ex.args, :(return defaultstate(f.style))) + return ex +end + +function (f::FieldSink{T,S,V,M,C})(k, v) where {T,S,V,M,C} + N = fieldcount(T) + i = typeof(k) == Int ? ((1 <= k <= N) ? k : 0) : + cursorhit(k, f.cursor, f.metadata, fieldnamestrings(T)) + if i == 0 + typeof(k) == Int && return unknownfield(f.style, T, k, v) + i = matchscan(T, k, f.metadata) + i == 0 && return unknownfield(f.style, T, k, v) + advancecursor!(f.cursor, i, N) + end + return applyfield!(f, i, v) end -(f::StructClosure{T,A,S,FS,FSS,FT})(k, v) where {T,A,S,FS,FSS,FT} = findfield(T, k, v, f) +# Build the sink for one make of `T` (tags fetched exactly once per make) and +# run the source through it. Generic sources use an allocation-free NoCursor; +# source integrations can provide private cursor storage for owned key types. +function fillfields!(style::StructStyle, ::Type{T}, vals, source) where {T} + tags = _fieldtagtuple(style, T, fieldnamesymbols(T)) + metadata = fieldmetadata(tags) + cursor = fieldcursor(style, metadata) + return applyeach(style, FieldSink{T}(vals, style, metadata, cursor), source) +end -@inline makenoarg(style, ::Type{T}, source) where {T} = makenoarg(style, initialize(style, T, source), source) +makenoarg(style, ::Type{T}, source) where {T} = makenoarg(style, initialize(style, T, source), source) function makenoarg(style, y::T, source) where {T} - fsyms = fieldnamesymbols(T) - fstrs = fieldnamestrings(T) - st = applyeach(style, StructClosure{T}(y, style, fsyms, fstrs), source) + st = fillfields!(style, T, y, source) return y, st end @@ -1192,27 +1320,28 @@ end @generated function _construct(::Type{T}, vals, style, fsyms) where {T} n = fieldcount(T) - ex = Expr(:block) - push!(ex.args, :(Base.@_inline_meta)) # fast path: all fields assigned, skip fielddefaults entirely - all_assigned = Expr(:&&, [:(isassigned(vals, $i)) for i = 1:n]...) - fast = Expr(:call, Any[:T, [:(@inbounds(vals[$i])::fieldtype(T, $i)) for i = 1:n]...]...) - slow = Expr(:block, - :(defs = fielddefaults(style, T, vals)), - Expr(:call, Any[:T, [:(@_v($i)) for i = 1:n]...]...)) - push!(ex.args, Expr(:if, all_assigned, fast, slow)) - return ex + all_assigned = n == 0 ? true : :(isassigned(vals, 1)) + for i = 2:n + all_assigned = Expr(:&&, all_assigned, :(isassigned(vals, $i))) + end + fast = Expr(:call, :T) + slowcall = Expr(:call, :T) + for i = 1:n + push!(fast.args, :(@inbounds(vals[$i])::fieldtype(T, $i))) + push!(slowcall.args, :(@_v($i))) + end + slow = Expr(:block, :(defs = fielddefaults(style, T, vals)), slowcall) + return Expr(:block, :(Base.@_inline_meta), Expr(:if, all_assigned, fast, slow)) end function makestruct(style, ::Type{T}, source) where {T} vals = mem(fieldcount(T)) - fsyms = fieldnamesymbols(T) - fstrs = fieldnamestrings(T) - st = applyeach(style, StructClosure{T}(vals, style, fsyms, fstrs), source) + st = fillfields!(style, T, vals, source) if T <: NamedTuple return T(_tuple(T, vals, style)), st else - return _construct(T, vals, style, fsyms), st + return _construct(T, vals, style, fieldnamesymbols(T)), st end end diff --git a/test/construction.jl b/test/construction.jl new file mode 100644 index 0000000..35aa02f --- /dev/null +++ b/test/construction.jl @@ -0,0 +1,245 @@ +# Focused regression tests for struct-shaped construction (`makestruct`/`makenoarg` +# internals). These pin behavior that the field-matching/construction machinery +# must preserve regardless of how it is implemented. +using Test, StructUtils + +# --- inner constructors must run (no construction bypass) --- +struct Positive + x::Int + function Positive(x) + x > 0 || throw(ArgumentError("x must be positive")) + return new(x) + end +end + +mutable struct CtorCounter + n::Int +end +const CTOR_COUNTER = CtorCounter(0) +struct CountedCtor + a::Int + function CountedCtor(a) + CTOR_COUNTER.n += 1 + return new(a) + end +end + +# --- stateful styles: metadata call cadence --- +mutable struct CadenceWholeStyle <: StructUtils.StructStyle + calls::Int +end +mutable struct CadencePerFieldStyle <: StructUtils.StructStyle + calls::Int +end + +struct CadenceTagged + a::Int + b::Int +end + +function StructUtils.fieldtags(style::CadenceWholeStyle, ::Type{CadenceTagged}) + style.calls += 1 + return (a=(name="A",), b=(name="B",)) +end + +function StructUtils.fieldtags(style::CadencePerFieldStyle, ::Type{CadenceTagged}, field::Symbol) + style.calls += 1 + return (;) +end + +# --- alias tuples and rename asymmetry --- +@tags struct AliasTupleT + id::Int &(name=("ident", :idx),) + code::Int +end + +@tags struct RenamedT + id::Int &(name="identifier",) + code::Int +end + +@tags struct CollidingName + a::Int &(name="b",) + b::Int +end +StructUtils.fielddefaults(::StructUtils.StructStyle, ::Type{CollidingName}) = + (a=-1, b=-2) + +@tags mutable struct MutableCollidingName + a::Int &(name="b",) + b::Int + MutableCollidingName() = new(-1, -2) +end +StructUtils.noarg(::StructUtils.StructStyle, ::Type{MutableCollidingName}) = true + +@tags struct OverlappingNames + a::Int &(name="x",) + b::Int &(name="x",) +end +StructUtils.fielddefaults(::StructUtils.StructStyle, ::Type{OverlappingNames}) = + (a=-1, b=-2) + +@tags struct ExplicitNothingName + a::Int &(name=nothing,) +end +StructUtils.fielddefaults(::StructUtils.StructStyle, ::Type{ExplicitNothingName}) = + (a=99,) + +# --- nullable fields must retain normal custom make dispatch --- +abstract type NullableChoice end +struct ChosenValue <: NullableChoice + x::Int +end +const OptionalChoice = Union{Nothing,NullableChoice} +StructUtils.@choosetype OptionalChoice source -> + source === nothing ? Nothing : ChosenValue +struct ChoiceHolder + value::OptionalChoice +end + +struct CustomOptionalValue + x::Int +end +const CustomOptional = Union{Nothing,CustomOptionalValue} +function StructUtils.make( + style::StructUtils.StructStyle, + ::Type{CustomOptional}, + source, + tags, +) + value = source === nothing ? nothing : CustomOptionalValue(source.x + 1) + return value, StructUtils.defaultstate(style) +end +struct CustomOptionalHolder + value::CustomOptional +end + +struct WildcardKey end +StructUtils.keyeq(::WildcardKey, ::String) = true +struct WildcardTarget + a::Int + b::Int +end +StructUtils.fielddefaults(::StructUtils.StructStyle, ::Type{WildcardTarget}) = + (a=-1, b=-2) + +# --- shuffled key order --- +struct Ten + f1::Int; f2::Int; f3::Int; f4::Int; f5::Int + f6::Int; f7::Int; f8::Int; f9::Int; f10::Int +end + +# --- wide struct (64 fields) --- +let fields = join(("g$i::Int" for i in 1:64), "\n") + eval(Meta.parse("struct Wide64\n$fields\nend")) +end + +@testset "struct construction regressions" begin + @testset "inner constructor executes" begin + @test StructUtils.make(Positive, (x=3,)) == Positive(3) + @test_throws ArgumentError StructUtils.make(Positive, (x=-1,)) + @test_throws ArgumentError StructUtils.make(Positive, Dict("x" => -1)) + CTOR_COUNTER.n = 0 + @test StructUtils.make(CountedCtor, (a=7,)).a == 7 + @test CTOR_COUNTER.n == 1 + end + + @testset "stateful style call cadence" begin + # whole-type fieldtags: exactly one call per make, per style instance + s1 = CadenceWholeStyle(0) + @test StructUtils.make(CadenceTagged, (A=1, B=2), s1) == CadenceTagged(1, 2) + @test s1.calls == 1 + @test StructUtils.make(CadenceTagged, (A=3, B=4), s1) == CadenceTagged(3, 4) + @test s1.calls == 2 # no cross-make caching + s2 = CadenceWholeStyle(0) + @test StructUtils.make(CadenceTagged, (A=5, B=6), s2) == CadenceTagged(5, 6) + @test s2.calls == 1 # instances independent + @test s1.calls == 2 + + # per-field public fieldtags: one call per field per make + p1 = CadencePerFieldStyle(0) + @test StructUtils.make(CadenceTagged, (a=1, b=2), p1) == CadenceTagged(1, 2) + @test p1.calls == 2 + @test StructUtils.make(CadenceTagged, (a=3, b=4), p1) == CadenceTagged(3, 4) + @test p1.calls == 4 + end + + @testset "alias tuples and rename asymmetry" begin + # every alias matches, from String- and Symbol-keyed sources + @test StructUtils.make(AliasTupleT, Dict("ident" => 1, "code" => 2)) == AliasTupleT(1, 2) + @test StructUtils.make(AliasTupleT, Dict("idx" => 1, "code" => 2)) == AliasTupleT(1, 2) + @test StructUtils.make(AliasTupleT, Dict(:ident => 1, :code => 2)) == AliasTupleT(1, 2) + @test StructUtils.make(AliasTupleT, Dict(:idx => 1, :code => 2)) == AliasTupleT(1, 2) + # Symbol keys also match the original field name; String keys do not + @test StructUtils.make(AliasTupleT, Dict(:id => 1, :code => 2)) == AliasTupleT(1, 2) + @test_throws Exception StructUtils.make(AliasTupleT, Dict("id" => 1, "code" => 2)) + @test StructUtils.make(RenamedT, Dict(:id => 1, :code => 2)) == RenamedT(1, 2) + @test StructUtils.make(RenamedT, Dict(:identifier => 1, :code => 2)) == RenamedT(1, 2) + @test StructUtils.make(RenamedT, Dict("identifier" => 1, "code" => 2)) == RenamedT(1, 2) + @test_throws Exception StructUtils.make(RenamedT, Dict("id" => 1, "code" => 2)) + end + + @testset "overlapping and explicit names" begin + # Field matching is first-to-last for every source key. A cursor must + # not let a later raw field bypass an earlier alias with the same name. + @test StructUtils.make(CollidingName, [:a => 10, :b => 20]) == + CollidingName(20, -2) + mutable_collision = StructUtils.make(MutableCollidingName, [:a => 10, :b => 20]) + @test (mutable_collision.a, mutable_collision.b) == (20, -2) + @test StructUtils.make(OverlappingNames, ["x" => 1, "x" => 2]) == + OverlappingNames(2, -2) + + # An explicit `name=nothing` differs from an absent name for string + # sources. Symbol sources still match the raw Julia field name. + @test StructUtils.make(ExplicitNothingName, ["a" => 1]) == + ExplicitNothingName(99) + @test StructUtils.make(ExplicitNothingName, [:a => 1]) == + ExplicitNothingName(1) + end + + @testset "custom nullable dispatch" begin + @test StructUtils.make(ChoiceHolder, (value=(x=2,),)) == + ChoiceHolder(ChosenValue(2)) + @test StructUtils.make(ChoiceHolder, (value=nothing,)) == + ChoiceHolder(nothing) + @test StructUtils.make(CustomOptionalHolder, (value=(x=2,),)) == + CustomOptionalHolder(CustomOptionalValue(3)) + @test StructUtils.make(CustomOptionalHolder, (value=nothing,)) == + CustomOptionalHolder(nothing) + end + + @testset "custom key precedence" begin + @test StructUtils.make( + WildcardTarget, + [WildcardKey() => 1, WildcardKey() => 2], + ) == WildcardTarget(2, -2) + end + + @testset "shuffled key order" begin + ordered = [Symbol("f$i") => i for i in 1:10] + expected = StructUtils.make(Ten, ordered) + @test expected == Ten((1:10)...) + # reversed, interleaved, and rotated orders all produce the same value + @test StructUtils.make(Ten, reverse(ordered)) == expected + shuffled = [ordered[i] for i in [7, 2, 10, 4, 1, 9, 3, 6, 5, 8]] + @test StructUtils.make(Ten, shuffled) == expected + rotated = vcat(ordered[4:end], ordered[1:3]) + @test StructUtils.make(Ten, rotated) == expected + # String keys, shuffled, with unknown keys interleaved (ignored by default) + strkeys = vcat(["zzz" => 99], ["f$i" => i for i in 10:-1:1], ["extra" => -1]) + @test StructUtils.make(Ten, strkeys) == expected + end + + @testset "wide struct" begin + ordered = ["g$i" => i for i in 1:64] + w = StructUtils.make(Wide64, ordered) + @test all(getfield(w, i) == i for i in 1:64) + # deterministic full-cycle permutation of key order + perm = [(i * 37) % 64 + 1 for i in 0:63] + @test length(unique(perm)) == 64 + shuffled = ["g$(p)" => p for p in perm] + w2 = StructUtils.make(Wide64, shuffled) + @test all(getfield(w2, i) == i for i in 1:64) + @test w2 == w + end +end diff --git a/test/ignore_inbound.jl b/test/ignore_inbound.jl new file mode 100644 index 0000000..ae565a1 --- /dev/null +++ b/test/ignore_inbound.jl @@ -0,0 +1,91 @@ +using Test, StructUtils + +mutable struct InboundAuditStyle <: StructUtils.StructStyle + unknowns::Vector{Any} +end + +StructUtils.fieldtagkey(::InboundAuditStyle) = :wire +StructUtils.defaultstate(::InboundAuditStyle) = :audit_state +function StructUtils.unknownfield( + style::InboundAuditStyle, + ::Type{T}, + key, + value, +) where {T} + push!(style.unknowns, (T, key, value)) + return :unknown_state +end + +struct StrictInboundStyle <: StructUtils.StructStyle end +StructUtils.fieldtagkey(::StrictInboundStyle) = :wire +StructUtils.unknownfield(::StrictInboundStyle, ::Type{T}, key, value) where {T} = + throw(ArgumentError("unknown $(repr(key)) for $T")) + +@defaults struct PlainIgnored + visible::Int = 1 + ignored::Int = 99 &(ignore=true,) +end + +@defaults struct WireIgnored + visible::Int = 1 &(wire=(name="shown",),) + ignored::Int = 99 &(wire=(name="secret", ignore=true),) + notignored::Int = 7 &(wire=(ignore=false,),) +end + +@noarg mutable struct MutableWireIgnored + visible::Int = 1 &(wire=(name="shown",),) + ignored::Int = 99 &(wire=(name="secret", ignore=true),) +end + +@testset "ignore=true on inbound make" begin + @test StructUtils.make(PlainIgnored, (visible=2, ignored=200)) == + PlainIgnored(2, 99) + @test StructUtils.make(PlainIgnored, Dict("visible" => 2, "ignored" => 200)) == + PlainIgnored(2, 99) + @test StructUtils.make(PlainIgnored, [2, 200]) == PlainIgnored(2, 99) + + for source in ( + Dict{String,Int}("shown" => 2, "secret" => 200, "notignored" => 8), + Dict{Symbol,Int}(:shown => 2, :ignored => 200, :notignored => 8), + [2, 200, 8], + ) + style = InboundAuditStyle(Any[]) + value, state = StructUtils.make(style, WireIgnored, source) + @test value == WireIgnored(2, 99, 8) + @test state === :audit_state + @test isempty(style.unknowns) + end + + style = InboundAuditStyle(Any[]) + value, state = StructUtils.make( + style, + WireIgnored, + ["shown" => 3, "secret" => 300, "extra" => 4], + ) + @test value == WireIgnored(3, 99, 7) + @test state === :audit_state + @test style.unknowns == Any[(WireIgnored, "extra", 4)] + + @test StructUtils.make(WireIgnored, (secret=300,), StrictInboundStyle()) == + WireIgnored(1, 99, 7) + @test_throws ArgumentError StructUtils.make( + WireIgnored, + (extra=4,), + StrictInboundStyle(), + ) + + for source in ( + Dict{String,Int}("shown" => 2, "secret" => 200), + Dict{Symbol,Int}(:shown => 2, :ignored => 200), + [2, 200], + ) + style = InboundAuditStyle(Any[]) + value = MutableWireIgnored() + value.ignored = 55 + state = StructUtils.make!(style, value, source) + @test value.visible == 2 + @test value.ignored == 55 + @test state === :audit_state + @test isempty(style.unknowns) + end +end diff --git a/test/runtests.jl b/test/runtests.jl index 0075093..76bb151 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -44,6 +44,8 @@ end include(joinpath(dirname(pathof(StructUtils)), "../test/macros.jl")) include(joinpath(dirname(pathof(StructUtils)), "../test/struct.jl")) include(joinpath(dirname(pathof(StructUtils)), "../test/selectors.jl")) +include(joinpath(dirname(pathof(StructUtils)), "../test/construction.jl")) +include(joinpath(dirname(pathof(StructUtils)), "../test/ignore_inbound.jl")) @testset "StructUtils" begin