Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Project.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
name = "StructUtils"
uuid = "ec057cc2-7a8d-4b58-b3b3-92acb9f63b42"
version = "2.8.2"
version = "2.9.0"

[deps]
Dates = "ade2ca70-3891-5945-98fb-dc099432e06a"
Expand Down
75 changes: 70 additions & 5 deletions src/StructUtils.jl
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,24 @@ function unknownfield end

unknownfield(st::StructStyle, ::Type{T}, key, value) where {T} = defaultstate(st)

"""
StructUtils.handleunknownfield(::StructStyle, ::Type{T}, key, value)

Source-side dispatch point used by [`StructUtils.make`](@ref) and
[`StructUtils.make!`](@ref) after a source key or index does not match target
type `T`.

The default implementation delegates to [`StructUtils.unknownfield`](@ref).
Custom struct styles should generally overload `unknownfield`; source/format
packages that need source-local behavior should overload `handleunknownfield`
for source value types they own, then delegate to `unknownfield` when the
unknown field should reach user style hooks.
"""
function handleunknownfield end

handleunknownfield(st::StructStyle, ::Type{T}, key, value) where {T} =
unknownfield(st, T, key, value)

"""
StructUtils.fieldtags(::StructStyle, ::Type{T}) -> NamedTuple
StructUtils.fieldtags(::StructStyle, ::Type{T}, fieldname) -> NamedTuple
Expand Down Expand Up @@ -488,6 +506,53 @@ liftkey(::StructStyle, ::Type{T}, x) where {T} = liftkey(T, x)
liftkey(::Type{T}, x) where {T} = lift(T, x)
liftkey(f, st::StructStyle, ::Type{T}, x) where {T} = f(liftkey(st, T, x))

Base.Experimental.@max_methods 1 function extract end

"""
StructUtils.extract(style, ::Type{T}, source, tags) -> (value, state)

The plumbing boundary between the `make` recursion and leaf-value conversion.
When `make` determines a target type `T` is not aggregate-like (not `Tuple`,
`dictlike`, `arraylike`, `noarg`, or `structlike`), it calls `extract` to
convert the raw `source` representation into a value of type `T`.

The default implementation calls [`StructUtils.lift`](@ref) and normalizes its
return value, so user-defined `lift` methods may return either a plain value
(preferred) or a `(value, state)` tuple.

Source/format packages (e.g. JSON.jl) should overload `extract` for *source
types they own* (e.g. `JSON.LazyValue`) in order to materialize scalar values
before calling user-level `lift` hooks, and to thread source-specific state
(e.g. a byte position) back through the `make` machinery. `extract` should not
be overloaded for target types — overload `lift` instead. This split keeps the
user-facing `lift` free of raw source representations and internal state.

`extract` is declared with `Base.Experimental.@max_methods 1`: at abstract call
sites (where the target type is not concrete, e.g. after inference widening in
deeply nested `make` recursion), inference bails out immediately instead of
exploring every candidate method body. Concrete call sites — the entire static
hot path — always have exactly one applicable method and infer precisely.
"""
extract

_isliftpair(x) = x isa Tuple && !(x isa NamedTuple) && length(x) == 2
_normalizelift(x, state) = _isliftpair(x) ? x : (x, state)

extract(st::StructStyle, ::Type{T}, source, tags) where {T} =
_normalizelift(lift(st, T, source, tags), defaultstate(st))

"""
StructUtils.preparekey(k)

