Skip to content

Commit 208d8b9

Browse files
committed
fix(lazy-patch): address review feedback on PR #44
- LazyObject.__newindex: raise on non-string keys instead of silently recording an unreadable patch (Copilot C3). - Remove unused has_patches / has_deletions helpers (Copilot C2). - encode_with_patches: classify each patch in a single qjson_cursor_field_bytes pass (was doing two) and propagate unexpected return codes via check() (Copilot C4 + CodeRabbit R6). - encode_lazy_object_walking_with_patches: propagate unexpected qjson_cursor_field_bytes return codes (CodeRabbit R7). - lazy_object_iter: same error-propagation fix (CodeRabbit R5). - tests/lua/lazy_patch_spec.lua: extend special-key test to verify encoded output, swap the unicode test to multi-byte UTF-8, add "delete non-existent field", "all originals deleted + new fields added", and "non-string key raises" cases (CodeRabbit R8/R9/R10 + nitpick N2). - docs/lazy-patch-spec.md: add upfront note that the listing is design pseudocode (pointing helper names at qjson_cursor_field_bytes), and correct the patch-record example to include lua_value alongside encoded_value (CodeRabbit R1 + nitpick N3).
1 parent 176558a commit 208d8b9

3 files changed

Lines changed: 70 additions & 46 deletions

File tree

docs/lazy-patch-spec.md

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Lazy Patch: Structural Patching for qjson.decode
22

3+
> **Note:** This document is the original *design* memo. The Lua snippets below are high-level pseudocode showing intent — they are not the shipped code. The actual implementation lives in `lua/qjson/table.lua` and `src/ffi.rs`; the splice path is used only for the "all patches target existing fields, no deletions" case, while deletions and new fields fall through to a walking-based encoder (`encode_lazy_object_walking_with_patches`) that avoids the comma-rewriting issues that the splice pseudocode below has. Helper names like `find_field_value_span` correspond to the FFI symbol `qjson_cursor_field_bytes` (`src/ffi.rs:821`).
4+
35
## Overview
46

57
This spec describes an optimization for the "decode → modify few fields → encode" workflow. Instead of materializing the entire root container on first write, we record patches and apply them during encode by splicing the original buffer.
@@ -53,12 +55,13 @@ local out = qjson.encode(tab) -- Splices: buf[0..model_start] + "gpt4o" +
5355
#### Patch Record (Lua side)
5456

5557
```lua
56-
-- Stored in the lazy view's internal table
58+
-- Stored in the lazy view's internal table.
59+
-- Each patch records key, encoded JSON value, and the original Lua value
60+
-- (so __index can hand back the user's value without re-decoding).
61+
-- Byte offsets are resolved lazily during encode via qjson_cursor_field_bytes.
5762
_patches = {
58-
-- Each patch records the key and new encoded value
59-
-- Byte offsets are resolved lazily during encode
60-
{ key = "model", encoded_value = '"gpt4o"' },
61-
{ key = "temperature", encoded_value = "0.9" },
63+
{ key = "model", encoded_value = '"gpt4o"', lua_value = "gpt4o" },
64+
{ key = "temperature", encoded_value = "0.9", lua_value = 0.9 },
6265
}
6366
```
6467

