Files
json/tests/src/unit-json_patch.cpp
T
Niels Lohmann 85f8b21e1c Add tests for uncovered code paths (#5581)
* Add tests for uncovered code paths

Cover code the test suite did not reach, found from the Coveralls report
of develop and a local coverage run of HEAD:

- dump() of every kind of value below the bound of the recursive descent
  (pretty-printed objects, binary values, discarded values, scalars), and
  flushes of the escape and write buffers mid-string and mid-binary
- the iterative comparison: objects with different keys, containers that
  are a prefix of each other, and elements that cannot be ordered, each
  both at the top level and below the nesting bound
- SAX handlers that stop at any event, including the end of a nested
  container, in the BSON, CBOR, MessagePack, UBJSON and BJData readers
- from_bson/cbor/msgpack/ubjson/bjdata returning a discarded value
  through the iterator and pointer overloads
- JSON Patch, diff, merge_patch and update(..., true) on ordered_json
- smaller gaps: get_allocator(), to_ubjson/to_bjdata into a string,
  value() with an unresolvable JSON pointer, integer/float comparison
  below the integer range and with negative fractions, conversion to a
  custom binary type, std::formatter::parse on a spec without '}',
  unescape() of a lone '~', and the callback parser's start_array()

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

* Cover more paths that were thought unreachable

- parse_float_fast() declining malformed or inexact input, called
  directly since the lexer only passes well-formed numbers to it
- a UTF-16 high surrogate followed by a unit above the low surrogates
- self-assignment of a const_iterator
- a truncated CBOR string read through non-contiguous iterators
- serializing a long double under the de_DE locale, which undoes the
  locale's decimal point and thousands separator
- values read from a binary format carrying no diagnostic positions,
  with and without a parser callback

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

* Fix the CI failures of the new coverage tests

- declare the self-assignment reference const (misc-const-correctness)
- expect the (/path) prefix that JSON_DIAGNOSTICS adds to the messages
  of the failing ordered_json patch operations

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

* Expect the byte range JSON_DIAGNOSTIC_POSITIONS adds to the patch errors

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

* Build the expected dump of the nested-object test with +=

clang-tidy (performance-inefficient-string-concatenation) reported the
chain of operator+ calls that assembled the expected indented output.

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

* Compare the BJData and UBJSON test outputs byte by byte

Building a std::string from the byte vector converts each byte
implicitly, which -fsanitize=integer reports for bytes of 0x80 and
above (ci_test_clang_sanitizer).

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

---------

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-09-27 14:19:22 +02:00

1849 lines
72 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 - 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);
}
}
}