From ce80352e790c35adb6d2502941b9c2926cbc1e75 Mon Sep 17 00:00:00 2001 From: xfy Date: Sat, 9 May 2026 16:09:49 +0800 Subject: [PATCH] feat(gjson): add encode_sort_keys option for stable JSON output Add configurable key sorting for JSON object encoding. When enabled, object keys are sorted alphabetically for deterministic output. Default is disabled for better performance. Co-Authored-By: Claude Opus 4.7 --- gjson/api.go | 17 +++++++++++ gjson/config.go | 4 +++ gjson/encode.go | 73 ++++++++++++++++++++++++++++++++++++++------- gjson/gjson.go | 2 ++ gjson/gjson_test.go | 37 +++++++++++++++++++++++ gjson/types.go | 1 + 6 files changed, 123 insertions(+), 11 deletions(-) diff --git a/gjson/api.go b/gjson/api.go index 9dc0487..ae21ab4 100644 --- a/gjson/api.go +++ b/gjson/api.go @@ -111,6 +111,23 @@ func (g *GJSON) cfgEncodeKeepBuffer(L *glua.LState) int { return 1 } +// cfgEncodeSortKeys configures whether to sort object keys for stable output. +// Lua: gjson.encode_sort_keys([sort]) +// Returns current value when called without arguments. +// When enabled, object keys are sorted alphabetically for deterministic output. +// When disabled (default), keys are output in arbitrary order for better performance. +func (g *GJSON) cfgEncodeSortKeys(L *glua.LState) int { + if L.GetTop() == 0 { + L.Push(glua.LBool(g.config.encodeSortKeys)) + return 1 + } + + g.config.encodeSortKeys = L.CheckBool(1) + + L.Push(glua.LBool(g.config.encodeSortKeys)) + return 1 +} + // encode is the Lua function for gjson.encode(value). // Returns (json_string, nil) on success or (nil, error_message) on failure. func (g *GJSON) encode(L *glua.LState) int { diff --git a/gjson/config.go b/gjson/config.go index 4135ef0..a318557 100644 --- a/gjson/config.go +++ b/gjson/config.go @@ -9,6 +9,7 @@ const ( defaultDecodeMaxDepth = 1000 defaultEncodeNumberPrecision = 14 defaultEncodeKeepBuffer = true + defaultEncodeSortKeys = false // 默认不排序,保持高性能 ) // sparseArrayConfig controls sparse array handling during encoding. @@ -25,6 +26,7 @@ type Config struct { decodeMaxDepth int encodeNumberPrecision int encodeKeepBuffer bool + encodeSortKeys bool // 是否对 object 键排序(稳定输出) } // defaultConfig returns a new Config with default values. @@ -39,6 +41,7 @@ func defaultConfig() *Config { decodeMaxDepth: defaultDecodeMaxDepth, encodeNumberPrecision: defaultEncodeNumberPrecision, encodeKeepBuffer: defaultEncodeKeepBuffer, + encodeSortKeys: defaultEncodeSortKeys, } } @@ -54,5 +57,6 @@ func (c *Config) Clone() *Config { decodeMaxDepth: c.decodeMaxDepth, encodeNumberPrecision: c.encodeNumberPrecision, encodeKeepBuffer: c.encodeKeepBuffer, + encodeSortKeys: c.encodeSortKeys, } } diff --git a/gjson/encode.go b/gjson/encode.go index a001598..70804c8 100644 --- a/gjson/encode.go +++ b/gjson/encode.go @@ -2,6 +2,7 @@ package gjson import ( "fmt" + "sort" "strconv" "strings" @@ -159,7 +160,51 @@ func encodeArray(L *glua.LState, tbl *glua.LTable, maxIndex int, config *Config, // encodeObject encodes a Lua table as a JSON object. func encodeObject(L *glua.LState, tbl *glua.LTable, config *Config, depth int) (string, error) { - elements := make([]string, 0) + // 快速路径:不需要排序时直接编码 + if !config.encodeSortKeys { + elements := make([]string, 0) + var encodeErr error + tbl.ForEach(func(key, value glua.LValue) { + if encodeErr != nil { + return + } + + // Encode key + var keyStr string + switch k := key.(type) { + case glua.LString: + encoded, _ := json.Marshal(string(k)) + keyStr = string(encoded) + case glua.LNumber: + keyStr = formatNumber(float64(k), config.encodeNumberPrecision) + keyStr = "\"" + keyStr + "\"" + default: + return + } + + // Encode value + valStr, err := encodeLuaValue(L, value, config, depth+1) + if err != nil { + encodeErr = err + return + } + + elements = append(elements, keyStr+":"+valStr) + }) + + if encodeErr != nil { + return "", encodeErr + } + + return "{" + strings.Join(elements, ",") + "}", nil + } + + // 排序路径:收集所有键值对后排序 + type kv struct { + key string + value glua.LValue + } + pairs := make([]kv, 0) var encodeErr error tbl.ForEach(func(key, value glua.LValue) { @@ -174,28 +219,34 @@ func encodeObject(L *glua.LState, tbl *glua.LTable, config *Config, depth int) ( encoded, _ := json.Marshal(string(k)) keyStr = string(encoded) case glua.LNumber: - // Numeric keys are converted to strings keyStr = formatNumber(float64(k), config.encodeNumberPrecision) - // Quote the numeric key for JSON object keyStr = "\"" + keyStr + "\"" default: - return // Skip invalid keys - } - - // Encode value - valStr, err := encodeLuaValue(L, value, config, depth+1) - if err != nil { - encodeErr = err return } - elements = append(elements, keyStr+":"+valStr) + pairs = append(pairs, kv{key: keyStr, value: value}) }) if encodeErr != nil { return "", encodeErr } + // 按键排序(保证输出顺序稳定) + sort.Slice(pairs, func(i, j int) bool { + return pairs[i].key < pairs[j].key + }) + + // 编码值 + elements := make([]string, 0, len(pairs)) + for _, p := range pairs { + valStr, err := encodeLuaValue(L, p.value, config, depth+1) + if err != nil { + return "", err + } + elements = append(elements, p.key+":"+valStr) + } + return "{" + strings.Join(elements, ",") + "}", nil } diff --git a/gjson/gjson.go b/gjson/gjson.go index a4ca591..eb632d5 100644 --- a/gjson/gjson.go +++ b/gjson/gjson.go @@ -63,6 +63,7 @@ func Loader(L *glua.LState) int { L.SetField(mod, "decode_max_depth", L.NewFunction(instance.cfgDecodeMaxDepth)) L.SetField(mod, "encode_number_precision", L.NewFunction(instance.cfgEncodeNumberPrecision)) L.SetField(mod, "encode_keep_buffer", L.NewFunction(instance.cfgEncodeKeepBuffer)) + L.SetField(mod, "encode_sort_keys", L.NewFunction(instance.cfgEncodeSortKeys)) L.SetField(mod, "new", L.NewFunction(gjsonNew)) // Set gjson.null (lightuserdata representing JSON null) @@ -94,6 +95,7 @@ func RegisterGlobal(L *glua.LState) { L.SetField(mod, "decode_max_depth", L.NewFunction(instance.cfgDecodeMaxDepth)) L.SetField(mod, "encode_number_precision", L.NewFunction(instance.cfgEncodeNumberPrecision)) L.SetField(mod, "encode_keep_buffer", L.NewFunction(instance.cfgEncodeKeepBuffer)) + L.SetField(mod, "encode_sort_keys", L.NewFunction(instance.cfgEncodeSortKeys)) L.SetField(mod, "new", L.NewFunction(gjsonNew)) L.SetField(mod, "null", instance.null) L.SetField(mod, "_NAME", glua.LString(ModuleName)) diff --git a/gjson/gjson_test.go b/gjson/gjson_test.go index 5432ec4..94847c2 100644 --- a/gjson/gjson_test.go +++ b/gjson/gjson_test.go @@ -349,3 +349,40 @@ func TestEncodeSparseArray(t *testing.T) { t.Fatalf("test failed: %v", err) } } + +func TestEncodeSortKeys(t *testing.T) { + L := glua.NewState() + defer L.Close() + + Preload(L) + + err := L.DoString(` + local gjson = require("gjson") + + -- Test default (no sorting) + local default_sort = gjson.encode_sort_keys() + assert(default_sort == false, "default should be false") + + -- Enable sorting (returns new value) + local new_val = gjson.encode_sort_keys(true) + assert(new_val == true, "should return new value") + assert(gjson.encode_sort_keys() == true, "should be true now") + + -- Test sorted output + local data = {c = 3, a = 1, b = 2} + local result = gjson.encode(data) + -- Keys should be in alphabetical order: a, b, c + local a_pos = string.find(result, '"a"') + local b_pos = string.find(result, '"b"') + local c_pos = string.find(result, '"c"') + assert(a_pos < b_pos, "a should come before b") + assert(b_pos < c_pos, "b should come before c") + + -- Disable sorting (back to fast mode) + gjson.encode_sort_keys(false) + assert(gjson.encode_sort_keys() == false, "should be false again") + `) + if err != nil { + t.Fatalf("test failed: %v", err) + } +} diff --git a/gjson/types.go b/gjson/types.go index 27bfe42..17527c0 100644 --- a/gjson/types.go +++ b/gjson/types.go @@ -49,6 +49,7 @@ func gjsonNew(L *glua.LState) int { L.SetField(tbl, "decode_max_depth", L.NewFunction(instance.cfgDecodeMaxDepth)) L.SetField(tbl, "encode_number_precision", L.NewFunction(instance.cfgEncodeNumberPrecision)) L.SetField(tbl, "encode_keep_buffer", L.NewFunction(instance.cfgEncodeKeepBuffer)) + L.SetField(tbl, "encode_sort_keys", L.NewFunction(instance.cfgEncodeSortKeys)) L.SetField(tbl, "new", L.NewFunction(gjsonNew)) // Set null