From 72c45349c93450b993d3fe4b3e829834847bad04 Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Fri, 16 Oct 2026 14:58:28 +0200 Subject: [PATCH] Apply plain JSON Patch operations without building a json_pointer patch() built a json_pointer for every operation, which splits the path into a vector of strings, and for "add" and "remove" a second one for the parent. That took 20% to 45% of the time not spent copying the document. "add", "remove", "replace" and "test" with a path that has no escapes and denotes existing values (the parent, for "add"), as diff() creates them, are now applied by resolving the path in place, reusing one string for the reference tokens. Every other operation, and every operation that would fail, takes the existing code, so the results, the exceptions and the state left after a failing operation are unchanged. patch() of the diffs of canada.json, citm_catalog.json and twitter.json is 1.02x to 1.10x as fast; the rest is the copy of the document. Signed-off-by: Niels Lohmann --- include/nlohmann/detail/json_pointer.hpp | 79 +++++++++ include/nlohmann/json.hpp | 131 +++++++++++++- single_include/nlohmann/json.hpp | 210 ++++++++++++++++++++++- 3 files changed, 418 insertions(+), 2 deletions(-) diff --git a/include/nlohmann/detail/json_pointer.hpp b/include/nlohmann/detail/json_pointer.hpp index dd08b4c40..3c832268e 100644 --- a/include/nlohmann/detail/json_pointer.hpp +++ b/include/nlohmann/detail/json_pointer.hpp @@ -711,6 +711,85 @@ class json_pointer } } + /*! + @brief resolve a plain JSON pointer without building its reference tokens + + @param[in] ptr the value the pointer starts at + @param[in] path a JSON pointer string + @param[in] length the number of leading bytes of @a path to resolve; 0 or + the position of a slash + @param[in,out] token a string to hold the reference tokens, to reuse its storage + + @return the pointed to value, or `nullptr` if the pointer does not denote + an existing value, or does so in a way that is not plain: a + reference token with an escape, an array index that is malformed or + out of range, or a "-". No exception is thrown for these; callers + use @ref json_pointer instead, which handles them. + */ + template + static BasicJsonType* resolve_plain(BasicJsonType* ptr, const string_t& path, const std::size_t length, string_t& token) + { + for (std::size_t pos = 0; pos < length;) + { + if (JSON_HEDLEY_UNLIKELY(path[pos] != '/')) + { + return nullptr; + } + + // the reference token between the slash at pos and the next one + std::size_t end = path.find_first_of('/', pos + 1); + if (end == string_t::npos || end > length) + { + end = length; + } + token.clear(); + token.append(path.data() + pos + 1, end - pos - 1); + if (JSON_HEDLEY_UNLIKELY(token.find_first_of('~') != string_t::npos)) + { + return nullptr; + } + + switch (ptr->type()) + { + case detail::value_t::object: + { + const auto it = ptr->m_data.m_value.object->find(token); + if (JSON_HEDLEY_UNLIKELY(it == ptr->m_data.m_value.object->end())) + { + return nullptr; + } + ptr = &it->second; + break; + } + + case detail::value_t::array: + { + typename BasicJsonType::size_type idx{}; + if (JSON_HEDLEY_UNLIKELY(parse_array_index(token, idx) != array_index_status::ok || idx >= ptr->m_data.m_value.array->size())) + { + return nullptr; + } + ptr = &(*ptr->m_data.m_value.array)[idx]; + break; + } + + case detail::value_t::null: + case detail::value_t::string: + case detail::value_t::boolean: + case detail::value_t::number_integer: + case detail::value_t::number_unsigned: + case detail::value_t::number_float: + case detail::value_t::binary: + case detail::value_t::discarded: + default: + return nullptr; + } + pos = end; + } + + return ptr; + } + /*! @brief return a reference to the pointed to value diff --git a/include/nlohmann/json.hpp b/include/nlohmann/json.hpp index 595fe11e5..901f787a2 100644 --- a/include/nlohmann/json.hpp +++ b/include/nlohmann/json.hpp @@ -7066,6 +7066,125 @@ public: detail::throw_patch_not_an_array(json_patch); } + // the usual patch operations "add", "remove", "replace" and "test" on + // a pointer without escapes that denotes existing array elements and + // object members (as "add" does for the parent of the new value), as + // a diff creates them: apply the operation and return true; return + // false, with nothing changed, for all others + string_t token; + const string_t value_key = "value"; + const auto try_plain = [&result, &token, &value_key](patch_operations operation, const string_t& path, const basic_json & val) + { + if (path.empty() || path.find_first_of('~') != string_t::npos) + { + return false; + } + + // a missing "value" is reported below + const auto value_it = val.m_data.m_value.object->find(value_key); + const basic_json& value = value_it != val.m_data.m_value.object->end() ? value_it->second : val; + if (operation != patch_operations::remove && value_it == val.m_data.m_value.object->end()) + { + return false; + } + + switch (operation) + { + case patch_operations::replace: + { + basic_json* const target = json_pointer::resolve_plain(&result, path, path.size(), token); + if (target == nullptr) + { + return false; + } + *target = value; + return true; + } + + case patch_operations::test: + { + const basic_json* const target = json_pointer::resolve_plain(&result, path, path.size(), token); + // a failing test is reported below + return target != nullptr && *target == value; + } + + case patch_operations::add: + case patch_operations::remove: + { + // the parent of the pointer and its last reference token + std::size_t slash = path.size(); + while (slash > 0 && path[slash - 1] != '/') + { + --slash; + } + if (slash == 0) + { + return false; + } + --slash; + + basic_json* const parent = json_pointer::resolve_plain(&result, path, slash, token); + if (parent == nullptr) + { + return false; + } + token.clear(); + token.append(path.data() + slash + 1, path.size() - slash - 1); + + if (operation == patch_operations::add) + { + if (parent->is_object()) + { + (*parent)[token] = value; + return true; + } + if (parent->is_array()) + { + typename basic_json::size_type idx{}; + if (token == "-") + { + parent->push_back(value); + return true; + } + if (json_pointer::template parse_array_index(token, idx) == json_pointer::array_index_status::ok && idx <= parent->size()) + { + parent->insert(parent->begin() + static_cast(idx), value); + return true; + } + } + return false; + } + + if (parent->is_object()) + { + const auto it = parent->find(token); + if (it == parent->end()) + { + return false; + } + parent->erase(it); + return true; + } + if (parent->is_array()) + { + typename basic_json::size_type idx{}; + if (json_pointer::template parse_array_index(token, idx) == json_pointer::array_index_status::ok && idx < parent->size()) + { + parent->erase(idx); + return true; + } + } + return false; + } + + case patch_operations::move: + case patch_operations::copy: + case patch_operations::invalid: + default: + return false; + } + }; + // iterate and apply the operations for (const auto& val : json_patch) { @@ -7102,9 +7221,19 @@ public: // collect mandatory members const string_t& op = *get_value("op", "op", true).m_data.m_value.string; const string_t& path = *get_value(op, "path", true).m_data.m_value.string; + const patch_operations operation = get_op(op); + + // the usual operations on a plain pointer are applied without + // building the pointer's reference tokens; anything else (and + // every error) is handled below + if (try_plain(operation, path, val)) + { + continue; + } + json_pointer ptr(path); - switch (get_op(op)) + switch (operation) { case patch_operations::add: { diff --git a/single_include/nlohmann/json.hpp b/single_include/nlohmann/json.hpp index 3eba58e16..73cb05b97 100644 --- a/single_include/nlohmann/json.hpp +++ b/single_include/nlohmann/json.hpp @@ -21683,6 +21683,85 @@ class json_pointer } } + /*! + @brief resolve a plain JSON pointer without building its reference tokens + + @param[in] ptr the value the pointer starts at + @param[in] path a JSON pointer string + @param[in] length the number of leading bytes of @a path to resolve; 0 or + the position of a slash + @param[in,out] token a string to hold the reference tokens, to reuse its storage + + @return the pointed to value, or `nullptr` if the pointer does not denote + an existing value, or does so in a way that is not plain: a + reference token with an escape, an array index that is malformed or + out of range, or a "-". No exception is thrown for these; callers + use @ref json_pointer instead, which handles them. + */ + template + static BasicJsonType* resolve_plain(BasicJsonType* ptr, const string_t& path, const std::size_t length, string_t& token) + { + for (std::size_t pos = 0; pos < length;) + { + if (JSON_HEDLEY_UNLIKELY(path[pos] != '/')) + { + return nullptr; + } + + // the reference token between the slash at pos and the next one + std::size_t end = path.find_first_of('/', pos + 1); + if (end == string_t::npos || end > length) + { + end = length; + } + token.clear(); + token.append(path.data() + pos + 1, end - pos - 1); + if (JSON_HEDLEY_UNLIKELY(token.find_first_of('~') != string_t::npos)) + { + return nullptr; + } + + switch (ptr->type()) + { + case detail::value_t::object: + { + const auto it = ptr->m_data.m_value.object->find(token); + if (JSON_HEDLEY_UNLIKELY(it == ptr->m_data.m_value.object->end())) + { + return nullptr; + } + ptr = &it->second; + break; + } + + case detail::value_t::array: + { + typename BasicJsonType::size_type idx{}; + if (JSON_HEDLEY_UNLIKELY(parse_array_index(token, idx) != array_index_status::ok || idx >= ptr->m_data.m_value.array->size())) + { + return nullptr; + } + ptr = &(*ptr->m_data.m_value.array)[idx]; + break; + } + + case detail::value_t::null: + case detail::value_t::string: + case detail::value_t::boolean: + case detail::value_t::number_integer: + case detail::value_t::number_unsigned: + case detail::value_t::number_float: + case detail::value_t::binary: + case detail::value_t::discarded: + default: + return nullptr; + } + pos = end; + } + + return ptr; + } + /*! @brief return a reference to the pointed to value @@ -37243,6 +37322,125 @@ public: detail::throw_patch_not_an_array(json_patch); } + // the usual patch operations "add", "remove", "replace" and "test" on + // a pointer without escapes that denotes existing array elements and + // object members (as "add" does for the parent of the new value), as + // a diff creates them: apply the operation and return true; return + // false, with nothing changed, for all others + string_t token; + const string_t value_key = "value"; + const auto try_plain = [&result, &token, &value_key](patch_operations operation, const string_t& path, const basic_json & val) + { + if (path.empty() || path.find_first_of('~') != string_t::npos) + { + return false; + } + + // a missing "value" is reported below + const auto value_it = val.m_data.m_value.object->find(value_key); + const basic_json& value = value_it != val.m_data.m_value.object->end() ? value_it->second : val; + if (operation != patch_operations::remove && value_it == val.m_data.m_value.object->end()) + { + return false; + } + + switch (operation) + { + case patch_operations::replace: + { + basic_json* const target = json_pointer::resolve_plain(&result, path, path.size(), token); + if (target == nullptr) + { + return false; + } + *target = value; + return true; + } + + case patch_operations::test: + { + const basic_json* const target = json_pointer::resolve_plain(&result, path, path.size(), token); + // a failing test is reported below + return target != nullptr && *target == value; + } + + case patch_operations::add: + case patch_operations::remove: + { + // the parent of the pointer and its last reference token + std::size_t slash = path.size(); + while (slash > 0 && path[slash - 1] != '/') + { + --slash; + } + if (slash == 0) + { + return false; + } + --slash; + + basic_json* const parent = json_pointer::resolve_plain(&result, path, slash, token); + if (parent == nullptr) + { + return false; + } + token.clear(); + token.append(path.data() + slash + 1, path.size() - slash - 1); + + if (operation == patch_operations::add) + { + if (parent->is_object()) + { + (*parent)[token] = value; + return true; + } + if (parent->is_array()) + { + typename basic_json::size_type idx{}; + if (token == "-") + { + parent->push_back(value); + return true; + } + if (json_pointer::template parse_array_index(token, idx) == json_pointer::array_index_status::ok && idx <= parent->size()) + { + parent->insert(parent->begin() + static_cast(idx), value); + return true; + } + } + return false; + } + + if (parent->is_object()) + { + const auto it = parent->find(token); + if (it == parent->end()) + { + return false; + } + parent->erase(it); + return true; + } + if (parent->is_array()) + { + typename basic_json::size_type idx{}; + if (json_pointer::template parse_array_index(token, idx) == json_pointer::array_index_status::ok && idx < parent->size()) + { + parent->erase(idx); + return true; + } + } + return false; + } + + case patch_operations::move: + case patch_operations::copy: + case patch_operations::invalid: + default: + return false; + } + }; + // iterate and apply the operations for (const auto& val : json_patch) { @@ -37279,9 +37477,19 @@ public: // collect mandatory members const string_t& op = *get_value("op", "op", true).m_data.m_value.string; const string_t& path = *get_value(op, "path", true).m_data.m_value.string; + const patch_operations operation = get_op(op); + + // the usual operations on a plain pointer are applied without + // building the pointer's reference tokens; anything else (and + // every error) is handled below + if (try_plain(operation, path, val)) + { + continue; + } + json_pointer ptr(path); - switch (get_op(op)) + switch (operation) { case patch_operations::add: {