From 97265e57c6decd447f0dea490c2218d2eaad1429 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Tue, 17 Mar 2026 11:08:20 -0600 Subject: [PATCH 1/2] Add sort_keys keyword argument for deterministic JSON output Adds `sort_keys::Bool=false` to `JSON.json` and `JSON.print` that produces output with alphabetically sorted dictionary keys. This is a standard feature in JSON libraries across languages (Python's json.dumps(sort_keys=True), Go's encoding/json, etc.) and enables reproducible output for snapshot testing, diffing, and caching. The implementation sorts dict keys by their lowered string representation and works recursively for nested dicts. It correctly handles String, Symbol, and Integer key types, JSON.Object, and composes with all existing options (pretty, omit_null, jsonlines, buffered IO, etc.). Struct and NamedTuple field order is unaffected since it is already deterministic. Closes #437 Co-Authored-By: Claude Opus 4.6 --- src/write.jl | 12 +++++++-- test/json.jl | 73 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/write.jl b/src/write.jl index 2f1d7a2..3514b0d 100644 --- a/src/write.jl +++ b/src/write.jl @@ -434,6 +434,7 @@ function json end inline_limit::Int = 0 float_style::Symbol = :shortest # :shortest, :fixed, :exp float_precision::Int = 1 + sort_keys::Bool = false bufsize::Int = 2^22 # 4MB default buffer size for IO flushing style::S = JSONWriteStyle() end @@ -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 @@ -668,7 +669,14 @@ 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) + if opts.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) diff --git a/test/json.jl b/test/json.jl index 8725b43..27d3e56 100644 --- a/test/json.jl +++ b/test/json.jl @@ -663,4 +663,77 @@ end end end +@testset "sort_keys" begin + # basic alphabetical sorting + @test JSON.json(Dict("c" => 3, "a" => 1, "b" => 2); sort_keys=true) == "{\"a\":1,\"b\":2,\"c\":3}" + + # sort_keys=false is default (no sorting guarantee, but should not error) + @test JSON.json(Dict("a" => 1); sort_keys=false) == "{\"a\":1}" + + # empty dict + @test JSON.json(Dict{String,Any}(); sort_keys=true) == "{}" + + # single key + @test JSON.json(Dict("only" => 42); sort_keys=true) == "{\"only\":42}" + + # nested dicts are sorted recursively + @test JSON.json(Dict("z" => Dict("b" => 2, "a" => 1), "a" => 3); sort_keys=true) == "{\"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)]; sort_keys=true) == "[{\"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); sort_keys=true) == "{\"apple\":2,\"zebra\":1}" + + # Integer keys (lowered to strings, sorted lexicographically as strings) + @test JSON.json(Dict(3 => "c", 1 => "a", 2 => "b"); sort_keys=true) == "{\"1\":\"a\",\"2\":\"b\",\"3\":\"c\"}" + + # JSON.Object (preserves insertion order normally, but sort_keys overrides) + obj = JSON.Object("c" => 3, "a" => 1, "b" => 2) + @test JSON.json(obj; sort_keys=true) == "{\"a\":1,\"b\":2,\"c\":3}" + # without sort_keys, Object preserves insertion order + @test JSON.json(obj) == "{\"c\":3,\"a\":1,\"b\":2}" + + # sort_keys + pretty printing + @test JSON.json(Dict("c" => 3, "a" => 1, "b" => 2); sort_keys=true, 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); sort_keys=true, 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; sort_keys=true) == "{\"m\":2,\"z\":[{\"a\":1,\"b\":{\"c\":3,\"d\":4}}]}" + + # IO path + io = IOBuffer() + JSON.json(io, Dict("c" => 3, "a" => 1, "b" => 2); sort_keys=true) + @test String(take!(io)) == "{\"a\":1,\"b\":2,\"c\":3}" + + # file path + fname = tempname() + JSON.json(fname, Dict("c" => 3, "a" => 1); sort_keys=true) + @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)]; sort_keys=true, 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; sort_keys=true, 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" From b70073930ba9df188ee70d200dbbcbcb3cc55303 Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Tue, 17 Mar 2026 11:23:51 -0600 Subject: [PATCH 2/2] Default to sorted keys for Dict, preserve insertion order for Object Change sort_keys from Bool to Union{Bool, Nothing} with default nothing. The three-valued semantics: - nothing (default): sort non-Object AbstractDicts, preserve Object order - true: sort all AbstractDicts including Object - false: no sorting for any AbstractDict This matches Go/Rust behavior (sorted by default) while respecting the explicit ordering contract of JSON.Object. Co-Authored-By: Claude Opus 4.6 --- src/write.jl | 5 +++-- test/json.jl | 48 +++++++++++++++++++++++++++--------------------- 2 files changed, 30 insertions(+), 23 deletions(-) diff --git a/src/write.jl b/src/write.jl index 3514b0d..11a2d1a 100644 --- a/src/write.jl +++ b/src/write.jl @@ -434,7 +434,7 @@ function json end inline_limit::Int = 0 float_style::Symbol = :shortest # :shortest, :fixed, :exp float_precision::Int = 1 - sort_keys::Bool = false + sort_keys::Union{Bool, Nothing} = nothing bufsize::Int = 2^22 # 4MB default buffer size for IO flushing style::S = JSONWriteStyle() end @@ -669,7 +669,8 @@ 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) - if opts.sort_keys && !al && x isa AbstractDict + _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])) diff --git a/test/json.jl b/test/json.jl index 27d3e56..213a679 100644 --- a/test/json.jl +++ b/test/json.jl @@ -664,41 +664,47 @@ end end @testset "sort_keys" begin - # basic alphabetical sorting + # 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 is default (no sorting guarantee, but should not error) - @test JSON.json(Dict("a" => 1); sort_keys=false) == "{\"a\":1}" + # 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); sort_keys=true) == "{\"only\":42}" + @test JSON.json(Dict("only" => 42)) == "{\"only\":42}" - # nested dicts are sorted recursively - @test JSON.json(Dict("z" => Dict("b" => 2, "a" => 1), "a" => 3); sort_keys=true) == "{\"a\":3,\"z\":{\"a\":1,\"b\":2}}" + # 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)]; sort_keys=true) == "[{\"a\":1,\"b\":2},{\"c\":3,\"d\":4}]" + @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); sort_keys=true) == "{\"apple\":2,\"zebra\":1}" + @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"); sort_keys=true) == "{\"1\":\"a\",\"2\":\"b\",\"3\":\"c\"}" + @test JSON.json(Dict(3 => "c", 1 => "a", 2 => "b")) == "{\"1\":\"a\",\"2\":\"b\",\"3\":\"c\"}" - # JSON.Object (preserves insertion order normally, but sort_keys overrides) - obj = JSON.Object("c" => 3, "a" => 1, "b" => 2) - @test JSON.json(obj; sort_keys=true) == "{\"a\":1,\"b\":2,\"c\":3}" - # without sort_keys, Object preserves insertion order - @test JSON.json(obj) == "{\"c\":3,\"a\":1,\"b\":2}" + # 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); sort_keys=true, pretty=true) == "{\n \"a\": 1,\n \"b\": 2,\n \"c\": 3\n}" + @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); sort_keys=true, omit_null=true) == "{\"a\":1,\"b\":2}" + @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}" @@ -711,26 +717,26 @@ end # deeply nested mixed structures deep = Dict("z" => [Dict("b" => Dict("d" => 4, "c" => 3), "a" => 1)], "m" => 2) - @test JSON.json(deep; sort_keys=true) == "{\"m\":2,\"z\":[{\"a\":1,\"b\":{\"c\":3,\"d\":4}}]}" + @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); sort_keys=true) + 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); sort_keys=true) + 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)]; sort_keys=true, jsonlines=true) == "{\"a\":1,\"b\":2}\n{\"c\":3,\"d\":4}\n" + @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; sort_keys=true, bufsize=64) + 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)