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
23 changes: 20 additions & 3 deletions docs/migrating-from-cjson.md
Original file line number Diff line number Diff line change
Expand Up @@ -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")`
Expand All @@ -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.
1 change: 1 addition & 0 deletions lua/qjson.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
53 changes: 52 additions & 1 deletion lua/qjson/table.lua
Original file line number Diff line number Diff line change
Expand Up @@ -604,11 +604,56 @@ 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

local new_convert = ENCODE_SPARSE_CONVERT
local new_ratio = ENCODE_SPARSE_RATIO
local new_safe = ENCODE_SPARSE_SAFE

if convert ~= nil then
new_convert = convert ~= false
end
if ratio ~= nil then
validate_non_negative_integer(ratio, 2)
new_ratio = ratio
end
if safe ~= nil then
validate_non_negative_integer(safe, 3)
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

-- 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
Expand Down Expand Up @@ -685,7 +730,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
Expand Down
130 changes: 130 additions & 0 deletions tests/lua/encode_cjson_compat_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,136 @@ 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("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)
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))
Expand Down
Loading