From 1d4baf429ea6aef77e0f1619ef121dc47c7a9994 Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Mon, 26 Oct 2026 12:08:40 +0100 Subject: [PATCH] docs: make the diff() and patch() documentation accurate; add tests Document the exceptions to the diff round-trip invariant (NaN, discarded values, number types), the path parameter, non-minimal patches, array and object operation order, the ordered_json fallback, binary values in serialized patches, the real complexity, duplicate member names in operations, test on missing paths, and the exact out_of_range.403 message. Add tests for these behaviors. Signed-off-by: Niels Lohmann --- docs/mkdocs/docs/api/basic_json/diff.md | 45 +++- docs/mkdocs/docs/api/basic_json/patch.md | 11 +- .../docs/api/basic_json/patch_inplace.md | 11 +- docs/mkdocs/docs/features/json_patch.md | 19 +- tests/src/unit-json_patch.cpp | 206 +++++++++++++++++- 5 files changed, 282 insertions(+), 10 deletions(-) diff --git a/docs/mkdocs/docs/api/basic_json/diff.md b/docs/mkdocs/docs/api/basic_json/diff.md index c4e710a7d..d45a9a094 100644 --- a/docs/mkdocs/docs/api/basic_json/diff.md +++ b/docs/mkdocs/docs/api/basic_json/diff.md @@ -2,17 +2,27 @@ ```cpp static basic_json diff(const basic_json& source, - const basic_json& target); + const basic_json& target, + const string_t& path = ""); ``` Creates a [JSON Patch](http://jsonpatch.com) so that value `source` can be changed into the value `target` by calling [`patch`](patch.md) function. -For two JSON values `source` and `target`, the following code always yields `#!cpp true`: +For two JSON values `source` and `target`, the following code yields `#!cpp true`: ```cpp source.patch(diff(source, target)) == target; ``` +The equality holds up to [`operator==`](operator_eq.md), so it has the following exceptions: + +- A value that is not equal to itself cannot round-trip. This applies to `NaN` (also nested in an array or object) and + to [discarded](is_discarded.md) values: `diff` emits a `replace` operation for them. A `NaN` in the patch is + serialized as `#!json null`, so a patch that was dumped and parsed again would set `#!json null`. +- Numbers that compare equal produce no operation, even if their types or signs differ: `diff(1, 1.0)`, + `diff(1u, 1)` (unsigned and signed integer) and `diff(0.0, -0.0)` all return `#!json []`. The patched value is then + the `source` and keeps its number type. + ## Parameters `source` (in) @@ -21,6 +31,12 @@ source.patch(diff(source, target)) == target; `target` (in) : JSON value to compare against +`path` (in) +: a prefix that is put verbatim in front of the `path` member of every operation (default: `""`, which addresses the + root of the value). The prefix is neither escaped nor validated: `#!cpp diff(1, 2, "abc")` yields an operation with + the `path` `abc`, which is not a valid JSON Pointer. Use it with a valid JSON Pointer such as `/root` and escape + `~` and `/` in its reference tokens (see [JSON Pointer](../../features/json_pointer.md)). + ## Return value a JSON patch to convert the `source` to `target` @@ -31,11 +47,32 @@ Strong guarantee: `source` and `target` are never modified. ## Complexity -Linear in the lengths of `source` and `target`. +Linear in the total number of values (including nested ones) in `source` and `target`, times the cost of a key-based +object operation (looking up a key in an object): logarithmic for [`json`](../json.md), and linear for +[`ordered_json`](../ordered_json.md) (see [`ordered_map` complexity](../ordered_map.md#complexity)). For +`ordered_json`, diffing two objects with `n` and `m` members therefore takes O((n+m)²) when their keys do not line up +(e.g., a key was added or the keys are in a different order). The size of the patch is proportional to the number of +operations times the length of their paths, so a small change deep inside a value yields a long `path`. ## Notes -Currently, only `remove`, `add`, and `replace` operations are generated. +- Currently, only `remove`, `add`, and `replace` operations are generated. +- The patch is correct, but not guaranteed to be minimal. For instance, `diff([1,2,3], [0,1,2,3])` yields three + `replace` operations and one `add` operation instead of a single insertion. +- Differences between two arrays are reported index by index. Elements that were added at the end of the array are added + with `add` operations on the path `/-` (appended in order), and elements that were removed are removed with `remove` + operations from the highest index down to the lowest, so that the remaining indices stay valid. +- For objects, the operations for the members of `source` come first (`replace`, `remove`, or the operations for nested + values, in the iteration order of `source`), followed by `add` operations for the members that only `target` has. +- For [`ordered_json`](../ordered_json.md), objects with the same keys in a different order are different. As a patch + cannot reorder members, `diff` removes all keys of the `source` object and adds them again in the order of the + `target`. +- If `source` and `target` differ as a whole (e.g., different types), the patch consists of a single `replace` + operation for the root of the value, i.e., with the `path` `""` (or the given `path` prefix). +- The `value` members of the patch hold copies of values of `target`. A [binary](../../features/binary_values.md) value + has no JSON representation: when a patch that contains one is serialized, it becomes an object such as + `#!json {"bytes":[1,3],"subtype":null}`, so a parsed patch no longer holds a binary value (and would set that object + instead). ## Examples diff --git a/docs/mkdocs/docs/api/basic_json/patch.md b/docs/mkdocs/docs/api/basic_json/patch.md index 39f601683..1166c9a71 100644 --- a/docs/mkdocs/docs/api/basic_json/patch.md +++ b/docs/mkdocs/docs/api/basic_json/patch.md @@ -40,7 +40,7 @@ Strong guarantee: if an exception is thrown, there are no changes in the JSON va where an existing element is required (the "path" of "replace", the "from" of "move" and "copy"); example: `"array index '-' (3) is out of range"`. - Throws [`out_of_range.403`](../../home/exceptions.md#jsonexceptionout_of_range403) if a JSON pointer inside the patch - could not be resolved successfully in the current JSON value; example: `"key baz not found"`. + could not be resolved successfully in the current JSON value; example: `"key 'baz' not found"`. - Throws [`out_of_range.404`](../../home/exceptions.md#jsonexceptionout_of_range404) if a reference token of a JSON pointer inside the patch cannot be resolved, e.g., `-` in a "remove" operation or `1a` for an array; example: `"unresolved reference token '-'"`. @@ -53,7 +53,10 @@ Strong guarantee: if an exception is thrown, there are no changes in the JSON va - Throws [`out_of_range.414`](../../home/exceptions.md#jsonexceptionout_of_range414) if a "move" operation's "from" location is a proper prefix of its "path" location. - Throws [`other_error.501`](../../home/exceptions.md#jsonexceptionother_error501) if "test" operation was - unsuccessful. + unsuccessful. A "test" operation whose "path" does not exist is unsuccessful, too: the failed lookup is reported as a + failed test, even if the operation has no "value" member (which would otherwise be + [`parse_error.105`](../../home/exceptions.md#jsonexceptionparse_error105)). Which of the two errors is reported for + this combination depends on the order in which the operands of a comparison are evaluated, so do not rely on it. ## Complexity @@ -65,6 +68,10 @@ the JSON value, the complexity can usually be neglected. The application of a patch is atomic: Either all operations succeed and the patched document is returned or an exception is thrown. In any case, the original value is not changed: the patch is applied to a copy of the value. +Duplicate member names inside an operation object are resolved by the parser before the patch is applied (the last one +is kept, see [`object_t`](object_t.md#behavior)), so `#!json {"op": "add", "op": "remove", "path": "/a"}` is a +`remove` operation. + ## Examples ??? example "Example: apply a JSON patch" diff --git a/docs/mkdocs/docs/api/basic_json/patch_inplace.md b/docs/mkdocs/docs/api/basic_json/patch_inplace.md index 1bcfc0067..571ca9c12 100644 --- a/docs/mkdocs/docs/api/basic_json/patch_inplace.md +++ b/docs/mkdocs/docs/api/basic_json/patch_inplace.md @@ -36,7 +36,7 @@ No guarantees, value may be corrupted by an unsuccessful patch operation. where an existing element is required (the "path" of "replace", the "from" of "move" and "copy"); example: `"array index '-' (3) is out of range"`. - Throws [`out_of_range.403`](../../home/exceptions.md#jsonexceptionout_of_range403) if a JSON pointer inside the patch - could not be resolved successfully in the current JSON value; example: `"key baz not found"`. + could not be resolved successfully in the current JSON value; example: `"key 'baz' not found"`. - Throws [`out_of_range.404`](../../home/exceptions.md#jsonexceptionout_of_range404) if a reference token of a JSON pointer inside the patch cannot be resolved, e.g., `-` in a "remove" operation or `1a` for an array; example: `"unresolved reference token '-'"`. @@ -49,7 +49,10 @@ No guarantees, value may be corrupted by an unsuccessful patch operation. - Throws [`out_of_range.414`](../../home/exceptions.md#jsonexceptionout_of_range414) if a "move" operation's "from" location is a proper prefix of its "path" location. - Throws [`other_error.501`](../../home/exceptions.md#jsonexceptionother_error501) if "test" operation was - unsuccessful. + unsuccessful. A "test" operation whose "path" does not exist is unsuccessful, too: the failed lookup is reported as a + failed test, even if the operation has no "value" member (which would otherwise be + [`parse_error.105`](../../home/exceptions.md#jsonexceptionparse_error105)). Which of the two errors is reported for + this combination depends on the order in which the operands of a comparison are evaluated, so do not rely on it. ## Complexity @@ -62,6 +65,10 @@ Unlike [`patch`](patch.md), `patch_inplace` applies the operation "in place" and That makes it faster for large documents by avoiding the copy. However, the JSON value might be corrupted if the function throws an exception. +Duplicate member names inside an operation object are resolved by the parser before the patch is applied (the last one +is kept, see [`object_t`](object_t.md#behavior)), so `#!json {"op": "add", "op": "remove", "path": "/a"}` is a +`remove` operation. + ## Examples ??? example "Example: apply a JSON patch in place" diff --git a/docs/mkdocs/docs/features/json_patch.md b/docs/mkdocs/docs/features/json_patch.md index 878f0084c..d35ea55b0 100644 --- a/docs/mkdocs/docs/features/json_patch.md +++ b/docs/mkdocs/docs/features/json_patch.md @@ -13,6 +13,12 @@ to the current JSON value by executing all operations from the patch, yielding t If the document is large and the original value is no longer needed, [`patch_inplace`](../api/basic_json/patch_inplace.md) applies the same operations in place instead. +!!! note "Duplicate member names" + + Duplicate member names inside an operation object are resolved by the parser before the patch is applied (the last + one is kept, see [`object_t`](../api/basic_json/object_t.md#behavior)), so + `#!json {"op": "add", "op": "remove", "path": "/a"}` is a `remove` operation. + ??? example "Example: apply a JSON Patch" The following code shows how a JSON patch is applied to a value. @@ -41,12 +47,23 @@ flowchart LR !!! success "Invariant" - For two JSON values *source* and *target*, the following code yields always true: + For two JSON values *source* and *target*, the following code yields true: ```cpp source.patch(diff(source, target)) == target; ``` + The equality holds up to [`operator==`](../api/basic_json/operator_eq.md), with these exceptions: + + - `NaN` and [discarded](../api/basic_json/is_discarded.md) values are not equal to themselves, so the invariant + cannot hold for them. `diff` emits a `replace` operation for such a value, and a `NaN` in a patch is serialized + as `#!json null`. + - Numbers that compare equal produce no operation even if their types differ: `diff(1, 1.0)`, `diff(1u, 1)`, and + `diff(0.0, -0.0)` return `#!json []`, so the patched value keeps the number type of *source*. + - A [binary value](binary_values.md) in a patch is serialized as an object + (`#!json {"bytes":[...],"subtype":null}`), so a patch that was dumped and parsed again no longer holds a binary + value. + ??? example "Example: create a JSON Patch from the difference of two values" The following code shows how a JSON patch is created as a diff for two JSON values. diff --git a/tests/src/unit-json_patch.cpp b/tests/src/unit-json_patch.cpp index de868737f..b6c78532d 100644 --- a/tests/src/unit-json_patch.cpp +++ b/tests/src/unit-json_patch.cpp @@ -15,6 +15,7 @@ using nlohmann::json; #endif #include +#include #include #include #include "make_test_data_available.hpp" @@ -490,7 +491,9 @@ TEST_CASE("JSON patch") } // A.13. Invalid JSON Patch Document - // not applicable + // not applicable: the library cannot tell the duplicate "op" members + // apart, because the parser resolves them (the last one is kept); see + // "duplicate member names in an operation" below SECTION("A.14. Escape Ordering") { @@ -2121,3 +2124,204 @@ TEST_CASE_TEMPLATE("JSON patch - diff round trip on hand-made documents", Json, roundtrip(Json(1), Json::parse("[1]")); } } + +TEST_CASE("JSON patch - behavior documented in the API reference") +{ + SECTION("duplicate member names in an operation") + { + // RFC 6902, A.13: the parser resolves the duplicates (the last one + // is kept), so this is a "remove" and not an "add" + const json patch = json::parse(R"([{"op": "add", "op": "remove", "path": "/a"}])"); + CHECK(patch == json::parse(R"([{"op": "remove", "path": "/a"}])")); + CHECK(json({{"a", 1}, {"b", 2}}).patch(patch) == json({{"b", 2}})); + } + + SECTION("add with a leading zero in an array index") + { + const json patch = R"([{"op": "add", "path": "/01", "value": 1}])"_json; + json doc = json::array(); +#if !defined(JSON_NOEXCEPTION) + CHECK_THROWS_AS(doc.patch(patch), json::parse_error&); + CHECK_THROWS_AS(doc.patch_inplace(patch), json::parse_error&); +#endif + CHECK(doc == json::array()); + } + + SECTION("test compares numbers by value") + { + const json doc = R"({"a": 1, "b": 0, "c": 1.5})"_json; + CHECK(doc.patch(R"([{"op": "test", "path": "/a", "value": 1.0}])"_json) == doc); + CHECK(doc.patch(R"([{"op": "test", "path": "/b", "value": -0.0}])"_json) == doc); + CHECK(doc.patch(R"([{"op": "test", "path": "/c", "value": 1.5}])"_json) == doc); + CHECK_THROWS_AS(doc.patch(R"([{"op": "test", "path": "/a", "value": 2}])"_json), json::other_error&); + CHECK_THROWS_AS(doc.patch(R"([{"op": "test", "path": "/a", "value": "1"}])"_json), json::other_error&); + } + + SECTION("test ignores the order of object members, but not the order of array elements") + { + const json doc = R"({"o": {"x": 1, "y": 2}, "a": [1, 2]})"_json; + CHECK(doc.patch(R"([{"op": "test", "path": "/o", "value": {"y": 2, "x": 1}}])"_json) == doc); + CHECK(doc.patch(R"([{"op": "test", "path": "/a", "value": [1, 2]}])"_json) == doc); + CHECK_THROWS_AS(doc.patch(R"([{"op": "test", "path": "/a", "value": [2, 1]}])"_json), json::other_error&); + } + + SECTION("test on a path that does not exist") + { + const json doc = R"({"a": 1})"_json; + // with a "value" member, the failed lookup is a failed test + CHECK_THROWS_AS(doc.patch(R"([{"op": "test", "path": "/b", "value": 1}])"_json), json::other_error&); + CHECK_THROWS_AS(doc.patch(R"([{"op": "test", "path": "/b", "value": null}])"_json), json::other_error&); + // without it, an exception is thrown, too; which one depends on the + // order of evaluation of the operands of ==, so it is not checked + CHECK_THROWS(doc.patch(R"([{"op": "test", "path": "/b"}])"_json)); + } + + SECTION("patch() has the strong guarantee") + { + const json doc = R"({"a": [1, 2], "o": {"k": "v"}})"_json; + const json before = doc; + + const std::vector failing = + { + // remove of a member that does not exist + R"([{"op": "add", "path": "/n", "value": 1}, {"op": "remove", "path": "/missing"}])"_json, + // replace of a member that does not exist + R"([{"op": "remove", "path": "/a/0"}, {"op": "replace", "path": "/missing", "value": 1}])"_json, + // move from a member that does not exist + R"([{"op": "add", "path": "/n", "value": 1}, {"op": "move", "from": "/missing", "path": "/m"}])"_json, + // copy from a member that does not exist + R"([{"op": "add", "path": "/n", "value": 1}, {"op": "copy", "from": "/missing", "path": "/m"}])"_json, + // out-of-range array index + R"([{"op": "add", "path": "/n", "value": 1}, {"op": "add", "path": "/a/5", "value": 1}])"_json, + // failing test after a successful operation + R"([{"op": "remove", "path": "/o"}, {"op": "test", "path": "/a", "value": []}])"_json, + }; + for (const auto& patch : failing) + { + CAPTURE(patch.dump()) + CHECK_THROWS_AS(doc.patch(patch), json::exception&); + CHECK(doc == before); + + json copy = doc; + CHECK_THROWS_AS(copy.patch(patch), json::exception&); + CHECK(copy == before); + } + } +} + +TEST_CASE("JSON patch - diff behavior documented in the API reference") +{ + SECTION("path is a verbatim prefix") + { + // not escaped and not validated + CHECK(json::diff(1, 2) == R"([{"op": "replace", "path": "", "value": 2}])"_json); + CHECK(json::diff(1, 2, "abc") == R"([{"op": "replace", "path": "abc", "value": 2}])"_json); + CHECK(json::diff(1, 2, "/root") == R"([{"op": "replace", "path": "/root", "value": 2}])"_json); + // an escaped token in a non-root prefix is kept as given + CHECK(json::diff(R"({"a": 1})"_json, R"({"a": 2})"_json, "/x~1y/~0") + == R"([{"op": "replace", "path": "/x~1y/~0/a", "value": 2}])"_json); + CHECK(json::diff(R"({"a": 1})"_json, R"({"a": 2})"_json, "/p") + == R"([{"op": "replace", "path": "/p/a", "value": 2}])"_json); + } + + SECTION("keys are escaped in the paths") + { + const json source = json::object(); + const json target = + { + {"", 0}, {"/", 1}, {"~", 2}, {"~1", 3}, {"~01", 4} + }; + const json patch = json::diff(source, target); + REQUIRE(patch.size() == 5); + + // the order of the operations is not checked + json paths = json::object(); + for (const auto& op : patch) + { + CHECK(op["op"] == "add"); + paths[op["path"].get()] = op["value"]; + } + CHECK(paths == json({{"/", 0}, {"/~1", 1}, {"/~0", 2}, {"/~01", 3}, {"/~001", 4}})); + CHECK(source.patch(patch) == target); + } + + SECTION("NaN is not equal to itself") + { + const json nan = std::numeric_limits::quiet_NaN(); + const json patch = json::diff(nan, nan); + CHECK(patch.size() == 1); + CHECK(patch[0]["op"] == "replace"); + CHECK(patch[0]["path"] == ""); + CHECK(patch[0]["value"].is_number_float()); + CHECK(nan.patch(patch) != nan); + + // a NaN in a patch is serialized as null + CHECK(json::parse(patch.dump())[0]["value"].is_null()); + + const json nested = {{"a", {1, nan}}}; + const json nested_patch = json::diff(nested, nested); + CHECK(nested_patch.size() == 1); + CHECK(nested_patch[0]["path"] == "/a/1"); + + // infinity is equal to itself + const json inf = std::numeric_limits::infinity(); + CHECK(json::diff(inf, inf).empty()); + } + + SECTION("discarded values are not equal to themselves") + { + const json discarded(json::value_t::discarded); + const json patch = json::diff(discarded, discarded); + CHECK(patch.size() == 1); + CHECK(patch[0]["op"] == "replace"); + CHECK(patch[0]["path"] == ""); + CHECK(patch[0]["value"].is_discarded()); + CHECK(discarded.patch(patch) != discarded); + } + + SECTION("numbers that compare equal produce no operation") + { + CHECK(json::diff(json(1), json(1.0)).empty()); + CHECK(json::diff(json(1.0), json(1)).empty()); + CHECK(json::diff(json(1U), json(1)).empty()); + CHECK(json::diff(json(1), json(1U)).empty()); + CHECK(json::diff(json(0.0), json(-0.0)).empty()); + + // the patched value keeps the number type of the source + const json source = 1; + const json patched = source.patch(json::diff(source, json(1.0))); + CHECK(patched.is_number_integer()); + CHECK(patched == json(1.0)); + + // different values are replaced + CHECK(json::diff(json(1), json(2.0)) == R"([{"op": "replace", "path": "", "value": 2.0}])"_json); + CHECK(json::diff(json(1), json(-1)) == R"([{"op": "replace", "path": "", "value": -1}])"_json); + CHECK(json::diff(json(1.0), json(1.5)) == R"([{"op": "replace", "path": "", "value": 1.5}])"_json); + CHECK(json::diff(json(1), json("1")) == R"([{"op": "replace", "path": "", "value": "1"}])"_json); + } + + SECTION("binary values") + { + const json a = json::binary({1, 2}); + const json b = json::binary({1, 3}); + CHECK(json::diff(a, a).empty()); + + const json patch = json::diff(a, b); + CHECK(patch.size() == 1); + CHECK(patch[0]["op"] == "replace"); + CHECK(patch[0]["path"] == ""); + CHECK(patch[0]["value"].is_binary()); + CHECK(a.patch(patch) == b); + + // serialized, the binary value is an object + const json parsed = json::parse(patch.dump()); + CHECK(parsed[0]["value"].is_object()); + CHECK(parsed[0]["value"] == R"({"bytes": [1, 3], "subtype": null})"_json); + } + + SECTION("a difference as a whole is a replace of the root") + { + CHECK(json::diff(json::array({1}), json::object()) == R"([{"op": "replace", "path": "", "value": {}}])"_json); + CHECK(json::diff(json(nullptr), json::array()) == R"([{"op": "replace", "path": "", "value": []}])"_json); + } +}