Skip to content

feat: preserve key order in decode-modify-encode workflow#60

Merged
membphis merged 14 commits into
mainfrom
feat/ordered-encode
May 29, 2026
Merged

feat: preserve key order in decode-modify-encode workflow#60
membphis merged 14 commits into
mainfrom
feat/ordered-encode

Conversation

@membphis
Copy link
Copy Markdown
Collaborator

@membphis membphis commented May 28, 2026

Summary

Enhance qjson's lazy table API to preserve original JSON key order when encoding after modifications. This enables API gateway use cases where JSON payloads are decoded, lightly modified, and re-encoded while maintaining key order consistency.

Changes

  • Add _keys and _values to INTERNAL_KEYS
  • Implement ordered materialization in LazyObject.__newindex
  • Add materialized state check in read_object_field
  • Update encode_lazy_object_walking to use _keys order
  • Update LazyObject.__pairs for ordered iteration
  • Add comprehensive tests for ordered encode scenarios

Supported Scenarios

  1. Modify existing values — Change the value of an existing key; key order unchanged
  2. Delete keys — Set t.key = nil to remove a key from output; remaining keys preserve order
  3. Add new keys — New keys append to the end of the object

Example

local t = qjson.decode('{"c":3,"a":1,"b":2}')
t.a = 100
print(qjson.encode(t))  -- {"c":3,"a":100,"b":2}

Closes #59
Closes #61

Summary by CodeRabbit

  • Bug Fixes

    • Stable object key ordering preserved across edits, deletions, re-additions, materialization, and JSON encoding
    • Duplicate object keys handled correctly: distinct entries preserved when unmodified and last-wins after mutations
    • Mutations no longer force full conversion: lazy proxies and child identity are preserved; counts update after add/delete
    • Rejects non-string object keys with a clear error; user fields named "_keys"/"_values" no longer collide
  • Tests

    • Expanded coverage for ordered encoding, duplicates, lazy-child mutations, materialization, iteration, and error handling

Review Change Stack

- Add _keys and _values to INTERNAL_KEYS
- Implement ordered materialization in LazyObject.__newindex
- Add materialized state check in read_object_field
- Update encode_lazy_object_walking to use _keys order
- Update LazyObject.__pairs for ordered iteration
- Add comprehensive tests for ordered encode scenarios

Closes #59
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 28, 2026

📝 Walkthrough

Walkthrough

Preserves JSON key order across decode-modify-encode by adding ORDER_KEYS/ORDER_VALUES caches and a per-view CHILD_CACHE, updating read, iteration, mutation, materialize, and JSON-encode paths to prefer cached ordering and values while preserving lazy proxies.

Changes

Ordered key preservation in lazy objects

