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
13 changes: 11 additions & 2 deletions src/write.jl
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,7 @@ function json end
inline_limit::Int = 0
float_style::Symbol = :shortest # :shortest, :fixed, :exp
float_precision::Int = 1
sort_keys::Union{Bool, Nothing} = nothing
bufsize::Int = 2^22 # 4MB default buffer size for IO flushing
style::S = JSONWriteStyle()
end
Expand Down Expand Up @@ -589,7 +590,7 @@ function (f::WriteClosure{JS, arraylike, T, I})(key, val) where {JS, arraylike,
track_ref && push!(f.ancestor_stack, val)
# if jsonlines, we need to recursively set to false
if f.opts.jsonlines
opts = WriteOptions(; omit_null=f.opts.omit_null, omit_empty=f.opts.omit_empty, allownan=f.opts.allownan, jsonlines=false, pretty=f.opts.pretty, ninf=f.opts.ninf, inf=f.opts.inf, nan=f.opts.nan, inline_limit=f.opts.inline_limit, float_style=f.opts.float_style, float_precision=f.opts.float_precision)
opts = WriteOptions(; omit_null=f.opts.omit_null, omit_empty=f.opts.omit_empty, allownan=f.opts.allownan, jsonlines=false, pretty=f.opts.pretty, ninf=f.opts.ninf, inf=f.opts.inf, nan=f.opts.nan, inline_limit=f.opts.inline_limit, float_style=f.opts.float_style, float_precision=f.opts.float_precision, sort_keys=f.opts.sort_keys)
else
opts = f.opts
end
Expand Down Expand Up @@ -668,7 +669,15 @@ function json!(buf, pos, x, opts::WriteOptions, ancestor_stack::Union{Nothing, V
wroteanyref = Ref(false)
GC.@preserve ref wroteanyref begin
c = WriteClosure{typeof(opts), al, typeof(x), typeof(io)}(buf, Base.unsafe_convert(Ptr{Int}, ref), Base.unsafe_convert(Ptr{Bool}, wroteanyref), local_ind, depth + 1, opts, ancestor_stack, io, bufsize)
StructUtils.applyeach(opts.style, c, x)
_sort_keys = opts.sort_keys === true || (opts.sort_keys === nothing && !al && x isa AbstractDict && !(x isa Object))
if _sort_keys && !al && x isa AbstractDict
sorted_keys = sort!(collect(keys(x)), by=k -> StructUtils.lowerkey(opts.style, k))
for k in sorted_keys
c(StructUtils.lowerkey(opts.style, k), StructUtils.lower(opts.style, x[k]))
end
else
StructUtils.applyeach(opts.style, c, x)
end
# get updated pos
pos = unsafe_load(c.pos)
wroteany = unsafe_load(c.wroteany)
Expand Down
79 changes: 79 additions & 0 deletions test/json.jl
Original file line number Diff line number Diff line change
Expand Up @@ -663,4 +663,83 @@ end
end
end

@testset "sort_keys" begin
# default (nothing): Dict keys are sorted, Object preserves insertion order
@test JSON.json(Dict("c" => 3, "a" => 1, "b" => 2)) == "{\"a\":1,\"b\":2,\"c\":3}"
obj = JSON.Object("c" => 3, "a" => 1, "b" => 2)
@test JSON.json(obj) == "{\"c\":3,\"a\":1,\"b\":2}"

# sort_keys=true: all AbstractDicts sorted, including Object
@test JSON.json(Dict("c" => 3, "a" => 1, "b" => 2); sort_keys=true) == "{\"a\":1,\"b\":2,\"c\":3}"
@test JSON.json(obj; sort_keys=true) == "{\"a\":1,\"b\":2,\"c\":3}"

# sort_keys=false: no sorting for any AbstractDict
@test JSON.json(obj; sort_keys=false) == "{\"c\":3,\"a\":1,\"b\":2}"

# empty dict
@test JSON.json(Dict{String,Any}()) == "{}"
@test JSON.json(Dict{String,Any}(); sort_keys=true) == "{}"

# single key
@test JSON.json(Dict("only" => 42)) == "{\"only\":42}"

# nested dicts are sorted recursively by default
@test JSON.json(Dict("z" => Dict("b" => 2, "a" => 1), "a" => 3)) == "{\"a\":3,\"z\":{\"a\":1,\"b\":2}}"

# arrays are not affected, but dicts inside arrays are sorted
@test JSON.json([Dict("b" => 2, "a" => 1), Dict("d" => 4, "c" => 3)]) == "[{\"a\":1,\"b\":2},{\"c\":3,\"d\":4}]"

# Symbol keys (lowered to strings via lowerkey, sorted as strings)
@test JSON.json(Dict(:zebra => 1, :apple => 2)) == "{\"apple\":2,\"zebra\":1}"

# Integer keys (lowered to strings, sorted lexicographically as strings)
@test JSON.json(Dict(3 => "c", 1 => "a", 2 => "b")) == "{\"1\":\"a\",\"2\":\"b\",\"3\":\"c\"}"

# nested Object inside Dict: Dict sorted, Object preserves order (default)
@test JSON.json(Dict("z" => JSON.Object("b" => 2, "a" => 1), "a" => 3)) == "{\"a\":3,\"z\":{\"b\":2,\"a\":1}}"
# with sort_keys=true, both are sorted
@test JSON.json(Dict("z" => JSON.Object("b" => 2, "a" => 1), "a" => 3); sort_keys=true) == "{\"a\":3,\"z\":{\"a\":1,\"b\":2}}"

# sort_keys + pretty printing
@test JSON.json(Dict("c" => 3, "a" => 1, "b" => 2); pretty=true) == "{\n \"a\": 1,\n \"b\": 2,\n \"c\": 3\n}"

# sort_keys + omit_null
@test JSON.json(Dict("c" => nothing, "a" => 1, "b" => 2); omit_null=true) == "{\"a\":1,\"b\":2}"

# sort_keys does not affect structs (struct field order is deterministic)
@test JSON.json(A(1, 2, 3, 4); sort_keys=true) == "{\"a\":1,\"b\":2,\"c\":3,\"d\":4}"

# sort_keys does not affect arrays
@test JSON.json([3, 1, 2]; sort_keys=true) == "[3,1,2]"

# sort_keys does not affect NamedTuples (field order is deterministic)
@test JSON.json((z=1, a=2); sort_keys=true) == "{\"z\":1,\"a\":2}"

# deeply nested mixed structures
deep = Dict("z" => [Dict("b" => Dict("d" => 4, "c" => 3), "a" => 1)], "m" => 2)
@test JSON.json(deep) == "{\"m\":2,\"z\":[{\"a\":1,\"b\":{\"c\":3,\"d\":4}}]}"

# IO path
io = IOBuffer()
JSON.json(io, Dict("c" => 3, "a" => 1, "b" => 2))
@test String(take!(io)) == "{\"a\":1,\"b\":2,\"c\":3}"

# file path
fname = tempname()
JSON.json(fname, Dict("c" => 3, "a" => 1))
@test read(fname, String) == "{\"a\":1,\"c\":3}"
rm(fname)

# sort_keys + jsonlines (dicts inside array should be sorted)
@test JSON.json([Dict("b" => 2, "a" => 1), Dict("d" => 4, "c" => 3)]; jsonlines=true) == "{\"a\":1,\"b\":2}\n{\"c\":3,\"d\":4}\n"

# sort_keys + buffered IO (small buffer forces flushes)
io = IOBuffer()
large = Dict(string(Char('a' + i)) => i for i in 0:25)
JSON.json(io, large; bufsize=64)
result = String(take!(io))
parsed_keys = [m.match for m in eachmatch(r"\"([a-z])\"", result)]
@test parsed_keys == sort(parsed_keys)
end

end # @testset "JSON.json"
Loading