@@ -105,23 +108,23 @@ LazyObject.__newindex = function(t, k, v)
105108
end
106109
end
107110
else
108-
-- Encode the new value
111+
-- Encode the new value (we keep both encoded and the original Lua
112+
-- value so __index can return v without re-decoding).
109113
local encoded = encode_value(v)
110-
111-
-- Update or add patch
114+
112115
local found = false
113116
for _, p in ipairs(patches) do
114117
if p.key == k then
115118
p.encoded_value = encoded
119+
p.lua_value = v
116120
found = true
117121
break
118122
end
119123
end
120124
if not found then
121-
patches[#patches + 1] = { key = k, encoded_value = encoded }
125+
patches[#patches + 1] = { key = k, encoded_value = encoded, lua_value = v }
122126
end
123-
124-
-- Remove from deleted if previously deleted
127+
125128
deleted[k] = nil
126129
end
127130
end

lua/qjson/table.lua

Lines changed: 16 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,7 @@ local function lazy_object_iter(state, _prev_key)
210210
if rc == QJSON_NOT_FOUND then
211211
return p.key, p.lua_value
212212
end
213+
check(rc) -- propagate unexpected errors
213214
end
214215
end
215216

@@ -324,6 +325,9 @@ local encode
324325
-- On write, record a patch instead of materializing the entire object.
325326
-- This allows encode to splice the original buffer with patched values.
326327
LazyObject.__newindex = function(t, k, v)
328+
if type(k) ~= "string" then
329+
error("qjson: LazyObject key must be a string, got " .. type(k))
330+
end
327331
-- Initialize patches table if needed
328332
if not rawget(t, "_patches") then
329333
rawset(t, "_patches", {})
@@ -515,20 +519,6 @@ local function is_dirty(v)
515519
return false
516520
end
517521

518-
-- Check if a lazy view has patches (but not necessarily dirty children)
519-
local function has_patches(v)
520-
local patches = rawget(v, "_patches")
521-
return patches and #patches > 0
522-
end
523-
524-
-- Check if a lazy view has deletions
525-
local function has_deletions(v)
526-
local deleted = rawget(v, "_deleted")
527-
if not deleted then return false end
528-
for _ in pairs(deleted) do return true end
529-
return false
530-
end
531-
532522
-- Walk a dirty LazyObject and emit JSON, preferring cached children (which
533523
-- may be materialized) over freshly resolved cursors. Non-cached children
534524
-- emit through a fresh proxy and naturally fast-path their unmodified subtree.
@@ -637,6 +627,8 @@ local function encode_lazy_object_walking_with_patches(t, patches, deleted)
637627
local rc = C.qjson_cursor_field_bytes(t._cur, p.key, #p.key, sz_a, sz_b)
638628
if rc == QJSON_NOT_FOUND then
639629
parts[#parts + 1] = encode_string(p.key) .. ":" .. p.encoded_value
630+
elseif rc ~= QJSON_OK then
631+
check(rc) -- propagate unexpected errors
640632
end
641633
end
642634

@@ -650,39 +642,31 @@ local function encode_with_patches(t)
650642
local patches = rawget(t, "_patches") or {}
651643
local deleted = rawget(t, "_deleted") or {}
652644

653-
-- Collect replacements: { {start, end_, value}, ... }
645+
-- Classify each patch into a replacement (existing field) or a new field
646+
-- in a single FFI pass. Replacements carry the byte span; presence of any
647+
-- new field forces the walking fallback.
654648
local replacements = {}
655-
656-
-- For each patch, find the field's byte range in the original buffer
649+
local has_new_field = false
657650
for _, p in ipairs(patches) do
658651
local rc = C.qjson_cursor_field_bytes(t._cur, p.key, #p.key, sz_a, sz_b)
659652
if rc == QJSON_OK then
660-
-- Existing field: replace value
661653
replacements[#replacements + 1] = {
662654
start = tonumber(sz_a[0]),
663655
end_ = tonumber(sz_b[0]),
664656
value = p.encoded_value,
665657
}
658+
elseif rc == QJSON_NOT_FOUND then
659+
has_new_field = true
660+
else
661+
check(rc) -- propagate unexpected errors
666662
end
667-
-- If NOT_FOUND, it's a new field - handled separately below
668663
end
669664

670-
-- Collect deleted field spans (we need to find the full field span including key)
671-
-- For simplicity, we'll handle deletions by walking and skipping deleted keys
665+
-- Deletions and new fields are handled by the walking fallback,
666+
-- which avoids the comma-rewriting headache the splice path would have.
672667
local has_deleted = false
673668
for _ in pairs(deleted) do has_deleted = true; break end
674669

675-
-- If we have deletions or new fields, fall back to walking
676-
-- (splicing deletions is complex due to comma handling)
677-
local has_new_field = false
678-
for _, p in ipairs(patches) do
679-
local rc = C.qjson_cursor_field_bytes(t._cur, p.key, #p.key, sz_a, sz_b)
680-
if rc == QJSON_NOT_FOUND then
681-
has_new_field = true
682-
break
683-
end
684-
end
685-
686670
if has_deleted or has_new_field then
687671
return encode_lazy_object_walking_with_patches(t, patches, deleted)
688672
end

tests/lua/lazy_patch_spec.lua

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,16 +242,21 @@ describe("Lazy Patch - edge cases", function()
242242
local t = qjson.decode('{"a.b":1}')
243243
t["a.b"] = 10
244244
assert.are.equal(10, t["a.b"])
245+
local out = qjson.encode(t)
246+
local cjson = require("cjson")
247+
local parsed = cjson.decode(out)
248+
assert.are.equal(10, parsed["a.b"])
245249
end)
246250

247251
it("handles unicode in values", function()
248252
local t = qjson.decode('{"a":"hello"}')
249-
t.a = "hello world"
250-
assert.are.equal("hello world", t.a)
253+
local unicode = "你好 🌏 café"
254+
t.a = unicode
255+
assert.are.equal(unicode, t.a)
251256
local out = qjson.encode(t)
252257
local cjson = require("cjson")
253258
local parsed = cjson.decode(out)
254-
assert.are.equal("hello world", parsed.a)
259+
assert.are.equal(unicode, parsed.a)
255260
end)
256261

257262
it("handles boolean values", function()
@@ -273,6 +278,38 @@ describe("Lazy Patch - edge cases", function()
273278
local parsed = cjson.decode(out)
274279
assert.are.equal(cjson.null, parsed.a)
275280
end)
281+
282+
it("deletes a non-existent field without error", function()
283+
local t = qjson.decode('{"a":1}')
284+
t.b = nil
285+
assert.is_nil(t.b)
286+
assert.are.equal(1, t.a)
287+
local out = qjson.encode(t)
288+
local cjson = require("cjson")
289+
local parsed = cjson.decode(out)
290+
assert.is_nil(parsed.b)
291+
assert.are.equal(1, parsed.a)
292+
end)
293+
294+
it("handles all original fields deleted plus new fields added", function()
295+
local t = qjson.decode('{"a":1,"b":2}')
296+
t.a = nil
297+
t.b = nil
298+
t.c = 3
299+
t.d = 4
300+
local out = qjson.encode(t)
301+
local cjson = require("cjson")
302+
local parsed = cjson.decode(out)
303+
assert.is_nil(parsed.a)
304+
assert.is_nil(parsed.b)
305+
assert.are.equal(3, parsed.c)
306+
assert.are.equal(4, parsed.d)
307+
end)
308+
309+
it("rejects non-string keys on LazyObject", function()
310+
local t = qjson.decode('{"a":1}')
311+
assert.has_error(function() t[1] = "x" end)
312+
end)
276313
end)
277314

278315
describe("Lazy Patch - metatable preservation", function()

0 commit comments

Comments
 (0)