Add RFC 7386 JSON Merge Patch - #18
Conversation
`JSON.merge-patch` applies a merge patch with the RFC's semantics: a non-object patch replaces the target, a `null` member deletes a key, everything else merges recursively, and a non-object target starts out as an empty object. Arrays are replaced whole, so the format can address neither a position inside an array nor a member whose value should become `null`; both docstrings say so rather than pretending otherwise. `JSON.merge-diff` builds the smallest patch taking one document to another, which is the half of the pair that is awkward to write by hand. Tests cover the full Appendix A table plus a round-trip property over hand-built nested documents, including the case that provably cannot round-trip. Core's `Map.remove` decrements a map's length whether or not the key was present, so deleting an absent key left a map whose `Map.length` disagreed with its contents and compared unequal under `JSON.=`. A merge patch deletes absent keys routinely, and `JSON.delete-key` already had the same bug against its documented behaviour; both now go through a guard.
There was a problem hiding this comment.
Build & Tests
carp -x test/json.carp on 51d751a: 399 assertions, 0 failures. CI green on ubuntu + macOS against this exact head (run 31158295681), pull_request-triggered, with lint and format-check passing. Merge-base is the origin/main tip (3013166, the #17 merge), so this is a clean fast-forward with no stale-branch hazard. Still a draft; leaving it that way.
Findings
None. What follows is what I did to try to find some.
A differential against an independent RFC 7386 implementation
I wrote merge-patch and merge-diff from the RFC in Python, generated 400 random document pairs over a deliberately small key alphabet (so key collisions, nested objects, arrays, and null values are dense rather than rare), and compared parsed results — not strings, since Carp's Map is unordered.
| mismatches | |
|---|---|
JSON.merge-patch vs reference |
0 / 400 |
JSON.merge-diff vs reference |
0 / 400 |
The reference also confirms the docstrings' stated limitation is exactly the whole limitation: over the same 400 pairs, merge-patch(a, merge-diff(a,b)) == b on 377, fails on 23, and every one of the 23 is a b that introduces a null-valued object member. 0 unexpected round-trip failures. So "a b that introduces one does not survive the round trip" is not an approximation — it is the precise characterisation, and the assert-false pinning {} → {"a":null} is pinning the only failure mode there is.
The harness was vacuous at first, and that is worth knowing
My first version compared serialized output only, and it reported 0 mismatches with the remove-member guard deleted. The guard corrupts Map.length, and JSON.str walks buckets — it never reads the length, so serialization is completely blind to the bug this PR exists to work around.
I added a self-equality check (every result must satisfy JSON.= against its own reparse) and re-ran:
| tree | self-equality failures over 400 pairs |
|---|---|
51d751a as submitted |
0 |
remove-member guard deleted |
38 |
e.g. pair 345 produces {"b":[0,"t"],"c":0,"bb":false}, which serializes correctly and compares unequal to itself. So the guard is load-bearing and correctly placed, and the harness that says so can actually fail. Flagging the blindness because it is a trap for whoever writes the next test here: a string-comparison test cannot catch a regression in remove-member. The committed test "delete-key on an absent key leaves the object equal to itself" uses =, so it is on the right side of this already — that looks deliberate, and it is the right call.
The core Map.remove bug, confirmed independently
Reduced away from JSON entirely:
(let-do [m (Map.put (Map.put (the (Map String Int) {}) "a" &1) "b" &2)
m2 (Map.remove @&m "zzz")] ; key is ABSENT
...)before: len=2 contains a=1 b=1
after remove of ABSENT key: len=1 contains a=1 b=1
Map.= m m2 (should be true, both hold a,b): 0
(%b renders as 1/0 here, so: both keys still present after the removal, length silently down one, and the two maps compare unequal.)
Exactly as described: core/Map.carp:307 decrements unconditionally, remove! at 319 guards with Bucket.contains?, and Map.= (line 340) compares length before contents, so two maps with identical contents compare unequal. Appendix A rows 7, 14 and 15 are the shape that hits it. The upstream diagnosis holds and is worth the lead it was written up as.
Things I checked that turned out fine
- The other
Map.removein this file is already safe.json.carp:1086, in the RFC 6902Edit.Removepath, sits inside(if (Map.contains? &m tok) ...). Sodelete-keyandmerge-patchwere the only two unguarded sites, and both now route throughremove-member. No third site was missed. - The paired
Map.keys/Map.valsiteration is safe by contract, not by luck. Both arekv-reduceover the same traversal and core documents it: "Order corresponds to order of (vals m)". register-as-forward-declaration is this file's established idiom, not something invented here —json-parse-value,serialize-obj-into!,set-in-at,=,edit-at,value-depthandvalue-nodesall do it already.- The recursion is bounded for parsed input.
merge-patchandmerge-diffrecurse without a depth guard, but the parser caps nesting atjson-max-depth128 and errors withDepthLimitExceeded, so no parseable document can drive them deep. Hand-built values could, but that is equally true ofJSON.=and every other recursive function here — not a new exposure. - Adversarial shapes behave. Non-object patch replaces wholesale; non-object target is treated as empty;
nullinside an array survives untouched (arrays are opaque to the format), which the"5"→{"a":[1,{"b":null}]}round-trip test already pins. - Docs deliberately left for a release commit, as in #17. That matches the repo's precedent; local
gendocsdoes produce three uncommittedJSON.Patch*pages, but that is #17's tail, not this PR's, and CI does not gate on committed docs being current.
Verdict: merge
The semantics are right — 400 random pairs against an independent implementation of the RFC with zero disagreement in either direction, and the round-trip property fails on exactly the class the docstrings say it fails on and nothing else. The Appendix A transcription and the assert-false are doing real work. The Map.remove workaround is minimal, correctly scoped to the two sites that needed it, commented with the reason, and provably load-bearing. Splitting it out for separate review is not necessary from my side — it is four lines, it is right, and the regression test pins it. The upstream bug report is accurate and I would send it on.
The library had the harder, rarer half of the pair: RFC 6902 JSON Patch (#17)
and RFC 6901 JSON Pointer (#12). An API that accepts HTTP PATCH almost always
speaks
application/merge-patch+jsoninstead, so this adds RFC 7386.JSON.merge-patchApplies a merge patch to a target with the RFC's exact semantics: a non-object
patch replaces the target wholesale, a non-object target is treated as an empty
object, a
nullmember deletes that key (a no-op if absent), and every othermember merges recursively. Arrays are replaced, never merged elementwise.
(JSON.merge-patch doc &patch)JSON.merge-diffBuilds the smallest merge patch taking
atob, emittingnullfor membersbdrops and omitting the ones it leaves alone. This is the half that makesthe format usable from Carp — hand-writing a merge patch is the easy part.
Both docstrings and the README state the two things the format cannot express,
rather than pretending otherwise: a member cannot be set to
null, becausethat spelling already means delete, and nothing inside an array can be
addressed without resending the array.
The
Map.removebugCore's
Map.removedecrements a map's length whether or not the key wasactually present:
Map.remove!guards onBucket.contains?; the by-valueremovedoes not. Soremoving an absent key yields a map whose
Map.lengthdisagrees with itscontents, and
JSON.=— which compares object sizes first — then calls twoequal documents unequal. That is exactly the shape of RFC 7386 Appendix A rows
7, 14 and 15, where a
nullmember names a key the target does not have; thosethree assertions failed until the guard went in.
JSON.delete-keyalready had the same bug against its own docstring ("if thevalue is not an object or the key is absent, returns it unchanged" — it did
not, under
=). Both now go through one private guard, and there is aregression test for
delete-key. Happy to split that out if you would ratherreview it on its own.
Tests
All 15 rows of RFC 7386 Appendix A transcribed as assertions, plus the RFC
section 3 worked example and six more round-trip properties over hand-built
nested documents with nulls, empty objects and empty arrays — including an
assert-falsepinning the one case that provably cannot round-trip(
{}→{"a":null}). Comparison reusesJSON.=from #17.carp -x test/json.carp: 399 passed, 0 failed.carp-fmt --checkandanglerare clean;
gendocs.carpruns (docs themselves left for a release commit, asin #17).
Opened by the carpentry-org heartbeat agent (Claude). Veit has not reviewed this yet.