Files
json/tests/src/unit-json_patch.cpp
T
Niels Lohmann 1151826508 Take diff()'s fast path unless the object type reorders members (#5691)
* Take diff()'s fast path unless the object type reorders members

For every object type except an insertion-ordered one like ordered_map,
diff() no longer produced a member-by-member patch when target had a key
that sorts before a key the two objects share: it fell through to the
slow path, which removes every member of source and re-adds every member
of target, instead of just adding the new key.

#5465 added an order check to require the fast path to also reproduce
target's member order, needed because ordered_map's patch()-driven "add"
appends a new member at the end. The check compared the common keys'
order between source and target and also required that every added key
come after every common key in target's order ("new_keys_form_suffix").
The comment above it argued this check is always true for std::map, and
that reasoning is correct for the order of the common keys themselves,
but not for new_keys_form_suffix: a std::map iterates in sorted key
order, so a new key that sorts before an existing common key is
enumerated between common keys, making new_keys_form_suffix false even
though std::map's own key order does not need reordering at all - it
places every member itself, regardless of insertion history, so a
member-by-member diff already reproduces target's iteration order.

Only require the order check for an object type that keeps insertion
order, using the same detail::is_ordered_map trait the library already
uses to recognize such an object type in set_parent(). Every other
object type - std::map in key order, a hash map in an order its
operator== ignores - always takes the fast path.

Added a regression test to unit-json_patch.cpp: the issue's example now
yields a single "add" op for json, while ordered_json still takes the
slow path to reproduce target's member order.

Fixes #5639.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

* Only track the target key order in diff() for insertion-ordered objects

common_keys_target_order and new_keys_form_suffix are only read when object_t keeps its members in insertion order; skip building them otherwise. Addresses review comment by @gregmarr.

Signed-off-by: Niels Lohmann <mail@nlohmann.me>

---------

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-09-30 20:07:10 +02:00

1901 lines
75 KiB
C++

