diff --git a/src/write.jl b/src/write.jl index 2f1d7a2..11a2d1a 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::Union{Bool, Nothing} = nothing 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,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) diff --git a/test/json.jl b/test/json.jl index 8725b43..213a679 100644 --- a/test/json.jl +++ b/test/json.jl @@ -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"