概述
为 qjson 添加 encode_sparse_array(convert, ratio, safe) API,和 lua-cjson / OpenResty lua-cjson 的行为一致。允许调用方控制 qjson.encode() 对稀疏数组的检测阈值和转换行为,用于需要编码稀疏 Lua table 的场景。
需求
场景
当前 classify_plain_table 硬编码 ENCODE_SPARSE_RATIO = 2 和 ENCODE_SPARSE_SAFE = 10,超稀疏数组始终报错。调用方无法微调行为,例如需要编码 {[1] = "a", [1000] = "b"} 的场景。
local qjson = require (" qjson" )
-- 默认行为不变(向后兼容)
qjson .encode ({[1 ] = " a" , [1000 ] = " b" })
-- error: "Cannot serialise table: excessively sparse array"
-- 放宽阈值:ratio=10, safe=100
qjson .encode_sparse_array (false , 10 , 100 )
qjson .encode ({[1 ] = " a" , [1000 ] = " b" })
-- '["a",null,null, ... null,"b"]'
-- 转为对象
qjson .encode_sparse_array (true , 2 , 10 )
qjson .encode ({[1 ] = " a" , [1000 ] = " b" })
-- '{"1":"a","1000":"b"}'
-- 禁用稀疏检查:ratio=0
qjson .encode_sparse_array (false , 0 , 10 )
qjson .encode ({[1 ] = " a" , [1000 ] = " b" })
-- 始终按数组编码,填 null 空洞
行为规范
所有调用形式都返回更新后的 convert, ratio, safe 三元组 (getter/setter 合一,与 lua-cjson 一致)。setter 不再只在无参时返回 —— local c, r, s = qjson.encode_sparse_array(true) 应拿到 true, 2, 10。
调用
行为
qjson.encode_sparse_array()
纯读取,返回当前 convert, ratio, safe(默认 false, 2, 10)
qjson.encode_sparse_array(false, 2, 10)
恢复默认行为
qjson.encode_sparse_array(true, 2, 10)
超稀疏数组转为 object
qjson.encode_sparse_array(false, 0, 10)
禁用稀疏检查,始终按数组编码
qjson.encode_sparse_array(false, 1, 0)
禁止任何稀疏(任何空洞数组都视为超稀疏)
参数说明:
convert (boolean):true 时超稀疏数组转为 JSON object;false 时报错(默认)。任意非 false/nil 值按 true 处理(Lua 真值语义)。
ratio (number, 非负整数):0 = 禁用稀疏检查(所有正整数 key 的 table 都按数组编码);>0 = 稀疏比例阈值(默认 2)。判定始终受 safe 下限约束 —— 只有 max > safe 时才可能触发。因此 ratio=1 单独并不能"禁止任何稀疏",需同时设 safe=0(见行为表最后一行)。
safe (number, 非负整数):max_index ≤ safe 时始终按数组处理(默认 10)。
超稀疏判断逻辑 (与 lua-cjson 一致):
excessively_sparse = (ratio > 0) AND (max_index > safe) AND (max_index > key_count * ratio)
三个条件同时成立才触发,safe 与 ratio 是与关系,缺一不可。
对 encode 的影响
仅影响无显式类型 hint 且走 classify_plain_table() 的 plain table。
带 qjson.array() / qjson.object() 类型 hint 或 empty_array_mt 的 table 走 TABLE_TYPE_HINT 快路径,不经过 classify_plain_table,因此不受稀疏配置影响 (显式 hint 优先于稀疏检测)。
convert=true 且超稀疏时,classify_plain_table 返回 "object" 而非报错,由 encode_plain_table 走 encode_object 路径。
convert=false 且超稀疏时,行为与当前一致(报错)。
ratio=0 时跳过稀疏检查,所有正整数 key 的 table 均按数组编码。
lazy proxy 的编码路径不受影响(decode 出的数组已有 max 长度,且 JSON 数组本身无空洞,不经过 classify_plain_table)。
设计
方案:模块级全局变量 + getter/setter
在 lua/qjson/table.lua 中替换硬编码常量,添加模块级状态和 API 函数,修改 classify_plain_table 使用变量。
状态(复用现有 line 588-589 常量名 + 新增 ENCODE_SPARSE_CONVERT):
必须是 local upvalue —— 裸赋值会泄漏进 _G,既污染全局命名空间,又比 upvalue 访问慢(与下文"性能"小节的 upvalue 主张相矛盾)。沿用现有 ENCODE_SPARSE_* 命名,不要加前导下划线,使 diff 最小。
local ENCODE_SPARSE_CONVERT = false
local ENCODE_SPARSE_RATIO = 2
local ENCODE_SPARSE_SAFE = 10
getter/setter(先按参数做局部更新,再无条件返回三元组):
function _M .encode_sparse_array (convert , ratio , safe )
if convert ~= nil then
ENCODE_SPARSE_CONVERT = convert ~= false
end
if ratio ~= nil then
if type (ratio ) ~= " number" or ratio < 0
or ratio ~= math.floor (ratio ) then
error (" bad argument #2 to qjson.encode_sparse_array (expected non-negative integer)" )
end
ENCODE_SPARSE_RATIO = ratio
end
if safe ~= nil then
if type (safe ) ~= " number" or safe < 0
or safe ~= math.floor (safe ) then
error (" bad argument #3 to qjson.encode_sparse_array (expected non-negative integer)" )
end
ENCODE_SPARSE_SAFE = safe
end
return ENCODE_SPARSE_CONVERT , ENCODE_SPARSE_RATIO , ENCODE_SPARSE_SAFE
end
无参调用时三个 if 全部跳过,直接 return 当前值 —— getter 自然成立,无需单独分支。这同时修正了 lua-cjson 兼容性:cjson 的 setter 也始终返回当前配置。
classify_plain_table 修改(line 652-673):
local function classify_plain_table (t )
local count = 0
local max = 0
local all_positive_integer_keys = true
local saw_key = false
for k in pairs (t ) do
saw_key = true
count = count + 1
if type (k ) ~= " number" or k < 1 or k ~= math.floor (k ) then
all_positive_integer_keys = false
elseif k > max then
max = k
end
end
if not saw_key or not all_positive_integer_keys then
return " object"
end
local ratio = ENCODE_SPARSE_RATIO
local safe = ENCODE_SPARSE_SAFE
if ratio > 0 and max > safe and max > count * ratio then
if ENCODE_SPARSE_CONVERT then
return " object"
end
error (" Cannot serialise table: excessively sparse array" )
end
return " array" , max
end
导出(lua/qjson.lua):
_M .encode_sparse_array = _lazy .encode_sparse_array
编码决策流程
qjson.encode(t)
└─ encode_plain_table(t, depth, active)
├─ empty_array_mt / TABLE_TYPE_HINT → 直接 array/object(旁路稀疏检查)
└─ classify_plain_table(t)
├─ 非正整数 key → "object"
├─ 空 table → "object"
├─ ratio == 0 → "array", max (跳过稀疏检查)
├─ max > safe AND max > count * ratio
│ ├─ convert == false → error("excessively sparse array")
│ └─ convert == true → "object"
└─ 其他 → "array", max
边界情况
场景
行为
{[1]=1, [5]=2}, default (2, 10)
数组:max=5 ≤ safe=10,不触发稀疏检查
{[1]=1, [100]=2}, (2, 0)
报错:max=100 > safe=0 且 100 > 2×2
{[1]=1, [100]=2}, (false, 0, 0)
数组:ratio=0 跳过检查
{[1]=1, [100]=2}, (true, 2, 10)
object:convert=true,转为 {"1":1,"100":2}
空 table {}, any settings
object(不受稀疏配置影响)
key 含非数字,any settings
object(不受稀疏配置影响)
性能
classify_plain_table 中额外两次局部变量读取(ENCODE_SPARSE_RATIO / ENCODE_SPARSE_SAFE),开销可忽略。
状态以 local upvalue 而非 _M 字段保存,setter 与 classify_plain_table 共享同一批 upvalue,内部直接引用,保持热路径性能。
测试
该功能引入模块级全局可变状态 ,单条断言失败会跳过其后的内联 restore,污染后续用例。用 after_each 在框架层统一复位,而不是依赖业务代码顺序执行恢复。
describe (" encode_sparse_array" , function ()
after_each (function ()
-- 框架保证执行:无论断言成败都复位,避免全局状态泄漏到后续用例
qjson .encode_sparse_array (false , 2 , 10 )
end )
it (" defaults to false, 2, 10" , function ()
local c , r , s = qjson .encode_sparse_array ()
assert .are .equal (false , c )
assert .are .equal (2 , r )
assert .are .equal (10 , s )
end )
it (" setter returns updated values (cjson-compatible)" , function ()
local c , r , s = qjson .encode_sparse_array (true , 5 , 20 )
assert .are .equal (true , c )
assert .are .equal (5 , r )
assert .are .equal (20 , s )
end )
it (" getter returns current values" , function ()
qjson .encode_sparse_array (true , 5 , 20 )
local c , r , s = qjson .encode_sparse_array ()
assert .are .equal (true , c )
assert .are .equal (5 , r )
assert .are .equal (20 , s )
end )
it (" partial update keeps other fields" , function ()
qjson .encode_sparse_array (true , 5 , 20 )
local c , r , s = qjson .encode_sparse_array (false ) -- 仅改 convert
assert .are .equal (false , c )
assert .are .equal (5 , r )
assert .are .equal (20 , s )
end )
it (" convert=true: excessive sparse becomes object" , function ()
qjson .encode_sparse_array (true , 2 , 3 )
local out = qjson .encode ({[1 ] = " one" , [5 ] = " sparse" })
assert .are .equal (' {"1":"one","5":"sparse"}' , out )
end )
it (" convert=false: excessive sparse errors (default)" , function ()
local ok , err = pcall (qjson .encode , {[1 ] = 1 , [1000 ] = 2 })
assert .is_false (ok )
assert .is_truthy (string.find (err , " excessively sparse array" ))
end )
it (" ratio=0 disables sparse check" , function ()
qjson .encode_sparse_array (false , 0 , 10 )
local out = qjson .encode ({[1 ] = " a" , [500 ] = " b" })
local decoded = require (" qjson" ).decode (out )
assert .are .equal (500 , qjson .len (decoded )) -- lazy proxy 用 qjson.len,不要用 #
assert .are .equal (" b" , decoded [500 ])
end )
it (" safe threshold: max <= safe always array" , function ()
local out = qjson .encode ({[1 ] = " a" , [5 ] = " e" })
assert .are .equal (' ["a",null,null,null,"e"]' , out )
end )
it (" rejects non-integer / negative args" , function ()
assert .is_false (pcall (qjson .encode_sparse_array , false , 2.5 , 10 ))
assert .is_false (pcall (qjson .encode_sparse_array , false , - 1 , 10 ))
assert .is_false (pcall (qjson .encode_sparse_array , false , 2 , - 1 ))
end )
it (" backward compatible: default behavior unchanged" , function ()
assert .are .equal (" [1,null,3]" , qjson .encode ({[1 ] = 1 , [3 ] = 3 }))
assert .are .equal (' ["a",null,null,null,"e"]' , qjson .encode ({[1 ] = " a" , [5 ] = " e" }))
local ok , _ = pcall (qjson .encode , {[1 ] = 1 , [1000 ] = 2 })
assert .is_false (ok )
end )
end )
需修改的文件
lua/qjson/table.lua — 核心实现(local 状态变量、getter/setter 始终返回三元组、修改 classify_plain_table)。
lua/qjson.lua — 对外导出 _M.encode_sparse_array = _lazy.encode_sparse_array。
docs/migrating-from-cjson.md —
将 cjson.encode_sparse_array() 从 Unsupported 表(line ~179)移除,改为"已支持"并加迁移说明;
更新 Incremental checklist 第 6 条(line ~197),移除 "sparse-array configuration"(保留 cjson.new 与 number-precision,后者属 encode_number_precision():控制浮点编码输出精度 #121 );
补充"全局状态、跨请求残留"的 OpenResty 注意事项(见下方"已知限制")。
tests/lua/encode_cjson_compat_spec.lua — 新增上述测试用例(含 after_each 复位)。
已知限制
全局可变状态,OpenResty 跨请求残留 :encode_sparse_array 修改的是模块级配置,在同一个 worker 内对所有后续请求生效 ;qjson 不提供 cjson.new() 式的实例隔离。建议在 init_by_lua 阶段设置一次,避免在请求处理阶段临时修改(会污染同 worker 的其他请求)。此限制需在迁移文档中明确说明。
不在范围内
Rust/FFI 侧变更(纯 Lua 层功能)。
每次 encode() 调用传入 opts 参数(仅模块全局设置,兼容 lua-cjson 模式)。
cjson.new() 式的实例级配置隔离。
qjson_options C 结构体扩展(稀疏检测在 Lua 编码器中,C 层只负责解析)。
概述
为 qjson 添加
encode_sparse_array(convert, ratio, safe)API,和 lua-cjson / OpenResty lua-cjson 的行为一致。允许调用方控制qjson.encode()对稀疏数组的检测阈值和转换行为,用于需要编码稀疏 Lua table 的场景。需求
场景
当前
classify_plain_table硬编码ENCODE_SPARSE_RATIO = 2和ENCODE_SPARSE_SAFE = 10,超稀疏数组始终报错。调用方无法微调行为,例如需要编码{[1] = "a", [1000] = "b"}的场景。行为规范
qjson.encode_sparse_array()convert, ratio, safe(默认 false, 2, 10)qjson.encode_sparse_array(false, 2, 10)qjson.encode_sparse_array(true, 2, 10)qjson.encode_sparse_array(false, 0, 10)qjson.encode_sparse_array(false, 1, 0)参数说明:
convert(boolean):true时超稀疏数组转为 JSON object;false时报错(默认)。任意非false/nil值按true处理(Lua 真值语义)。ratio(number, 非负整数):0= 禁用稀疏检查(所有正整数 key 的 table 都按数组编码);>0= 稀疏比例阈值(默认2)。判定始终受safe下限约束 —— 只有max > safe时才可能触发。因此ratio=1单独并不能"禁止任何稀疏",需同时设safe=0(见行为表最后一行)。safe(number, 非负整数):max_index ≤ safe时始终按数组处理(默认10)。超稀疏判断逻辑(与 lua-cjson 一致):
三个条件同时成立才触发,
safe与ratio是与关系,缺一不可。对
encode的影响classify_plain_table()的 plain table。qjson.array()/qjson.object()类型 hint 或empty_array_mt的 table 走TABLE_TYPE_HINT快路径,不经过classify_plain_table,因此不受稀疏配置影响(显式 hint 优先于稀疏检测)。convert=true且超稀疏时,classify_plain_table返回"object"而非报错,由encode_plain_table走encode_object路径。convert=false且超稀疏时,行为与当前一致(报错)。ratio=0时跳过稀疏检查,所有正整数 key 的 table 均按数组编码。max长度,且 JSON 数组本身无空洞,不经过classify_plain_table)。设计
方案:模块级全局变量 + getter/setter
在
lua/qjson/table.lua中替换硬编码常量,添加模块级状态和 API 函数,修改classify_plain_table使用变量。状态(复用现有 line 588-589 常量名 + 新增
ENCODE_SPARSE_CONVERT):getter/setter(先按参数做局部更新,再无条件返回三元组):
classify_plain_table 修改(line 652-673):
导出(
lua/qjson.lua):编码决策流程
边界情况
{[1]=1, [5]=2}, default (2, 10){[1]=1, [100]=2}, (2, 0){[1]=1, [100]=2}, (false, 0, 0){[1]=1, [100]=2}, (true, 2, 10){"1":1,"100":2}{}, any settings性能
classify_plain_table中额外两次局部变量读取(ENCODE_SPARSE_RATIO/ENCODE_SPARSE_SAFE),开销可忽略。localupvalue 而非_M字段保存,setter 与classify_plain_table共享同一批 upvalue,内部直接引用,保持热路径性能。测试
需修改的文件
lua/qjson/table.lua— 核心实现(local状态变量、getter/setter 始终返回三元组、修改classify_plain_table)。lua/qjson.lua— 对外导出_M.encode_sparse_array = _lazy.encode_sparse_array。docs/migrating-from-cjson.md—cjson.encode_sparse_array()从 Unsupported 表(line ~179)移除,改为"已支持"并加迁移说明;cjson.new与 number-precision,后者属 encode_number_precision():控制浮点编码输出精度 #121);tests/lua/encode_cjson_compat_spec.lua— 新增上述测试用例(含after_each复位)。已知限制
encode_sparse_array修改的是模块级配置,在同一个 worker 内对所有后续请求生效;qjson 不提供cjson.new()式的实例隔离。建议在init_by_lua阶段设置一次,避免在请求处理阶段临时修改(会污染同 worker 的其他请求)。此限制需在迁移文档中明确说明。不在范围内
encode()调用传入 opts 参数(仅模块全局设置,兼容 lua-cjson 模式)。cjson.new()式的实例级配置隔离。qjson_optionsC 结构体扩展(稀疏检测在 Lua 编码器中,C 层只负责解析)。