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