Merge deeply nested objects without recursing per nesting level (#5547)

* Merge deeply nested objects without recursing per nesting level

merge_patch() and update(j, true) merged a nested object by calling
themselves on it, once per nesting level. A value nested deeply enough -
50,000 levels of objects on an 8 MiB stack - exhausted the call stack
and terminated the process, although parse() accepts such values without
complaint.

Bound the descent the same way dump() does. The recursion now carries
the nesting level, and once merge_depth_limit() (128) levels have been
entered, update_members_iteratively() and merge_patch_iteratively()
finish the merge on an explicit stack. They still merge a nested object
completely before the next member, and in the same order, so the results,
including the parents JSON_DIAGNOSTICS reports paths from, are unchanged.
Values nested less deeply than the bound run the same code as before, so
the common case does not pay for the stack: merging only on it cost
10-14% in a first version.

The public signatures are unchanged. The recursive worker behind
merge_patch() has its own name rather than being a private overload, so
that &basic_json::merge_patch stays unambiguous.

Tests check every depth up to 300 against recursive reference
implementations of both operations, check the diagnostic paths past the
bound, and merge objects nested 100,000 levels deep.

Fixes #5545 for update(j, true), and #5393 for merge_patch().

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

* Use the shared recursion limit in update() and merge_patch()

merge_depth_limit() is gone in favor of detail::recursion_depth_limit().
The two identical function-local frame structs become one member struct,
merge_frame, with a constructor, so both loops emplace_back() their
frames. merge_patch_iteratively() copies the frame it works on out of the
stack and changes it only through stack.back().

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

* Build the update()/merge_patch() diagnostics test values instead of parsing them

Parsed values carry byte positions under JSON_DIAGNOSTIC_POSITIONS, which
the expected messages do not include.

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-24 17:12:03 +02:00
committed by GitHub
parent 7c90ec2323
commit 4daca40d7b
5 changed files with 590 additions and 6 deletions

View File

@@ -363,3 +363,52 @@ TEST_CASE("Regression tests for extended diagnostics")
}
}
TEST_CASE("Better diagnostics past the descent bound of update() and merge_patch()")
{
// Both merge 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
// carry no byte positions under JSON_DIAGNOSTIC_POSITIONS.
const std::size_t depth = 200;
json target = {{"x", 1}};
json patch = {{"y", 2}};
std::string path;
for (std::size_t i = 0; i < depth; ++i)
{
target = json{{"a", std::move(target)}};
patch = json{{"a", std::move(patch)}};
path += "/a";
}
const std::string expected_x = "[json.exception.type_error.304] (" + path + "/x) cannot use at() with number";
const std::string expected_y = "[json.exception.type_error.304] (" + path + "/y) cannot use at() with number";
SECTION("update()")
{
json j = target;
j.update(patch, true);
// walk down through const references, which leave m_parent alone
const json* p = &j;
for (std::size_t i = 0; i < depth; ++i)
{
p = &p->at("a");
}
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_patch()")
{
json j = target;
j.merge_patch(patch);
const json* p = &j;
for (std::size_t i = 0; i < depth; ++i)
{
p = &p->at("a");
}
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);
}
}