Skip to content
Merged
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
39 changes: 39 additions & 0 deletions docs/src/writing.md
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,45 @@ Or by using a convenient macro annotation when defining the struct:
end
```

#### Field-level overrides with `JSON.Null` / `JSON.Omit`

Sometimes you want a struct to opt into `omit_null=true` globally, while still forcing specific
fields to emit `null`, or vice-versa. JSON.jl provides two sentinel constructors (defined in the
`JSON` module but intentionally not exported) to cover those cases:

- `JSON.Null()` always serializes as the literal `null`, even when omit-null logic would normally
skip it.
- `JSON.Omit()` drops the enclosing field/entry regardless of omit settings. (It is only valid
inside an object/array; using it as the top-level value throws an error.)

You can reference these sentinels directly in your data types or return them from custom `lower`
functions attached via field tags.

```julia
struct Profile
id::Int
email::Union{String, JSON.Null}
nickname::Union{String, JSON.Omit}
end

profile = Profile(1, JSON.Null(), JSON.Omit())

# `email` stays in the payload even with omit_null=true
JSON.json(profile; omit_null=true)
# {"id":1,"email":null}

@tags struct User
id::Int
display_name::Union{Nothing, String} &(json=(lower=n -> something(n, JSON.Omit()),),)
end

user = User(2, nothing)

# Field-level lowering can return JSON.Omit() to remove the entry entirely
JSON.json(user)
# {"id":2}
```

### Special Numeric Values

By default, JSON.json throws an error when trying to serialize `NaN`, `Inf`, or `-Inf` as they are not valid JSON. However, you can enable them with the `allownan` option:
Expand Down
36 changes: 35 additions & 1 deletion src/write.jl
Original file line number Diff line number Diff line change
@@ -1,11 +1,32 @@
struct JSONWriteStyle <: JSONStyle end

"""
JSON.Null()

Singleton sentinel that always serializes as the JSON literal `null`,
even when `omit_null=true` at the struct or callsite level. Useful for
per-field overrides (e.g. `Union{Nothing, JSON.Null}`) or custom field
lowering that must force a `null` emission.
"""
struct Null end

"""
JSON.Omit()

Singleton sentinel that removes the enclosing value from the JSON output,
regardless of `omit_null` / `omit_empty` settings. Valid within objects
and arrays; using it as the root value throws an error.
"""
struct Omit end

sizeguess(::Nothing) = 4
sizeguess(x::Bool) = 5
sizeguess(x::Integer) = 20
sizeguess(x::AbstractFloat) = 20
sizeguess(x::Union{Float16, Float32, Float64}) = Base.Ryu.neededdigits(typeof(x))
sizeguess(x::AbstractString) = 2 + sizeof(x)
sizeguess(::Null) = 4
sizeguess(::Omit) = 0
sizeguess(_) = 512

StructUtils.lower(::JSONStyle, ::Missing) = nothing
Expand All @@ -16,6 +37,7 @@ StructUtils.lower(::JSONStyle, x::AbstractArray{<:Any,0}) = x[1]
StructUtils.lower(::JSONStyle, x::AbstractArray{<:Any, N}) where {N} = (view(x, ntuple(_ -> :, N - 1)..., j) for j in axes(x, N))
StructUtils.lower(::JSONStyle, x::AbstractVector) = x
StructUtils.arraylike(::JSONStyle, x::AbstractVector{<:Pair}) = false
StructUtils.structlike(::JSONStyle, ::Type{<:NamedTuple}) = true

# for pre-1.0 compat, which serialized Tuple object keys by default
StructUtils.lowerkey(::JSONStyle, x::Tuple) = string(x)
Expand Down Expand Up @@ -234,6 +256,12 @@ All methods accept the following keyword arguments:
If `true`, empty fields are excluded. If `false`, empty fields are included.
If `nothing`, the behavior is determined by `JSON.omit_empty(::Type{T})`.

- `JSON.Null()` / `JSON.Omit()` sentinels: `JSON.Null()` always emits a JSON `null`
literal even when `omit_null=true`, enabling per-field overrides (for example by
declaring a field as `Union{Nothing, JSON.Null}`) or defining a custom `lower` function for a field that returns `JSON.Null`.
`JSON.Omit()` removes the enclosing value from the output regardless of global omit settings, making it easy for field-level
lowering code to drop optional data entirely. For example, by defining a custom `lower` function for a field that returns `JSON.Omit`.

- `allownan::Bool=false`: If `true`, allow `Inf`, `-Inf`, and `NaN` in the output.
If `false`, throw an error if `Inf`, `-Inf`, or `NaN` is encountered.

Expand Down Expand Up @@ -403,6 +431,7 @@ float_precision_check(fs, fp) = (fs == :shortest || fp > 0) || float_precision_t
# if jsonlines and pretty is not 0 or false, throw an ArgumentError
@noinline _jsonlines_pretty_throw() = throw(ArgumentError("pretty printing is not supported when writing jsonlines"))
_jsonlines_pretty_check(jsonlines, pretty) = jsonlines && pretty !== false && !iszero(pretty) && _jsonlines_pretty_throw()
@noinline _root_omit_throw() = throw(ArgumentError("JSON.Omit() is only valid inside arrays or objects"))

function json(io::IO, x::T; pretty::Union{Integer,Bool}=false, kw...) where {T}
opts = WriteOptions(; pretty=pretty === true ? 2 : Int(pretty), kw...)
Expand Down Expand Up @@ -503,6 +532,7 @@ checkkey(s) = s isa AbstractString || throw(ArgumentError("Value returned from `
function (f::WriteClosure{JS, arraylike, T, I})(key, val) where {JS, arraylike, T, I}
track_ref = ismutabletype(typeof(val))
is_circ_ref = track_ref && any(x -> x === val, f.ancestor_stack)
val isa Omit && return
if !arraylike
# for objects, check omit_null/omit_empty
# and skip if the value is null or empty
Expand Down Expand Up @@ -563,7 +593,11 @@ end
# assume x is lowered value
function json!(buf, pos, x, opts::WriteOptions, ancestor_stack::Union{Nothing, Vector{Any}}=nothing, io::Union{Nothing, IO}=nothing, ind::Int=opts.pretty, depth::Int=0, bufsize::Int=opts.bufsize)
# string
if x isa AbstractString
if x isa Omit
_root_omit_throw()
elseif x isa Null
return _null(buf, pos, io, bufsize)
elseif x isa AbstractString
return _string(buf, pos, x, io, bufsize)
# write JSONText out directly
elseif x isa JSONText
Expand Down
19 changes: 19 additions & 0 deletions test/json.jl
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ end
values::Vector{Int}
end

