From d42b5a9e7147a703f99170ca94ad519f1b8a42b9 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Sat, 3 Oct 2026 17:18:12 -0600 Subject: [PATCH 1/2] Preserve field dispatch for ignored defaults Remove the invalid fallback that references x in the type-only dispatcher. Unknown or excluded names return false without running the callback; defaults continue to be applied by construction. Add regression tests for these cases. Restore validation on current Julia: account for UUID becoming public in Base, replace retired CI actions, authenticate coverage with GitHub OIDC, and repair strict documentation builds. The corrected NamedTuple defaults example was proposed by hellemo in #101. Full suites pass on Julia 1.6.7, 1.12.6, and 1.13.1; the new 20-check regression set and JSON3 1.14.3 reproduction pass. Documenter 1.19 builds with strict checks. AI disclosure: This work was prepared with assistance from OpenAI Codex. --- .github/workflows/ci.yml | 28 +++++++++++++++------ docs/Project.toml | 4 +++ docs/make.jl | 3 +-- docs/src/index.md | 45 +++++++++++++++++++++++++-------- src/StructTypes.jl | 11 +++----- test/runtests.jl | 54 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 117 insertions(+), 28 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8c379f1..007532a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,9 @@ jobs: test: name: Julia ${{ matrix.version }} - ${{ matrix.os }} - ${{ matrix.arch }} - ${{ github.event_name }} runs-on: ${{ matrix.os }} + permissions: + contents: read + id-token: write strategy: fail-fast: false matrix: @@ -24,12 +27,12 @@ jobs: version: '1' arch: x86 steps: - - uses: actions/checkout@v2 - - uses: julia-actions/setup-julia@v1 + - uses: actions/checkout@v4 + - uses: julia-actions/setup-julia@v2 with: version: ${{ matrix.version }} arch: ${{ matrix.arch }} - - uses: actions/cache@v1 + - uses: actions/cache@v4 env: cache-name: cache-artifacts with: @@ -42,16 +45,25 @@ jobs: - uses: julia-actions/julia-buildpkg@v1 - uses: julia-actions/julia-runtest@v1 - uses: julia-actions/julia-processcoverage@v1 - - uses: codecov/codecov-action@v1 + - uses: codecov/codecov-action@v5 with: - file: lcov.info + files: lcov.info + fail_ci_if_error: true + # Repository jobs authenticate with GitHub OIDC; public fork PRs upload without a token. + use_oidc: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} docs: name: Documentation runs-on: ubuntu-latest + permissions: + contents: write + statuses: write steps: - - uses: actions/checkout@v2 - - uses: julia-actions/julia-buildpkg@latest - - uses: julia-actions/julia-docdeploy@latest + - uses: actions/checkout@v4 + - uses: julia-actions/setup-julia@v2 + with: + version: '1' + - uses: julia-actions/julia-buildpkg@v1 + - uses: julia-actions/julia-docdeploy@v1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} DOCUMENTER_KEY: ${{ secrets.DOCUMENTER_KEY }} diff --git a/docs/Project.toml b/docs/Project.toml index dfa65cd..c2b4a8e 100644 --- a/docs/Project.toml +++ b/docs/Project.toml @@ -1,2 +1,6 @@ [deps] Documenter = "e30172f5-a6a5-5a46-863b-614d45cd2de4" +StructTypes = "856f2bd8-1eba-4b0a-8007-ebc267875bd4" + +[compat] +Documenter = "1" diff --git a/docs/make.jl b/docs/make.jl index 98b5fac..5a704bd 100644 --- a/docs/make.jl +++ b/docs/make.jl @@ -6,10 +6,9 @@ makedocs(; pages=[ "Home" => "index.md", ], - repo="https://github.com/JuliaData/StructTypes.jl/blob/{commit}{path}#L{line}", + repo=Documenter.Remotes.GitHub("JuliaData", "StructTypes.jl"), sitename="StructTypes.jl", authors="Jacob Quinn", - assets=String[], ) deploydocs(; diff --git a/docs/src/index.md b/docs/src/index.md index 2eddafd..29cc731 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -14,6 +14,10 @@ Depth = 3 ## `StructTypes.StructType` +```@docs +StructTypes.StructType +``` + In general, custom Julia types tend to be one of: 1) "data types", 2) "interface types" or sometimes 3) "custom types" or 4) "abstract types" with a known set of concrete subtypes. Data types tend to be "collection of fields" kind of types; fields are generally public and directly accessible, they might also be made to model "objects" in the object-oriented sense. In any case, the type is "nominal" in the sense that it's "made up" of the fields it has, sometimes even if just for making it more convenient to pass them around together in functions. Interface types, on the other hand, are characterized by **private** fields; they contain optimized representations "under the hood" to provide various features/functionality and are useful via interface methods implemented: iteration, `getindex`, accessor methods, etc. Many package-provided libraries or Base-provided structures are like this: `Dict`, `Array`, `Socket`, etc. For these types, their underlying fields are mostly cryptic and provide little value to users directly, and are often explictly documented as being implementation details and not to be relied upon directly under warning of breakage. @@ -33,6 +37,10 @@ Each of these 4 kinds of struct type categories will be now be detailed. ### DataTypes +```@docs +StructTypes.DataType +``` + You'll remember that "data types" are Julia structs that are "made up" of their fields. In the object-oriented world, this would be characterized by marking a field as `public`. A quick example is: @@ -74,6 +82,7 @@ Support functions for `StructTypes.DataType`s: StructTypes.names StructTypes.excludes StructTypes.omitempties +StructTypes.defaults StructTypes.keywordargs StructTypes.idproperty StructTypes.fieldprefix @@ -81,6 +90,10 @@ StructTypes.fieldprefix ### Interface Types +```@docs +StructTypes.InterfaceType +``` + For interface types, we don't want the internal fields of a type exposed, so an alternative API is to define the closest "basic" type that our custom type should map to. This is done by choosing one of the following definitions: ```julia StructTypes.StructType(::Type{MyType}) = StructTypes.DictType() @@ -129,6 +142,12 @@ StructTypes.BoolType StructTypes.NullType ``` +#### `StructTypes.NoStructType` + +```@docs +StructTypes.NoStructType +``` + ### CustomStruct ```@docs @@ -165,14 +184,18 @@ The `StructType` of a type can be set using the following utility macros StructTypes.@Struct ``` -And similarly for -- `StructTypes.@Mutable` -- `StructTypes.@CustomStruct` -- `StructTypes.@OrderedStruct` -- `StructTypes.@AbstractType` -- `StructTypes.@DictType` -- `StructTypes.@ArrayType` -- `StructTypes.@StringType` -- `StructTypes.@NumberType` -- `StructTypes.@BoolType` -- `StructTypes.@NullType` \ No newline at end of file +The corresponding macros are available for the other struct types: + +```@docs +StructTypes.@Mutable +StructTypes.@CustomStruct +StructTypes.@OrderedStruct +StructTypes.@AbstractType +StructTypes.@DictType +StructTypes.@ArrayType +StructTypes.@StringType +StructTypes.@NumberType +StructTypes.@BoolType +StructTypes.@NullType +StructTypes.@register_struct_subtype +``` diff --git a/src/StructTypes.jl b/src/StructTypes.jl index 2a45d86..8baf61d 100644 --- a/src/StructTypes.jl +++ b/src/StructTypes.jl @@ -34,7 +34,7 @@ struct CustomStruct <: StructType end This is a required method for types declaring `StructTypes.CustomStruct`. Allows objects of type `T` to conveniently serialize/deserialize as another type, when their own structure/definition isn't significant. Useful for wrapper types. See also -[`StructTypes.CustomStruct`](@ref) and [`StructType.lowertype`](@ref). +[`StructTypes.CustomStruct`](@ref) and [`StructTypes.lowertype`](@ref). """ function lower end @@ -218,7 +218,7 @@ isempty(::Type{T}, x) where {T} = isempty(x) # generic fallback isempty(::Type{T}, x, i) where {T} = isempty(T, Core.getfield(x, i)) # generic fallback """ - StructTypes.defaults(::Type{MyType}) = (:field_a=default_value, :field_b=>default_value) + StructTypes.defaults(::Type{MyType}) = (field_a=default_value, field_b=default_value) Define default arguments for various fields of `MyType`, which will be used to initialize the name-value dictionary used in `StructTypes.construct`. @@ -847,11 +847,11 @@ end """ StructTypes.applyfield(f, ::Type{T}, nm::Symbol) => Bool -Convenience function for working with a `StructTypes.Mutable` object. For a given serialization name `nm`, +Convenience function for working with the fields of type `T`. For a given serialization name `nm`, apply the function `f(i, name, FT; kw...)` to the field index `i`, field name `name`, field type `FT`, and any keyword arguments `kw` defined in `StructTypes.keywordargs`. Various StructType configurations are respected like keyword arguments, names, and exclusions. -`applyfield` returns whether `f` was executed or not; if `nm` isn't a valid field name on `x`, `false` +`applyfield` returns whether `f` was executed or not; if `nm` isn't a valid field name on `T`, `false` will be returned (important for applications where the input still needs to consume the field, like json parsing). Note that the input `nm` is treated as the serialization name, so any `StructTypes.names` mappings will be applied, and the function will be passed the Julia field name. @@ -890,9 +890,6 @@ mappings will be applied, and the function will be passed the Julia field name. end end ) - if !f_applied && haskey(defaults(T), nm) - setfield!(x, nm, defaults(T)[nm]) - end return f_applied end diff --git a/test/runtests.jl b/test/runtests.jl index 1ca2e57..1d21335 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -72,6 +72,7 @@ builtin_type_mapping = Dict( Base.Cwstring, Base.Regex, Base.VersionNumber, + UUID, }, StructTypes.NumberType() => Union{ Core.Number, # Should all of `Number` and its subtypes be Numbers by default? @@ -825,6 +826,59 @@ StructTypes.defaults(::Type{Defaultable}) = (b="b",) @test StructTypes.constructfrom(Defaultable, (a="a", b="c")) == Defaultable("a", "c") end +struct DefaultedFields + value::Int +end +StructTypes.StructType(::Type{DefaultedFields}) = StructTypes.Struct() +StructTypes.defaults(::Type{DefaultedFields}) = (value=10, extra=20) + +mutable struct MutableDefaultedFields + value::Int + MutableDefaultedFields() = new(42) +end +StructTypes.StructType(::Type{MutableDefaultedFields}) = StructTypes.Mutable() +StructTypes.defaults(::Type{MutableDefaultedFields}) = (value=10, extra=20) + +mutable struct ExcludedDefaultedField + value::Int + ExcludedDefaultedField() = new(42) +end +StructTypes.StructType(::Type{ExcludedDefaultedField}) = StructTypes.Mutable() +StructTypes.defaults(::Type{ExcludedDefaultedField}) = (value=10,) +StructTypes.excludes(::Type{ExcludedDefaultedField}) = (:value,) +StructTypes.names(::Type{ExcludedDefaultedField}) = ((:value, :wire_value),) + +@testset "field dispatch with defaults" begin + calls = Ref(0) + callback = (i, nm, FT) -> (calls[] += 1; 99) + + @test StructTypes.applyfield(callback, DefaultedFields, :extra) === false + @test StructTypes.applyfield(callback, MutableDefaultedFields, :extra) === false + @test StructTypes.applyfield(callback, ExcludedDefaultedField, :wire_value) === false + @test calls[] == 0 + @test StructTypes.applyfield(callback, DefaultedFields, :value) === true + @test calls[] == 1 + + x = MutableDefaultedFields() + @test StructTypes.applyfield!(callback, x, :extra) === false + @test x.value == 42 + @test calls[] == 1 + @test StructTypes.applyfield!(callback, x, :value) === true + @test x.value == 99 + @test calls[] == 2 + + excluded = ExcludedDefaultedField() + @test StructTypes.applyfield!(callback, excluded, :wire_value) === false + @test excluded.value == 42 + @test calls[] == 2 + + @test StructTypes.constructfrom(DefaultedFields, Dict{Symbol, Int}()).value == 10 + @test StructTypes.constructfrom(DefaultedFields, Dict(:value=>3, :extra=>4)).value == 3 + @test StructTypes.constructfrom(MutableDefaultedFields, Dict{Symbol, Int}()).value == 10 + @test StructTypes.constructfrom(MutableDefaultedFields, Dict(:value=>3, :extra=>4)).value == 3 + @test StructTypes.constructfrom(ExcludedDefaultedField, Dict(:wire_value=>3)).value == 42 +end + # CustomStruct subtypes abstract type Vehicle2 end From c8b773a04f61114406fd64596e1207a87a54960b Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Tue, 13 Oct 2026 18:22:44 -0600 Subject: [PATCH 2/2] Trim applyfield defaults test; don't fail CI on Codecov upload errors Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 1 - test/runtests.jl | 39 +-------------------------------------- 2 files changed, 1 insertion(+), 39 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 007532a..10d5fff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,7 +48,6 @@ jobs: - uses: codecov/codecov-action@v5 with: files: lcov.info - fail_ci_if_error: true # Repository jobs authenticate with GitHub OIDC; public fork PRs upload without a token. use_oidc: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} docs: diff --git a/test/runtests.jl b/test/runtests.jl index 1d21335..8712567 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -832,51 +832,14 @@ end StructTypes.StructType(::Type{DefaultedFields}) = StructTypes.Struct() StructTypes.defaults(::Type{DefaultedFields}) = (value=10, extra=20) -mutable struct MutableDefaultedFields - value::Int - MutableDefaultedFields() = new(42) -end -StructTypes.StructType(::Type{MutableDefaultedFields}) = StructTypes.Mutable() -StructTypes.defaults(::Type{MutableDefaultedFields}) = (value=10, extra=20) - -mutable struct ExcludedDefaultedField - value::Int - ExcludedDefaultedField() = new(42) -end -StructTypes.StructType(::Type{ExcludedDefaultedField}) = StructTypes.Mutable() -StructTypes.defaults(::Type{ExcludedDefaultedField}) = (value=10,) -StructTypes.excludes(::Type{ExcludedDefaultedField}) = (:value,) -StructTypes.names(::Type{ExcludedDefaultedField}) = ((:value, :wire_value),) - -@testset "field dispatch with defaults" begin +@testset "applyfield with defaults for unknown names" begin calls = Ref(0) callback = (i, nm, FT) -> (calls[] += 1; 99) - @test StructTypes.applyfield(callback, DefaultedFields, :extra) === false - @test StructTypes.applyfield(callback, MutableDefaultedFields, :extra) === false - @test StructTypes.applyfield(callback, ExcludedDefaultedField, :wire_value) === false @test calls[] == 0 @test StructTypes.applyfield(callback, DefaultedFields, :value) === true @test calls[] == 1 - - x = MutableDefaultedFields() - @test StructTypes.applyfield!(callback, x, :extra) === false - @test x.value == 42 - @test calls[] == 1 - @test StructTypes.applyfield!(callback, x, :value) === true - @test x.value == 99 - @test calls[] == 2 - - excluded = ExcludedDefaultedField() - @test StructTypes.applyfield!(callback, excluded, :wire_value) === false - @test excluded.value == 42 - @test calls[] == 2 - @test StructTypes.constructfrom(DefaultedFields, Dict{Symbol, Int}()).value == 10 - @test StructTypes.constructfrom(DefaultedFields, Dict(:value=>3, :extra=>4)).value == 3 - @test StructTypes.constructfrom(MutableDefaultedFields, Dict{Symbol, Int}()).value == 10 - @test StructTypes.constructfrom(MutableDefaultedFields, Dict(:value=>3, :extra=>4)).value == 3 - @test StructTypes.constructfrom(ExcludedDefaultedField, Dict(:wire_value=>3)).value == 42 end # CustomStruct subtypes