From 40a62947d3e23f46cbc863afdcc6f5c933d5ed10 Mon Sep 17 00:00:00 2001 From: satelliteprogrammer Date: Mon, 12 Oct 2026 08:33:43 +0100 Subject: [PATCH] support JSON Merge Patch (RFC 7396) diff creation (#4965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * support JSON Merge Patch (RFC 7396) diff creation The JSON merge patch document format describes the set of modifications to a resource's content, that more closely mimics the syntax of the resource being modified. However, and in contrast to JSON Patch (RFC 6902), a JSON Merge Patch cannot express certain modifications, e.g., changing an array element at a specific index, or setting a specific object value to null. The null value in a JSON Merge Patch is used to remove the key from the object. The diff algorithm is not part of the RFC 7396, but it was tested against all examples provided, plus additional cases on how null values are handled. Signed-off-by: Luís Murta * improve algorithm efficiency Signed-off-by: Luís Murta * fix inconsistent nulls and empty arrays Also add CHECKs for the patch, documentation and run `make amalgamate`. Signed-off-by: Luís Murta * documentation cleanups Signed-off-by: Luís Murta * add an iterative fallback to merge_diff Bound the recursion depth like diff() and merge_patch(), and test values nested past the bound. Signed-off-by: Luís Murta * address the remaining review comments Signed-off-by: Luís Murta * move multi-line raw strings out of CHECK macros GCC 4.8/5, ICPC and NVHPC mis-tokenize multi-line raw string literals inside macro arguments, breaking the build or the parsed JSON. Signed-off-by: Luís Murta --------- Signed-off-by: Luís Murta --- docs/mkdocs/docs/api/basic_json/diff.md | 1 + docs/mkdocs/docs/api/basic_json/index.md | 1 + docs/mkdocs/docs/api/basic_json/merge_diff.md | 79 ++ .../mkdocs/docs/api/basic_json/merge_patch.md | 1 + docs/mkdocs/docs/examples/merge_diff.cpp | 51 + docs/mkdocs/docs/examples/merge_diff.output | 22 + docs/mkdocs/docs/features/merge_patch.md | 4 + docs/mkdocs/mkdocs.yml | 1 + include/nlohmann/json.hpp | 170 +++ single_include/nlohmann/json.hpp | 170 +++ tests/src/unit-diagnostics.cpp | 19 +- tests/src/unit-merge_diff.cpp | 1024 +++++++++++++++++ 12 files changed, 1541 insertions(+), 2 deletions(-) create mode 100644 docs/mkdocs/docs/api/basic_json/merge_diff.md create mode 100644 docs/mkdocs/docs/examples/merge_diff.cpp create mode 100644 docs/mkdocs/docs/examples/merge_diff.output create mode 100644 tests/src/unit-merge_diff.cpp diff --git a/docs/mkdocs/docs/api/basic_json/diff.md b/docs/mkdocs/docs/api/basic_json/diff.md index ae22454dd..c4e710a7d 100644 --- a/docs/mkdocs/docs/api/basic_json/diff.md +++ b/docs/mkdocs/docs/api/basic_json/diff.md @@ -59,6 +59,7 @@ Currently, only `remove`, `add`, and `replace` operations are generated. - [patch](patch.md) applies a JSON Patch - [patch_inplace](patch_inplace.md) applies a JSON Patch in place - [merge_patch](merge_patch.md) applies a JSON Merge Patch +- [merge_diff](merge_diff.md) creates a diff as a JSON Merge Patch ## Version history diff --git a/docs/mkdocs/docs/api/basic_json/index.md b/docs/mkdocs/docs/api/basic_json/index.md index fdb57105c..ee40bc2b0 100644 --- a/docs/mkdocs/docs/api/basic_json/index.md +++ b/docs/mkdocs/docs/api/basic_json/index.md @@ -285,6 +285,7 @@ Access to the JSON value ### JSON Merge Patch functions - [**merge_patch**](merge_patch.md) - applies a JSON Merge Patch +- [**merge_diff**](merge_diff.md) (_static_) - creates a diff as a JSON Merge Patch ## Static functions diff --git a/docs/mkdocs/docs/api/basic_json/merge_diff.md b/docs/mkdocs/docs/api/basic_json/merge_diff.md new file mode 100644 index 000000000..b606dbad0 --- /dev/null +++ b/docs/mkdocs/docs/api/basic_json/merge_diff.md @@ -0,0 +1,79 @@ +# nlohmann::basic_json::merge_diff + +```cpp +static basic_json merge_diff(const basic_json& source, + const basic_json& target); +``` + +Creates a [JSON Merge Patch](https://tools.ietf.org/html/rfc7396) so that value `source` can be changed into the value +`target` by calling the [`merge_patch`](merge_patch.md) function. The patch contains only what differs: changed and +added members with their new values, and removed members with the value `#!json null`. Nested objects are compared +member by member; any other value that differs, including an array, is replaced as a whole. + +For two JSON values `source` and `target`, where `target` contains no object member whose value is `#!json null`, the +following code always yields `#!cpp true`: +```cpp +basic_json patched = source; +patched.merge_patch(merge_diff(source, target)); +patched == target; +``` + +## Parameters + +`source` (in) +: JSON value to compare from + +`target` (in) +: JSON value to compare against + +## Return value + +a JSON Merge Patch to convert the `source` to `target` + +## Exception safety + +Strong guarantee: `source` and `target` are never modified. + +## Complexity + +Linear in the total number of values (including nested ones) in `source` and `target`, times the cost of a key-based +object operation (looking up a key, or adding one to the patch): logarithmic for [`json`](../json.md), and linear for +[`ordered_json`](../ordered_json.md) (see [`ordered_map` complexity](../ordered_map.md#complexity)). For +`ordered_json`, diffing two objects with `n` and `m` members therefore takes O((n+m)²). + +## Notes + +- A merge patch uses `#!json null` to remove a member, so it cannot set a member to `#!json null`. As in RFC 7396, a + `#!json null` member of `target` is treated as absent: the patch removes the member from `source` instead of setting + it to `#!json null`. A member that is `#!json null` in both `source` and `target` is unchanged and left out of the + patch, so `source` keeps it. +- A merge patch cannot reorder object members, and [`merge_patch`](merge_patch.md) appends added members at the end. + For [`ordered_json`](../ordered_json.md), the patched value therefore has the same members as `target`, but not + necessarily in the same order, and may compare unequal to it. The patch lists changed and removed members in the + order of `source`, followed by added members in the order of `target`. + +## Examples + +??? example + + The following code shows how a JSON Merge Patch is created as a diff for two JSON values. + + ```cpp + --8<-- "examples/merge_diff.cpp" + ``` + + Output: + + ```json + --8<-- "examples/merge_diff.output" + ``` + +## See also + +- [RFC 7396 (JSON Merge Patch)](https://tools.ietf.org/html/rfc7396) +- [merge_patch](merge_patch.md) applies a JSON Merge Patch +- [diff](diff.md) creates a diff as a JSON Patch + +## Version history + +- Added in version 3.13.0. diff --git a/docs/mkdocs/docs/api/basic_json/merge_patch.md b/docs/mkdocs/docs/api/basic_json/merge_patch.md index dbda23f42..00e1bc8e5 100644 --- a/docs/mkdocs/docs/api/basic_json/merge_patch.md +++ b/docs/mkdocs/docs/api/basic_json/merge_patch.md @@ -67,6 +67,7 @@ returned by `#!cpp (*this)[key]`); it is read as it was when `merge_patch()` was - [RFC 7396 (JSON Merge Patch)](https://tools.ietf.org/html/rfc7396) - [patch](patch.md) apply a JSON patch +- [merge_diff](merge_diff.md) creates a diff as a JSON Merge Patch ## Version history diff --git a/docs/mkdocs/docs/examples/merge_diff.cpp b/docs/mkdocs/docs/examples/merge_diff.cpp new file mode 100644 index 000000000..22ae9b087 --- /dev/null +++ b/docs/mkdocs/docs/examples/merge_diff.cpp @@ -0,0 +1,51 @@ +#include +#include +#include + +using json = nlohmann::json; +using namespace nlohmann::literals; + +int main() +{ + // the source document + json source = R"( + { + "title": "Goodbye!", + "author": { + "givenName": "John", + "familyName": "Doe" + }, + "tags": [ + "example", + "sample" + ], + "content": "This will be unchanged" + } + )"_json; + + // the target document + json target = R"( + { + "title": "Hello!", + "author": { + "givenName": "John" + }, + "tags": [ + "example" + ], + "content": "This will be unchanged", + "phoneNumber": "+01-123-456-7890" + } + )"_json; + + // create the patch + json patch = json::merge_diff(source, target); + + // roundtrip + json patched_source = source; + patched_source.merge_patch(patch); + + // output patch and roundtrip result + std::cout << std::setw(4) << patch << "\n\n" + << std::setw(4) << patched_source << std::endl; +} diff --git a/docs/mkdocs/docs/examples/merge_diff.output b/docs/mkdocs/docs/examples/merge_diff.output new file mode 100644 index 000000000..ca7c59bf2 --- /dev/null +++ b/docs/mkdocs/docs/examples/merge_diff.output @@ -0,0 +1,22 @@ +{ + "author": { + "familyName": null + }, + "phoneNumber": "+01-123-456-7890", + "tags": [ + "example" + ], + "title": "Hello!" +} + +{ + "author": { + "givenName": "John" + }, + "content": "This will be unchanged", + "phoneNumber": "+01-123-456-7890", + "tags": [ + "example" + ], + "title": "Hello!" +} diff --git a/docs/mkdocs/docs/features/merge_patch.md b/docs/mkdocs/docs/features/merge_patch.md index c0a316e65..396ea87dc 100644 --- a/docs/mkdocs/docs/features/merge_patch.md +++ b/docs/mkdocs/docs/features/merge_patch.md @@ -9,6 +9,9 @@ syntax that closely mimics the document being modified. Unlike [JSON Patch](json express every kind of change (e.g., it cannot reorder array elements or remove a specific array element), but it is easier to read and write for object-shaped documents. +The function [`merge_diff`](../api/basic_json/merge_diff.md) creates a merge patch that changes one JSON value into +another. + !!! tip "Not a general deep merge" A merge patch is not a general deep merge: a `#!json null` value in the patch deletes the key from the target. @@ -35,4 +38,5 @@ easier to read and write for object-shaped documents. - [JSON Patch and Diff](json_patch.md) - a more expressive alternative that describes a sequence of operations - [JSON Pointer](json_pointer.md) - the addressing scheme used by JSON Patch - Function [`merge_patch`](../api/basic_json/merge_patch.md) +- Function [`merge_diff`](../api/basic_json/merge_diff.md) - Function [`update`](../api/basic_json/update.md) - merge objects, optionally recursively diff --git a/docs/mkdocs/mkdocs.yml b/docs/mkdocs/mkdocs.yml index 111e0da81..74b6c4d46 100644 --- a/docs/mkdocs/mkdocs.yml +++ b/docs/mkdocs/mkdocs.yml @@ -182,6 +182,7 @@ nav: - 'json_serializer': api/basic_json/json_serializer.md - 'max_size': api/basic_json/max_size.md - 'meta': api/basic_json/meta.md + - 'merge_diff': api/basic_json/merge_diff.md - 'merge_patch': api/basic_json/merge_patch.md - 'number_float_t': api/basic_json/number_float_t.md - 'number_integer_t': api/basic_json/number_integer_t.md diff --git a/include/nlohmann/json.hpp b/include/nlohmann/json.hpp index b91fa3e27..fae89829a 100644 --- a/include/nlohmann/json.hpp +++ b/include/nlohmann/json.hpp @@ -7449,6 +7449,176 @@ public: } public: + /// @brief creates a diff as a JSON Merge Patch + /// @sa https://json.nlohmann.me/api/basic_json/merge_diff/ + JSON_HEDLEY_WARN_UNUSED_RESULT + static basic_json merge_diff(const basic_json& source, const basic_json& target) + { + // unless both are objects, the patch is the target itself + if (!source.is_object() || !target.is_object()) + { + return target; + } + + return merge_diff_recursively(source, target, 0); + } + + private: + /// @brief two objects @ref merge_diff_iteratively is diffing + struct merge_diff_frame + { + merge_diff_frame(const basic_json* source_, const basic_json* target_) + : source(source_), target(target_), member(source_->cbegin()), patch(value_t::object) + {} + + // declared for GCC's -Weffc++, as in diff_frame + merge_diff_frame(const merge_diff_frame&) = default; + merge_diff_frame(merge_diff_frame&&) = default; + merge_diff_frame& operator=(const merge_diff_frame&) = default; + merge_diff_frame& operator=(merge_diff_frame&&) = default; + ~merge_diff_frame() = default; + + /// the objects being diffed + const basic_json* source; + const basic_json* target; + /// the member of source being diffed; advanced once it is done + const_iterator member; + /// the patch for the members of source diffed so far + basic_json patch; + }; + + /// @brief add the members of object @a target that @a source does not + /// have to the merge patch @a result + static void merge_diff_added_members(basic_json& result, const basic_json& source, const basic_json& target) + { + for (auto it = target.begin(); it != target.end(); ++it) + { + auto itf = source.find(it.key()); + if (itf == source.end()) + { + result[it.key()] = it.value(); + } + } + } + + /*! + @brief @ref merge_diff of two objects at nesting level @a depth + + Diffing two nested objects calls this function again, once per nesting + level, so values nested deeply enough would exhaust the call stack and + terminate the process. The descent is bounded here: once @ref + detail::recursion_depth_limit levels have been entered, @ref + merge_diff_iteratively diffs what is left without the call stack. + */ + static basic_json merge_diff_recursively(const basic_json& source, const basic_json& target, const std::size_t depth) + { + JSON_ASSERT(source.is_object() && target.is_object()); + + if (JSON_HEDLEY_UNLIKELY(depth >= detail::recursion_depth_limit())) + { + return merge_diff_iteratively(source, target); + } + + basic_json result(value_t::object); + + for (auto it = source.begin(); it != source.end(); ++it) + { + auto itf = target.find(it.key()); + if (itf != target.end()) + { + if (it.value().is_object() && itf.value().is_object()) + { + auto diff = merge_diff_recursively(it.value(), itf.value(), depth + 1); + if (!diff.empty()) + { + result[it.key()] = std::move(diff); + } + } + else if (it.value() != itf.value()) + { + result[it.key()] = itf.value(); + } + } + else + { + result[it.key()] = value_t::null; + } + } + + merge_diff_added_members(result, source, target); + return result; + } + + /*! + @brief @ref merge_diff of two objects without the call stack + + Produces the same patch as @ref merge_diff_recursively, keeping the objects + being diffed on an explicit stack instead of descending into them. Two + nested objects are still diffed completely before the next member, so the + patch has its members in the same order as in the recursive version. Only + reached for objects nested more deeply than @ref + detail::recursion_depth_limit. + */ + static basic_json merge_diff_iteratively(const basic_json& source, const basic_json& target) + { + // the patch of source and target, set once their frame is popped + basic_json result; + std::vector stack; + stack.emplace_back(&source, &target); + while (!stack.empty()) + { + // the frame's members are copied out and it is changed through + // stack.back(): pushing a frame for nested objects or popping + // this one would invalidate a reference to it + const basic_json* const s = stack.back().source; + const basic_json* const t = stack.back().target; + const const_iterator it = stack.back().member; + + if (it != s->cend()) + { + const const_iterator itf = t->find(it.key()); + if (itf != t->cend()) + { + if (it.value().is_object() && itf.value().is_object()) + { + // member is advanced once their patch is under its key + stack.emplace_back(&it.value(), &itf.value()); + continue; + } + if (it.value() != itf.value()) + { + stack.back().patch[it.key()] = itf.value(); + } + } + else + { + stack.back().patch[it.key()] = value_t::null; + } + ++stack.back().member; + continue; + } + + merge_diff_added_members(stack.back().patch, *s, *t); + + // these objects are done: continue with the ones they are in + basic_json patch = std::move(stack.back().patch); + stack.pop_back(); + if (stack.empty()) + { + result = std::move(patch); + } + else + { + if (!patch.empty()) + { + stack.back().patch[stack.back().member.key()] = std::move(patch); + } + ++stack.back().member; + } + } + return result; + } + /// @} }; diff --git a/single_include/nlohmann/json.hpp b/single_include/nlohmann/json.hpp index d8f7e41b3..b6b927835 100644 --- a/single_include/nlohmann/json.hpp +++ b/single_include/nlohmann/json.hpp @@ -35570,6 +35570,176 @@ public: } public: + /// @brief creates a diff as a JSON Merge Patch + /// @sa https://json.nlohmann.me/api/basic_json/merge_diff/ + JSON_HEDLEY_WARN_UNUSED_RESULT + static basic_json merge_diff(const basic_json& source, const basic_json& target) + { + // unless both are objects, the patch is the target itself + if (!source.is_object() || !target.is_object()) + { + return target; + } + + return merge_diff_recursively(source, target, 0); + } + + private: + /// @brief two objects @ref merge_diff_iteratively is diffing + struct merge_diff_frame + { + merge_diff_frame(const basic_json* source_, const basic_json* target_) + : source(source_), target(target_), member(source_->cbegin()), patch(value_t::object) + {} + + // declared for GCC's -Weffc++, as in diff_frame + merge_diff_frame(const merge_diff_frame&) = default; + merge_diff_frame(merge_diff_frame&&) = default; + merge_diff_frame& operator=(const merge_diff_frame&) = default; + merge_diff_frame& operator=(merge_diff_frame&&) = default; + ~merge_diff_frame() = default; + + /// the objects being diffed + const basic_json* source; + const basic_json* target; + /// the member of source being diffed; advanced once it is done + const_iterator member; + /// the patch for the members of source diffed so far + basic_json patch; + }; + + /// @brief add the members of object @a target that @a source does not + /// have to the merge patch @a result + static void merge_diff_added_members(basic_json& result, const basic_json& source, const basic_json& target) + { + for (auto it = target.begin(); it != target.end(); ++it) + { + auto itf = source.find(it.key()); + if (itf == source.end()) + { + result[it.key()] = it.value(); + } + } + } + + /*! + @brief @ref merge_diff of two objects at nesting level @a depth + + Diffing two nested objects calls this function again, once per nesting + level, so values nested deeply enough would exhaust the call stack and + terminate the process. The descent is bounded here: once @ref + detail::recursion_depth_limit levels have been entered, @ref + merge_diff_iteratively diffs what is left without the call stack. + */ + static basic_json merge_diff_recursively(const basic_json& source, const basic_json& target, const std::size_t depth) + { + JSON_ASSERT(source.is_object() && target.is_object()); + + if (JSON_HEDLEY_UNLIKELY(depth >= detail::recursion_depth_limit())) + { + return merge_diff_iteratively(source, target); + } + + basic_json result(value_t::object); + + for (auto it = source.begin(); it != source.end(); ++it) + { + auto itf = target.find(it.key()); + if (itf != target.end()) + { + if (it.value().is_object() && itf.value().is_object()) + { + auto diff = merge_diff_recursively(it.value(), itf.value(), depth + 1); + if (!diff.empty()) + { + result[it.key()] = std::move(diff); + } + } + else if (it.value() != itf.value()) + { + result[it.key()] = itf.value(); + } + } + else + { + result[it.key()] = value_t::null; + } + } + + merge_diff_added_members(result, source, target); + return result; + } + + /*! + @brief @ref merge_diff of two objects without the call stack + + Produces the same patch as @ref merge_diff_recursively, keeping the objects + being diffed on an explicit stack instead of descending into them. Two + nested objects are still diffed completely before the next member, so the + patch has its members in the same order as in the recursive version. Only + reached for objects nested more deeply than @ref + detail::recursion_depth_limit. + */ + static basic_json merge_diff_iteratively(const basic_json& source, const basic_json& target) + { + // the patch of source and target, set once their frame is popped + basic_json result; + std::vector stack; + stack.emplace_back(&source, &target); + while (!stack.empty()) + { + // the frame's members are copied out and it is changed through + // stack.back(): pushing a frame for nested objects or popping + // this one would invalidate a reference to it + const basic_json* const s = stack.back().source; + const basic_json* const t = stack.back().target; + const const_iterator it = stack.back().member; + + if (it != s->cend()) + { + const const_iterator itf = t->find(it.key()); + if (itf != t->cend()) + { + if (it.value().is_object() && itf.value().is_object()) + { + // member is advanced once their patch is under its key + stack.emplace_back(&it.value(), &itf.value()); + continue; + } + if (it.value() != itf.value()) + { + stack.back().patch[it.key()] = itf.value(); + } + } + else + { + stack.back().patch[it.key()] = value_t::null; + } + ++stack.back().member; + continue; + } + + merge_diff_added_members(stack.back().patch, *s, *t); + + // these objects are done: continue with the ones they are in + basic_json patch = std::move(stack.back().patch); + stack.pop_back(); + if (stack.empty()) + { + result = std::move(patch); + } + else + { + if (!patch.empty()) + { + stack.back().patch[stack.back().member.key()] = std::move(patch); + } + ++stack.back().member; + } + } + return result; + } + /// @} }; diff --git a/tests/src/unit-diagnostics.cpp b/tests/src/unit-diagnostics.cpp index 1ada023b7..37427587c 100644 --- a/tests/src/unit-diagnostics.cpp +++ b/tests/src/unit-diagnostics.cpp @@ -567,9 +567,9 @@ TEST_CASE("Regression tests for extended diagnostics") } } -TEST_CASE("Better diagnostics past the descent bound of update() and merge_patch()") +TEST_CASE("Better diagnostics past the descent bound of update(), merge_patch() and merge_diff()") { - // Both merge objects nested more than detail::recursion_depth_limit() + // All three handle objects nested more than detail::recursion_depth_limit() // (128) levels deep without recursing; the values they add or replace // there must still know their parents. // The values are built rather than parsed, so that the expected messages @@ -615,4 +615,19 @@ TEST_CASE("Better diagnostics past the descent bound of update() and merge_patch CHECK_THROWS_WITH_AS(p->at("x").at(0), expected_x.c_str(), json::type_error); CHECK_THROWS_WITH_AS(p->at("y").at(0), expected_y.c_str(), json::type_error); } + + SECTION("merge_diff()") + { + // target and patch differ only in their innermost members, so the + // diff of the two is nested as deeply: {"a": ... {"x": null, "y": 2}} + const json diff = json::merge_diff(target, patch); // NOLINT(readability-suspicious-call-argument) + + const json* p = &diff; + for (std::size_t i = 0; i < depth; ++i) + { + p = &p->at("a"); + } + CHECK(p->at("x").is_null()); + CHECK_THROWS_WITH_AS(p->at("y").at(0), expected_y.c_str(), json::type_error); + } } diff --git a/tests/src/unit-merge_diff.cpp b/tests/src/unit-merge_diff.cpp new file mode 100644 index 000000000..77fa58768 --- /dev/null +++ b/tests/src/unit-merge_diff.cpp @@ -0,0 +1,1024 @@ +// __ _____ _____ _____ +// __| | __| | | | JSON for Modern C++ (supporting code) +// | | |__ | | | | | | version 3.12.0 +// |_____|_____|_____|_|___| https://github.com/nlohmann/json +// +// SPDX-FileCopyrightText: 2013-2026 Niels Lohmann +// SPDX-License-Identifier: MIT + +#include "doctest_compatibility.h" + +#include +using nlohmann::json; +using nlohmann::ordered_json; +#ifdef JSON_TEST_NO_GLOBAL_UDLS + using namespace nlohmann::literals; // NOLINT(google-build-using-namespace) +#endif + +#include +#include + +namespace +{ +// merge_diff() recursing once per nesting level, without the bound on its +// descent; only usable on values nested a few hundred levels deep +template +BasicJsonType reference_merge_diff(const BasicJsonType& source, const BasicJsonType& target) +{ + if (!source.is_object() || !target.is_object()) + { + return target; + } + + BasicJsonType result = BasicJsonType::object(); + for (auto it = source.begin(); it != source.end(); ++it) + { + const auto itf = target.find(it.key()); + if (itf == target.end()) + { + result[it.key()] = nullptr; + } + else if (it.value().is_object() && itf.value().is_object()) + { + auto diff = reference_merge_diff(it.value(), itf.value()); + if (!diff.empty()) + { + result[it.key()] = std::move(diff); + } + } + else if (it.value() != itf.value()) + { + result[it.key()] = itf.value(); + } + } + for (auto it = target.begin(); it != target.end(); ++it) + { + if (source.find(it.key()) == source.end()) + { + result[it.key()] = it.value(); + } + } + return result; +} + +// objects nested `depth` levels deep under the key "a", with members before +// and after it that differ by `variant` on the way down, so that diffing two +// variants removes, adds and replaces members on many levels, and diffs nested +// objects that are equal, that differ, or that are not objects in one of the +// variants; only variant 2 has members that are null +template +BasicJsonType nested_objects(const std::size_t depth, const int variant) +{ + BasicJsonType value = BasicJsonType::object({{"v", variant}}); + for (std::size_t i = 0; i < depth; ++i) + { + BasicJsonType object = BasicJsonType::object(); + if ((i + static_cast(variant)) % 3 == 0) + { + object["s" + std::to_string(variant)] = i; + } + object["c"] = i % 4 == 0 ? variant : 0; + if (i % 5 == 0) + { + if (variant == 0) + { + object["o"] = BasicJsonType::object({{"p", i}}); + } + else if (variant == 1) + { + object["o"] = BasicJsonType::array({i}); + } + else + { + object["o"] = nullptr; + } + } + if (variant == 2 && i % 4 == 2) + { + object["n"] = nullptr; + } + object["a"] = std::move(value); + if (i % 6 == 0) + { + object["d"] = BasicJsonType::object({{"e", variant == 1 ? 1 : 0}}); + } + if (variant == 1 && i % 7 == 0) + { + object["z"] = "z"; + } + value = std::move(object); + } + return value; +} +} // namespace + +TEST_CASE("JSON Merge Patch diff") +{ + SECTION("examples from RFC 7396") + { + SECTION("Section 1") + { + json document = R"({ + "a": "b", + "c": { + "d": "e", + "f": "g" + } + })"_json; + + json expected = R"({ + "a": "z", + "c": { + "d": "e" + } + })"_json; + + json const expected_patch = R"({ + "a": "z", + "c": { + "f": null + } + })"_json; + + auto patch = json::merge_diff(document, expected); + CHECK(patch == expected_patch); + + document.merge_patch(patch); + CHECK(document == expected); + } + + SECTION("Section 3") + { + json document = R"({ + "title": "Goodbye!", + "author": { + "givenName": "John", + "familyName": "Doe" + }, + "tags": [ + "example", + "sample" + ], + "content": "This will be unchanged" + })"_json; + + json expected = R"({ + "title": "Hello!", + "author": { + "givenName": "John" + }, + "tags": [ + "example" + ], + "content": "This will be unchanged", + "phoneNumber": "+01-123-456-7890" + })"_json; + + json const expected_patch = R"({ + "title": "Hello!", + "phoneNumber": "+01-123-456-7890", + "author": { + "familyName": null + }, + "tags": [ + "example" + ] + })"_json; + + auto patch = json::merge_diff(document, expected); + CHECK(patch == expected_patch); + + document.merge_patch(patch); + CHECK(document == expected); + } + + SECTION("Appendix A") + { + SECTION("Example 1") + { + json original = R"({"a":"b"})"_json; + json result = R"({"a":"c"})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":"c"})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("Example 2") + { + json original = R"({"a":"b"})"_json; + json result = R"({"a":"b", "b":"c"})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"b":"c"})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("Example 3") + { + json original = R"({"a":"b"})"_json; + json result = R"({})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":null})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("Example 4") + { + json original = R"({"a":"b","b":"c"})"_json; + json result = R"({"b":"c"})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":null})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("Example 5") + { + json original = R"({"a":["b"]})"_json; + json result = R"({"a":"c"})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":"c"})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("Example 6") + { + json original = R"({"a":"c"})"_json; + json result = R"({"a":["b"]})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":["b"]})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("Example 7") + { + json original = R"({"a":{"b": "c"}})"_json; + json result = R"({"a": {"b": "d"}})"_json; + + auto patch = json::merge_diff(original, result); + // differs from the RFC 7396, "c": null is not in the patch + // because neither the source nor the target have the "c" key + CHECK(patch == R"({"a":{"b":"d"}})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("Example 8") + { + json original = R"({"a":[{"b":"c"}]})"_json; + json result = R"({"a":[1]})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":[1]})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("Example 9") + { + json original = R"(["a","b"])"_json; + json result = R"(["c","d"])"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"(["c","d"])"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("Example 10") + { + json original = R"({"a":"b"})"_json; + json result = R"(["c"])"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"(["c"])"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("Example 11") + { + json original = R"({"a":"foo"})"_json; + json result = R"(null)"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"(null)"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("Example 12") + { + json original = R"({"a":"foo"})"_json; + json result = R"("bar")"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"("bar")"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("Example 13") + { + json original = R"({"e":null})"_json; + json result = R"({"e":null,"a":1})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":1})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("Example 14") + { + json original = R"([1,2])"_json; + json result = R"({"a":"b"})"_json; + + auto patch = json::merge_diff(original, result); + // differs from the RFC 7396, "c": null is not in the patch + // because neither the source nor the target have the "c" key + CHECK(patch == R"({"a":"b"})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("Example 15") + { + json original = R"({})"_json; + json result = R"({"a":{"bb":{}}})"_json; + + auto patch = json::merge_diff(original, result); + // differs from the RFC 7396, "ccc": null is not in the patch + // because neither the source nor the target have the "ccc" key + CHECK(patch == R"({"a":{"bb":{}}})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + } + } + + SECTION("null values") + { + SECTION("object with null value to object") + { + json original = R"({"a":null})"_json; + json result = R"({"a":{"b":"c"}})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":{"b":"c"}})"_json); + + original.merge_patch(patch); + CHECK(original == R"({"a":{"b":"c"}})"_json); + } + + SECTION("object to object with null value") + { + json original = R"({"a":{"b":"c"}})"_json; + json result = R"({"a":null})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":null})"_json); + + original.merge_patch(patch); + CHECK(original == R"({})"_json); + } + + SECTION("primitive to object with null value") + { + json original = R"({"a":1})"_json; + json result = R"({"a":null})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":null})"_json); + + original.merge_patch(patch); + CHECK(original == R"({})"_json); + } + + SECTION("nested primitive to object with null value") + { + json original = R"({"a":{"b":1}})"_json; + json result = R"({"a":{"b":null}})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":{"b":null}})"_json); + + original.merge_patch(patch); + CHECK(original == R"({"a":{}})"_json); + } + + SECTION("array value to object with null value") + { + json original = R"({"a":[1,2]})"_json; + json result = R"({"a":null})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":null})"_json); + + original.merge_patch(patch); + CHECK(original == R"({})"_json); + } + + SECTION("nested object to object with null value") + { + json original = R"({"a":{"b":{"c":"d"}}})"_json; + json result = R"({"a":{"b":null}})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":{"b":null}})"_json); + + original.merge_patch(patch); + CHECK(original == R"({"a":{}})"_json); + } + + SECTION("nested primitive to object with null value with sibling") + { + json original = R"({"x":"y","a":{"b":"c"}})"_json; + json result = R"({"x":"y","a":{"b":null, "c":"d"}})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":{"b":null,"c":"d"}})"_json); + + original.merge_patch(patch); + CHECK(original == R"({"x":"y","a":{"c":"d"}})"_json); + } + + SECTION("empty object to object with null value") + { + json original = R"({})"_json; + json result = R"({"a":null})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":null})"_json); + + original.merge_patch(patch); + CHECK(original == R"({})"_json); + } + + SECTION("null to object with null value") + { + json original = R"(null)"_json; + json result = R"({"a":null})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":null})"_json); + + original.merge_patch(patch); + CHECK(original == R"({})"_json); + } + + SECTION("array to object with null value") + { + json original = R"([])"_json; + json result = R"({"a":null})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":null})"_json); + + original.merge_patch(patch); + CHECK(original == R"({})"_json); + } + + SECTION("empty object to nested null value") + { + json original = R"({})"_json; + json result = R"({"a":{"b":null}})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":{"b":null}})"_json); + + original.merge_patch(patch); + CHECK(original == R"({"a":{}})"_json); + } + + SECTION("object to nested null value") + { + json original = R"({"a": 1})"_json; + json result = R"({"a":{"b":null}})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":{"b":null}})"_json); + + original.merge_patch(patch); + CHECK(original == R"({"a":{}})"_json); + } + + SECTION("primitive to nested null value") + { + json original = R"(1)"_json; + json result = R"({"a":{"b":null}})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":{"b":null}})"_json); + + original.merge_patch(patch); + CHECK(original == R"({"a":{}})"_json); + } + + SECTION("object with null value unchanged") + { + json original = R"({"a":null})"_json; + json result = R"({"a":null})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("object with null value to empty object") + { + json original = R"({"a":null})"_json; + json result = R"({})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":null})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("object with null value to primitive") + { + json original = R"({"a":null})"_json; + json result = R"({"a":1})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":1})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("array to array with null value") + { + json original = R"({"a":[1]})"_json; + json result = R"({"a":[null]})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":[null]})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("empty object to array with null value") + { + json original = R"({})"_json; + json result = R"({"a":[null]})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":[null]})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("doubly nested primitive to null value") + { + json original = R"({"a":{"b":{"c":1}}})"_json; + json result = R"({"a":{"b":{"c":null}}})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":{"b":{"c":null}}})"_json); + + original.merge_patch(patch); + CHECK(original == R"({"a":{"b":{}}})"_json); + } + } + + SECTION("no change") + { + SECTION("object") + { + json original = R"({"a":"b","b":"c"})"_json; + json result = R"({"a":"b","b":"c"})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch.empty()); + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("empty object") + { + json original = R"({})"_json; + json result = R"({})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch.empty()); + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("array") + { + json original = R"([1,2,3])"_json; + json result = R"([1,2,3])"_json; + + auto patch = json::merge_diff(original, result); + CHECK(!patch.empty()); + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("null") + { + json original = R"(null)"_json; + json result = R"(null)"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch.empty()); + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("string") + { + json original = R"("ab")"_json; + json result = R"("ab")"_json; + + auto patch = json::merge_diff(original, result); + CHECK(!patch.empty()); + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("number") + { + json original = R"(42)"_json; + json result = R"(42)"_json; + + auto patch = json::merge_diff(original, result); + CHECK(!patch.empty()); + original.merge_patch(patch); + CHECK(original == result); + } + } + + SECTION("primitives") + { + SECTION("string") + { + json original = R"("a")"_json; + json result = R"("b")"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"("b")"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("number") + { + json original = R"(1)"_json; + json result = R"(2)"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"(2)"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("boolean") + { + json original = R"(false)"_json; + json result = R"(true)"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"(true)"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("object to primitive") + { + json original = R"({"a":{"b":"c"}})"_json; + json result = R"({"a":1})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":1})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("primitive to object") + { + json original = R"({"a":1})"_json; + json result = R"({"a":{"b":"c"}})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":{"b":"c"}})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("object to empty string") + { + json original = R"({"a":{"b":"c"}})"_json; + json result = R"({"a":""})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":""})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("object to zero") + { + json original = R"({"a":{"b":"c"}})"_json; + json result = R"({"a":0})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":0})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("object to empty object") + { + json original = R"({"a":{"b":"c"}})"_json; + json result = R"({"a":{}})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":{"b":null}})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + } + + SECTION("arrays") + { + SECTION("array to array") + { + json original = R"([1,2,3])"_json; + json result = R"([1,2,4])"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"([1,2,4])"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("array to empty array") + { + json original = R"([1,2,3])"_json; + json result = R"([])"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"([])"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("empty array to array") + { + json original = R"([])"_json; + json result = R"([1,2,3])"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"([1,2,3])"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("object to empty array") + { + json original = R"({"a":{"b":"c"}})"_json; + json result = R"({"a":[]})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":[]})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("same keys") + { + json original = R"(["a","b"])"_json; + json result = R"({"a":1,"b":2})"_json; + + auto patch = json::merge_diff(original, result); + CHECK(patch == R"({"a":1,"b":2})"_json); + + original.merge_patch(patch); + CHECK(original == result); + } + } + + SECTION("ordered_json") + { + SECTION("changed value keeps member order") + { + auto original = ordered_json::parse(R"({"c":1,"b":2,"a":3})"); + auto result = ordered_json::parse(R"({"c":1,"b":5,"a":3})"); + + auto patch = ordered_json::merge_diff(original, result); + CHECK(patch.dump() == R"({"b":5})"); + + original.merge_patch(patch); + CHECK(original == result); + CHECK(original.dump() == R"({"c":1,"b":5,"a":3})"); + } + + SECTION("added members follow target order") + { + auto original = ordered_json::parse(R"({"b":1})"); + auto result = ordered_json::parse(R"({"b":1,"z":2,"a":3})"); + + auto patch = ordered_json::merge_diff(original, result); + CHECK(patch.dump() == R"({"z":2,"a":3})"); + + original.merge_patch(patch); + CHECK(original == result); + CHECK(original.dump() == R"({"b":1,"z":2,"a":3})"); + } + + SECTION("removed members follow source order") + { + auto original = ordered_json::parse(R"({"c":1,"b":2,"a":3})"); + auto result = ordered_json::parse(R"({"b":2})"); + + auto patch = ordered_json::merge_diff(original, result); + CHECK(patch.dump() == R"({"c":null,"a":null})"); + + original.merge_patch(patch); + CHECK(original == result); + } + + SECTION("nested objects keep member order") + { + auto original = ordered_json::parse(R"({"z":{"y":1,"x":2},"a":0})"); + auto result = ordered_json::parse(R"({"z":{"y":1,"x":3,"w":4},"a":0})"); + + auto patch = ordered_json::merge_diff(original, result); + CHECK(patch.dump() == R"({"z":{"x":3,"w":4}})"); + + original.merge_patch(patch); + CHECK(original == result); + CHECK(original.dump() == R"({"z":{"y":1,"x":3,"w":4},"a":0})"); + } + + SECTION("reordered members cannot be expressed") + { + auto original = ordered_json::parse(R"({"a":1,"b":2})"); + auto result = ordered_json::parse(R"({"b":2,"a":1})"); + + auto patch = ordered_json::merge_diff(original, result); + CHECK(patch.empty()); + + original.merge_patch(patch); + CHECK(original != result); + CHECK(original.dump() == R"({"a":1,"b":2})"); + } + + SECTION("member added before existing ones is appended") + { + auto original = ordered_json::parse(R"({"b":1})"); + auto result = ordered_json::parse(R"({"a":0,"b":1})"); + + auto patch = ordered_json::merge_diff(original, result); + CHECK(patch.dump() == R"({"a":0})"); + + original.merge_patch(patch); + CHECK(original != result); + CHECK(original.dump() == R"({"b":1,"a":0})"); + } + } +} + +TEST_CASE("JSON Merge Patch diff of deeply nested values") +{ + SECTION("the patch is the same past the descent bound") + { + // depths on either side of the nesting depth up to which merge_diff() + // recurses (detail::recursion_depth_limit(), 128); not every depth up + // to 300, to keep the test fast under Valgrind + std::vector depths; + for (std::size_t depth = 0; depth <= 16; ++depth) + { + depths.push_back(depth); + } + for (std::size_t depth = 120; depth <= 136; ++depth) + { + depths.push_back(depth); + } + depths.push_back(300); + + for (const auto depth : depths) + { + CAPTURE(depth) + for (int from = 0; from < 3; ++from) + { + for (int to = 0; to < 3; ++to) + { + CAPTURE(from) + CAPTURE(to) + const auto source = nested_objects(depth, from); + const auto target = nested_objects(depth, to); + const auto patch = json::merge_diff(source, target); + CHECK(patch == reference_merge_diff(source, target)); + CHECK(patch.empty() == (from == to)); + + // a merge patch cannot set a member to null, so it only + // reproduces a target without null members + if (to != 2) + { + json patched = source; + patched.merge_patch(patch); + CHECK(patched == target); + } + + // the members of the patch also come in the same order + const auto ordered_source = nested_objects(depth, from); + const auto ordered_target = nested_objects(depth, to); + CHECK(ordered_json::merge_diff(ordered_source, ordered_target).dump() == reference_merge_diff(ordered_source, ordered_target).dump()); + } + } + } + } + + SECTION("a difference only in the innermost value is a patch nested as deeply") + { + for (std::size_t depth = 0; depth <= 300; ++depth) + { + CAPTURE(depth) + json source = {{"v", 1}}; + json target = {{"v", 2}}; + json expected = {{"v", 2}}; + for (std::size_t i = 0; i < depth; ++i) + { + source = json{{"a", std::move(source)}, {"b", i}}; + target = json{{"a", std::move(target)}, {"b", i}}; + expected = json{{"a", std::move(expected)}}; + } + CHECK(json::merge_diff(source, target) == expected); + } + } + + SECTION("values nested too deeply for the call stack") + { + // recursing once per nesting level would exhaust the call stack here. + // The patch is walked rather than compared as a whole, to keep the + // test fast. + const std::size_t depth = 100000; + std::string opening; + for (std::size_t i = 0; i < depth; ++i) + { + opening += R"({"a":)"; + } + const std::string closing(depth, '}'); + const auto source = json::parse(opening + R"({"v":1,"w":0})" + closing); + + const auto patch = json::merge_diff(source, json::parse(opening + R"({"v":2,"w":0})" + closing)); + std::size_t members = 0; + const json* p = &patch; + for (std::size_t i = 0; i < depth; ++i) + { + members += p->size(); + p = &p->at("a"); + } + CHECK(members == depth); + CHECK(*p == json({{"v", 2}})); + + CHECK(json::merge_diff(source, json::parse(opening + R"({"v":1,"w":0})" + closing)).empty()); + } +}