From aeaf593972d10ebe17b912029ff73fcbed862f55 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Sun, 4 Oct 2026 06:04:13 -0600 Subject: [PATCH 1/2] Convert custom sources to dictionaries through lower Dictionary conversion has no source dispatcher for CustomStruct, so even a valid lower implementation raises MethodError. Lower the object and resume the existing dictionary conversion, preserving target key/value types and ordinary source handling. Document the source contract and add 22 checks. Full four-thread/bounds suites and a 12-check public reproduction pass on Julia 1.6.7 and 1.13.1. The strict Documenter 1.19 build also passes. AI disclosure: This work was prepared with assistance from OpenAI Codex. --- src/StructTypes.jl | 6 +++++- test/runtests.jl | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/src/StructTypes.jl b/src/StructTypes.jl index 8baf61d..787f031 100644 --- a/src/StructTypes.jl +++ b/src/StructTypes.jl @@ -933,7 +933,8 @@ and respecting various serialization/deserialization names, keyword args, etc. Most typical use-case is construct a custom type `T` from an `obj::AbstractDict`, but `constructfrom` is fully generic, so the inverse is also supported (turning any custom -struct into an `AbstractDict`). For example, an external service may be providing JSON +struct into an `AbstractDict`). A `CustomStruct` source is converted to a dictionary +through its `lower` method. For example, an external service may be providing JSON data with an evolving schema; as opposed to trying a strict "typed parsing" like `JSON3.read(json, T)`, it may be preferrable to setup a local custom struct with just the desired properties and call `StructTypes.constructfrom(T, JSON3.read(json))`. This @@ -1007,6 +1008,9 @@ constructfrom(::DictType, ::Type{T}, obj) where {T <: AbstractDict} = @inline constructfrom(::DictType, ::Type{T}, ::Type{K}, ::Type{V}, obj::S) where {T, K, V, S} = constructfrom(DictType(), T, K, V, StructType(S), obj) +constructfrom(::DictType, ::Type{T}, ::Type{K}, ::Type{V}, ::CustomStruct, obj) where {T, K, V} = + constructfrom(DictType(), T, K, V, lower(obj)) + function constructfrom(::DictType, ::Type{T}, ::Type{K}, ::Type{V}, ::DictType, obj) where {T, K, V} d = Dict{K, V}() for (k, v) in keyvaluepairs(obj) diff --git a/test/runtests.jl b/test/runtests.jl index 8712567..aaa83bd 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -877,3 +877,36 @@ end @testset "struct type on complex" begin @test StructTypes.StructType(ComplexF64) == StructTypes.Struct() end + +struct CustomColumn{T, S} + values::Vector{T} +end +StructTypes.StructType(::Type{<:CustomColumn}) = StructTypes.CustomStruct() +StructTypes.lowertype(::Type{<:CustomColumn}) = Dict{String, Any} +StructTypes.lower(c::CustomColumn{T, S}) where {T, S} = Dict{String, Any}( + "type" => string(T), "storage" => string(S), "values" => c.values) + +struct DictionarySource{T} + value::T +end +StructTypes.StructType(::Type{<:DictionarySource}) = StructTypes.CustomStruct() +StructTypes.lower(x::DictionarySource) = x.value + +@testset "dictionary conversion of custom sources" begin + c = CustomColumn{Int, Nothing}([1, 2]) + d = StructTypes.constructfrom(Dict{String, Any}, c) + @test d == Dict("type" => string(Int), "storage" => "Nothing", "values" => [1, 2]) + @test d["values"] === c.values + + for value in (Dict("a" => 1, "b" => 2), (a=1, b=2)), + target in (Dict, AbstractDict, Dict{String, Any}, Dict{Symbol, Float64}) + expected = StructTypes.constructfrom(target, value) + actual = StructTypes.constructfrom(target, DictionarySource(value)) + @test actual == expected + @test typeof(actual) === typeof(expected) + end + @test StructTypes.constructfrom(Dict{String, Int}, DictionarySource(DictionarySource(Dict("a" => 1)))) == Dict("a" => 1) + @test StructTypes.constructfrom(Dict{String, Int}, DictionarySource(Dict{String, Int}())) == Dict{String, Int}() + @test_throws MethodError StructTypes.constructfrom(Dict, DictionarySource([1, 2])) + @test StructTypes.constructfrom(Any, c) === c +end From 497e27c89bd378ec650cdde219da9c3156f1039e Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Tue, 13 Oct 2026 18:24:26 -0600 Subject: [PATCH 2/2] Lower CustomStruct sources for struct and mutable targets too; trim tests Co-Authored-By: Claude Opus 5.5 --- src/StructTypes.jl | 10 ++++++++-- test/runtests.jl | 46 ++++++++++++++++++++++++++-------------------- 2 files changed, 34 insertions(+), 22 deletions(-) diff --git a/src/StructTypes.jl b/src/StructTypes.jl index 787f031..e1b7325 100644 --- a/src/StructTypes.jl +++ b/src/StructTypes.jl @@ -933,8 +933,8 @@ and respecting various serialization/deserialization names, keyword args, etc. Most typical use-case is construct a custom type `T` from an `obj::AbstractDict`, but `constructfrom` is fully generic, so the inverse is also supported (turning any custom -struct into an `AbstractDict`). A `CustomStruct` source is converted to a dictionary -through its `lower` method. For example, an external service may be providing JSON +struct into an `AbstractDict`). A `CustomStruct` source is converted through its +`lower` method. For example, an external service may be providing JSON data with an evolving schema; as opposed to trying a strict "typed parsing" like `JSON3.read(json, T)`, it may be preferrable to setup a local custom struct with just the desired properties and call `StructTypes.constructfrom(T, JSON3.read(json))`. This @@ -1072,6 +1072,9 @@ function constructfrom!(::Mutable, x::T, ::Union{Struct, Mutable}, obj) where {T return x end +constructfrom!(::Mutable, x::T, ::CustomStruct, obj) where {T} = + constructfrom!(x, lower(obj)) + constructfrom(::Struct, ::Type{T}, obj::S) where {T, S} = constructfrom(Struct(), T, StructType(S), obj) @@ -1106,6 +1109,9 @@ end constructfrom(::Struct, ::Type{T}, ::Union{Struct, Mutable}, obj) where {T} = construct(StructClosure(obj), T) +constructfrom(::Struct, ::Type{T}, ::CustomStruct, obj) where {T} = + constructfrom(T, lower(obj)) + struct TupleClosure{T} obj::T end diff --git a/test/runtests.jl b/test/runtests.jl index aaa83bd..adf3c57 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -886,27 +886,33 @@ StructTypes.lowertype(::Type{<:CustomColumn}) = Dict{String, Any} StructTypes.lower(c::CustomColumn{T, S}) where {T, S} = Dict{String, Any}( "type" => string(T), "storage" => string(S), "values" => c.values) -struct DictionarySource{T} - value::T +mutable struct MutableColumn + type::String + values::Vector{Int} + MutableColumn() = new() +end +StructTypes.StructType(::Type{MutableColumn}) = StructTypes.Mutable() + +struct ImmutableColumn + type::String + values::Vector{Int} end -StructTypes.StructType(::Type{<:DictionarySource}) = StructTypes.CustomStruct() -StructTypes.lower(x::DictionarySource) = x.value +StructTypes.StructType(::Type{ImmutableColumn}) = StructTypes.Struct() -@testset "dictionary conversion of custom sources" begin +struct LowersToNamedTuple + x::NamedTuple +end +StructTypes.StructType(::Type{LowersToNamedTuple}) = StructTypes.CustomStruct() +StructTypes.lower(x::LowersToNamedTuple) = x.x + +@testset "constructfrom with CustomStruct sources" begin c = CustomColumn{Int, Nothing}([1, 2]) - d = StructTypes.constructfrom(Dict{String, Any}, c) - @test d == Dict("type" => string(Int), "storage" => "Nothing", "values" => [1, 2]) - @test d["values"] === c.values - - for value in (Dict("a" => 1, "b" => 2), (a=1, b=2)), - target in (Dict, AbstractDict, Dict{String, Any}, Dict{Symbol, Float64}) - expected = StructTypes.constructfrom(target, value) - actual = StructTypes.constructfrom(target, DictionarySource(value)) - @test actual == expected - @test typeof(actual) === typeof(expected) - end - @test StructTypes.constructfrom(Dict{String, Int}, DictionarySource(DictionarySource(Dict("a" => 1)))) == Dict("a" => 1) - @test StructTypes.constructfrom(Dict{String, Int}, DictionarySource(Dict{String, Int}())) == Dict{String, Int}() - @test_throws MethodError StructTypes.constructfrom(Dict, DictionarySource([1, 2])) - @test StructTypes.constructfrom(Any, c) === c + @test StructTypes.constructfrom(Dict{String, Any}, c) == + Dict("type" => string(Int), "storage" => "Nothing", "values" => [1, 2]) + @test StructTypes.constructfrom(Dict{Symbol, Any}, c)[:storage] == "Nothing" + w = LowersToNamedTuple((type="Int64", values=[1, 2])) + m = StructTypes.constructfrom(MutableColumn, w) + @test (m.type, m.values) == ("Int64", [1, 2]) + s = StructTypes.constructfrom(ImmutableColumn, w) + @test (s.type, s.values) == ("Int64", [1, 2]) end