Take diff()'s fast path unless the object type reorders members (#5691)

* 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>
This commit is contained in:
Niels Lohmann
2026-09-30 20:07:10 +02:00
committed by GitHub
parent 6218aa212b
commit 1151826508
3 changed files with 102 additions and 20 deletions

View File

@@ -1752,6 +1752,58 @@ TEST_CASE("JSON patch - diff emits array removals in descending index order")
}
}
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;