From 8e38628c1b2996a69e3c57908b7efda31ac4809b Mon Sep 17 00:00:00 2001 From: Yuansheng Wang Date: Sun, 31 May 2026 23:20:30 +0800 Subject: [PATCH 1/2] feat(lua): add sparse array encode controls --- docs/migrating-from-cjson.md | 23 ++++- lua/qjson.lua | 1 + lua/qjson/table.lua | 43 ++++++++- tests/lua/encode_cjson_compat_spec.lua | 116 +++++++++++++++++++++++++ 4 files changed, 179 insertions(+), 4 deletions(-) diff --git a/docs/migrating-from-cjson.md b/docs/migrating-from-cjson.md index 56580e6..16d0c9b 100644 --- a/docs/migrating-from-cjson.md +++ b/docs/migrating-from-cjson.md @@ -177,12 +177,29 @@ qjson intentionally does not implement every lua-cjson configuration API. | lua-cjson API | qjson status | | --- | --- | | `cjson.new()` | No equivalent. qjson has module-level functions and no isolated per-instance encoder/decoder state. | -| `cjson.encode_sparse_array()` | No equivalent. qjson follows its own table shape rules and uses `qjson.empty_array_mt` for empty arrays. | If your application depends on an unsupported lua-cjson knob, keep lua-cjson for that path or isolate the migration to call sites that use decode/encode without per-instance configuration. +## Supported sparse-array configuration + +qjson now supports lua-cjson style sparse-array controls via +`qjson.encode_sparse_array(convert, ratio, safe)`. + +- Getter mode (`qjson.encode_sparse_array()`) returns the current triplet. +- Setter mode updates only arguments that are non-`nil`, and still returns the + full updated triplet. +- Defaults match lua-cjson/OpenResty lua-cjson: `false, 2, 10`. +- Excessive sparsity triggers only when + `ratio > 0 and max_index > safe and max_index > key_count * ratio`; setting + `ratio = 0` disables the excessive-sparse check. + +`qjson.encode_sparse_array` is module-level global state, just like other qjson +module settings. There is no `cjson.new()`-style isolated instance, so in +OpenResty the setting persists for the lifetime of each worker process and can +affect subsequent requests handled by the same worker. + ## Incremental checklist 1. Replace `local cjson = require("cjson")` with `local qjson = require("qjson")` @@ -194,5 +211,5 @@ per-instance configuration. encoders or helpers that require plain Lua tables. 5. In hot paths that only read a few fields, consider `qjson.parse` plus `doc:get_*` or cursor getters instead of `qjson.decode`. -6. Leave call sites that depend on `cjson.new` or sparse-array configuration on - lua-cjson until they can be redesigned. +6. Leave call sites that depend on `cjson.new` on lua-cjson until they can be + redesigned. diff --git a/lua/qjson.lua b/lua/qjson.lua index 820a89b..8a3f9d3 100644 --- a/lua/qjson.lua +++ b/lua/qjson.lua @@ -217,6 +217,7 @@ local _lazy = require("qjson.table") _M.decode = _lazy.decode _M.encode = _lazy.encode _M.encode_number_precision = _lazy.encode_number_precision +_M.encode_sparse_array = _lazy.encode_sparse_array _M.materialize = _lazy.materialize _M.pairs = _lazy.pairs _M.ipairs = _lazy.ipairs diff --git a/lua/qjson/table.lua b/lua/qjson/table.lua index cf0e4f7..83fcc9a 100644 --- a/lua/qjson/table.lua +++ b/lua/qjson/table.lua @@ -604,11 +604,46 @@ end local encode local ENCODE_MAX_DEPTH = 1000 +local ENCODE_SPARSE_CONVERT = false local ENCODE_SPARSE_RATIO = 2 local ENCODE_SPARSE_SAFE = 10 local ENCODE_DEPTH_ERROR = "qjson.encode: max depth exceeded" local ENCODE_CYCLE_ERROR = "qjson.encode: circular reference" +local function validate_non_negative_integer(value, arg_index) + if type(value) ~= "number" + or value ~= value + or value == math.huge + or value == -math.huge + or value < 0 + or value ~= math.floor(value) + then + error( + "bad argument #" .. tostring(arg_index) + .. " to qjson.encode_sparse_array (expected non-negative integer)", + 2 + ) + end +end + +function _M.encode_sparse_array(convert, ratio, safe, ...) + if select("#", ...) > 0 then + error("bad argument #4 to qjson.encode_sparse_array (found too many arguments)", 2) + end + if convert ~= nil then + ENCODE_SPARSE_CONVERT = convert ~= false + end + if ratio ~= nil then + validate_non_negative_integer(ratio, 2) + ENCODE_SPARSE_RATIO = ratio + end + if safe ~= nil then + validate_non_negative_integer(safe, 3) + ENCODE_SPARSE_SAFE = safe + end + return ENCODE_SPARSE_CONVERT, ENCODE_SPARSE_RATIO, ENCODE_SPARSE_SAFE +end + -- Emit a dirty LazyObject as JSON in ORDER_KEYS (first-appearance) order. -- A dirty object without ORDER state yet (e.g. dirtied only via a child -- mutation) is materialized on demand by ensure_object_order_state, which @@ -685,7 +720,13 @@ local function classify_plain_table(t) if not saw_key or not all_positive_integer_keys then return "object" end - if max > ENCODE_SPARSE_SAFE and max > count * ENCODE_SPARSE_RATIO then + if ENCODE_SPARSE_RATIO > 0 + and max > ENCODE_SPARSE_SAFE + and max > count * ENCODE_SPARSE_RATIO + then + if ENCODE_SPARSE_CONVERT then + return "object" + end error("Cannot serialise table: excessively sparse array") end return "array", max diff --git a/tests/lua/encode_cjson_compat_spec.lua b/tests/lua/encode_cjson_compat_spec.lua index 689d1b0..6ef2f77 100644 --- a/tests/lua/encode_cjson_compat_spec.lua +++ b/tests/lua/encode_cjson_compat_spec.lua @@ -116,6 +116,122 @@ describe("qjson.encode lua-cjson compatible Lua inputs", function() end) end) +describe("qjson.encode_sparse_array lua-cjson compatible controls", function() + local function reset_sparse_defaults() + qjson.encode_sparse_array(false, 2, 10) + end + + local function assert_sparse_array_arg_error(fn, expected) + local ok, err = pcall(fn) + assert.is_false(ok) + assert.matches(expected, tostring(err), 1, true) + end + + before_each(function() + reset_sparse_defaults() + end) + + after_each(function() + reset_sparse_defaults() + end) + + it("returns lua-cjson compatible defaults via getter", function() + assert.same({false, 2, 10}, {qjson.encode_sparse_array()}) + end) + + it("setter always returns a triplet and getter reflects current values", function() + assert.same({true, 2, 10}, {qjson.encode_sparse_array(true)}) + assert.same({true, 2, 3}, {qjson.encode_sparse_array(nil, nil, 3)}) + assert.same({true, 2, 3}, {qjson.encode_sparse_array()}) + end) + + it("only updates fields that are explicitly provided", function() + assert.same({true, 5, 7}, {qjson.encode_sparse_array(true, 5, 7)}) + assert.same({true, 0, 7}, {qjson.encode_sparse_array(nil, 0, nil)}) + assert.same({false, 0, 7}, {qjson.encode_sparse_array(false, nil, nil)}) + assert.same({false, 0, 11}, {qjson.encode_sparse_array(nil, nil, 11)}) + end) + + it("treats any truthy convert input as true", function() + assert.same({true, 2, 10}, {qjson.encode_sparse_array("on")}) + assert.same({true, 2, 10}, {qjson.encode_sparse_array()}) + end) + + it("defaults to rejecting excessively sparse arrays", function() + assert_encode_error({[1] = "one", [1000] = "thousand"}, "excessively sparse array") + end) + + it("forbids any sparse holes when ratio=1 and safe=0", function() + qjson.encode_sparse_array(false, 1, 0) + assert_encode_error({[1] = 1, [3] = 3}, "excessively sparse array") + assert.are.equal("[1,2]", qjson.encode({[1] = 1, [2] = 2})) + end) + + it("converts excessively sparse arrays to objects when convert=true", function() + qjson.encode_sparse_array(true) + assert_json_equal(qjson.encode({[1] = "one", [1000] = "thousand"}), '{"1":"one","1000":"thousand"}') + end) + + it("disables excessive sparse checks when ratio=0 and fills null holes", function() + qjson.encode_sparse_array(false, 0, 10) + assert.are.equal("[1,null,null,null,null,6]", qjson.encode({[1] = 1, [6] = 6})) + assert.are.equal('[null,null,"v"]', qjson.encode({[3] = "v"})) + end) + + it("applies safe threshold before triggering excessive sparse handling", function() + qjson.encode_sparse_array(false, 2, 7) + assert.are.equal('["a",null,null,null,null,null,"g"]', qjson.encode({[1] = "a", [7] = "g"})) + + qjson.encode_sparse_array(true, 2, 5) + assert_json_equal(qjson.encode({[1] = "a", [7] = "g"}), '{"1":"a","7":"g"}') + end) + + it("rejects invalid ratio and safe values", function() + assert_sparse_array_arg_error(function() + qjson.encode_sparse_array(nil, -1) + end, "bad argument #2 to qjson.encode_sparse_array (expected non-negative integer)") + assert_sparse_array_arg_error(function() + qjson.encode_sparse_array(nil, 1.5) + end, "bad argument #2 to qjson.encode_sparse_array (expected non-negative integer)") + assert_sparse_array_arg_error(function() + qjson.encode_sparse_array(nil, math.huge) + end, "bad argument #2 to qjson.encode_sparse_array (expected non-negative integer)") + assert_sparse_array_arg_error(function() + qjson.encode_sparse_array(nil, nil, -1) + end, "bad argument #3 to qjson.encode_sparse_array (expected non-negative integer)") + assert_sparse_array_arg_error(function() + qjson.encode_sparse_array(nil, nil, 1.2) + end, "bad argument #3 to qjson.encode_sparse_array (expected non-negative integer)") + assert_sparse_array_arg_error(function() + qjson.encode_sparse_array(nil, nil, math.huge) + end, "bad argument #3 to qjson.encode_sparse_array (expected non-negative integer)") + end) + + it("rejects too many arguments", function() + assert_sparse_array_arg_error(function() + qjson.encode_sparse_array(false, 2, 10, true) + end, "bad argument #4 to qjson.encode_sparse_array (found too many arguments)") + end) + + it("keeps default sparse-array encoding behavior compatible with lua-cjson", function() + local value = {[1] = "one", [5] = "five"} + assert_json_equal(qjson.encode(value), cjson.encode(value)) + end) + + it("does not let strict sparse settings affect empty_array_mt or lazy-array paths", function() + qjson.encode_sparse_array(false, 1, 0) + + assert.are.equal("[]", qjson.encode(setmetatable({}, qjson.empty_array_mt))) + + local lazy_clean = qjson.decode("[1,null,3]") + assert.are.equal("[1,null,3]", qjson.encode(lazy_clean)) + + local lazy_materialized = qjson.decode("[1,2]") + lazy_materialized[4] = 4 + assert.are.equal("[1,2,null,4]", qjson.encode(lazy_materialized)) + end) +end) + describe("qjson.encode qjson lazy proxy extensions", function() it("encodes 64-bit integer cdata as a qjson extension", function() assert.are.equal("9007199254740993", qjson.encode(9007199254740993LL)) From fd152b9f71e48af3c9b18a7de2906840113a3197 Mon Sep 17 00:00:00 2001 From: Yuansheng Wang Date: Sun, 31 May 2026 23:51:22 +0800 Subject: [PATCH 2/2] fix(lua): make sparse array settings atomic --- docs/migrating-from-cjson.md | 2 +- lua/qjson/table.lua | 16 +++++++++++++--- tests/lua/encode_cjson_compat_spec.lua | 14 ++++++++++++++ 3 files changed, 28 insertions(+), 4 deletions(-) diff --git a/docs/migrating-from-cjson.md b/docs/migrating-from-cjson.md index 16d0c9b..b8fa199 100644 --- a/docs/migrating-from-cjson.md +++ b/docs/migrating-from-cjson.md @@ -184,7 +184,7 @@ per-instance configuration. ## Supported sparse-array configuration -qjson now supports lua-cjson style sparse-array controls via +qjson now supports lua-cjson-style sparse-array controls via `qjson.encode_sparse_array(convert, ratio, safe)`. - Getter mode (`qjson.encode_sparse_array()`) returns the current triplet. diff --git a/lua/qjson/table.lua b/lua/qjson/table.lua index 83fcc9a..0d90be4 100644 --- a/lua/qjson/table.lua +++ b/lua/qjson/table.lua @@ -630,17 +630,27 @@ function _M.encode_sparse_array(convert, ratio, safe, ...) if select("#", ...) > 0 then error("bad argument #4 to qjson.encode_sparse_array (found too many arguments)", 2) end + + local new_convert = ENCODE_SPARSE_CONVERT + local new_ratio = ENCODE_SPARSE_RATIO + local new_safe = ENCODE_SPARSE_SAFE + if convert ~= nil then - ENCODE_SPARSE_CONVERT = convert ~= false + new_convert = convert ~= false end if ratio ~= nil then validate_non_negative_integer(ratio, 2) - ENCODE_SPARSE_RATIO = ratio + new_ratio = ratio end if safe ~= nil then validate_non_negative_integer(safe, 3) - ENCODE_SPARSE_SAFE = safe + new_safe = safe end + + ENCODE_SPARSE_CONVERT = new_convert + ENCODE_SPARSE_RATIO = new_ratio + ENCODE_SPARSE_SAFE = new_safe + return ENCODE_SPARSE_CONVERT, ENCODE_SPARSE_RATIO, ENCODE_SPARSE_SAFE end diff --git a/tests/lua/encode_cjson_compat_spec.lua b/tests/lua/encode_cjson_compat_spec.lua index 6ef2f77..d743a6c 100644 --- a/tests/lua/encode_cjson_compat_spec.lua +++ b/tests/lua/encode_cjson_compat_spec.lua @@ -207,6 +207,20 @@ describe("qjson.encode_sparse_array lua-cjson compatible controls", function() end, "bad argument #3 to qjson.encode_sparse_array (expected non-negative integer)") end) + it("keeps sparse-array settings unchanged when setter validation fails", function() + qjson.encode_sparse_array(false, 5, 7) + + assert_sparse_array_arg_error(function() + qjson.encode_sparse_array(true, -1, 9) + end, "bad argument #2 to qjson.encode_sparse_array (expected non-negative integer)") + assert.same({false, 5, 7}, {qjson.encode_sparse_array()}) + + assert_sparse_array_arg_error(function() + qjson.encode_sparse_array(true, 6, math.huge) + end, "bad argument #3 to qjson.encode_sparse_array (expected non-negative integer)") + assert.same({false, 5, 7}, {qjson.encode_sparse_array()}) + end) + it("rejects too many arguments", function() assert_sparse_array_arg_error(function() qjson.encode_sparse_array(false, 2, 10, true)