@omit_null struct SentinelOverrides
id::Int
forced::Union{Nothing, JSON.Null}
passthrough::Union{Nothing, String}
dropped::Union{Nothing, JSON.Omit}
end

@testset "JSON.json" begin

@testset "Basics" begin
Expand Down Expand Up @@ -254,6 +261,18 @@ end
@test JSON.json((a=1, b=nothing); omit_null=false) == "{\"a\":1,\"b\":null}"
@test JSON.json((a=1, b=[]); omit_empty=true) == "{\"a\":1}"
@test JSON.json((a=1, b=[]); omit_empty=false) == "{\"a\":1,\"b\":[]}"
@testset "Sentinel overrides" begin
@test JSON.json(JSON.Null()) == "null"
@test_throws ArgumentError JSON.json(JSON.Omit())
@test JSON.json((a=1, b=JSON.Null()); omit_null=true) == "{\"a\":1,\"b\":null}"
@test JSON.json((a=JSON.Omit(), b=JSON.Null())) == "{\"b\":null}"
@test JSON.json((a=JSON.Omit(), b=2); omit_null=false) == "{\"b\":2}"
@test JSON.json([JSON.Omit(), 1, JSON.Omit(), 2]) == "[1,2]"
@test JSON.json([JSON.Omit(), JSON.Omit()]) == "[]"
x = SentinelOverrides(1, JSON.Null(), nothing, JSON.Omit())
@test JSON.json(x) == "{\"id\":1,\"forced\":null}"
@test JSON.json(x; omit_null=false) == "{\"id\":1,\"forced\":null,\"passthrough\":null}"
end
# custom style overload
JSON.lower(::CustomJSONStyle, x::Rational) = (num=x.num, den=x.den)
@test JSON.json(1//3; style=CustomJSONStyle()) == "{\"num\":1,\"den\":3}"
Expand Down
Loading