Layer / File(s) Summary
Caches and child-proxy helpers
lua/qjson/table.lua
Adds sentinel ORDER_KEYS/ORDER_VALUES caches and a per-view CHILD_CACHE with helpers to cache child container proxies.
Field read fast-path
lua/qjson/table.lua
read_object_field fast-paths to ORDER_VALUES[key] when present; otherwise resolves via cursor and caches returned container proxies in the child cache.
Iteration and duplicate-safe iterator
lua/qjson/table.lua
lazy_object_iter/LazyObject.__pairs handle duplicate keys safely and prefer ORDER_KEYS/ORDER_VALUES when materialized; otherwise fall back to cursor iteration.
Length (#) fast-path
lua/qjson/table.lua
lazy_len returns #ORDER_KEYS when materialized; otherwise uses cursor length computation.
First-write materialization
lua/qjson/table.lua
ensure_object_order_state builds ORDER_KEYS (first-appearance) and ORDER_VALUES (last-wins) on first mutation; LazyObject.__newindex invokes this lazily while preserving laziness.
Subsequent mutation updates
lua/qjson/table.lua
Subsequent writes/deletes update ORDER_KEYS/ORDER_VALUES only: deletions remove keys and clear values; new keys append; updates overwrite values.
Materialize from caches
lua/qjson/table.lua
materialize emits fields in ORDER_KEYS order using ORDER_VALUES; falls back to cursor-based materialization and consults CHILD_CACHE when absent.
Ordered JSON encoding
lua/qjson/table.lua
encode_lazy_object_walking prefers ORDER_KEYS/ORDER_VALUES to encode JSON in cached order (encoding only non-nil cached values), creating caches on demand when needed.
lazy_table tests
tests/lua/lazy_table_spec.lua
Adds/updates tests for qjson.len after mutations, preserves LazyObject metatable and nested lazy proxies after first-write, and ensures cached nested-child mutations are preserved by qjson.materialize.
ordered_encode tests
tests/lua/ordered_encode_spec.lua
Adds extensive tests verifying key-order preservation, duplicate-key semantics (fast-path vs last-wins after mutation), cached child mutation/replace/delete scenarios, and invalid-key assignment handling.
sequenceDiagram
  participant Decoder
  participant LazyObject
  participant ReadPath as "__index / __pairs"
  participant MutationPath as "__newindex"
  participant EncodePath as "encode_lazy_object_walking"
  Decoder->>LazyObject: decode JSON to lazy proxy
  ReadPath->>LazyObject: read field before mutation (cursor)
  LazyObject-->>ReadPath: resolve via cursor (or cached child)
  MutationPath->>LazyObject: first mutation triggers ensure_object_order_state
  LazyObject->>LazyObject: build ORDER_KEYS and ORDER_VALUES from cursor
  LazyObject->>LazyObject: apply mutation to ORDER_KEYS/ORDER_VALUES
  ReadPath->>LazyObject: read field after mutation
  LazyObject-->>ReadPath: return from ORDER_VALUES
  EncodePath->>LazyObject: encode output
  LazyObject->>LazyObject: iterate ORDER_KEYS and emit values from ORDER_VALUES
  LazyObject-->>EncodePath: JSON output with preserved key order
Loading

🎯 4 (Complex) | ⏱️ ~45 minutes

  • Possibly related PRs:
    • api7/lua-qjson#54: Modifies lua/qjson/table.lua mutation/encoding behavior; related to ordered key/materialization changes.
🚥 Pre-merge checks | ✅ 5 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
E2e Test Quality Review ⚠️ Warning Two nested tests use weak substring assertions that cannot verify key ordering. Issue #61 requested exact string matching for nested scenarios. Replace assertions at lines 29-36 and 86-94 with exact string equality checks to validate complete nested object key ordering.
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: preserve key order in decode-modify-encode workflow' directly and precisely matches the main feature implemented in the PR: preserving JSON key order when decoding, modifying, and re-encoding LazyObjects.
Linked Issues check ✅ Passed The PR addresses all coding objectives from issues #59 and #61: implements ordered materialization with _keys/_values caching [#59], handles duplicate key deduplication with last-wins semantics [#61], supports add/delete/modify scenarios, maintains child proxy caching to preserve mutations, and rejects non-string key writes.
Out of Scope Changes check ✅ Passed All changes in table.lua, ordered_encode_spec.lua, and lazy_table_spec.lua are directly scoped to the linked issue objectives: supporting ordered key preservation, implementing materialization semantics, handling duplicates, and caching child proxies. No unrelated modifications detected.
Security Check ✅ Passed No security vulnerabilities found. Uses opaque sentinel keys to avoid user data collisions; no sensitive data exposure, database operations, auth issues, or crypto errors present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/ordered-encode

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

The new ordered encode implementation preserves LazyObject metatable
after modification instead of converting to plain table. Update tests
to reflect this intentional behavior change.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/lua/ordered_encode_spec.lua (1)

52-57: ⚡ Quick win

Cover the cached-child read-before-modify path too.

This only exercises a scalar read. The fragile case here is local child = t.nested, then a parent write, then mutating child through that saved reference. Adding that regression test would catch the current lost-update path in LazyObject.__newindex.

Suggested test case
     it("read before modify works correctly", function()
         local t = qjson.decode('{"a":1,"b":2,"c":3}')
         local _ = t.b  -- read first
         t.b = 999
         assert.are.equal('{"a":1,"b":999,"c":3}', qjson.encode(t))
     end)
+
+    it("keeps a pre-read nested proxy wired after parent materialization", function()
+        local t = qjson.decode('{"nested":{"a":1}}')
+        local nested = t.nested
+        t.extra = 1
+        nested.a = 2
+        assert.are.equal('{"nested":{"a":2},"extra":1}', qjson.encode(t))
+    end)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/lua/ordered_encode_spec.lua` around lines 52 - 57, Add a test that
exercises the cached-child read-before-modify path: decode an object with a
nested table into t (using qjson.decode), read and store the nested child (e.g.,
local child = t.nested), then mutate the parent (e.g., t.nested = <new table> or
change parent keys) and mutate the saved child (e.g., child.x = ...), and
finally assert qjson.encode(t) reflects the mutation; this targets the
LazyObject.__newindex lost-update path and should use the same variable names
(t, child, nested) and qjson.encode/qjson.decode so the test fails if
cached-child updates are lost.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@lua/qjson/table.lua`:
- Around line 299-315: The materialize() logic currently rebuilds the object
solely from the cursor (`t._cur`) and ignores any prior mutations stored in
`_keys`/`_values`, so after `t.a = 2` `qjson.materialize(t)` can return stale
data; change materialize() (the function that uses
`qjson_cursor_object_entry_at`, `decode_cursor`, `rawget`, `rawset`) to first
check `local keys = rawget(t, "_keys")` and if present iterate `for _, key in
ipairs(keys) do result[key] = values[key]` (honoring nil to represent deletions)
and return that materialized table, otherwise fall back to the existing cursor
loop that populates `_keys` and `_values` as it currently does. Ensure the code
references `LazyObject`’s `_keys`/`_values` storage and does not re-read the
cursor when `_keys` exists so mutations are preserved.
- Around line 299-315: During first materialization (in the loop that populates
_keys/_values) reuse any already-cached child proxy instead of always creating a
fresh one: for each key, check for an existing child proxy (e.g. via rawget(t,
key) or t[key]) and if present assign that proxy into values[key] and update its
contents from the cursor (or call a decode-into helper) rather than calling
decode_cursor to produce a new table; otherwise fall back to decode_cursor as
now. This ensures later mutations on the previously-read proxy are preserved
when encode_lazy_object_walking() reads _values[k].

---

Nitpick comments:
In `@tests/lua/ordered_encode_spec.lua`:
- Around line 52-57: Add a test that exercises the cached-child
read-before-modify path: decode an object with a nested table into t (using
qjson.decode), read and store the nested child (e.g., local child = t.nested),
then mutate the parent (e.g., t.nested = <new table> or change parent keys) and
mutate the saved child (e.g., child.x = ...), and finally assert qjson.encode(t)
reflects the mutation; this targets the LazyObject.__newindex lost-update path
and should use the same variable names (t, child, nested) and
qjson.encode/qjson.decode so the test fails if cached-child updates are lost.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e0d93637-eeab-4649-9e0f-8d200076e489

📥 Commits

Reviewing files that changed from the base of the PR and between 8205c84 and 598a860.

📒 Files selected for processing (2)
  • lua/qjson/table.lua
  • tests/lua/ordered_encode_spec.lua

Comment thread lua/qjson/table.lua Outdated
- __newindex now checks for cached child proxies before decoding
- materialize() uses _keys/_values when object is already materialized
- Add tests for cached nested mutations and materialize on modified objects

Addresses CodeRabbit review feedback.
Copy link
Copy Markdown
Collaborator Author

@membphis membphis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

结论

暂不建议合并。

PR 目标清楚,方向也合理,但当前实现会在部分 decode-modify-encode 流程中静默丢失子对象修改,并且让 qjson.materialize 对已修改 LazyObject 返回旧数据。这两个都是用户可见正确性问题,需要先修。

阻塞项

[P1] 已缓存子对象的后续修改会被 encode 静默丢弃

  • 问题:LazyObject 首次写入时 _values 通过 decode_cursor 重新创建子对象,没有复用之前 rawset 缓存过的子 proxy;之后 encode 只读取 _values,不读取 raw 缓存。
  • 为什么阻塞:decode-modify-encode 会静默输出旧值,属于核心功能正确性回退。
  • 影响范围:调用方先读取子对象,再修改父对象触发 ordered materialization,然后继续修改之前拿到的子对象引用。
  • 触发条件:
local qjson = require("qjson")
local t = qjson.decode('{"a":{"x":1},"b":2}')
local inner = t.a

t.c = 3
inner.x = 99

print(qjson.encode(t))
-- actual:   {"a":{"x":1},"b":2,"c":3}
-- expected: {"a":{"x":99},"b":2,"c":3}
  • 证据位置:lua/qjson/table.lua 中首次物化直接 decode_cursor 填充 _valuesencode_lazy_object_walking 对 materialized object 只按 _values 输出。
  • 建议修复:首次建立 _values 时合并已有非内部 raw 缓存,优先使用已缓存子 proxy;或迁移/清理 raw 用户 key,让读取、写入、encode 始终只有一个权威数据源。补充该触发序列的回归测试。

[P2] qjson.materialize 对已修改 LazyObject 会丢失修改

  • 问题:PR 保留 LazyObject metatable 后,qjson.materialize 仍调用 materialize_object_contents 从原始 cursor 读取内容,完全忽略 _keys/_values
  • 为什么阻塞:qjson.materialize 是公开 API,修改后的对象 materialize 出旧数据会误导调用方。
  • 影响范围:所有先修改 LazyObject 再调用 qjson.materialize 的对象,包括新增、删除、更新字段。
  • 触发条件:
local qjson = require("qjson")
local t = qjson.decode('{"a":1}')
t.b = 2
local m = qjson.materialize(t)
print(m.a, m.b, qjson.encode(t))
-- actual: 1 nil {"a":1,"b":2}
  • 证据位置:materializeLazyObject 路径仍只走 materialize_object_contents,而该 cursor walker 不读取 _values
  • 建议修复:materialize 遇到带 _keys/_valuesLazyObject 时按 _keys 遍历 _values 并递归 materialize;同时覆盖新增、删除、更新和嵌套修改测试。

其他检查

  • 新增/更新依赖:无。PR 仅涉及 lua/qjson/table.luatests/lua/lazy_table_spec.luatests/lua/ordered_encode_spec.lua,没有 manifest、lockfile、rockspec 或 Cargo 依赖声明变更。
  • Breaking change:首次写入后的 LazyObject 从 plain table 改为继续保留 lazy metatable,这是可观察行为变化;更关键的是当前实现引入了上面的正确性回退。
  • 重复实现风险:__newindex 新增了一套对象物化循环,和既有 materialize_object_contents/旧缓存保留逻辑分叉,直接导致缓存 proxy 未被合并。建议把“按顺序物化并合并缓存”的逻辑集中成一个内部 helper,供 encode/materialize/write 路径共享。

Copy link
Copy Markdown
Collaborator Author

@membphis membphis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

阻塞项

[P1] JSON 字段 _keys / _values 为容器时会和新增内部状态撞名

  • 问题:PR 新增 rawget(self, "_values") 作为 materialized 状态判断,并用 rawget(t, "_keys") 作为 key-order 存储。但 read_object_field 会把用户 JSON 容器字段 rawset 到同一个 lazy object 上;如果 payload 本身含有 _keys_values 容器字段,用户字段会被误当成内部状态。
  • 为什么阻塞:这是合法 JSON key,且 _keys / _values 在实际业务 payload 中并不罕见。当前行为会导致读取错误或写入崩溃,属于用户可见正确性回退。
  • 影响范围:读取过 _values 容器字段后,后续普通字段读取会从该 _values 子对象里查;读取过 _keys 容器字段后,后续对父对象写入会把该用户字段误当内部 key order。
  • 触发条件:
local t = qjson.decode('{"_keys":["user"],"a":1}')
local user_keys = t._keys
t.b = 2
-- lua/qjson/table.lua:334: attempt to index local 'values' (a nil value)
local t = qjson.decode('{"_values":{"z":9},"a":1}')
local user_values = t._values
print(t.a)
-- actual: nil
-- expected: 1
  • 证据位置:lua/qjson/table.luaread_object_field 用 raw _values 判断 materialized 状态;LazyObject.__newindex 用 raw _keys 判断是否已建立 key order;同时 read_object_field 会 rawset 缓存用户容器字段。
  • 建议修复:不要用用户字符串 key 存储新增内部状态。可以改为弱表 side table,或用本地 sentinel table/cdata 作为 raw key,例如 ORDER_KEYS = {}ORDER_VALUES = {},避免和任意 JSON string key 冲突。补充 _keys / _values 作为用户字段的读、写、encode 回归测试。

Replace string keys '_keys' and '_values' with sentinel table keys
ORDER_KEYS and ORDER_VALUES. This prevents collision when user JSON
contains fields named '_keys' or '_values'.

Add regression tests for _keys and _values as user JSON fields.
Copy link
Copy Markdown
Collaborator Author

@membphis membphis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

阻塞项

[P1] 父对象物化后整体替换/删除已缓存子容器字段会被 encode 忽略

  • 问题:read_object_field 会把子容器 rawset 到父 lazy object;父对象第一次写入时又把该 cached proxy 放进 ORDER_VALUES。之后如果直接给这个已存在 raw key 赋新值或 nil,Lua 会绕过 LazyObject.__newindex,只改 raw 表项,不会同步 ORDER_VALUES,最终 encode 仍输出旧子对象。
  • 为什么阻塞:这是 decode-modify-encode 的静默数据错误。对比 main,同样在父对象物化后替换已缓存子字段,main 会输出替换后的值;最新 PR 会输出旧对象。
  • 影响范围:调用方先读取某个子 object/array,再修改父对象触发 materialization,然后整体替换或删除这个子字段。
  • 触发条件:
local qjson = require("qjson")

local t = qjson.decode('{"a":{"x":1},"b":2}')
local old = t.a

t.c = 3
t.a = "replaced"

print(qjson.encode(t))
-- actual:   {"a":{"x":1},"b":2,"c":3}
-- expected: {"a":"replaced","b":2,"c":3}

删除也同样失败:

local qjson = require("qjson")

local t = qjson.decode('{"a":{"x":1},"b":2}')
local old = t.a

t.c = 3
t.a = nil

print(qjson.encode(t))
-- actual still includes "a":{"x":1}
  • 证据位置:lua/qjson/table.luaread_object_field rawset 缓存子容器;首次物化把 cached proxy 存入 ORDER_VALUESencode_lazy_object_walking 对 materialized object 只读取 ORDER_VALUES
  • 建议修复:父对象建立 ORDER_VALUES 后,不要继续保留可被普通赋值绕过 __newindex 的 raw 用户字段;可以把 cached proxy 迁移到 ORDER_VALUES 后清掉 raw string key,让后续 t.a = ... 进入 __newindex。补充替换和删除已缓存子容器字段的回归测试。

@membphis
Copy link
Copy Markdown
Collaborator Author

Follow-up from re-review: the previous fix only covered replacing/deleting a cached child after the parent had already materialized via another write. The remaining root cause is that read_object_field caches child proxies with rawset(self, key, v), so if the first write is to that same cached key, Lua bypasses LazyObject.__newindex entirely and encode can still use the original bytes. I will fix this by moving child proxy caching behind a sentinel/side cache instead of raw user keys, and also make qjson.len honor materialized ORDER_KEYS after object add/delete.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
tests/lua/ordered_encode_spec.lua (1)

130-153: 💤 Low value

Consider verifying full value preservation in collision tests.

The collision safety tests correctly verify that user fields _keys and _values don't collide with internal bookkeeping. However, lines 138 and 150 only check that the field names appear in the encoded output (out:find('"_keys"') and out:find('"_values"')), without verifying that the nested values are correctly preserved.

For more thorough coverage, consider decoding the output and asserting the full structure, e.g.:

local parsed = require("cjson").decode(out)
assert.are.same({"user"}, parsed._keys)  -- for line 138
assert.are.equal(9, parsed._values.z)    -- for line 150

That said, the current tests are sufficient as regression tests for the collision fix and align with the focused goal of ensuring no key loss.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/lua/ordered_encode_spec.lua` around lines 130 - 153, The tests for
collision safety should also assert that the user-provided nested values survive
round-trip encoding; after calling qjson.encode(t) (variable out) decode the
encoded string (e.g., into parsed) and assert that parsed._keys equals the
original {"user"} for the _keys test and parsed._values.z equals 9 for the
_values test, leaving the existing name-presence checks intact; modify the two
specs that create t, set t.b, and produce out to add these decode-and-assert
steps to fully verify value preservation.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@tests/lua/ordered_encode_spec.lua`:
- Around line 130-153: The tests for collision safety should also assert that
the user-provided nested values survive round-trip encoding; after calling
qjson.encode(t) (variable out) decode the encoded string (e.g., into parsed) and
assert that parsed._keys equals the original {"user"} for the _keys test and
parsed._values.z equals 9 for the _values test, leaving the existing
name-presence checks intact; modify the two specs that create t, set t.b, and
produce out to add these decode-and-assert steps to fully verify value
preservation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 5f976046-0eaa-457a-b918-a5921a09b597

📥 Commits

Reviewing files that changed from the base of the PR and between 598a860 and 931c13c.

📒 Files selected for processing (3)
  • lua/qjson/table.lua
  • tests/lua/lazy_table_spec.lua
  • tests/lua/ordered_encode_spec.lua
🚧 Files skipped from review as they are similar to previous changes (1)
  • lua/qjson/table.lua

The old comment described the now-removed behavior of stripping the
LazyObject metatable on first write. Rewrite to describe the current
approach: building ORDER_KEYS/ORDER_VALUES under sentinel keys while
keeping the metatable alive.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@membphis
Copy link
Copy Markdown
Collaborator Author

Quick re-check on origin/main vs this PR:

  • qjson.pairs-child mutation lost in encode/materialize is pre-existing on main (not introduced by this PR).
  • non-string key write on LazyObject is also pre-existing, but this PR makes it worse/regressive:
    • main: fails with explicit object key must be a string
    • PR branch: can enter inconsistent state (t[1] unreadable / qjson.len drift) and then fail later in encode_string.

So from merge-readiness perspective: one old bug, plus one old bug amplified into a regression in this branch. I still consider the latter a blocker for this PR.

membphis added 4 commits May 28, 2026 17:59
Restores the pre-PR behavior: writing a non-string key to a lazy object
now raises 'object key must be a string' immediately, instead of silently
inserting the key into ORDER_KEYS and producing an inconsistent state
(unreadable via __index, broken qjson.len, crash in encode_string).
When pairs() iterates an unmodified lazy object, check CHILD_CACHE before
calling decode_cursor so mutations on previously accessed child containers
are visible to the caller.
membphis added 3 commits May 29, 2026 02:31
ensure_object_order_state always returns a non-nil order table, so the
post-materialization branch in encode_lazy_object_walking made the old
cursor-walk path unreachable; remove it and flatten the now-constant
guard. INTERNAL_KEYS was only referenced from that dead branch, so drop
it and its stale comment. No behavior change; 156/156 Lua tests pass.
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 0

🧹 Nitpick comments (1)
lua/qjson/table.lua (1)

591-596: 💤 Low value

Consider aligning nil-value handling with materialize().

materialize() at line 494 asserts that values[k] ~= nil for any key in ORDER_KEYS, treating a nil value as an invariant violation. Here, encode_lazy_object_walking silently skips nil values. If the invariant holds, the v ~= nil check is redundant; if it doesn't, the silent skip may mask a bug that materialize() would catch.

Either remove the check (relying on the invariant) or add an assertion here for consistency. Given that deletions remove keys from ORDER_KEYS, the check is technically unnecessary.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lua/qjson/table.lua` around lines 591 - 596, The loop in
encode_lazy_object_walking silently skips nil values which is inconsistent with
materialize()'s invariant (materialize asserts values[k] ~= nil for keys in
ORDER_KEYS); update encode_lazy_object_walking to enforce the same invariant by
replacing the silent nil-check with an assertion (e.g., assert(v ~= nil,
"ORDER_KEYS invariant violated for key "..tostring(k))) so missing values
surface as errors; reference encode_lazy_object_walking, materialize,
ORDER_KEYS, encode_string, and encode when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@lua/qjson/table.lua`:
- Around line 591-596: The loop in encode_lazy_object_walking silently skips nil
values which is inconsistent with materialize()'s invariant (materialize asserts
values[k] ~= nil for keys in ORDER_KEYS); update encode_lazy_object_walking to
enforce the same invariant by replacing the silent nil-check with an assertion
(e.g., assert(v ~= nil, "ORDER_KEYS invariant violated for key "..tostring(k)))
so missing values surface as errors; reference encode_lazy_object_walking,
materialize, ORDER_KEYS, encode_string, and encode when making the change.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6fc7a909-6334-447e-ae8b-821be261f183

📥 Commits

Reviewing files that changed from the base of the PR and between 931c13c and b5acadb.

📒 Files selected for processing (2)
  • lua/qjson/table.lua
  • tests/lua/ordered_encode_spec.lua

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

LazyObject 修改后 encode:源 JSON 含重复 key 时会输出重复 key feat: preserve key order in decode-modify-encode workflow

1 participant