Files
json/tests/src/unit-json_patch.cpp
T
Niels Lohmann 8c38e270b5 Reject JSON Patch move when from is a proper prefix of path (#5497)
* Reject JSON Patch move when from is a proper prefix of path

RFC 6902 (section 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 with no
check for this. For object targets, the subsequent "add" happened to
throw as a side effect of resolving through the now-removed parent,
but for array targets, removing the "from" element shifts subsequent
indices, so "path" silently re-resolves to a different element and
the operation "succeeds" with a silently corrupted document.

Add a check, before performing the remove/add, for whether "from" is
a proper prefix of "path" at the reference-token level. This compares
json_pointer's already-unescaped reference_tokens vectors (basic_json
is a friend of json_pointer) rather than the raw pointer strings, so
that tokens containing escaped '/' or '~' characters are compared
correctly, and a token that merely looks like a string prefix (e.g.
"/ab" vs "/abc/x") is not mistaken for a pointer-token prefix. When
"from" is a proper prefix of "path", throw out_of_range.414.

Fixes #5397.

Stacked on top of the fix for #5396 (branch
issue-5396-patch-remove-primitive-parent), since both touch the same
patch_inplace move/remove handling in include/nlohmann/json.hpp.

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

* Add root-pointer and array-append-token edge case tests for the move prefix check

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

* Replace std::equal with an explicitly-bounded loop in the move prefix check

The three-iterator std::equal(first1, last1, first2) form has no
explicit end iterator for the second range, which a static analyzer
(Flawfinder, CWE-126) flags as a potential over-read even though the
preceding size comparison already guarantees the second range is long
enough. Rather than argue the point, make the bound visible in the code
itself via an explicit loop -- every access to ptr.reference_tokens is
now guarded by the same index the loop condition bounds against
from_size.

(The C++14 four-iterator std::equal(first1, last1, first2, last2) form
was tried first as a more minimal fix, but this codebase targets C++11
and that overload is not safely usable under -std=c++11 with all
supported standard library implementations.)

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

* Extract the move prefix check into a named helper lambda

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

* Account for JSON_DIAGNOSTIC_POSITIONS in the move-prefix-check error messages

out_of_range::create() includes a "(bytes X-Y)" position annotation when
JSON_DIAGNOSTIC_POSITIONS is enabled, which the ci_test_diagnostic_positions
CI job builds the whole suite with. The five new out_of_range.414
assertions only checked the annotation-free message. Confirmed
JSON_DIAGNOSTICS produces the same (annotation-free) message as the
default build for this particular throw site (its path-based annotation
is empty at the root, where &result always points here), so only two
message variants are needed, not three.

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

---------

Signed-off-by: Niels Lohmann <mail@nlohmann.me>
2026-09-09 09:49:12 +02:00

1658 lines
62 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("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);
}
}