// __ _____ _____ _____
// __| | __| | | | JSON for Modern C++ (supporting code)
// | | |__ | | | | | | version 3.12.0
// |_____|_____|_____|_|___| https://github.com/nlohmann/json
//
// SPDX-FileCopyrightText: 2013-2026 Niels Lohmann <https://nlohmann.me>
// SPDX-License-Identifier: MIT
#include "doctest_compatibility.h"
#include <nlohmann/json.hpp>
using nlohmann::json;
#ifdef JSON_TEST_NO_GLOBAL_UDLS
using namespace nlohmann::literals; // NOLINT(google-build-using-namespace)
#endif
#include <fstream>
#include "make_test_data_available.hpp"
TEST_CASE("JSON patch")
{
SECTION("examples from RFC 6902")
{
SECTION("4. Operations")
{
// the ordering of members in JSON objects is not significant:
const json op1 = R"({ "op": "add", "path": "/a/b/c", "value": "foo" })"_json;
const json op2 = R"({ "path": "/a/b/c", "op": "add", "value": "foo" })"_json;
const json op3 = R"({ "value": "foo", "path": "/a/b/c", "op": "add" })"_json;
// check if the operation objects are equivalent
CHECK(op1 == op2);
CHECK(op1 == op3);
}
SECTION("4.1 add")
{
json const patch1 = R"([{ "op": "add", "path": "/a/b", "value": [ "foo", "bar" ] }])"_json;
// However, the object itself or an array containing it does need
// to exist, and it remains an error for that not to be the case.
// For example, an "add" with a target location of "/a/b" starting
// with this document
json const doc1 = R"({ "a": { "foo": 1 } })"_json;
// is not an error, because "a" exists, and "b" will be added to
// its value.
CHECK_NOTHROW(doc1.patch(patch1));
auto doc1_ans = R"(
{
"a": {
"foo": 1,
"b": [ "foo", "bar" ]
}
}
)"_json;
CHECK(doc1.patch(patch1) == doc1_ans);
// It is an error in this document:
json const doc2 = R"({ "q": { "bar": 2 } })"_json;
// because "a" does not exist.
#if JSON_DIAGNOSTIC_POSITIONS
CHECK_THROWS_WITH_AS(doc2.patch(patch1), "[json.exception.out_of_range.403] (bytes 0-21) key 'a' not found", json::out_of_range&);
#else
CHECK_THROWS_WITH_AS(doc2.patch(patch1), "[json.exception.out_of_range.403] key 'a' not found", json::out_of_range&);
#endif
json const doc3 = R"({ "a": {} })"_json;
json const patch2 = R"([{ "op": "add", "path": "/a/b/c", "value": 1 }])"_json;
// should cause an error because "b" does not exist in doc3
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(doc3.patch(patch2), "[json.exception.out_of_range.403] (/a) key 'b' not found", json::out_of_range&);
#elif JSON_DIAGNOSTIC_POSITIONS
CHECK_THROWS_WITH_AS(doc3.patch(patch2), "[json.exception.out_of_range.403] (bytes 7-9) key 'b' not found", json::out_of_range&);
#else
CHECK_THROWS_WITH_AS(doc3.patch(patch2), "[json.exception.out_of_range.403] key 'b' not found", json::out_of_range&);
#endif
}
SECTION("4.2 remove")
{
// If removing an element from an array, any elements above the
// specified index are shifted one position to the left.
json const doc = {1, 2, 3, 4};
json const patch = {{{"op", "remove"}, {"path", "/1"}}};
CHECK(doc.patch(patch) == json({1, 3, 4}));
}
SECTION("A.1. Adding an Object Member")
{
// An example target JSON document:
json const doc = R"(
{ "foo": "bar"}
)"_json;
// A JSON Patch document:
json const patch = R"(
[
{ "op": "add", "path": "/baz", "value": "qux" }
]
)"_json;
// The resulting JSON document:
json expected = R"(
{
"baz": "qux",
"foo": "bar"
}
)"_json;
// check if patched value is as expected
CHECK(doc.patch(patch) == expected);
// check roundtrip
CHECK(doc.patch(json::diff(doc, expected)) == expected);
}
SECTION("A.2. Adding an Array Element")
{
// An example target JSON document:
json const doc = R"(
{ "foo": [ "bar", "baz" ] }
)"_json;
// A JSON Patch document:
json const patch = R"(
[
{ "op": "add", "path": "/foo/1", "value": "qux" }
]
)"_json;
// The resulting JSON document:
json expected = R"(
{ "foo": [ "bar", "qux", "baz" ] }
)"_json;
// check if patched value is as expected
CHECK(doc.patch(patch) == expected);
// check roundtrip
CHECK(doc.patch(json::diff(doc, expected)) == expected);
}
SECTION("A.3. Removing an Object Member")
{
// An example target JSON document:
json const doc = R"(
{
"baz": "qux",
"foo": "bar"
}
)"_json;
// A JSON Patch document:
json const patch = R"(
[
{ "op": "remove", "path": "/baz" }
]
)"_json;
// The resulting JSON document:
json expected = R"(
{ "foo": "bar" }
)"_json;
// check if patched value is as expected
CHECK(doc.patch(patch) == expected);
// check roundtrip
CHECK(doc.patch(json::diff(doc, expected)) == expected);
}
SECTION("A.4. Removing an Array Element")
{
// An example target JSON document:
json const doc = R"(
{ "foo": [ "bar", "qux", "baz" ] }
)"_json;
// A JSON Patch document:
json const patch = R"(
[
{ "op": "remove", "path": "/foo/1" }
]
)"_json;
// The resulting JSON document:
json expected = R"(
{ "foo": [ "bar", "baz" ] }
)"_json;
// check if patched value is as expected
CHECK(doc.patch(patch) == expected);
// check roundtrip
CHECK(doc.patch(json::diff(doc, expected)) == expected);
}
SECTION("A.5. Replacing a Value")
{
// An example target JSON document:
json const doc = R"(
{
"baz": "qux",
"foo": "bar"
}
)"_json;
// A JSON Patch document:
json const patch = R"(
[
{ "op": "replace", "path": "/baz", "value": "boo" }
]
)"_json;
json expected = R"(
{
"baz": "boo",
"foo": "bar"
}
)"_json;
// check if patched value is as expected
CHECK(doc.patch(patch) == expected);
// check roundtrip
CHECK(doc.patch(json::diff(doc, expected)) == expected);
}
SECTION("A.6. Moving a Value")
{
// An example target JSON document:
json const doc = R"(
{
"foo": {
"bar": "baz",
"waldo": "fred"
},
"qux": {
"corge": "grault"
}
}
)"_json;
// A JSON Patch document:
json const patch = R"(
[
{ "op": "move", "from": "/foo/waldo", "path": "/qux/thud" }
]
)"_json;
// The resulting JSON document:
json expected = R"(
{
"foo": {
"bar": "baz"
},
"qux": {
"corge": "grault",
"thud": "fred"
}
}
)"_json;
// check if patched value is as expected
CHECK(doc.patch(patch) == expected);
// check roundtrip
CHECK(doc.patch(json::diff(doc, expected)) == expected);
}
SECTION("A.7. Moving a Value")
{
// An example target JSON document:
json const doc = R"(
{ "foo": [ "all", "grass", "cows", "eat" ] }
)"_json;
// A JSON Patch document:
json const patch = R"(
[
{ "op": "move", "from": "/foo/1", "path": "/foo/3" }
]
)"_json;
// The resulting JSON document:
json expected = R"(
{ "foo": [ "all", "cows", "eat", "grass" ] }
)"_json;
// check if patched value is as expected
CHECK(doc.patch(patch) == expected);
// check roundtrip
CHECK(doc.patch(json::diff(doc, expected)) == expected);
}
SECTION("A.8. Testing a Value: Success")
{
// An example target JSON document:
json doc = R"(
{
"baz": "qux",
"foo": [ "a", 2, "c" ]
}
)"_json;
// A JSON Patch document that will result in successful evaluation:
json const patch = R"(
[
{ "op": "test", "path": "/baz", "value": "qux" },
{ "op": "test", "path": "/foo/1", "value": 2 }
]
)"_json;
// check if evaluation does not throw
CHECK_NOTHROW(doc.patch(patch));
// check if patched document is unchanged
CHECK(doc.patch(patch) == doc);
}
SECTION("A.9. Testing a Value: Error")
{
// An example target JSON document:
json const doc = R"(
{ "baz": "qux" }
)"_json;
// A JSON Patch document that will result in an error condition:
json patch = R"(
[
{ "op": "test", "path": "/baz", "value": "bar" }
]
)"_json;
// check that evaluation throws
CHECK_THROWS_AS(doc.patch(patch), json::other_error&);
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_STD_STR(doc.patch(patch), "[json.exception.other_error.501] (/0) unsuccessful: " + patch[0].dump());
#elif JSON_DIAGNOSTIC_POSITIONS
CHECK_THROWS_WITH_STD_STR(doc.patch(patch), "[json.exception.other_error.501] (bytes 47-95) unsuccessful: " + patch[0].dump());
#else
CHECK_THROWS_WITH_STD_STR(doc.patch(patch), "[json.exception.other_error.501] unsuccessful: " + patch[0].dump());
#endif
}
SECTION("A.10. Adding a Nested Member Object")
{
// An example target JSON document:
json const doc = R"(
{ "foo": "bar" }
)"_json;
// A JSON Patch document:
json const patch = R"(
[
{ "op": "add", "path": "/child", "value": { "grandchild": { } } }
]
)"_json;
// The resulting JSON document:
json expected = R"(
{
"foo": "bar",
"child": {
"grandchild": {
}
}
}
)"_json;
// check if patched value is as expected
CHECK(doc.patch(patch) == expected);
// check roundtrip
CHECK(doc.patch(json::diff(doc, expected)) == expected);
}
SECTION("A.11. Ignoring Unrecognized Elements")
{
// An example target JSON document:
json const doc = R"(
{ "foo": "bar" }
)"_json;
// A JSON Patch document:
json const patch = R"(
[
{ "op": "add", "path": "/baz", "value": "qux", "xyz": 123 }
]
)"_json;
json expected = R"(
{
"foo": "bar",
"baz": "qux"
}
)"_json;
// check if patched value is as expected
CHECK(doc.patch(patch) == expected);
// check roundtrip
CHECK(doc.patch(json::diff(doc, expected)) == expected);
}
SECTION("A.12. Adding to a Nonexistent Target")
{
// An example target JSON document:
json const doc = R"(
{ "foo": "bar" }
)"_json;
// A JSON Patch document:
json const patch = R"(
[
{ "op": "add", "path": "/baz/bat", "value": "qux" }
]
)"_json;
// This JSON Patch document, applied to the target JSON document
// above, would result in an error (therefore, it would not be
// applied), because the "add" operation's target location that
// references neither the root of the document, nor a member of
// an existing object, nor a member of an existing array.
#if JSON_DIAGNOSTIC_POSITIONS
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.403] (bytes 21-37) key 'baz' not found", json::out_of_range&);
#else
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.403] key 'baz' not found", json::out_of_range&);
#endif
}
// A.13. Invalid JSON Patch Document
// not applicable
SECTION("A.14. Escape Ordering")
{
// An example target JSON document:
json const doc = R"(
{
"/": 9,
"~1": 10
}
)"_json;
// A JSON Patch document:
json const patch = R"(
[
{"op": "test", "path": "/~01", "value": 10}
]
)"_json;
json expected = R"(
{
"/": 9,
"~1": 10
}
)"_json;
// check if patched value is as expected
CHECK(doc.patch(patch) == expected);
// check roundtrip
CHECK(doc.patch(json::diff(doc, expected)) == expected);
}
SECTION("A.15. Comparing Strings and Numbers")
{
// An example target JSON document:
json const doc = R"(
{
"/": 9,
"~1": 10
}
)"_json;
// A JSON Patch document that will result in an error condition:
json patch = R"(
[
{"op": "test", "path": "/~01", "value": "10"}
]
)"_json;
// check that evaluation throws
CHECK_THROWS_AS(doc.patch(patch), json::other_error&);
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_STD_STR(doc.patch(patch), "[json.exception.other_error.501] (/0) unsuccessful: " + patch[0].dump());
#elif JSON_DIAGNOSTIC_POSITIONS
CHECK_THROWS_WITH_STD_STR(doc.patch(patch), "[json.exception.other_error.501] (bytes 47-92) unsuccessful: " + patch[0].dump());
#else
CHECK_THROWS_WITH_STD_STR(doc.patch(patch), "[json.exception.other_error.501] unsuccessful: " + patch[0].dump());
#endif
}
SECTION("A.16. Adding an Array Value")
{
// An example target JSON document:
json const doc = R"(
{ "foo": ["bar"] }
)"_json;
// A JSON Patch document:
json const patch = R"(
[
{ "op": "add", "path": "/foo/-", "value": ["abc", "def"] }
]
)"_json;
// The resulting JSON document:
json expected = R"(
{ "foo": ["bar", ["abc", "def"]] }
)"_json;
// check if patched value is as expected
CHECK(doc.patch(patch) == expected);
// check roundtrip
CHECK(doc.patch(json::diff(doc, expected)) == expected);
}
}
SECTION("own examples")
{
SECTION("add")
{
SECTION("add to the root element")
{
// If the path is the root of the target document - the
// specified value becomes the entire content of the target
// document.
// An example target JSON document:
json const doc = 17;
// A JSON Patch document:
json const patch = R"(
[
{ "op": "add", "path": "", "value": [1,2,3] }
]
)"_json;
// The resulting JSON document:
json expected = {1, 2, 3};
// check if patched value is as expected
CHECK(doc.patch(patch) == expected);
// check roundtrip
CHECK(doc.patch(json::diff(doc, expected)) == expected);
}
SECTION("add to end of the array")
{
// The specified index MUST NOT be greater than the number of
// elements in the array. The example below uses and index of
// exactly the number of elements in the array which is legal.
// An example target JSON document:
json const doc = {0, 1, 2};
// A JSON Patch document:
json const patch = R"(
[
{ "op": "add", "path": "/3", "value": 3 }
]
)"_json;
// The resulting JSON document:
json expected = {0, 1, 2, 3};
// check if patched value is as expected
CHECK(doc.patch(patch) == expected);
// check roundtrip
CHECK(doc.patch(json::diff(doc, expected)) == expected);
}
}
SECTION("copy")
{
// An example target JSON document:
json const doc = R"(
{
"foo": {
"bar": "baz",
"waldo": "fred"
},
"qux": {
"corge": "grault"
}
}
)"_json;
// A JSON Patch document:
json const patch = R"(
[
{ "op": "copy", "from": "/foo/waldo", "path": "/qux/thud" }
]
)"_json;
// The resulting JSON document:
json expected = R"(
{
"foo": {
"bar": "baz",
"waldo": "fred"
},
"qux": {
"corge": "grault",
"thud": "fred"
}
}
)"_json;
// check if patched value is as expected
CHECK(doc.patch(patch) == expected);
// check roundtrip
CHECK(doc.patch(json::diff(doc, expected)) == expected);
}
SECTION("replace")
{
json const j = "string";
json const patch = {{{"op", "replace"}, {"path", ""}, {"value", 1}}};
CHECK(j.patch(patch) == json(1));
}
SECTION("documentation GIF")
{
{
// a JSON patch
json const p1 = R"(
[{"op": "add", "path": "/GB", "value": "London"}]
)"_json;
// a JSON value
json const source = R"(
{"D": "Berlin", "F": "Paris"}
)"_json;
// apply the patch
const json target = source.patch(p1);
// target = { "D": "Berlin", "F": "Paris", "GB": "London" }
CHECK(target == R"({ "D": "Berlin", "F": "Paris", "GB": "London" })"_json);
// create a diff from two JSONs
const json p2 = json::diff(target, source); // NOLINT(readability-suspicious-call-argument)
// p2 = [{"op": "delete", "path": "/GB"}]
CHECK(p2 == R"([{"op":"remove","path":"/GB"}])"_json);
}
{
// a JSON value
json j = {"good", "bad", "ugly"};
// a JSON pointer
auto ptr = json::json_pointer("/2");
// use to access elements
j[ptr] = {{"it", "cattivo"}};
CHECK(j == R"(["good","bad",{"it":"cattivo"}])"_json);
// use user-defined string literal
j["/2/en"_json_pointer] = "ugly";
CHECK(j == R"(["good","bad",{"en":"ugly","it":"cattivo"}])"_json);
const json flat = j.flatten();
CHECK(flat == R"({"/0":"good","/1":"bad","/2/en":"ugly","/2/it":"cattivo"})"_json);
}
}
}
SECTION("patch_inplace")
{
SECTION("happy path: patch_inplace mirrors patch() on success")
{
// mirrors "A.5. Replacing a Value" above, but applies the patch with
// patch_inplace() to a mutable copy instead of using patch()'s
// returned copy
json doc = R"(
{
"baz": "qux",
"foo": "bar"
}
)"_json;
json const patch = R"(
[
{ "op": "replace", "path": "/baz", "value": "boo" }
]
)"_json;
json const expected = R"(
{
"baz": "boo",
"foo": "bar"
}
)"_json;
doc.patch_inplace(patch);
CHECK(doc == expected);
}
// this test relies on the "test" operation actually throwing so the
// partial-application state can be observed right after the throw
// point; under JSON_NOEXCEPTION, JSON_THROW() calls std::abort()
// instead (there is no C++ exception to throw), and doctest's
// CHECK_THROWS_AS() is compiled out to a no-op that never even
// invokes the given expression (see doctest's "--no-throw" test
// filter, which ci_test_noexceptions passes) -- so patch()/
// patch_inplace() would never be called at all and the follow-up
// state assertions below would fail against the untouched original
#if !defined(JSON_NOEXCEPTION)
SECTION("distinguishing contract vs patch(): partial application on failure")
{
// Unlike patch(), which is all-or-nothing because it applies the
// patch to an internal copy that is simply discarded when an
// exception is thrown (leaving the original untouched no matter
// what), patch_inplace() mutates the document it is called on
// directly and immediately, operation by operation. So if a JSON
// Patch fails partway through, whatever operations already
// succeeded remain applied -- the document is left in a partially
// patched state. This is empirically verified current behavior,
// not just documented intent, and is pinned here as such.
json const original = R"(
{
"baz": "qux",
"foo": "bar"
}
)"_json;
// the first operation ("replace") succeeds; the second ("test")
// fails because the value at "/baz" no longer (and never did)
// equal "not boo"
json const patch = R"(
[
{ "op": "replace", "path": "/baz", "value": "boo" },
{ "op": "test", "path": "/baz", "value": "not boo" }
]
)"_json;
// patch() never modifies the object it is called on -- it always
// operates on (and returns) a separate copy, so the original is
// left completely untouched, regardless of success or failure.
// copy_for_patch is intentionally a real copy, not a reference
// to `original`: the whole point of this check is to catch a
// hypothetical future regression where patch() *does* mutate its
// receiver. Using a reference here would make the assertion
// below compare `original` to itself -- trivially true even if
// such a bug existed -- which is exactly what a static analyzer
// can't see when it suggests "this copy is never modified, use
// a reference instead".
json copy_for_patch = original; // NOLINT(performance-unnecessary-copy-initialization)
CHECK_THROWS_AS(copy_for_patch.patch(patch), json::other_error&);
CHECK(copy_for_patch == original);
// patch_inplace(), in contrast, already applied the successful
// "replace" operation to the document before the "test" operation
// threw -- that change is not rolled back
json doc = original;
CHECK_THROWS_AS(doc.patch_inplace(patch), json::other_error&);
CHECK(doc != original);
CHECK(doc.at("baz") == "boo");
CHECK(doc.at("foo") == "bar");
}
#endif // !defined(JSON_NOEXCEPTION)
}
SECTION("errors")
{
SECTION("unknown operation")
{
SECTION("not an array")
{
json const j;
json const patch = {{"op", "add"}, {"path", ""}, {"value", 1}};
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.104] parse error: JSON patch must be an array of objects", json::parse_error&);
}
SECTION("not an array of objects")
{
json const j;
json const patch = {"op", "add", "path", "", "value", 1};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.104] parse error: (/0) JSON patch must be an array of objects", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.104] parse error: JSON patch must be an array of objects", json::parse_error&);
#endif
}
SECTION("missing 'op'")
{
json const j;
json const patch = {{{"foo", "bar"}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation must have member 'op'", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation must have member 'op'", json::parse_error&);
#endif
}
SECTION("non-string 'op'")
{
json const j;
json const patch = {{{"op", 1}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation must have string member 'op'", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation must have string member 'op'", json::parse_error&);
#endif
}
SECTION("invalid operation")
{
json const j;
json const patch = {{{"op", "foo"}, {"path", ""}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation value 'foo' is invalid", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation value 'foo' is invalid", json::parse_error&);
#endif
}
}
SECTION("add")
{
SECTION("missing 'path'")
{
json const j;
json const patch = {{{"op", "add"}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation 'add' must have member 'path'", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation 'add' must have member 'path'", json::parse_error&);
#endif
}
SECTION("non-string 'path'")
{
json const j;
json const patch = {{{"op", "add"}, {"path", 1}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation 'add' must have string member 'path'", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation 'add' must have string member 'path'", json::parse_error&);
#endif
}
SECTION("missing 'value'")
{
json const j;
json const patch = {{{"op", "add"}, {"path", ""}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation 'add' must have member 'value'", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation 'add' must have member 'value'", json::parse_error&);
#endif
}
SECTION("invalid array index")
{
json const j = {1, 2};
json const patch = {{{"op", "add"}, {"path", "/4"}, {"value", 4}}};
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.out_of_range.401] array index 4 is out of range", json::out_of_range&);
}
}
SECTION("remove")
{
SECTION("missing 'path'")
{
json const j;
json const patch = {{{"op", "remove"}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation 'remove' must have member 'path'", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation 'remove' must have member 'path'", json::parse_error&);
#endif
}
SECTION("non-string 'path'")
{
json const j;
json const patch = {{{"op", "remove"}, {"path", 1}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation 'remove' must have string member 'path'", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation 'remove' must have string member 'path'", json::parse_error&);
#endif
}
SECTION("nonexisting target location (array)")
{
json const j = {1, 2, 3};
json const patch = {{{"op", "remove"}, {"path", "/17"}}};
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.out_of_range.401] array index 17 is out of range", json::out_of_range&);
}
SECTION("nonexisting target location (object)")
{
json const j = {{"foo", 1}, {"bar", 2}};
json const patch = {{{"op", "remove"}, {"path", "/baz"}}};
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.out_of_range.403] key 'baz' not found", json::out_of_range&);
}
SECTION("root element as target location")
{
json const j = "string";
json const patch = {{{"op", "remove"}, {"path", ""}}};
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.out_of_range.405] JSON pointer has no parent", json::out_of_range&);
}
}
SECTION("replace")
{
SECTION("missing 'path'")
{
json const j;
json const patch = {{{"op", "replace"}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation 'replace' must have member 'path'", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation 'replace' must have member 'path'", json::parse_error&);
#endif
}
SECTION("non-string 'path'")
{
json const j;
json const patch = {{{"op", "replace"}, {"path", 1}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation 'replace' must have string member 'path'", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation 'replace' must have string member 'path'", json::parse_error&);
#endif
}
SECTION("missing 'value'")
{
json const j;
json const patch = {{{"op", "replace"}, {"path", ""}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation 'replace' must have member 'value'", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation 'replace' must have member 'value'", json::parse_error&);
#endif
}
SECTION("nonexisting target location (array)")
{
json const j = {1, 2, 3};
json const patch = {{{"op", "replace"}, {"path", "/17"}, {"value", 19}}};
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.out_of_range.401] array index 17 is out of range", json::out_of_range&);
}
SECTION("nonexisting target location (object)")
{
json const j = {{"foo", 1}, {"bar", 2}};
json const patch = {{{"op", "replace"}, {"path", "/baz"}, {"value", 3}}};
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.out_of_range.403] key 'baz' not found", json::out_of_range&);
}
}
SECTION("move")
{
SECTION("missing 'path'")
{
json const j;
json const patch = {{{"op", "move"}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation 'move' must have member 'path'", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation 'move' must have member 'path'", json::parse_error&);
#endif
}
SECTION("non-string 'path'")
{
json const j;
json const patch = {{{"op", "move"}, {"path", 1}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation 'move' must have string member 'path'", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation 'move' must have string member 'path'", json::parse_error&);
#endif
}
SECTION("missing 'from'")
{
json const j;
json const patch = {{{"op", "move"}, {"path", ""}}};
CHECK_THROWS_AS(j.patch(patch), json::parse_error&);
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation 'move' must have member 'from'", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation 'move' must have member 'from'", json::parse_error&);
#endif
}
SECTION("non-string 'from'")
{
json const j;
json const patch = {{{"op", "move"}, {"path", ""}, {"from", 1}}};
CHECK_THROWS_AS(j.patch(patch), json::parse_error&);
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation 'move' must have string member 'from'", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation 'move' must have string member 'from'", json::parse_error&);
#endif
}
SECTION("nonexisting from location (array)")
{
json const j = {1, 2, 3};
json const patch = {{{"op", "move"}, {"path", "/0"}, {"from", "/5"}}};
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.out_of_range.401] array index 5 is out of range", json::out_of_range&);
}
SECTION("nonexisting from location (object)")
{
json const j = {{"foo", 1}, {"bar", 2}};
json const patch = {{{"op", "move"}, {"path", "/baz"}, {"from", "/baz"}}};
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.out_of_range.403] key 'baz' not found", json::out_of_range&);
}
}
SECTION("copy")
{
SECTION("missing 'path'")
{
json const j;
json const patch = {{{"op", "copy"}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation 'copy' must have member 'path'", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation 'copy' must have member 'path'", json::parse_error&);
#endif
}
SECTION("non-string 'path'")
{
json const j;
json const patch = {{{"op", "copy"}, {"path", 1}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation 'copy' must have string member 'path'", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation 'copy' must have string member 'path'", json::parse_error&);
#endif
}
SECTION("missing 'from'")
{
json const j;
json const patch = {{{"op", "copy"}, {"path", ""}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation 'copy' must have member 'from'", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation 'copy' must have member 'from'", json::parse_error&);
#endif
}
SECTION("non-string 'from'")
{
json const j;
json const patch = {{{"op", "copy"}, {"path", ""}, {"from", 1}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation 'copy' must have string member 'from'", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation 'copy' must have string member 'from'", json::parse_error&);
#endif
}
SECTION("nonexisting from location (array)")
{
json const j = {1, 2, 3};
json const patch = {{{"op", "copy"}, {"path", "/0"}, {"from", "/5"}}};
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.out_of_range.401] array index 5 is out of range", json::out_of_range&);
}
SECTION("nonexisting from location (object)")
{
json const j = {{"foo", 1}, {"bar", 2}};
json const patch = {{{"op", "copy"}, {"path", "/fob"}, {"from", "/baz"}}};
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.out_of_range.403] key 'baz' not found", json::out_of_range&);
}
}
SECTION("test")
{
SECTION("missing 'path'")
{
json const j;
json const patch = {{{"op", "test"}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation 'test' must have member 'path'", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation 'test' must have member 'path'", json::parse_error&);
#endif
}
SECTION("non-string 'path'")
{
json const j;
json const patch = {{{"op", "test"}, {"path", 1}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation 'test' must have string member 'path'", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation 'test' must have string member 'path'", json::parse_error&);
#endif
}
SECTION("missing 'value'")
{
json const j;
json const patch = {{{"op", "test"}, {"path", ""}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: (/0) operation 'test' must have member 'value'", json::parse_error&);
#else
CHECK_THROWS_WITH_AS(j.patch(patch), "[json.exception.parse_error.105] parse error: operation 'test' must have member 'value'", json::parse_error&);
#endif
}
}
}
SECTION("Examples from jsonpatch.com")
{
SECTION("Simple Example")
{
// The original document
json const doc = R"(
{
"baz": "qux",
"foo": "bar"
}
)"_json;
// The patch
json const patch = R"(
[
{ "op": "replace", "path": "/baz", "value": "boo" },
{ "op": "add", "path": "/hello", "value": ["world"] },
{ "op": "remove", "path": "/foo"}
]
)"_json;
// The result
json result = R"(
{
"baz": "boo",
"hello": ["world"]
}
)"_json;
// check if patched value is as expected
CHECK(doc.patch(patch) == result);
// check roundtrip
CHECK(doc.patch(json::diff(doc, result)) == result);
}
SECTION("Operations")
{
// The original document
json const doc = R"(
{
"biscuits": [
{"name":"Digestive"},
{"name": "Choco Liebniz"}
]
}
)"_json;
SECTION("add")
{
// The patch
json const patch = R"(
[
{"op": "add", "path": "/biscuits/1", "value": {"name": "Ginger Nut"}}
]
)"_json;
// The result
json result = R"(
{
"biscuits": [
{"name": "Digestive"},
{"name": "Ginger Nut"},
{"name": "Choco Liebniz"}
]
}
)"_json;
// check if patched value is as expected
CHECK(doc.patch(patch) == result);
// check roundtrip
CHECK(doc.patch(json::diff(doc, result)) == result);
}
SECTION("remove")
{
// The patch
json const patch = R"(
[
{"op": "remove", "path": "/biscuits"}
]
)"_json;
// The result
json result = R"(
{}
)"_json;
// check if patched value is as expected
CHECK(doc.patch(patch) == result);
// check roundtrip
CHECK(doc.patch(json::diff(doc, result)) == result);
}
SECTION("replace")
{
// The patch
json const patch = R"(
[
{"op": "replace", "path": "/biscuits/0/name", "value": "Chocolate Digestive"}
]
)"_json;
// The result
json result = R"(
{
"biscuits": [
{"name": "Chocolate Digestive"},
{"name": "Choco Liebniz"}
]
}
)"_json;
// check if patched value is as expected
CHECK(doc.patch(patch) == result);
// check roundtrip
CHECK(doc.patch(json::diff(doc, result)) == result);
}
SECTION("copy")
{
// The patch
json const patch = R"(
[
{"op": "copy", "from": "/biscuits/0", "path": "/best_biscuit"}
]
)"_json;
// The result
json result = R"(
{
"biscuits": [
{"name": "Digestive"},
{"name": "Choco Liebniz"}
],
"best_biscuit": {
"name": "Digestive"
}
}
)"_json;
// check if patched value is as expected
CHECK(doc.patch(patch) == result);
// check roundtrip
CHECK(doc.patch(json::diff(doc, result)) == result);
}
SECTION("move")
{
// The patch
json const patch = R"(
[
{"op": "move", "from": "/biscuits", "path": "/cookies"}
]
)"_json;
// The result
json result = R"(
{
"cookies": [
{"name": "Digestive"},
{"name": "Choco Liebniz"}
]
}
)"_json;
// check if patched value is as expected
CHECK(doc.patch(patch) == result);
// check roundtrip
CHECK(doc.patch(json::diff(doc, result)) == result);
}
SECTION("test")
{
// The patch
json patch = R"(
[
{"op": "test", "path": "/best_biscuit/name", "value": "Choco Liebniz"}
]
)"_json;
// the test will fail
CHECK_THROWS_AS(doc.patch(patch), json::other_error&);
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_STD_STR(doc.patch(patch), "[json.exception.other_error.501] (/0) unsuccessful: " + patch[0].dump());
#elif JSON_DIAGNOSTIC_POSITIONS
CHECK_THROWS_WITH_STD_STR(doc.patch(patch), "[json.exception.other_error.501] (bytes 47-117) unsuccessful: " + patch[0].dump());
#else
CHECK_THROWS_WITH_STD_STR(doc.patch(patch), "[json.exception.other_error.501] unsuccessful: " + patch[0].dump());
#endif
}
}
}
SECTION("Examples from bruth.github.io/jsonpatch-js")
{
SECTION("add")
{
CHECK(R"( {} )"_json.patch(
R"( [{"op": "add", "path": "/foo", "value": "bar"}] )"_json
) == R"( {"foo": "bar"} )"_json);
CHECK(R"( {"foo": [1, 3]} )"_json.patch(
R"( [{"op": "add", "path": "/foo", "value": "bar"}] )"_json
) == R"( {"foo": "bar"} )"_json);
CHECK(R"( {"foo": [{}]} )"_json.patch(
R"( [{"op": "add", "path": "/foo/0/bar", "value": "baz"}] )"_json
) == R"( {"foo": [{"bar": "baz"}]} )"_json);
}
SECTION("remove")
{
CHECK(R"( {"foo": "bar"} )"_json.patch(
R"( [{"op": "remove", "path": "/foo"}] )"_json
) == R"( {} )"_json);
CHECK(R"( {"foo": [1, 2, 3]} )"_json.patch(
R"( [{"op": "remove", "path": "/foo/1"}] )"_json
) == R"( {"foo": [1, 3]} )"_json);
CHECK(R"( {"foo": [{"bar": "baz"}]} )"_json.patch(
R"( [{"op": "remove", "path": "/foo/0/bar"}] )"_json
) == R"( {"foo": [{}]} )"_json);
}
SECTION("replace")
{
CHECK(R"( {"foo": "bar"} )"_json.patch(
R"( [{"op": "replace", "path": "/foo", "value": 1}] )"_json
) == R"( {"foo": 1} )"_json);
CHECK(R"( {"foo": [1, 2, 3]} )"_json.patch(
R"( [{"op": "replace", "path": "/foo/1", "value": 4}] )"_json
) == R"( {"foo": [1, 4, 3]} )"_json);
CHECK(R"( {"foo": [{"bar": "baz"}]} )"_json.patch(
R"( [{"op": "replace", "path": "/foo/0/bar", "value": 1}] )"_json
) == R"( {"foo": [{"bar": 1}]} )"_json);
}
SECTION("move")
{
CHECK(R"( {"foo": [1, 2, 3]} )"_json.patch(
R"( [{"op": "move", "from": "/foo", "path": "/bar"}] )"_json
) == R"( {"bar": [1, 2, 3]} )"_json);
}
SECTION("copy")
{
CHECK(R"( {"foo": [1, 2, 3]} )"_json.patch(
R"( [{"op": "copy", "from": "/foo/1", "path": "/bar"}] )"_json
) == R"( {"foo": [1, 2, 3], "bar": 2} )"_json);
}
SECTION("copy")
{
CHECK_NOTHROW(R"( {"foo": "bar"} )"_json.patch(
R"( [{"op": "test", "path": "/foo", "value": "bar"}] )"_json));
}
}
SECTION("Tests from github.com/json-patch/json-patch-tests")
{
for (const auto* filename :
{
TEST_DATA_DIRECTORY "/json-patch-tests/spec_tests.json",
TEST_DATA_DIRECTORY "/json-patch-tests/tests.json"
})
{
CAPTURE(filename)
std::ifstream f(filename);
json const suite = json::parse(f);
for (const auto& test : suite)
{
INFO_WITH_TEMP(test.value("comment", ""));
// skip tests marked as disabled
if (test.value("disabled", false))
{
continue;
}
const auto& doc = test["doc"];
const auto& patch = test["patch"];
if (test.count("error") == 0) // NOLINT(readability-container-contains)
{
// if an expected value is given, use it; use doc otherwise
const auto& expected = test.value("expected", doc);
CHECK(doc.patch(patch) == expected);
}
else
{
CHECK_THROWS(doc.patch(patch));
}
}
}
}
}
TEST_CASE("JSON patch - add to a primitive parent (regression #4292)")
{
// Regression test for https://github.com/nlohmann/json/issues/4292
//
// An "add" operation whose parent location resolves to a primitive
// (non-container) value must be rejected with a catchable exception.
// Previously this hit JSON_ASSERT(false) in operation_add, which aborts
// the process in debug builds and silently dropped the operation (leaving
// a wrong result) when assertions were compiled out (NDEBUG). It now
// throws out_of_range.411.
//
// The documents below are constructed programmatically (not parsed) so
// they carry no byte positions; the JSON_DIAGNOSTICS path prefix is
// handled by the guards. The exact message with positions is covered in
// unit-diagnostic-positions.cpp.
SECTION("string parent")
{
json const doc = {{"foo", {{"bar", "a string"}}}};
json const patch = {{{"op", "add"}, {"path", "/foo/bar/baz"}, {"value", 1}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.411] (/foo/bar) cannot add value: the JSON Patch 'add' target's parent is of type string, but must be an object or array", json::out_of_range&);
#else
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.411] cannot add value: the JSON Patch 'add' target's parent is of type string, but must be an object or array", json::out_of_range&);
#endif
}
SECTION("number parent")
{
json const doc = {{"foo", 1}};
json const patch = {{{"op", "add"}, {"path", "/foo/bar"}, {"value", 2}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.411] (/foo) cannot add value: the JSON Patch 'add' target's parent is of type number, but must be an object or array", json::out_of_range&);
#else
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.411] cannot add value: the JSON Patch 'add' target's parent is of type number, but must be an object or array", json::out_of_range&);
#endif
}
SECTION("original two-step sequence from the issue")
{
// The user's two-step patch from #4292: first turn /xyz/1 into a
// string, then try to add a member inside that string.
json const doc = R"( { "xyz": [ { "lmn": "214", "nnp": "001" } ] } )"_json;
json const patch = R"(
[
{ "op": "add", "path": "/xyz/1", "value": "" },
{ "op": "add", "path": "/xyz/1/lmn", "value": "214" }
]
)"_json;
CHECK_THROWS_AS(doc.patch(patch), json::out_of_range&);
}
}
TEST_CASE("JSON patch - remove with primitive or null parent (regression #5396)")
{
// Regression test for https://github.com/nlohmann/json/issues/5396
//
// RFC 6902 (§4.2) requires the target location of a "remove" operation
// to exist. When the target's parent resolves to a primitive value or
// null, the operation must fail. Previously operation_remove silently
// did nothing in this case (neither the "is_object" nor the "is_array"
// branch matched, and there was no final "else"), so the patch appeared
// to succeed without changing the document. It now throws
// out_of_range.413.
SECTION("parent is a primitive (number)")
{
json const doc = {{"a", 1}};
json const patch = {{{"op", "remove"}, {"path", "/a/b"}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.413] (/a) cannot remove value: the JSON Patch 'remove' target's parent is of type number, but must be an object or array", json::out_of_range&);
#else
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.413] cannot remove value: the JSON Patch 'remove' target's parent is of type number, but must be an object or array", json::out_of_range&);
#endif
}
SECTION("parent is a primitive (string)")
{
json const doc = {{"foo", {{"bar", "a string"}}}};
json const patch = {{{"op", "remove"}, {"path", "/foo/bar/baz"}}};
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.413] (/foo/bar) cannot remove value: the JSON Patch 'remove' target's parent is of type string, but must be an object or array", json::out_of_range&);
#else
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.413] cannot remove value: the JSON Patch 'remove' target's parent is of type string, but must be an object or array", json::out_of_range&);
#endif
}
SECTION("top-level document is null")
{
json const doc = nullptr;
json const patch = {{{"op", "remove"}, {"path", "/a"}}};
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.413] cannot remove value: the JSON Patch 'remove' target's parent is of type null, but must be an object or array", json::out_of_range&);
}
SECTION("legitimate removes still work")
{
// object member
json const doc1 = {{"a", 1}, {"b", 2}};
json const patch1 = {{{"op", "remove"}, {"path", "/a"}}};
CHECK(doc1.patch(patch1) == json({{"b", 2}}));
// array element
json const doc2 = R"([1, 2, 3])"_json;
json const patch2 = {{{"op", "remove"}, {"path", "/1"}}};
CHECK(doc2.patch(patch2) == R"([1, 3])"_json);
}
}
TEST_CASE("JSON patch - move where 'from' is a proper prefix of 'path' (regression #5397)")
{
// Regression test for https://github.com/nlohmann/json/issues/5397
//
// RFC 6902 (§4.4) forbids "from" from being a proper prefix of "path"
// for a "move" operation: "a location cannot be moved into one of its
// children." "move" is implemented as remove-then-add; for an object
// target this happened to throw anyway as a side effect of the "add"
// step re-resolving through the now-removed parent, but for an array
// target the removal shifted subsequent indices, so "path" silently
// re-resolved to a different element and the operation "succeeded"
// with a corrupted result. It now throws out_of_range.414 for both
// object and array targets.
SECTION("array target (from the issue)")
{
json const doc = R"([[1,2],[3]])"_json;
json const patch = {{{"op", "move"}, {"from", "/0"}, {"path", "/0/0"}}};
#if JSON_DIAGNOSTIC_POSITIONS
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.414] (bytes 0-11) cannot move value: 'from' path '/0' is a proper prefix of 'path' '/0/0'", json::out_of_range&);
#else
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.414] cannot move value: 'from' path '/0' is a proper prefix of 'path' '/0/0'", json::out_of_range&);
#endif
}
SECTION("object target")
{
json const doc = R"({"a": {"b": 1}})"_json;
json const patch = {{{"op", "move"}, {"from", "/a"}, {"path", "/a/b"}}};
#if JSON_DIAGNOSTIC_POSITIONS
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.414] (bytes 0-15) cannot move value: 'from' path '/a' is a proper prefix of 'path' '/a/b'", json::out_of_range&);
#else
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.414] cannot move value: 'from' path '/a' is a proper prefix of 'path' '/a/b'", json::out_of_range&);
#endif
}
SECTION("from == path is not a proper prefix and must not be rejected")
{
// "from" equal to "path" is a no-op move; it is not a *proper*
// prefix relationship, so this new check must not reject it.
json const doc = R"({"a": 1, "b": 2})"_json;
json const patch = {{{"op", "move"}, {"from", "/a"}, {"path", "/a"}}};
CHECK(doc.patch(patch) == doc);
}
SECTION("raw string prefix that is not a pointer-token prefix must be allowed")
{
// "/ab" is a string-prefix of "/abc/x" as raw text, but "ab" and
// "abc" are different reference tokens, so this is NOT a
// pointer-token prefix relationship and the move must succeed.
// This is the key case proving the check compares tokens, not
// raw pointer text (a naive std::string prefix/rfind check on
// the undecoded pointer would wrongly reject this).
json const doc = R"({"ab": 1, "abc": {"x": 2}})"_json;
json const patch = {{{"op", "move"}, {"from", "/ab"}, {"path", "/abc/x"}}};
json const result = R"({"abc": {"x": 1}})"_json;
CHECK(doc.patch(patch) == result);
}
SECTION("escaped reference tokens are compared unescaped")
{
// "from" is the single token "a/b" (escaped as "a~1b"); "path"
// addresses member "x" of that same value, so "from" is a
// proper (token-level) prefix of "path" and must be rejected.
json const doc = R"({"a/b": {"x": 1}})"_json;
json const patch = {{{"op", "move"}, {"from", "/a~1b"}, {"path", "/a~1b/x"}}};
#if JSON_DIAGNOSTIC_POSITIONS
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.414] (bytes 0-17) cannot move value: 'from' path '/a~1b' is a proper prefix of 'path' '/a~1b/x'", json::out_of_range&);
#else
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.414] cannot move value: 'from' path '/a~1b' is a proper prefix of 'path' '/a~1b/x'", json::out_of_range&);
#endif
}
SECTION("ordinary valid moves still work")
{
// unrelated top-level members
json const doc1 = R"({"a": 1, "b": 2})"_json;
json const patch1 = {{{"op", "move"}, {"from", "/a"}, {"path", "/c"}}};
CHECK(doc1.patch(patch1) == R"({"b": 2, "c": 1})"_json);
// sibling paths that share a textual prefix but are unrelated
json const doc2 = R"({"a": {"x": 1}, "b": {"y": 2}})"_json;
json const patch2 = {{{"op", "move"}, {"from", "/a/x"}, {"path", "/b/z"}}};
CHECK(doc2.patch(patch2) == R"({"a": {}, "b": {"y": 2, "z": 1}})"_json);
// "path" is a proper prefix of "from" (the reverse relationship,
// which RFC 6902 does not forbid)
json const doc3 = R"({"a": {"b": 1}})"_json;
json const patch3 = {{{"op", "move"}, {"from", "/a/b"}, {"path", "/a"}}};
CHECK(doc3.patch(patch3) == R"({"a": 1})"_json);
}
SECTION("root 'from' is a proper prefix of every non-root 'path'")
{
// the whole document is a proper prefix of any location inside it
json const doc = R"({"a": 1})"_json;
json const patch = {{{"op", "move"}, {"from", ""}, {"path", "/a"}}};
#if JSON_DIAGNOSTIC_POSITIONS
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.414] (bytes 0-8) cannot move value: 'from' path '' is a proper prefix of 'path' '/a'", json::out_of_range&);
#else
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.414] cannot move value: 'from' path '' is a proper prefix of 'path' '/a'", json::out_of_range&);
#endif
}
SECTION("root 'path' is never a proper prefix violation for a non-root 'from'")
{
// the reverse of the above: moving a non-root location to the root
// is the "path is a prefix of from" relationship, which RFC 6902
// permits (already covered generally above; this pins the root
// case specifically, since root is the one path with no reference
// tokens at all)
json const doc = R"({"a": {"b": 1}})"_json;
json const patch = {{{"op", "move"}, {"from", "/a"}, {"path", ""}}};
CHECK(doc.patch(patch) == R"({"b": 1})"_json);
}
SECTION("the array-append token '-' is an ordinary child token")
{
// "-" (append-to-array) addresses a location *inside* the array,
// so "from" pointing at the array is still a proper prefix of
// "path" ending in "-" and must be rejected like any other child.
json const doc = R"({"a": [1, 2]})"_json;
json const patch = {{{"op", "move"}, {"from", "/a"}, {"path", "/a/-"}}};
#if JSON_DIAGNOSTIC_POSITIONS
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.414] (bytes 0-13) cannot move value: 'from' path '/a' is a proper prefix of 'path' '/a/-'", json::out_of_range&);
#else
CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.414] cannot move value: 'from' path '/a' is a proper prefix of 'path' '/a/-'", json::out_of_range&);
#endif
}
}
TEST_CASE("JSON patch - diff emits array removals in descending index order")
{
SECTION("array shrunk to empty")
{
json const source = {0, 1, 2, 3, 4};
json const target = json::array();
json const patch = json::diff(source, target);
json const expected = R"(
[
{"op": "remove", "path": "/4"},
{"op": "remove", "path": "/3"},
{"op": "remove", "path": "/2"},
{"op": "remove", "path": "/1"},
{"op": "remove", "path": "/0"}
]
)"_json;
CHECK(patch == expected);
CHECK(source.patch(patch) == target);
}
SECTION("array partially shrunk, after a replacement at a common index")
{
json const source = {0, 1, 2, 3, 4};
json const target = {0, 9};
json const patch = json::diff(source, target);
// the replacement comes first, then the removals, highest index first
json const expected = R"(
[
{"op": "replace", "path": "/1", "value": 9},
{"op": "remove", "path": "/4"},
{"op": "remove", "path": "/3"},
{"op": "remove", "path": "/2"}
]
)"_json;
CHECK(patch == expected);
CHECK(source.patch(patch) == target);
}
SECTION("nested array shrunk")
{
json const source = {{"a", {0, 1, 2}}};
json const target = {{"a", json::array()}};
json const patch = json::diff(source, target);
json const expected = R"(
[
{"op": "remove", "path": "/a/2"},
{"op": "remove", "path": "/a/1"},
{"op": "remove", "path": "/a/0"}
]
)"_json;
CHECK(patch == expected);
CHECK(source.patch(patch) == target);
}
SECTION("many removals still round-trip")
{
json source = json::array();
for (int i = 0; i < 1000; ++i)
{
source.push_back(i);
}
json const target = json::array();
json const patch = json::diff(source, target);
CHECK(patch.size() == 1000);
CHECK(patch.front().at("path") == "/999");
CHECK(patch.back().at("path") == "/0");
CHECK(source.patch(patch) == target);
}
}
TEST_CASE("JSON patch - diff() takes the fast path for non-reorderable object types (regression #5639)")
{
// #5465 added an order check to diff()'s object handling so a
// member-by-member diff is only used when it would also reproduce
// target's member *order* -- needed for ordered_json, whose object_t
// keeps insertion order and whose patch() "add" op appends a new
// member at the end. For json's default object_t (std::map, which
// orders members by key regardless of insertion history), that check
// could still fail: a new key that sorts before an existing common key
// makes target's iteration interleave the new key between common keys,
// even though nothing else about the object changed. That sent the
// whole object through the slow (remove-every-member,
// re-add-every-member) path instead of the minimal one.
SECTION("json: added key sorts before an existing common key")
{
const json source = {{"a", 1}, {"c", {{"x", 1}, {"y", 2}}}};
const json target = {{"a", 1}, {"b", 0}, {"c", {{"x", 1}, {"y", 2}}}};
const json patch = json::diff(source, target);
// only the new key is added; "a" and "c" are left alone instead of
// being removed and re-added
const json expected = R"([{"op": "add", "path": "/b", "value": 0}])"_json;
CHECK(patch == expected);
CHECK(source.patch(patch) == target);
}
SECTION("ordered_json: reordering behavior from #5465 is unchanged")
{
using nlohmann::ordered_json;
// same key/value shape as the json case above, but for ordered_json
// the *target*'s member order must be reproduced, so the slow path
// is still required here.
ordered_json source;
source["a"] = 1;
source["c"] = ordered_json{{"x", 1}, {"y", 2}};
ordered_json target;
target["a"] = 1;
target["b"] = 0;
target["c"] = ordered_json{{"x", 1}, {"y", 2}};
const ordered_json patch = ordered_json::diff(source, target);
// unlike the json case: every member is still removed and re-added
// so the result ends up in target's order (2 removes + 3 adds)
CHECK(patch.size() == 5);
CHECK(source.patch(patch) == target);
}
}
TEST_CASE("JSON patch - every operation on ordered_json")
{
using nlohmann::ordered_json;
const ordered_json doc = {{"foo", "bar"}, {"arr", {1, 2, 3}}, {"obj", {{"a", 1}}}};
SECTION("successful operations")
{
const ordered_json patch = ordered_json::parse(R"([
{"op": "add", "path": "/obj/b", "value": 2},
{"op": "add", "path": "/arr/1", "value": 9},
{"op": "add", "path": "/arr/-", "value": 4},
{"op": "remove", "path": "/arr/0"},
{"op": "remove", "path": "/obj/a"},
{"op": "replace", "path": "/foo", "value": "baz"},
{"op": "move", "from": "/foo", "path": "/moved"},
{"op": "copy", "from": "/obj", "path": "/copied"},
{"op": "test", "path": "/copied/b", "value": 2}
])");
const ordered_json expected = ordered_json::parse(R"({
"arr": [9, 2, 3, 4], "obj": {"b": 2}, "moved": "baz", "copied": {"b": 2}
})");
CHECK(doc.patch(patch) == expected);
// adding to the root replaces the document
CHECK(doc.patch(ordered_json::parse(R"([{"op": "add", "path": "", "value": [1]}])")) == ordered_json({1}));
}
SECTION("failing operations")
{
ordered_json _;
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(_ = doc.patch(ordered_json::parse(R"([{"op": "add", "path": "/arr/4", "value": 1}])")),
"[json.exception.out_of_range.401] (/arr) array index 4 is out of range", ordered_json::out_of_range&);
#else
CHECK_THROWS_WITH_AS(_ = doc.patch(ordered_json::parse(R"([{"op": "add", "path": "/arr/4", "value": 1}])")),
"[json.exception.out_of_range.401] array index 4 is out of range", ordered_json::out_of_range&);
#endif
CHECK_THROWS_WITH_AS(_ = doc.patch(ordered_json::parse(R"([{"op": "add", "path": "/nope/x", "value": 1}])")),
"[json.exception.out_of_range.403] key 'nope' not found", ordered_json::out_of_range&);
CHECK_THROWS_WITH_AS(_ = doc.patch(ordered_json::parse(R"([{"op": "remove", "path": "/obj/nope"}])")),
"[json.exception.out_of_range.403] key 'nope' not found", ordered_json::out_of_range&);
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(_ = doc.patch(ordered_json::parse(R"([{"op": "remove", "path": "/arr/3"}])")),
"[json.exception.out_of_range.401] (/arr) array index 3 is out of range", ordered_json::out_of_range&);
#else
CHECK_THROWS_WITH_AS(_ = doc.patch(ordered_json::parse(R"([{"op": "remove", "path": "/arr/3"}])")),
"[json.exception.out_of_range.401] array index 3 is out of range", ordered_json::out_of_range&);
#endif
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(_ = doc.patch(ordered_json::parse(R"([{"op": "test", "path": "/foo", "value": "qux"}])")),
"[json.exception.other_error.501] (/0) unsuccessful: {\"op\":\"test\",\"path\":\"/foo\",\"value\":\"qux\"}", ordered_json::other_error&);
#elif JSON_DIAGNOSTIC_POSITIONS
CHECK_THROWS_WITH_AS(_ = doc.patch(ordered_json::parse(R"([{"op": "test", "path": "/foo", "value": "qux"}])")),
"[json.exception.other_error.501] (bytes 1-47) unsuccessful: {\"op\":\"test\",\"path\":\"/foo\",\"value\":\"qux\"}", ordered_json::other_error&);
#else
CHECK_THROWS_WITH_AS(_ = doc.patch(ordered_json::parse(R"([{"op": "test", "path": "/foo", "value": "qux"}])")),
"[json.exception.other_error.501] unsuccessful: {\"op\":\"test\",\"path\":\"/foo\",\"value\":\"qux\"}", ordered_json::other_error&);
#endif
#if JSON_DIAGNOSTICS
CHECK_THROWS_WITH_AS(_ = doc.patch(ordered_json::parse(R"([{"op": "add", "path": "/foo"}])")),
"[json.exception.parse_error.105] parse error: (/0) operation 'add' must have member 'value'", ordered_json::parse_error&);
#elif JSON_DIAGNOSTIC_POSITIONS
CHECK_THROWS_WITH_AS(_ = doc.patch(ordered_json::parse(R"([{"op": "add", "path": "/foo"}])")),
"[json.exception.parse_error.105] parse error: (bytes 1-30) operation 'add' must have member 'value'", ordered_json::parse_error&);
#else
CHECK_THROWS_WITH_AS(_ = doc.patch(ordered_json::parse(R"([{"op": "add", "path": "/foo"}])")),
"[json.exception.parse_error.105] parse error: operation 'add' must have member 'value'", ordered_json::parse_error&);
#endif
CHECK_THROWS_WITH_AS(_ = doc.patch(ordered_json::parse(R"([{"op": "move", "from": "/obj", "path": "/obj/a/b"}])")),
"[json.exception.out_of_range.414] cannot move value: 'from' path '/obj' is a proper prefix of 'path' '/obj/a/b'", ordered_json::out_of_range&);
}
SECTION("diff reproduces the target")
{
const ordered_json source = {{"a", 1}, {"b", 2}, {"c", {{"x", 1}}}, {"l", {1, 2, 3}}};
const std::vector<ordered_json> targets =
{
// a key removed, a key added, a nested change, a shorter array
{{"a", 1}, {"c", {{"x", 2}}}, {"l", {1}}, {"d", 4}},
// the same keys in another order
{{"c", {{"x", 1}}}, {"a", 1}, {"b", 2}, {"l", {1, 2, 3}}},
// new keys ahead of the common ones
{{"new", true}, {"a", 1}, {"b", 3}, {"c", {{"x", 1}}}, {"l", {1, 2, 3}}},
};
for (const auto& target : targets)
{
CAPTURE(target.dump());
CHECK(source.patch(ordered_json::diff(source, target)) == target);
}
}
}