Hook for source/format packages to normalize raw key representations before
they are passed to [`StructUtils.liftkey`](@ref) during `dictlike`
construction. The default is the identity function. Source packages should
overload this for internal key types that must never escape to user-level code
(e.g. JSON.jl's `PtrString`). Like [`StructUtils.extract`](@ref), this should
only be overloaded for key types the source package owns.
"""
preparekey(k) = k

"""
StructUtils.applyeach(style, f, x) -> Union{StructUtils.EarlyReturn, Nothing}

Expand Down Expand Up @@ -900,7 +965,7 @@ function make(style::StructStyle, T::Type, source, tags)
if T <: Tuple || dictlike(style, T) || arraylike(style, T) || noarg(style, T) || structlike(style, T)
return make(style, T, source)
else
return lift(style, T, source, tags)
return extract(style, T, source, tags)
end
end

Expand Down Expand Up @@ -967,7 +1032,7 @@ function make(style::StructStyle, T::Type, source)
elseif structlike(style, T)
return makestruct(style, T, source)
else
return lift(style, T, source)
return extract(style, T, source, (;))
end
end

Expand Down Expand Up @@ -1013,7 +1078,7 @@ function (f::TupleClosure{T,A,S})(k, v) where {T,A,S}
end
end
end
return st isa _MatchedState ? st.value : unknownfield(f.style, T, k, v)
return st isa _MatchedState ? st.value : handleunknownfield(f.style, T, k, v)
end

function maketuple(style, ::Type{T}, source) where {T}
Expand All @@ -1033,7 +1098,7 @@ end

function (f::DictClosure{T,S})(k, v) where {T,S}
val, st = make(f.style, _valtype(f.dict), v)
addkeyval!(f.dict, liftkey(f.style, _keytype(f.dict), k), val)
addkeyval!(f.dict, liftkey(f.style, _keytype(f.dict), preparekey(k)), val)
return st
end

Expand Down Expand Up @@ -1164,7 +1229,7 @@ function findfield(::Type{T}, k, v, f) where {T}
end
end
end
return st isa _MatchedState ? st.value : unknownfield(f.style, T, k, v)
return st isa _MatchedState ? st.value : handleunknownfield(f.style, T, k, v)
end

(f::StructClosure{T,A,S,FS,FSS,FT})(k, v) where {T,A,S,FS,FSS,FT} = findfield(T, k, v, f)
Expand Down
59 changes: 59 additions & 0 deletions test/runtests.jl
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,45 @@ function StructUtils.fieldtags(st::PerFieldTagStyle, ::Type{PerFieldTagged}, fie
return (;)
end

# extract/preparekey plumbing hook test types
struct ExtractSource
val::Int
end
struct BareLiftStyle <: StructUtils.StructStyle end
StructUtils.lift(::BareLiftStyle, ::Type{Int}, x) = Int(x) + 100 # bare (non-tuple) return
struct WrappedKey
k::String
end
StructUtils.preparekey(k::WrappedKey) = k.k
# format-style extract overload: unwraps the raw source before lifting
StructUtils.extract(st::StructUtils.StructStyle, ::Type{T}, src::ExtractSource, tags) where {T} =
StructUtils.extract(st, T, src.val, tags)

struct SourceValue
val::Int
end
struct SourceObject
vals::Vector{Pair{String,SourceValue}}
end
struct SourceUnknownStyle <: StructUtils.StructStyle end
struct SourceUnknownTarget
x::Int
end
StructUtils.extract(st::StructUtils.StructStyle, ::Type{T}, src::SourceValue, tags) where {T} =
StructUtils.extract(st, T, src.val, tags)
function StructUtils.applyeach(st::StructUtils.StructStyle, f, src::SourceObject)
state = StructUtils.defaultstate(st)
for pair in src.vals
state = f(pair.first, pair.second)
state isa StructUtils.EarlyReturn && return state
end
return state
end
StructUtils.handleunknownfield(st::StructUtils.StructStyle, ::Type{T}, key, value::SourceValue) where {T} =
StructUtils.unknownfield(st, T, key, value)
StructUtils.unknownfield(::SourceUnknownStyle, ::Type{SourceUnknownTarget}, key, value) =
(:user_unknownfield, key)

include(joinpath(dirname(pathof(StructUtils)), "../test/macros.jl"))
include(joinpath(dirname(pathof(StructUtils)), "../test/struct.jl"))
include(joinpath(dirname(pathof(StructUtils)), "../test/selectors.jl"))
Expand Down Expand Up @@ -191,6 +230,26 @@ println("Tuple")
@test_throws UnknownFieldTestError StructUtils.make!(pmut2, (id=0, name="Joan", rate=3.14); style=StrictUnknownFieldStyle())
end

@testset "extract/preparekey plumbing" begin
# default extract: lift + normalization of bare (non-tuple) lift returns
@test StructUtils.extract(StructUtils.DefaultStyle(), Int, 3, (;)) == (3, nothing)
@test StructUtils.extract(BareLiftStyle(), Int, 3, (;)) == (103, nothing)
# make routes leaf conversion through extract, so bare lift returns work
@test StructUtils.make(Int, 3, BareLiftStyle()) == 103
@test StructUtils.make(Vector{Int}, [1, 2], BareLiftStyle()) == [101, 102]
# format packages can overload extract for source types they own
@test StructUtils.make(Int, ExtractSource(5)) == 5
@test StructUtils.make(Vector{Int}, [ExtractSource(1), ExtractSource(2)]) == [1, 2]
# preparekey normalizes raw keys before liftkey during dictlike construction
@test StructUtils.preparekey(WrappedKey("a")) == "a"
@test StructUtils.make(Dict{String,Int}, [WrappedKey("a") => 1, WrappedKey("b") => 2]) == Dict("a" => 1, "b" => 2)
# source packages can handle unknown source values without colliding with user unknownfield hooks
src = SourceObject(["x" => SourceValue(1), "extra" => SourceValue(2)])
val, state = StructUtils.make(SourceUnknownStyle(), SourceUnknownTarget, src)
@test val == SourceUnknownTarget(1)
@test state == (:user_unknownfield, "extra")
end

@testset "target fieldtags are cached during make" begin
style = CountingTagStyle(0)
@test StructUtils.make(CountingTagged, (a=1, b=2, c=3), style) == CountingTagged(1, 2, 3)
Expand Down
Loading