From 8c10cd2df45834cf4f5a1be844f561f7c2f4c5e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Murta?= Date: Sat, 25 Oct 2025 17:33:27 +0100 Subject: [PATCH 1/2] support JSON Merge Patch (RFC 7396) diff creation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- include/nlohmann/json.hpp | 51 ++++ tests/src/unit-merge_diff.cpp | 433 ++++++++++++++++++++++++++++++++++ 2 files changed, 484 insertions(+) create mode 100644 tests/src/unit-merge_diff.cpp diff --git a/include/nlohmann/json.hpp b/include/nlohmann/json.hpp index d4502a5c96..1386cc7a96 100644 --- a/include/nlohmann/json.hpp +++ b/include/nlohmann/json.hpp @@ -5254,6 +5254,57 @@ class basic_json // NOLINT(cppcoreguidelines-special-member-functions,hicpp-spec } } + /// @brief creates a diff as a JSON Merge Patch + JSON_HEDLEY_WARN_UNUSED_RESULT + static basic_json merge_diff(const basic_json& source, const basic_json& target) + { + if (!target.is_object()) + { + return target; + } + + basic_json result(value_t::object); + + if (source.is_object()) + { + for (auto it = source.begin(); it != source.end(); ++it) + { + auto itf = target.find(it.key()); + if (itf != target.end()) + { + if (it.value() != itf.value()) + { + auto diff = merge_diff(it.value(), itf.value()); + if (diff.is_null()) + { + JSON_THROW(other_error::create(503, detail::concat("cannot set \"", itf.key(), "\" to null"), &target)); + } + result[it.key()] = merge_diff(it.value(), itf.value()); + } + } + else + { + result[it.key()] = value_t::null; + } + } + } + + for (auto it = target.begin(); it != target.end(); ++it) + { + auto itf = source.find(it.key()); + if (itf == source.end()) + { + if (it.value().is_null()) + { + JSON_THROW(other_error::create(503, detail::concat("cannot set \"", it.key(), "\" to null"), &target)); + } + result[it.key()] = it.value(); + } + } + + return result; + } + /// @} }; diff --git a/tests/src/unit-merge_diff.cpp b/tests/src/unit-merge_diff.cpp new file mode 100644 index 0000000000..18a8455550 --- /dev/null +++ b/tests/src/unit-merge_diff.cpp @@ -0,0 +1,433 @@ +// __ _____ _____ _____ +// __| | __| | | | JSON for Modern C++ (supporting code) +// | | |__ | | | | | | version 3.12.0 +// |_____|_____|_____|_|___| https://github.com/nlohmann/json +// +// SPDX-FileCopyrightText: 2013-2025 Niels Lohmann +// SPDX-License-Identifier: MIT + +#include "doctest_compatibility.h" + +#include +using nlohmann::json; +#ifdef JSON_TEST_NO_GLOBAL_UDLS + using namespace nlohmann::literals; // NOLINT(google-build-using-namespace) +#endif + +TEST_CASE("JSON Merge Patch") +{ + 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; + + document.merge_patch(json::merge_diff(document, expected)); + 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; + + document.merge_patch(json::merge_diff(document, expected)); + CHECK(document == expected); + } + + SECTION("Appendix A") + { + SECTION("Example 1") + { + json original = R"({"a":"b"})"_json; + json result = R"({"a":"c"})"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + + SECTION("Example 2") + { + json original = R"({"a":"b"})"_json; + json result = R"({"a":"b", "b":"c"})"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + + SECTION("Example 3") + { + json original = R"({"a":"b"})"_json; + json result = R"({})"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + + SECTION("Example 4") + { + json original = R"({"a":"b","b":"c"})"_json; + json result = R"({"b":"c"})"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + + SECTION("Example 5") + { + json original = R"({"a":["b"]})"_json; + json result = R"({"a":"c"})"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + + SECTION("Example 6") + { + json original = R"({"a":"c"})"_json; + json result = R"({"a":["b"]})"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + + SECTION("Example 7") + { + json original = R"({"a":{"b": "c"}})"_json; + json result = R"({"a": {"b": "d"}})"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + + SECTION("Example 8") + { + json original = R"({"a":[{"b":"c"}]})"_json; + json result = R"({"a":[1]})"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + + SECTION("Example 9") + { + json original = R"(["a","b"])"_json; + json result = R"(["c","d"])"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + + SECTION("Example 10") + { + json original = R"({"a":"b"})"_json; + json result = R"(["c"])"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + + SECTION("Example 11") + { + json original = R"({"a":"foo"})"_json; + json result = R"(null)"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + + SECTION("Example 12") + { + json original = R"({"a":"foo"})"_json; + json result = R"("bar")"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + + SECTION("Example 13") + { + json original = R"({"e":null})"_json; + json result = R"({"e":null,"a":1})"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + + SECTION("Example 14") + { + json original = R"([1,2])"_json; + json result = R"({"a":"b"})"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + + SECTION("Example 15") + { + json original = R"({})"_json; + json result = R"({"a":{"bb":{}}})"_json; + + original.merge_patch(json::merge_diff(original, result)); + 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; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + + SECTION("object to object with null value") + { + json original = R"({"a":{"b":"c"}})"_json; + json result = R"({"a":null})"_json; + + json _; + CHECK_THROWS_WITH_AS(_ = json::merge_diff(original, result), "[json.exception.other_error.503] cannot set \"a\" to null", json::other_error&); + } + + SECTION("primitive to object with null value") + { + json original = R"({"a":1})"_json; + json result = R"({"a":null})"_json; + + json _; + CHECK_THROWS_WITH_AS(_ = json::merge_diff(original, result), "[json.exception.other_error.503] cannot set \"a\" to null", json::other_error&); + } + + SECTION("nested primitive to object with null value") + { + json original = R"({"a":{"b":1}})"_json; + json result = R"({"a":{"b":null}})"_json; + + json _; + CHECK_THROWS_WITH_AS(_ = json::merge_diff(original, result), "[json.exception.other_error.503] cannot set \"b\" to null", json::other_error&); + } + + SECTION("array value to object with null value") + { + json original = R"({"a":[1,2]})"_json; + json result = R"({"a":null})"_json; + + json _; + CHECK_THROWS_WITH_AS(_ = json::merge_diff(original, result), "[json.exception.other_error.503] cannot set \"a\" to null", json::other_error&); + } + + SECTION("nested object to object with null value") + { + json original = R"({"a":{"b":{"c":"d"}}})"_json; + json result = R"({"a":{"b":null}})"_json; + + json _; + CHECK_THROWS_WITH_AS(_ = json::merge_diff(original, result), "[json.exception.other_error.503] cannot set \"b\" to null", json::other_error&); + } + + 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; + + json _; + CHECK_THROWS_WITH_AS(_ = json::merge_diff(original, result), "[json.exception.other_error.503] cannot set \"b\" to null", json::other_error&); + } + + SECTION("empty object to object with null value") + { + json original = R"({})"_json; + json result = R"({"a":null})"_json; + + json _; + CHECK_THROWS_WITH_AS(_ = json::merge_diff(original, result), "[json.exception.other_error.503] cannot set \"a\" to null", json::other_error&); + } + + SECTION("null to object with null value") + { + json original = R"(null)"_json; + json result = R"({"a":null})"_json; + + json _; + CHECK_THROWS_WITH_AS(_ = json::merge_diff(original, result), "[json.exception.other_error.503] cannot set \"a\" to null", json::other_error&); + } + + SECTION("array to object with null value") + { + json original = R"([])"_json; + json result = R"({"a":null})"_json; + + json _; + CHECK_THROWS_WITH_AS(_ = json::merge_diff(original, result), "[json.exception.other_error.503] cannot set \"a\" to null", json::other_error&); + } + } + + 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; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + + SECTION("number") + { + json original = R"(1)"_json; + json result = R"(2)"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + + SECTION("boolean") + { + json original = R"(false)"_json; + json result = R"(true)"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + } + + SECTION("arrays") + { + SECTION("array to array") + { + json original = R"([1,2,3])"_json; + json result = R"([1,2,4])"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + + SECTION("array to empty array") + { + json original = R"([1,2,3])"_json; + json result = R"([])"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + + SECTION("empty array to array") + { + json original = R"([])"_json; + json result = R"([1,2,3])"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + } +} From 376be93cdca3b618b750536cd5b16b754d3a1561 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Murta?= Date: Thu, 19 Mar 2026 11:45:19 +0000 Subject: [PATCH 2/2] improve algorithm efficiency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Luís Murta --- include/nlohmann/json.hpp | 16 ++++++++++++---- tests/src/unit-merge_diff.cpp | 27 +++++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/include/nlohmann/json.hpp b/include/nlohmann/json.hpp index 1386cc7a96..1c1e35f7db 100644 --- a/include/nlohmann/json.hpp +++ b/include/nlohmann/json.hpp @@ -5272,14 +5272,22 @@ class basic_json // NOLINT(cppcoreguidelines-special-member-functions,hicpp-spec auto itf = target.find(it.key()); if (itf != target.end()) { - if (it.value() != itf.value()) + if (!it.value().is_null() && itf.value().is_null()) + { + JSON_THROW(other_error::create(503, detail::concat("cannot set \"", it.key(), "\" to null"), &source)); + } + + if (it.value().is_object()) { auto diff = merge_diff(it.value(), itf.value()); - if (diff.is_null()) + if (!diff.empty()) { - JSON_THROW(other_error::create(503, detail::concat("cannot set \"", itf.key(), "\" to null"), &target)); + result[it.key()] = std::move(diff); } - result[it.key()] = merge_diff(it.value(), itf.value()); + } + else if (it.value() != itf.value()) + { + result[it.key()] = itf.value(); } } else diff --git a/tests/src/unit-merge_diff.cpp b/tests/src/unit-merge_diff.cpp index 18a8455550..0898bad18f 100644 --- a/tests/src/unit-merge_diff.cpp +++ b/tests/src/unit-merge_diff.cpp @@ -399,6 +399,24 @@ TEST_CASE("JSON Merge Patch") original.merge_patch(json::merge_diff(original, result)); CHECK(original == result); } + + SECTION("object to primitive") + { + json original = R"({"a":{"b":"c"}})"_json; + json result = R"({"a":1})"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } + + SECTION("primitive to object") + { + json original = R"({"a":1})"_json; + json result = R"({"a":{"b":"c"}})"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } } SECTION("arrays") @@ -429,5 +447,14 @@ TEST_CASE("JSON Merge Patch") original.merge_patch(json::merge_diff(original, result)); CHECK(original == result); } + + SECTION("same keys") + { + json original = R"(["a","b"])"_json; + json result = R"({"a":1,"b":2})"_json; + + original.merge_patch(json::merge_diff(original, result)); + CHECK(original == result); + } } }