From 13d2b3e224df07f1019d92d523b13f17e310031f Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Fri, 9 Oct 2026 23:20:51 +0200 Subject: [PATCH 1/7] Return the first member of duplicate keys from lookups again Looking up the last member of a duplicate key cannot stop at a match, so every lookup scanned the whole object (1.6 to 3.4 times slower for small objects). operator[](key), at, find, value, contains, count, and JSON pointer resolution return the first member again, as yyjson and simdjson do; materialize() and get() keep the last value, as parse(). The documentation says so in the feature page and on each lookup page, and explains how to get the value parse() would give. The integer index templates and the discarded chaining of operator[] stay. Signed-off-by: Niels Lohmann --- docs/mkdocs/docs/api/basic_json_view/at.md | 13 ++++- docs/mkdocs/docs/api/basic_json_view/begin.md | 2 +- .../docs/api/basic_json_view/contains.md | 9 ++- docs/mkdocs/docs/api/basic_json_view/count.md | 11 +++- docs/mkdocs/docs/api/basic_json_view/find.md | 11 +++- docs/mkdocs/docs/api/basic_json_view/get.md | 6 +- docs/mkdocs/docs/api/basic_json_view/items.md | 6 +- .../docs/api/basic_json_view/operator[].md | 21 ++++--- docs/mkdocs/docs/api/basic_json_view/value.md | 13 ++++- .../docs/examples/basic_json_view__items.cpp | 6 +- .../examples/basic_json_view__items.output | 2 +- docs/mkdocs/docs/features/json_view.md | 57 +++++++++++++++---- include/nlohmann/detail/view/lookup.hpp | 16 +++--- include/nlohmann/json_view.hpp | 10 ++-- tests/src/unit-json_view.cpp | 50 ++++++++-------- 15 files changed, 153 insertions(+), 80 deletions(-) diff --git a/docs/mkdocs/docs/api/basic_json_view/at.md b/docs/mkdocs/docs/api/basic_json_view/at.md index b5deab72c..a0a57c9bc 100644 --- a/docs/mkdocs/docs/api/basic_json_view/at.md +++ b/docs/mkdocs/docs/api/basic_json_view/at.md @@ -15,7 +15,7 @@ basic_json_view at(IntegerType idx) const; basic_json_view at(const json_pointer& ptr) const; ``` -1. Returns the value of the object member with key `key` -- the last one, should the key occur more than once (see +1. Returns the value of the object member with key `key` -- the first one, should the key occur more than once (see [Notes on duplicate keys](operator[].md#notes)). 2. Returns the array element at index `idx`. The template accepts every integer type except `#!cpp bool` and `#!cpp std::size_t` and forwards to the `size_type` overload, as for [`operator[]`](operator[].md); a negative @@ -35,7 +35,7 @@ basic_json_view at(const json_pointer& ptr) const; ## Return value -1. the value of the last member with key `key` +1. the value of the first member with key `key` 2. the element at index `idx` 3. the value `ptr` resolves to, starting at this value @@ -75,7 +75,7 @@ None of these exceptions carry a [`JSON_DIAGNOSTICS`](../macros/json_diagnostics ## Complexity 1. Linear in the number of members: as for [`ordered_json`](../ordered_json.md), members are compared one after - another, in document order, scanning all of them, since the last match is wanted. Each comparison first checks the + another, in document order, stopping at the first match. Each comparison first checks the key's length -- already known from the index, without reading the key bytes -- before comparing its content. 2. Linear in `idx`: elements are skipped one at a time from the first one, since they are not a fixed size in the index (unlike `BasicJsonType`'s array, which is random-access). @@ -84,6 +84,13 @@ None of these exceptions carry a [`JSON_DIAGNOSTICS`](../macros/json_diagnostics ## Notes +!!! warning "Duplicate keys: the first member wins" + + If the source text repeats a key, this resolves to the *first* member with it, not to the last one that + [`materialize()`](materialize.md) and `parse()` keep. See the [Notes on duplicate keys](operator[].md#notes) of + `operator[]` and [Duplicate keys](../../features/json_view.md#duplicate-keys) for the reasons and for how to get + the last value. + Unlike [`operator[]`](operator[].md), which returns a [discarded](is_discarded.md) view for a missing key or an out-of-range index, `at` always throws -- exactly as `BasicJsonType::at` does, and with the same messages, so existing error handling written against `BasicJsonType::at` keeps working unchanged when switched to a view. This diff --git a/docs/mkdocs/docs/api/basic_json_view/begin.md b/docs/mkdocs/docs/api/basic_json_view/begin.md index f9c43802f..ae2665ee9 100644 --- a/docs/mkdocs/docs/api/basic_json_view/begin.md +++ b/docs/mkdocs/docs/api/basic_json_view/begin.md @@ -24,7 +24,7 @@ Constant. For an object, iteration visits **every** member, including all occurrences of a duplicate key -- unlike [`operator[]`](operator[].md), [`at`](at.md), [`find`](find.md), [`contains`](contains.md), and [`count`](count.md), -which all resolve to the *last* member with a given key. See the +which all resolve to the *first* member with a given key. See the [Notes on duplicate keys](operator[].md#notes) of `operator[]`. Because objects are iterated in document order rather than sorted by key, the order seen here can differ from what diff --git a/docs/mkdocs/docs/api/basic_json_view/contains.md b/docs/mkdocs/docs/api/basic_json_view/contains.md index 7c1e706cd..5094fe25c 100644 --- a/docs/mkdocs/docs/api/basic_json_view/contains.md +++ b/docs/mkdocs/docs/api/basic_json_view/contains.md @@ -33,7 +33,7 @@ No-throw guarantee: this function never throws exceptions. ## Complexity 1. Linear in the number of members: as for [`ordered_json`](../ordered_json.md), members are compared one after - another, in document order, scanning all of them, since the last match is wanted. Each comparison first checks the + another, in document order, stopping at the first match. Each comparison first checks the key's length -- already known from the index, without reading the key bytes -- before comparing its content. 2. Linear in the number of reference tokens of `ptr` and, for each token, in the number of members of the object at that level or the index into the array -- as for [`operator[]`](operator[].md#complexity) and @@ -41,6 +41,13 @@ No-throw guarantee: this function never throws exceptions. ## Notes +!!! warning "Duplicate keys: the first member wins" + + If the source text repeats a key, this resolves to the *first* member with it, not to the last one that + [`materialize()`](materialize.md) and `parse()` keep. See the [Notes on duplicate keys](operator[].md#notes) of + `operator[]` and [Duplicate keys](../../features/json_view.md#duplicate-keys) for the reasons and for how to get + the last value. + Overload 1 always returns `#!cpp false` when the value is not an object -- including a [discarded](is_discarded.md) view. diff --git a/docs/mkdocs/docs/api/basic_json_view/count.md b/docs/mkdocs/docs/api/basic_json_view/count.md index 7e0091d90..30d90b8e1 100644 --- a/docs/mkdocs/docs/api/basic_json_view/count.md +++ b/docs/mkdocs/docs/api/basic_json_view/count.md @@ -24,11 +24,18 @@ No-throw guarantee: this function never throws exceptions. ## Complexity Linear in the number of members: as for [`ordered_json`](../ordered_json.md), members are compared one after another, -in document order, scanning all of them, since the last match is wanted. Each comparison first checks the key's length +in document order, stopping at the first match. Each comparison first checks the key's length -- already known from the index, without reading the key bytes -- before comparing its content. ## Notes +!!! warning "Duplicate keys: the first member wins" + + If the source text repeats a key, this resolves to the *first* member with it, not to the last one that + [`materialize()`](materialize.md) and `parse()` keep. See the [Notes on duplicate keys](operator[].md#notes) of + `operator[]` and [Duplicate keys](../../features/json_view.md#duplicate-keys) for the reasons and for how to get + the last value. + This method always returns `#!cpp 0` when the value is not an object -- including a [discarded](is_discarded.md) view. @@ -36,7 +43,7 @@ Unlike [`BasicJsonType::count()`](../basic_json/count.md), whose return value ca an `ObjectType` that allows multiple entries per key, `count()` here never does: it is exactly [`contains()`](contains.md) as `#!cpp 0`/`#!cpp 1`. This holds even if the source text has a duplicate key -- see the [Notes on duplicate keys](operator[].md#notes) of `operator[]` -- because a `#!cpp count() > 1` result would require -counting every member with a matching key (the lookup functions resolve to the *last* one). +counting every member with a matching key (the lookup functions resolve to the *first* one). ## Examples diff --git a/docs/mkdocs/docs/api/basic_json_view/find.md b/docs/mkdocs/docs/api/basic_json_view/find.md index d2323353a..bb0cab041 100644 --- a/docs/mkdocs/docs/api/basic_json_view/find.md +++ b/docs/mkdocs/docs/api/basic_json_view/find.md @@ -6,7 +6,7 @@ iterator find(const char* key) const; iterator find(const string_t& key) const; ``` -Finds a member with key `key` -- the last one, should the key occur more than once (see +Finds a member with key `key` -- the first one, should the key occur more than once (see [Notes on duplicate keys](operator[].md#notes)). If the value is not an object, or no member has this key, [`end()`](end.md) is returned. @@ -26,11 +26,18 @@ No-throw guarantee: this function never throws exceptions. ## Complexity Linear in the number of members: as for [`ordered_json`](../ordered_json.md), members are compared one after another, -in document order, scanning all of them, since the last match is wanted. Each comparison first checks the key's length +in document order, stopping at the first match. Each comparison first checks the key's length -- already known from the index, without reading the key bytes -- before comparing its content. ## Notes +!!! warning "Duplicate keys: the first member wins" + + If the source text repeats a key, this resolves to the *first* member with it, not to the last one that + [`materialize()`](materialize.md) and `parse()` keep. See the [Notes on duplicate keys](operator[].md#notes) of + `operator[]` and [Duplicate keys](../../features/json_view.md#duplicate-keys) for the reasons and for how to get + the last value. + Unlike [`BasicJsonType::find`](../basic_json/find.md), which always returns `#!cpp end()` for a non-object type, this also does so for a [discarded](is_discarded.md) view -- there is no separate "invalid" iterator to return. diff --git a/docs/mkdocs/docs/api/basic_json_view/get.md b/docs/mkdocs/docs/api/basic_json_view/get.md index c3bb85790..d8ec333c9 100644 --- a/docs/mkdocs/docs/api/basic_json_view/get.md +++ b/docs/mkdocs/docs/api/basic_json_view/get.md @@ -87,9 +87,9 @@ exception thrown while converting through `materialize()` (the last bullet) is d !!! info "Duplicate keys" `#!cpp std::map`/`#!cpp std::unordered_map` conversions keep the *last* value of a repeated key, like - [`materialize()`](materialize.md) and [`BasicJsonType::parse()`](../basic_json/parse.md) do. This is the member - [`operator[]`](operator[].md)/[`at`](at.md)/[`find`](find.md)/[`contains`](contains.md) resolve to, too (see the - [Notes on duplicate keys](operator[].md#notes)). + [`materialize()`](materialize.md) and [`BasicJsonType::parse()`](../basic_json/parse.md) do. This is the opposite + of [`operator[]`](operator[].md)/[`at`](at.md)/[`find`](find.md)/[`contains`](contains.md), which resolve to the + *first* occurrence (see the [Notes on duplicate keys](operator[].md#notes)). !!! info "No pointers, references, or implicit conversion" diff --git a/docs/mkdocs/docs/api/basic_json_view/items.md b/docs/mkdocs/docs/api/basic_json_view/items.md index 8e13852da..d54f8d723 100644 --- a/docs/mkdocs/docs/api/basic_json_view/items.md +++ b/docs/mkdocs/docs/api/basic_json_view/items.md @@ -48,7 +48,7 @@ Constant. As for [`begin()`](begin.md)/[`end()`](end.md), `items()` visits **every** member of an object, including all occurrences of a duplicate key -- unlike [`operator[]`](operator[].md), [`at`](at.md), [`find`](find.md), -[`contains`](contains.md), and [`count`](count.md), which resolve to the *last* member with a given key. See the +[`contains`](contains.md), and [`count`](count.md), which resolve to the *first* member with a given key. See the [Notes on duplicate keys](operator[].md#notes) of `operator[]`. !!! danger "Lifetime issues" @@ -63,8 +63,8 @@ occurrences of a duplicate key -- unlike [`operator[]`](operator[].md), [`at`](a The example below shows a settings object whose source text records every update to a key as a duplicate member, in the order they happened. `items()` walks all of them, so the update history is visible, while - [`operator[]`](operator[].md) sees the *last* one, and so does [`materialize()`](materialize.md) -- like - [`BasicJsonType::parse()`](../basic_json/parse.md). + [`operator[]`](operator[].md) only ever sees the *first* one and [`materialize()`](materialize.md) -- like + [`BasicJsonType::parse()`](../basic_json/parse.md) -- keeps only the *last*. ```cpp --8<-- "examples/basic_json_view__items.cpp" diff --git a/docs/mkdocs/docs/api/basic_json_view/operator[].md b/docs/mkdocs/docs/api/basic_json_view/operator[].md index 53d95bed0..00ab1e9ac 100644 --- a/docs/mkdocs/docs/api/basic_json_view/operator[].md +++ b/docs/mkdocs/docs/api/basic_json_view/operator[].md @@ -15,7 +15,7 @@ basic_json_view operator[](IntegerType idx) const; basic_json_view operator[](const json_pointer& ptr) const; ``` -1. Returns the value of the object member with key `key` -- the last one, should the key occur more than once (see +1. Returns the value of the object member with key `key` -- the first one, should the key occur more than once (see the [Notes](#notes) below) -- or a [discarded](is_discarded.md) view if there is no such member. 2. Returns the array element at index `idx`, or a [discarded](is_discarded.md) view if `idx` is out of range. The template accepts every integer type except `#!cpp bool` and `#!cpp std::size_t` (`#!cpp int`, `#!cpp unsigned`, @@ -38,7 +38,7 @@ basic_json_view operator[](const json_pointer& ptr) const; ## Return value -1. the value of the last member with key `key`, or a discarded view if no member has this key (or if this view is +1. the value of the first member with key `key`, or a discarded view if no member has this key (or if this view is [discarded](is_discarded.md)) 2. the element at index `idx`, or a discarded view if `#!cpp idx >= size()` or `idx` is negative (or if this view is [discarded](is_discarded.md)) @@ -80,7 +80,7 @@ None of these exceptions carry a [`JSON_DIAGNOSTICS`](../macros/json_diagnostics ## Complexity 1. Linear in the number of members: as for [`ordered_json`](../ordered_json.md), members are compared one after - another, in document order, scanning all of them, since the last match is wanted. Each comparison first checks the + another, in document order, stopping at the first match. Each comparison first checks the key's length -- already known from the index, without reading the key bytes -- before comparing its content, so a key of a different length than `key` is rejected without touching the source text. 2. Linear in `idx`: elements are skipped one at a time from the first one, since they are not a fixed size in the @@ -105,15 +105,18 @@ document. key on an array or a primitive, or an index on an object or a primitive, is `type_error.305` as for `BasicJsonType`. [`at`](at.md) still throws for a discarded view, as it does for a missing key. -!!! info "Duplicate keys" +!!! warning "Duplicate keys: the first member wins" If the source text has an object with a duplicate key, `#!cpp operator[]` (and [`at`](at.md), [`find`](find.md), [`contains`](contains.md), [`count`](count.md), [`value`](value.md), and JSON pointer resolution) all resolve to - the *last* member with that key. This is the member [`materialize()`](materialize.md) (and - [`BasicJsonType::parse()`](../basic_json/parse.md)) keeps, so a lookup in the view and in the materialized value - agree. [`begin()`](begin.md)/[`end()`](end.md) and [`items()`](items.md) iterate over *all* members, including - duplicates, in document order. A lookup scans all members for this: it cannot stop at the first match. See the - example below and [`size()`](size.md#notes). + the *first* member with that key, because a lookup can stop as soon as it finds a match. This is different from + [`materialize()`](materialize.md) (and [`BasicJsonType::parse()`](../basic_json/parse.md)), which replay every + member in order and so keep the *last* value for a repeated key, so `#!cpp v["a"]` and + `#!cpp v.materialize()["a"]` can differ. To get the value `parse()` would give, use + [`materialize()`](materialize.md) or iterate the members with [`items()`](items.md) and keep the last match. + [`begin()`](begin.md)/[`end()`](end.md) and [`items()`](items.md) iterate over *all* members, including + duplicates, in document order. See [Duplicate keys](../../features/json_view.md#duplicate-keys) and + [`size()`](size.md#notes). !!! info "JSON pointer resolution" diff --git a/docs/mkdocs/docs/api/basic_json_view/value.md b/docs/mkdocs/docs/api/basic_json_view/value.md index c13c0cd63..2a8b9d828 100644 --- a/docs/mkdocs/docs/api/basic_json_view/value.md +++ b/docs/mkdocs/docs/api/basic_json_view/value.md @@ -12,7 +12,7 @@ T value(const json_pointer& ptr, const T& default_value) const; string_t value(const json_pointer& ptr, const char* default_value) const; ``` -1. Returns the value of the object member with key `key` -- the last one, should the key occur more than once (see +1. Returns the value of the object member with key `key` -- the first one, should the key occur more than once (see [Notes on duplicate keys](operator[].md#notes)) -- converted to `T`, or `default_value` if there is no such member. 2. Returns the value a JSON pointer `ptr` refers to, starting at this value, converted to `T`, or `default_value` if `ptr` cannot be resolved. @@ -39,7 +39,7 @@ equivalent) deduce `string_t`, not `const char*`, for their return type and for ## Return value -1. the last member with key `key`, converted to `T`, or `default_value` +1. the first member with key `key`, converted to `T`, or `default_value` 2. the value `ptr` resolves to, converted to `T`, or `default_value` ## Exception safety @@ -68,7 +68,7 @@ None of these exceptions carry a [`JSON_DIAGNOSTICS`](../macros/json_diagnostics ## Complexity 1. Linear in the number of members: as for [`operator[]`](operator[].md#complexity), members are compared one after - another, in document order, scanning all of them, since the last match is wanted. Plus the complexity of converting + another, in document order, stopping at the first match. Plus the complexity of converting the found member to `T` (see [`get`](get.md)). 2. Linear in the number of reference tokens of `ptr` and, for each token, in the number of members of the object at that level or the index into the array -- as for the [`operator[]`](operator[].md#complexity) and @@ -77,6 +77,13 @@ None of these exceptions carry a [`JSON_DIAGNOSTICS`](../macros/json_diagnostics ## Notes +!!! warning "Duplicate keys: the first member wins" + + If the source text repeats a key, this resolves to the *first* member with it, not to the last one that + [`materialize()`](materialize.md) and `parse()` keep. See the [Notes on duplicate keys](operator[].md#notes) of + `operator[]` and [Duplicate keys](../../features/json_view.md#duplicate-keys) for the reasons and for how to get + the last value. + !!! info "Differences to `at` and `operator[]`" Unlike [`at`](at.md), this function does not throw if `key`/`ptr` resolves to no value. Unlike diff --git a/docs/mkdocs/docs/examples/basic_json_view__items.cpp b/docs/mkdocs/docs/examples/basic_json_view__items.cpp index 6357c7e4b..3f7779c19 100644 --- a/docs/mkdocs/docs/examples/basic_json_view__items.cpp +++ b/docs/mkdocs/docs/examples/basic_json_view__items.cpp @@ -7,8 +7,8 @@ int main() { // a settings object whose source text records every update to a key as // a duplicate member. items() visits all of them, in document order, so - // the update history is visible; operator[] and materialize() -- like - // basic_json::parse() -- see the last one + // the update history is visible; operator[] only ever sees the first + // one, and materialize() -- like basic_json::parse() -- keeps the last json_document updates = json_document::parse(R"({"retries": 1, "timeout": 30, "retries": 5})"); const auto settings = updates.root(); @@ -17,6 +17,6 @@ int main() std::cout << item.key() << '=' << item.value().materialize().dump() << '\n'; } - std::cout << "last \"retries\" seen by operator[]: " << settings["retries"].materialize().dump() << '\n'; + std::cout << "first \"retries\" seen by operator[]: " << settings["retries"].materialize().dump() << '\n'; std::cout << "last \"retries\" kept by materialize(): " << settings.materialize()["retries"].dump() << '\n'; } diff --git a/docs/mkdocs/docs/examples/basic_json_view__items.output b/docs/mkdocs/docs/examples/basic_json_view__items.output index 8f51dbf18..78609c8ee 100644 --- a/docs/mkdocs/docs/examples/basic_json_view__items.output +++ b/docs/mkdocs/docs/examples/basic_json_view__items.output @@ -1,5 +1,5 @@ retries=1 timeout=30 retries=5 -last "retries" seen by operator[]: 5 +first "retries" seen by operator[]: 1 last "retries" kept by materialize(): 5 diff --git a/docs/mkdocs/docs/features/json_view.md b/docs/mkdocs/docs/features/json_view.md index 67b56cbe1..c42f2d642 100644 --- a/docs/mkdocs/docs/features/json_view.md +++ b/docs/mkdocs/docs/features/json_view.md @@ -134,16 +134,8 @@ document: `#!cpp auto v = json_document::parse(text).root();` does not compile. `operator[]` on a discarded view returns a discarded view without throwing: `#!cpp v["a"]["b"][0]` can be tested once at the end. Type errors on values that exist (a key on an array, an index on an object) still throw, and [`at`](../api/basic_json_view/at.md) throws for every missing value. -- **Duplicate keys are visible.** If an object in the source text repeats a key, - [`begin()`](../api/basic_json_view/begin.md)/[`end()`](../api/basic_json_view/end.md) and - [`items()`](../api/basic_json_view/items.md) visit *every* occurrence (and [`size()`](../api/basic_json_view/size.md) - counts all of them), while [`operator[]`](../api/basic_json_view/operator%5B%5D.md), - [`at`](../api/basic_json_view/at.md), [`find`](../api/basic_json_view/find.md), - [`contains`](../api/basic_json_view/contains.md), and [`count`](../api/basic_json_view/count.md) resolve to the - *last* occurrence -- the one `basic_json::parse()` (and so - [`materialize()`](../api/basic_json_view/materialize.md)) keeps for a repeated key -- which makes a lookup scan all - members instead of stopping at a match. - See the [Notes on duplicate keys](../api/basic_json_view/operator%5B%5D.md#notes) of `operator[]`. +- **Duplicate keys: lookups find the first member, `parse()` keeps the last.** See [Duplicate keys](#duplicate-keys) + below. - **No [`JSON_DIAGNOSTICS`](../api/macros/json_diagnostics.md) path.** Exceptions thrown by `basic_json_view`'s own element access and lookup functions never carry the JSON Pointer path `JSON_DIAGNOSTICS` would otherwise add: the view has no `basic_json` value to point at, so the exception is created without one, regardless of how @@ -151,6 +143,51 @@ document: `#!cpp auto v = json_document::parse(text).root();` does not compile. - **`dump()` and comparison are not (yet) provided** by `basic_json_view`. For now, [`materialize()`](../api/basic_json_view/materialize.md) is the way to get a value you can do those things with. +## Duplicate keys + +!!! warning "A lookup in a view and in the parsed value can give different answers" + + If an object in the source text repeats a key, [`operator[]`](../api/basic_json_view/operator%5B%5D.md), + [`at`](../api/basic_json_view/at.md), [`find`](../api/basic_json_view/find.md), + [`value`](../api/basic_json_view/value.md), [`contains`](../api/basic_json_view/contains.md), + [`count`](../api/basic_json_view/count.md), and JSON pointer resolution all return the **first** member with that + key. `basic_json::parse()` instead keeps the **last** value of a repeated key, so for `#!cpp {"a":1,"a":2}`, + `#!cpp view["a"]` is `#!cpp 1` while `#!cpp parse(text)["a"]` is `#!cpp 2`. + +[`begin()`](../api/basic_json_view/begin.md)/[`end()`](../api/basic_json_view/end.md) and +[`items()`](../api/basic_json_view/items.md) visit *every* occurrence, in document order, and +[`size()`](../api/basic_json_view/size.md) counts all of them, so `#!cpp view.size()` can be larger than +`#!cpp view.materialize().size()`. + +**Why the first?** A lookup can stop as soon as it finds a match. Returning the last member would force every lookup +to scan all members of the object, even when the key is found at the very first one: this made lookups in small +objects 1.6 to 3.4 times slower. Other zero-copy parsers that index the source text, such as yyjson and simdjson, also +return the first member. RFC 8259 only says that names within an object SHOULD be unique and that the behavior of a +receiver that sees duplicates is unpredictable, so neither choice is wrong. + +**What stays the same as `parse()`?** [`materialize()`](../api/basic_json_view/materialize.md) and +[`get>()`](../api/basic_json_view/get.md) replay every member in order, so they keep the *last* value +exactly like `#!cpp basic_json::parse()` (at the position of the first occurrence of the key, for an +[`ordered_json`](../api/ordered_json.md)). + +**How do I get the value `parse()` would give?** Either call `#!cpp view.materialize()` and look the key up in the +result, or iterate the members and keep the last match: + +```cpp +// the last member with the key "a", as parse() would keep it +json_view last; +for (const auto item : view.items()) +{ + if (item.key() == "a") + { + last = item.value(); + } +} +``` + +If you cannot trust the source text to have unique keys, check [`size()`](../api/basic_json_view/size.md) against the +number of distinct keys, or reject duplicates when iterating. + ## Getting values out without copying [`get()`](../api/basic_json_view/get.md) converts many `T` directly from the flat index, without ever building a diff --git a/include/nlohmann/detail/view/lookup.hpp b/include/nlohmann/detail/view/lookup.hpp index a58c9a33a..8ec1c155b 100644 --- a/include/nlohmann/detail/view/lookup.hpp +++ b/include/nlohmann/detail/view/lookup.hpp @@ -83,14 +83,14 @@ class short_key std::uint64_t m_b = 0; }; -/// the key node of the last member of an object with the given key, or -/// nullptr (the last one, as materialize() and parse() keep it); most keys are -/// rejected by their length, from the index alone +/// the key node of the first member of an object with the given key, or +/// nullptr (the search stops at the first match; materialize() and parse() +/// keep the last value of a duplicate key instead); most keys are rejected by +/// their length, from the index alone inline const node* find_member(const document_data& d, const node* object, const char* key, std::size_t n) noexcept { const node* const end = document_data::child_end(object); const auto* const k = reinterpret_cast(key); // NOLINT(cppcoreguidelines-pro-type-reinterpret-cast) - const node* last = nullptr; if (NLOHMANN_VIEW_LIKELY(n <= 16)) { const short_key probe(k, n); @@ -98,19 +98,19 @@ inline const node* find_member(const document_data& d, const node* object, const { if (m->len == n && probe.matches(reinterpret_cast(d.str(*m)))) // NOLINT(cppcoreguidelines-pro-type-reinterpret-cast) { - last = m; + return m; } } - return last; + return nullptr; } for (const node* m = document_data::first_child(object); m != end; m = document_data::after(m + 1)) { if (m->len == n && std::memcmp(d.str(*m), key, n) == 0) { - last = m; + return m; } } - return last; + return nullptr; } /// whether an integer type is accepted as an array index by the view's diff --git a/include/nlohmann/json_view.hpp b/include/nlohmann/json_view.hpp index d71d54488..256466fa0 100644 --- a/include/nlohmann/json_view.hpp +++ b/include/nlohmann/json_view.hpp @@ -228,7 +228,7 @@ class basic_json_view // element access // //////////////////// - /// the value of the member with this key (the last one, should the key + /// the value of the member with this key (the first one, should the key /// occur more than once); a discarded view if there is none, or if this /// is a discarded view (so that v["a"]["b"] is safe). Throws type_error.305 /// if this is any other value but an object. @@ -292,7 +292,7 @@ class basic_json_view return detail::view::resolve_pointer(*this, detail::json_pointer_access::reference_tokens(ptr), detail::view::pointer_mode::unchecked); } - /// the value of the member with this key (the last one, should the key + /// the value of the member with this key (the first one, should the key /// occur more than once). Throws type_error.304 if this is not an object, /// and out_of_range.403 if there is no such member. basic_json_view at(string_view_t key) const @@ -350,7 +350,7 @@ class basic_json_view } /// the member with this key converted to T, or the default value if there - /// is no such member (the last one, should the key occur more than + /// is no such member (the first one, should the key occur more than /// once). Throws type_error.306 if this is not an object. template < typename T, typename std::enable_if < !std::is_same::type, const char*>::value, int >::type = 0 > T value(string_view_t key, const T& default_value) const @@ -415,7 +415,7 @@ class basic_json_view // lookup // //////////// - /// an iterator to the member with this key (the last one, should the + /// an iterator to the member with this key (the first one, should the /// key occur more than once), or end(); end() also for non-objects iterator find(string_view_t key) const { @@ -593,7 +593,7 @@ class basic_json_view : m_doc(d), m_node(n) {} - /// the value of the last member with this key, or a discarded view + /// the value of the first member with this key, or a discarded view /// (object required) NLOHMANN_VIEW_ALWAYS_INLINE basic_json_view lookup(string_view_t key) const noexcept { diff --git a/tests/src/unit-json_view.cpp b/tests/src/unit-json_view.cpp index bc43a97bd..17d0fcb3b 100644 --- a/tests/src/unit-json_view.cpp +++ b/tests/src/unit-json_view.cpp @@ -576,7 +576,7 @@ std::string exception_of(F f) // compares a view with the ordered_json value materialize() gives for it: // types, sizes, elements and members (by index, key, and iteration), in -// document order; duplicate keys are found as their last occurrence +// document order; duplicate keys are found as their first occurrence void check_access(const ordered_json_view& v, const ordered_json& j) { REQUIRE(v.type() == j.type()); @@ -617,23 +617,13 @@ void check_access(const ordered_json_view& v, const ordered_json& j) const std::string key(it.key().data(), it.key().size()); CHECK(v.contains(key)); CHECK(v.count(key) == 1); - if (std::find(keys.begin(), keys.end(), key) == keys.end()) - { - keys.push_back(key); - } - // lookups find the last member with the key, which is this one if - // there is no later one - auto next = it; - ++next; - bool is_last = true; - for (; next != v.end(); ++next) - { - is_last = is_last && next.key() != it.key(); - } - if (!is_last) + // lookups find the first member with the key, which is this one if + // there is no earlier one + if (std::find(keys.begin(), keys.end(), key) != keys.end()) { continue; } + keys.push_back(key); CHECK(v.find(key) == it); CHECK(v[key].materialize() == it->materialize()); CHECK(v.at(key).materialize() == it.value().materialize()); @@ -724,20 +714,28 @@ TEST_CASE("json_view element access and iteration") #endif } - SECTION("duplicate keys: lookups find the last member, iteration all") + SECTION("duplicate keys: lookups find the first member, iteration all") { const json_document d = json_document::parse(R"({"a":1,"b":2,"a":3})"); const json_view v = d.root(); CHECK(v.size() == 3); - CHECK(v["a"].materialize() == 3); - CHECK(v.at("a").materialize() == 3); - CHECK(v.find("a") == std::next(v.begin(), 2)); - CHECK(v.find("a").value().materialize() == 3); + CHECK(v["a"].materialize() == 1); + CHECK(v.at("a").materialize() == 1); + CHECK(v.find("a") == v.begin()); + CHECK(v.find("a").value().materialize() == 1); CHECK(v.find("b") == std::next(v.begin())); CHECK(v.count("a") == 1); CHECK(v.contains("a")); - CHECK(v.value("a", 0) == 3); - CHECK(v["a"].materialize() == v.materialize()["a"]); // as materialize() + CHECK(v.value("a", 0) == 1); + CHECK(v.materialize()["a"] == 3); // materialize() keeps the last value, unlike the lookups + // JSON pointers resolve to the first member at every level + const json_document dp = json_document::parse(R"({"a":{"b":1},"a":{"b":2,"b":3}})"); + CHECK(dp.root()[json::json_pointer("/a/b")].materialize() == 1); + CHECK(dp.root().at(json::json_pointer("/a/b")).materialize() == 1); + CHECK(dp.root().value(json::json_pointer("/a/b"), 0) == 1); + CHECK(dp.root().materialize()[json::json_pointer("/a/b")] == 3); + // get keeps the last value, as materialize() + CHECK((v.get>() == std::map {{"a", 3}, {"b", 2}})); // keys of every length class (the 16-byte short compare and memcmp) for (const std::size_t n : { @@ -747,10 +745,10 @@ TEST_CASE("json_view element access and iteration") const std::string key(n, 'k'); const json_document dk = json_document::parse("{\"" + key + "\":1,\"" + key + "x\":2,\"" + key + "\":3,\"" + key + "\":4}"); CAPTURE(n) - CHECK(dk.root()[key].materialize() == 4); - CHECK(dk.root().at(key).materialize() == 4); - CHECK(dk.root().find(key) == std::next(dk.root().begin(), 3)); - CHECK(dk.root().value(key, 0) == 4); + CHECK(dk.root()[key].materialize() == 1); + CHECK(dk.root().at(key).materialize() == 1); + CHECK(dk.root().find(key) == dk.root().begin()); + CHECK(dk.root().value(key, 0) == 1); CHECK(dk.root()[key + "x"].materialize() == 2); } std::string order; From 3faacc1c2fa5779a2dbc8a5986095ecccc33d03a Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Fri, 9 Oct 2026 23:20:52 +0200 Subject: [PATCH 2/7] Regenerate the amalgamated headers Signed-off-by: Niels Lohmann --- single_include/nlohmann/json_view.hpp | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/single_include/nlohmann/json_view.hpp b/single_include/nlohmann/json_view.hpp index 0d9bc1f15..97185d18d 100644 --- a/single_include/nlohmann/json_view.hpp +++ b/single_include/nlohmann/json_view.hpp @@ -2173,14 +2173,14 @@ class short_key std::uint64_t m_b = 0; }; -/// the key node of the last member of an object with the given key, or -/// nullptr (the last one, as materialize() and parse() keep it); most keys are -/// rejected by their length, from the index alone +/// the key node of the first member of an object with the given key, or +/// nullptr (the search stops at the first match; materialize() and parse() +/// keep the last value of a duplicate key instead); most keys are rejected by +/// their length, from the index alone inline const node* find_member(const document_data& d, const node* object, const char* key, std::size_t n) noexcept { const node* const end = document_data::child_end(object); const auto* const k = reinterpret_cast(key); // NOLINT(cppcoreguidelines-pro-type-reinterpret-cast) - const node* last = nullptr; if (NLOHMANN_VIEW_LIKELY(n <= 16)) { const short_key probe(k, n); @@ -2188,19 +2188,19 @@ inline const node* find_member(const document_data& d, const node* object, const { if (m->len == n && probe.matches(reinterpret_cast(d.str(*m)))) // NOLINT(cppcoreguidelines-pro-type-reinterpret-cast) { - last = m; + return m; } } - return last; + return nullptr; } for (const node* m = document_data::first_child(object); m != end; m = document_data::after(m + 1)) { if (m->len == n && std::memcmp(d.str(*m), key, n) == 0) { - last = m; + return m; } } - return last; + return nullptr; } /// whether an integer type is accepted as an array index by the view's @@ -3039,7 +3039,7 @@ class basic_json_view // element access // //////////////////// - /// the value of the member with this key (the last one, should the key + /// the value of the member with this key (the first one, should the key /// occur more than once); a discarded view if there is none, or if this /// is a discarded view (so that v["a"]["b"] is safe). Throws type_error.305 /// if this is any other value but an object. @@ -3103,7 +3103,7 @@ class basic_json_view return detail::view::resolve_pointer(*this, detail::json_pointer_access::reference_tokens(ptr), detail::view::pointer_mode::unchecked); } - /// the value of the member with this key (the last one, should the key + /// the value of the member with this key (the first one, should the key /// occur more than once). Throws type_error.304 if this is not an object, /// and out_of_range.403 if there is no such member. basic_json_view at(string_view_t key) const @@ -3161,7 +3161,7 @@ class basic_json_view } /// the member with this key converted to T, or the default value if there - /// is no such member (the last one, should the key occur more than + /// is no such member (the first one, should the key occur more than /// once). Throws type_error.306 if this is not an object. template < typename T, typename std::enable_if < !std::is_same::type, const char*>::value, int >::type = 0 > T value(string_view_t key, const T& default_value) const @@ -3226,7 +3226,7 @@ class basic_json_view // lookup // //////////// - /// an iterator to the member with this key (the last one, should the + /// an iterator to the member with this key (the first one, should the /// key occur more than once), or end(); end() also for non-objects iterator find(string_view_t key) const { @@ -3404,7 +3404,7 @@ class basic_json_view : m_doc(d), m_node(n) {} - /// the value of the last member with this key, or a discarded view + /// the value of the first member with this key, or a discarded view /// (object required) NLOHMANN_VIEW_ALWAYS_INLINE basic_json_view lookup(string_view_t key) const noexcept { From 32853d2c905133230968c6fc69aad96e55c21040 Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Fri, 9 Oct 2026 23:22:34 +0200 Subject: [PATCH 3/7] Do not shrink the output of dump() finish() copied the whole output when the buffer was more than twice as large as the result (citm dump +11%). The tighter source_extent() estimate already keeps the buffer of a small value small. Signed-off-by: Niels Lohmann --- include/nlohmann/detail/view/serializer.hpp | 5 ----- single_include/nlohmann/json_view.hpp | 5 ----- 2 files changed, 10 deletions(-) diff --git a/include/nlohmann/detail/view/serializer.hpp b/include/nlohmann/detail/view/serializer.hpp index 7870601bc..bde8797c5 100644 --- a/include/nlohmann/detail/view/serializer.hpp +++ b/include/nlohmann/detail/view/serializer.hpp @@ -46,11 +46,6 @@ class output_buffer { const auto size = static_cast(m_pos - m_out.data()); m_out.resize(size); - // do not keep a buffer that was sized for a much larger output - if (m_out.capacity() > 1024 && m_out.capacity() / 2 > size) - { - m_out.shrink_to_fit(); - } } NLOHMANN_VIEW_ALWAYS_INLINE void reserve(std::size_t n) diff --git a/single_include/nlohmann/json_view.hpp b/single_include/nlohmann/json_view.hpp index 273e11474..5cd836097 100644 --- a/single_include/nlohmann/json_view.hpp +++ b/single_include/nlohmann/json_view.hpp @@ -3108,11 +3108,6 @@ class output_buffer { const auto size = static_cast(m_pos - m_out.data()); m_out.resize(size); - // do not keep a buffer that was sized for a much larger output - if (m_out.capacity() > 1024 && m_out.capacity() / 2 > size) - { - m_out.shrink_to_fit(); - } } NLOHMANN_VIEW_ALWAYS_INLINE void reserve(std::size_t n) From ef9a151308f301de545fb34e4962d6c3774dacc3 Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Fri, 9 Oct 2026 23:26:08 +0200 Subject: [PATCH 4/7] Document and test first-wins lookups next to dump() and == dump() writes every member and == resolves duplicate keys as parse() does, whereas lookups find the first member of a duplicate key. Signed-off-by: Niels Lohmann --- docs/mkdocs/docs/api/basic_json_view/operator_eq.md | 2 ++ docs/mkdocs/docs/features/json_view.md | 4 ++-- tests/src/unit-json_view.cpp | 5 +++++ 3 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/mkdocs/docs/api/basic_json_view/operator_eq.md b/docs/mkdocs/docs/api/basic_json_view/operator_eq.md index 6f96f4692..b43f5c633 100644 --- a/docs/mkdocs/docs/api/basic_json_view/operator_eq.md +++ b/docs/mkdocs/docs/api/basic_json_view/operator_eq.md @@ -18,6 +18,8 @@ bool operator==(const BasicJsonType& lhs, const basic_json_view& rhs); Neither overload builds a `BasicJsonType` value for a view to do the comparison (see [Notes](#notes) below). Numbers compare by value across their types (`#!cpp 1 == 1.0`), and an object compares by its members, with duplicate keys resolved exactly as `parse()` resolves them -- the last value, at the position of the first occurrence of the key. +This is not what [`operator[]`](operator[].md) and the other lookups return for a duplicate key: they find the *first* +member (see [Duplicate keys](../../features/json_view.md#duplicate-keys)). ## Parameters diff --git a/docs/mkdocs/docs/features/json_view.md b/docs/mkdocs/docs/features/json_view.md index f1664c89b..668eeb837 100644 --- a/docs/mkdocs/docs/features/json_view.md +++ b/docs/mkdocs/docs/features/json_view.md @@ -221,8 +221,8 @@ Both results are only valid as long as the view -- and, for a string with no esc [`dump()`](../api/basic_json_view/dump.md) serializes a view directly from the flat index, without ever building a `basic_json` value. An object's members are written in document order, not sorted by key, and *every* occurrence of a repeated key is written, not only the last one -- the same two ways [iteration](#what-is-different) already differs -from a [`materialize()`](../api/basic_json_view/materialize.md)d value, see above. `#!cpp materialize().dump()` gives -a different result in both respects for a `json_view`. +from a [`materialize()`](../api/basic_json_view/materialize.md)d value, see [Duplicate keys](#duplicate-keys). +`#!cpp materialize().dump()` gives a different result in both respects for a `json_view`. By default, numbers are written the way [`basic_json::dump()`](../api/basic_json/dump.md) would. [`number_format::source`](../api/basic_json_view/number_format.md) instead copies every number exactly as it was diff --git a/tests/src/unit-json_view.cpp b/tests/src/unit-json_view.cpp index f03660301..132f7c8d0 100644 --- a/tests/src/unit-json_view.cpp +++ b/tests/src/unit-json_view.cpp @@ -1461,6 +1461,8 @@ TEST_CASE("json_view dump") const json_document d = json_document::parse(R"({"b": 1, "a": 2, "b": 3})"); CHECK(d.root().dump() == R"({"b":1,"a":2,"b":3})"); CHECK(d.root().dump(1) == "{\n \"b\": 1,\n \"a\": 2,\n \"b\": 3\n}"); + // dump() writes all members, though the lookup finds the first + CHECK(d.root()["b"].dump() == "1"); } SECTION("deep nesting") @@ -1583,6 +1585,9 @@ TEST_CASE("json_view comparison") const ordered_json_document dup = ordered_json_document::parse(R"({"a": 1, "b": 2, "a": 3})"); const ordered_json_document last = ordered_json_document::parse(R"({"a": 3, "b": 2})"); CHECK(dup.root() == last.root()); + // (== compares as parse() resolves duplicates, a lookup finds the first) + CHECK(dup.root()["a"].materialize() == 1); + CHECK(dup.root()["a"] != last.root()["a"]); const ordered_json_document ab = ordered_json_document::parse(R"({"a": 1, "b": 2})"); const ordered_json_document ba = ordered_json_document::parse(R"({"b": 2, "a": 1})"); CHECK(ab.root() != ba.root()); From 61e3d97bfd611566a5bf8967f67c66d03e5473e6 Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Fri, 9 Oct 2026 23:33:31 +0200 Subject: [PATCH 5/7] Keep the first of duplicate keys in the object hash index again Lookups in objects with 128 members or more return the first member of a repeated key again, like the linear search of smaller objects. This undoes the code change of a69542046; its test now expects the first member from lookups (with and without a table) and the last value from materialize() and basic_json::parse(). Signed-off-by: Niels Lohmann --- .../docs/api/basic_json_view/operator[].md | 1 + docs/mkdocs/docs/features/json_view.md | 7 ++--- docs/mkdocs/docs/home/architecture.md | 2 +- include/nlohmann/detail/view/object_index.hpp | 9 +++---- single_include/nlohmann/json_view.hpp | 9 +++---- tests/src/unit-json_view.cpp | 26 +++++++++---------- 6 files changed, 27 insertions(+), 27 deletions(-) diff --git a/docs/mkdocs/docs/api/basic_json_view/operator[].md b/docs/mkdocs/docs/api/basic_json_view/operator[].md index 1e40b3093..ec1867c9f 100644 --- a/docs/mkdocs/docs/api/basic_json_view/operator[].md +++ b/docs/mkdocs/docs/api/basic_json_view/operator[].md @@ -116,6 +116,7 @@ document. member in order and so keep the *last* value for a repeated key, so `#!cpp v["a"]` and `#!cpp v.materialize()["a"]` can differ. To get the value `parse()` would give, use [`materialize()`](materialize.md) or iterate the members with [`items()`](items.md) and keep the last match. + The hash index of a larger object (128 members or more) leads to the first member of a key as well. [`begin()`](begin.md)/[`end()`](end.md) and [`items()`](items.md) iterate over *all* members, including duplicates, in document order. See [Duplicate keys](../../features/json_view.md#duplicate-keys) and [`size()`](size.md#notes). diff --git a/docs/mkdocs/docs/features/json_view.md b/docs/mkdocs/docs/features/json_view.md index 668eeb837..2b7d927c0 100644 --- a/docs/mkdocs/docs/features/json_view.md +++ b/docs/mkdocs/docs/features/json_view.md @@ -165,9 +165,10 @@ document: `#!cpp auto v = json_document::parse(text).root();` does not compile. **Why the first?** A lookup can stop as soon as it finds a match. Returning the last member would force every lookup to scan all members of the object, even when the key is found at the very first one: this made lookups in small -objects 1.6 to 3.4 times slower. Other zero-copy parsers that index the source text, such as yyjson and simdjson, also -return the first member. RFC 8259 only says that names within an object SHOULD be unique and that the behavior of a -receiver that sees duplicates is unpredictable, so neither choice is wrong. +objects 1.6 to 3.4 times slower. The hash index of objects with 128 members or more leads to the first member of a +key as well. Other zero-copy parsers that index the source text, such as yyjson and simdjson, also return the first +member. RFC 8259 only says that names within an object SHOULD be unique and that the behavior of a receiver that sees +duplicates is unpredictable, so neither choice is wrong. **What stays the same as `parse()`?** [`materialize()`](../api/basic_json_view/materialize.md) and [`get>()`](../api/basic_json_view/get.md) replay every member in order, so they keep the *last* value diff --git a/docs/mkdocs/docs/home/architecture.md b/docs/mkdocs/docs/home/architecture.md index 981f62d35..9246f7780 100644 --- a/docs/mkdocs/docs/home/architecture.md +++ b/docs/mkdocs/docs/home/architecture.md @@ -215,7 +215,7 @@ packet-beta - **Large objects** (128 members or more) get a hash index after parsing ([`detail/view/object_index.hpp`](https://github.com/nlohmann/json/blob/develop/include/nlohmann/detail/view/object_index.hpp)): an open-addressing table whose slots hold the distance from the object's node to a key's node, so that a lookup does - not compare every key. Of duplicate keys the table leads to the last, as a linear search does. The object's `extra` + not compare every key. Of duplicate keys the table leads to the first, as a linear search does. The object's `extra` holds the number of its table. Only 65,535 tables fit into `extra`; objects beyond them are searched linearly. For example, `#!json {"a": [1, 2.5]}` becomes five nodes. Each node's elements follow it, and `next` leads from an diff --git a/include/nlohmann/detail/view/object_index.hpp b/include/nlohmann/detail/view/object_index.hpp index 721b4d62b..8888f2345 100644 --- a/include/nlohmann/detail/view/object_index.hpp +++ b/include/nlohmann/detail/view/object_index.hpp @@ -22,8 +22,8 @@ // large objects). An object with document_data::index_min_members members or // more gets an open-addressing table after parsing; its node stores the // number of the table (1-based) in `extra`. A slot holds the offset of a key -// node from its object node (0: empty). Of duplicate keys, the last is kept, -// as for the linear search, and as basic_json::parse() does. +// node from its object node (0: empty). Of duplicate keys, the first is kept, +// as for the linear search. // // The hash is not seeded, so keys chosen to collide could make the build // quadratic. A key therefore sits at most index_max_displacement slots away @@ -92,8 +92,7 @@ inline void build_object_index(document_data& d, node* obj) const node* const other = obj + slots[i]; if (other->len == k->len && (k->len == 0 || std::memcmp(d.str(*other), key, k->len) == 0)) { - duplicate = true; // keep the last: the key's slot now leads to this member - slots[i] = static_cast(k - obj); + duplicate = true; // keep the first break; } if (++distance > index_max_displacement) @@ -130,7 +129,7 @@ inline void build_object_indexes(document_data& d) } } -/// the key node of the last member with this key of an indexed object, or +/// the key node of the first member with this key of an indexed object, or /// nullptr inline const node* find_indexed(const document_data& d, const node* obj, const char* key, std::size_t n) noexcept { diff --git a/single_include/nlohmann/json_view.hpp b/single_include/nlohmann/json_view.hpp index a15d27053..2e74e4441 100644 --- a/single_include/nlohmann/json_view.hpp +++ b/single_include/nlohmann/json_view.hpp @@ -2879,8 +2879,8 @@ NLOHMANN_JSON_NAMESPACE_END // large objects). An object with document_data::index_min_members members or // more gets an open-addressing table after parsing; its node stores the // number of the table (1-based) in `extra`. A slot holds the offset of a key -// node from its object node (0: empty). Of duplicate keys, the last is kept, -// as for the linear search, and as basic_json::parse() does. +// node from its object node (0: empty). Of duplicate keys, the first is kept, +// as for the linear search. // // The hash is not seeded, so keys chosen to collide could make the build // quadratic. A key therefore sits at most index_max_displacement slots away @@ -2949,8 +2949,7 @@ inline void build_object_index(document_data& d, node* obj) const node* const other = obj + slots[i]; if (other->len == k->len && (k->len == 0 || std::memcmp(d.str(*other), key, k->len) == 0)) { - duplicate = true; // keep the last: the key's slot now leads to this member - slots[i] = static_cast(k - obj); + duplicate = true; // keep the first break; } if (++distance > index_max_displacement) @@ -2987,7 +2986,7 @@ inline void build_object_indexes(document_data& d) } } -/// the key node of the last member with this key of an indexed object, or +/// the key node of the first member with this key of an indexed object, or /// nullptr inline const node* find_indexed(const document_data& d, const node* obj, const char* key, std::size_t n) noexcept { diff --git a/tests/src/unit-json_view.cpp b/tests/src/unit-json_view.cpp index 69636345b..19d432650 100644 --- a/tests/src/unit-json_view.cpp +++ b/tests/src/unit-json_view.cpp @@ -1635,31 +1635,26 @@ TEST_CASE("json_view large objects") for (std::size_t i = 0; i < members; ++i) { const std::string key = std::string(i % 23, 'k') + std::to_string(i) + (i % 7 == 0 ? "\n" : ""); + CHECK(v[key].get() == i); CHECK(v.contains(key)); CHECK(v.find(key).key() == key); - CHECK(!v.contains(key + "x")); - if (key == "k1") - { - continue; // repeated below: the last member wins - } - CHECK(v[key].get() == i); CHECK(v.at(key).get() == i); + CHECK(!v.contains(key + "x")); } CHECK(v[""].get_string() == "empty key"); - CHECK(v["k1"].get_string() == "a duplicate of an earlier key"); // the last of duplicate keys, as for small objects - CHECK(v.at("k1").get_string() == "a duplicate of an earlier key"); + CHECK(v["k1"].get() == 1); // the first of duplicate keys, as for small objects CHECK(!v.contains("missing")); CHECK_THROWS_WITH_AS(v.at("missing"), "[json.exception.out_of_range.403] key 'missing' not found", json::out_of_range&); CHECK(v == j); CHECK(v.materialize() == j); } - SECTION("duplicate keys: the last member wins, with and without a table") + SECTION("duplicate keys: the first member wins, with and without a table") { // an object of `total` members: the keys "k0".."k" in order, then // three keys repeated twice more (one copy in the middle, one at the // end), and two keys repeated once; the value of a member is its - // position, so that the last member of a key can be told apart + // position, so that the first member of a key can be told apart struct member { std::string key; @@ -1707,20 +1702,23 @@ TEST_CASE("json_view large objects") REQUIRE(members.size() >= total); REQUIRE(members.size() >= 100); std::string text = "{"; + std::map first; std::map last; for (const member& m : members) { text += (text.size() > 1 ? ",\"" : "\"") + m.key + "\":" + std::to_string(m.position); + first.insert({m.key, m.position}); last[m.key] = m.position; } text += '}'; - REQUIRE(last.size() < members.size()); + REQUIRE(first.size() < members.size()); const json_document d = json_document::parse(text); const json_view v = d.root(); const json j = json::parse(text); CHECK(v.size() == members.size()); // every occurrence is visited - for (const auto& entry : last) + const json m = v.materialize(); + for (const auto& entry : first) { CAPTURE(entry.first) const std::size_t expected = entry.second; @@ -1735,7 +1733,9 @@ TEST_CASE("json_view large objects") CHECK(v.at(json::json_pointer(pointer)).get() == expected); CHECK(v.value(json::json_pointer(pointer), std::size_t{0}) == expected); CHECK(v.contains(json::json_pointer(pointer))); - CHECK(j[entry.first].get() == expected); // as materialize() and parse() keep it + // materialize() and parse() keep the last value instead + CHECK(j[entry.first].get() == last.at(entry.first)); + CHECK(m[entry.first].get() == last.at(entry.first)); } CHECK(!v.contains("k")); CHECK(v["missing"].is_discarded()); From f4e0b06685794e464b51df8155e80db42d7110e8 Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Fri, 9 Oct 2026 23:44:10 +0200 Subject: [PATCH 6/7] Assign the first member when set() meets duplicate keys again Lookups find the first member of a repeated key again, so set(object, key, value) assigns that member (keeping the key where it is) and drops the others, as documented before, and a view taken from object[key] shows the new value. This undoes the code and documentation changes of 5cbb8e6d6. Lookups in edited objects (navigation of editable documents) stop at the first match, like those of parsed objects. The tests that commit added expect the first member from lookups now. In the seeded differential test, the documents with repeated keys repeat them after the real members; the reference holds the first members, which the edits address and the lookups are compared with, while materialize() is compared with what parse() makes of the document's dump. Signed-off-by: Niels Lohmann --- .../docs/api/basic_json_document/erase.md | 4 +- .../docs/api/basic_json_document/set.md | 16 +- include/nlohmann/detail/view/edit.hpp | 45 ++-- single_include/nlohmann/json_view.hpp | 45 ++-- tests/src/unit-json_view_edit.cpp | 192 +++++++++++------- 5 files changed, 166 insertions(+), 136 deletions(-) diff --git a/docs/mkdocs/docs/api/basic_json_document/erase.md b/docs/mkdocs/docs/api/basic_json_document/erase.md index 25249fa73..cbdc79765 100644 --- a/docs/mkdocs/docs/api/basic_json_document/erase.md +++ b/docs/mkdocs/docs/api/basic_json_document/erase.md @@ -86,8 +86,8 @@ view of a *different* document (overloads 1-2 only; overload 3 always starts fro !!! info "Duplicate keys" - Overload 1. removes *every* member with `key`, not just the last one that lookups find -- unlike [`set`](set.md), - which assigns that member and drops the rest. This is why it returns a count rather than a single view: there may be + Overload 1. removes *every* member with `key`, not just the first -- unlike [`set`](set.md), which assigns the + first occurrence and drops the rest. This is why it returns a count rather than a single view: there may be more than one member removed, or none. Like [`set`](set.md) and [`push_back`](push_back.md), `erase` never moves an element's *value*: a view still diff --git a/docs/mkdocs/docs/api/basic_json_document/set.md b/docs/mkdocs/docs/api/basic_json_document/set.md index 3f29a3f6f..ae26ed516 100644 --- a/docs/mkdocs/docs/api/basic_json_document/set.md +++ b/docs/mkdocs/docs/api/basic_json_document/set.md @@ -23,8 +23,7 @@ has `set`; calling it on a read-only `basic_json_document` fails to compile (`#! 1. Replaces the value `target` refers to with `value`. 2. Sets the member `key` of the object `object` to `value`: assigns it if `object` already has a member with this - key -- the last one, should the key occur more than once (the member - [`operator[]`](../basic_json_view/operator%5B%5D.md) returns), and the other duplicates are then dropped (see the + key -- the first one, should the key occur more than once, and the later duplicates are then dropped (see the [Notes](#notes) below) -- or appends a new member at the end otherwise. A [null](../basic_json_view/is_null.md) `object` first becomes an empty object. 3. Assigns `value` to the element at index `idx` of the array `array`, which must already exist (`#!cpp idx < @@ -147,13 +146,12 @@ document") if `target`/`object`/`array` is a [discarded](../basic_json_view/is_d !!! info "Duplicate keys" - If `object` already has more than one member with `key` (2.), `value` is assigned to the *last* one -- the member - [`operator[]`](../basic_json_view/operator%5B%5D.md), [`at`](../basic_json_view/at.md), and - [`find`](../basic_json_view/find.md) return for reading, so that a view taken from `object["key"]` before the call - shows `value` afterward -- and every other member with the same key is removed. The key stays at the - position of its *first* occurrence, where [`materialize()`](../basic_json_view/materialize.md) puts it as well. A - lookup, an iteration, and `materialize()` of `object` afterward therefore all agree on a single member for `key`. See the - [Notes on duplicate keys](../basic_json_view/operator%5B%5D.md#notes) of `operator[]`. + If `object` already has more than one member with `key` (2.), the *first* one is assigned `value` and every + later member with the same key is removed -- so that a lookup, an iteration, and + [`materialize()`](../basic_json_view/materialize.md) of `object` afterward all agree on a single value for + `key`, the same way [`operator[]`](../basic_json_view/operator%5B%5D.md) already picks the first occurrence of a + duplicate key for reading. See the [Notes on duplicate keys](../basic_json_view/operator%5B%5D.md#notes) of + `operator[]`. Setting a member (2.) or an element (3., through 4.) of an array or object whose elements have not been edited before switches it from its parsed layout to a growable block holding links to its elements; a later diff --git a/include/nlohmann/detail/view/edit.hpp b/include/nlohmann/detail/view/edit.hpp index 57a0b0826..7a9daa6bc 100644 --- a/include/nlohmann/detail/view/edit.hpp +++ b/include/nlohmann/detail/view/edit.hpp @@ -152,23 +152,25 @@ class editor { become_empty(o, value_t::object); } - // an existing member: assign the one that lookups find (the last - // one, should the key occur more than once), and drop the others, so - // that lookups, iteration, and materialize() agree. The key stays at - // the position of its first occurrence, as materialize() puts it. + // an existing member: assign it (and drop later duplicates, so that + // lookups, iteration, and materialize() agree) node* slot = nullptr; - std::size_t matches = 0; + bool duplicates = false; for (const node* k = nav::first(m_doc, o), *end = nav::end(m_doc, o); k != end; k = document_data::after(k + 1)) { if (key_equals(*k, key)) { + if (slot != nullptr) + { + duplicates = true; + break; + } slot = const_cast(nav::value(k + 1)); // NOLINT(cppcoreguidelines-pro-type-const-cast): the nodes belong to this document - ++matches; } } if (slot != nullptr) { - if (matches > 1) + if (duplicates) { erase_members(o, key, true); } @@ -314,38 +316,27 @@ class editor return k.len == key.size() && (key.size() == 0 || std::memcmp(m_doc.str(k), key.data(), key.size()) == 0); } - /// Remove the members with this key from an object: all of them, or all - /// but one. That one stays where the first occurrence is, but holds the - /// value of the last (the one that lookups find, which views may refer to). - std::size_t erase_members(node* o, string_view_t key, bool keep_one) + /// remove the members with this key (all, or all but the first) from an object + std::size_t erase_members(node* o, string_view_t key, bool keep_first) { node* const h = block_of(m_doc, o, 0); - node last_value{}; // the entry of the value of the last member - node* const end = h + h->next; - if (keep_one) - { - for (node* r = h + 1; r != end; r += 2) - { - if (key_equals(*r, key)) - { - last_value = r[1]; - } - } - } node* w = h + 1; std::size_t erased = 0; bool kept = false; - for (node* r = h + 1; r != end; r += 2) + for (node* r = h + 1, *end = h + h->next; r != end; r += 2) { const bool match = key_equals(*r, key); - if (match && (kept || !keep_one)) + if (match && (kept || !keep_first)) { ++erased; continue; } - w[0] = r[0]; - w[1] = match ? last_value : r[1]; kept = kept || match; + if (w != r) + { + w[0] = r[0]; + w[1] = r[1]; + } w += 2; } h->next = static_cast(w - h); diff --git a/single_include/nlohmann/json_view.hpp b/single_include/nlohmann/json_view.hpp index 87c1f0670..8557a6960 100644 --- a/single_include/nlohmann/json_view.hpp +++ b/single_include/nlohmann/json_view.hpp @@ -3358,23 +3358,25 @@ class editor { become_empty(o, value_t::object); } - // an existing member: assign the one that lookups find (the last - // one, should the key occur more than once), and drop the others, so - // that lookups, iteration, and materialize() agree. The key stays at - // the position of its first occurrence, as materialize() puts it. + // an existing member: assign it (and drop later duplicates, so that + // lookups, iteration, and materialize() agree) node* slot = nullptr; - std::size_t matches = 0; + bool duplicates = false; for (const node* k = nav::first(m_doc, o), *end = nav::end(m_doc, o); k != end; k = document_data::after(k + 1)) { if (key_equals(*k, key)) { + if (slot != nullptr) + { + duplicates = true; + break; + } slot = const_cast(nav::value(k + 1)); // NOLINT(cppcoreguidelines-pro-type-const-cast): the nodes belong to this document - ++matches; } } if (slot != nullptr) { - if (matches > 1) + if (duplicates) { erase_members(o, key, true); } @@ -3520,38 +3522,27 @@ class editor return k.len == key.size() && (key.size() == 0 || std::memcmp(m_doc.str(k), key.data(), key.size()) == 0); } - /// Remove the members with this key from an object: all of them, or all - /// but one. That one stays where the first occurrence is, but holds the - /// value of the last (the one that lookups find, which views may refer to). - std::size_t erase_members(node* o, string_view_t key, bool keep_one) + /// remove the members with this key (all, or all but the first) from an object + std::size_t erase_members(node* o, string_view_t key, bool keep_first) { node* const h = block_of(m_doc, o, 0); - node last_value{}; // the entry of the value of the last member - node* const end = h + h->next; - if (keep_one) - { - for (node* r = h + 1; r != end; r += 2) - { - if (key_equals(*r, key)) - { - last_value = r[1]; - } - } - } node* w = h + 1; std::size_t erased = 0; bool kept = false; - for (node* r = h + 1; r != end; r += 2) + for (node* r = h + 1, *end = h + h->next; r != end; r += 2) { const bool match = key_equals(*r, key); - if (match && (kept || !keep_one)) + if (match && (kept || !keep_first)) { ++erased; continue; } - w[0] = r[0]; - w[1] = match ? last_value : r[1]; kept = kept || match; + if (w != r) + { + w[0] = r[0]; + w[1] = r[1]; + } w += 2; } h->next = static_cast(w - h); diff --git a/tests/src/unit-json_view_edit.cpp b/tests/src/unit-json_view_edit.cpp index 3502b0d07..29be6bb1b 100644 --- a/tests/src/unit-json_view_edit.cpp +++ b/tests/src/unit-json_view_edit.cpp @@ -27,6 +27,7 @@ using ptr_t = ordered_json::json_pointer; #include #include #include +#include #include #include @@ -180,8 +181,10 @@ void compare(const ordered_json_editable_view& v, const ordered_json& j) } } -// the text of j, in which some objects repeat a key of theirs: before their -// members (the real member is then the last), or after (the repeat is) +// the text of j, in which some objects repeat a key of theirs after their +// members: lookups of the edited document find the first member of a key, so +// j (the first members) is what they must agree with, while parse() would +// keep the last value std::string text_with_duplicates(const ordered_json& j) { if (j.is_object()) @@ -196,10 +199,6 @@ std::string text_with_duplicates(const ordered_json& j) return ordered_json(keys[static_cast(r(static_cast(keys.size())))]).dump() + ":" + random_value(2).dump(); }; std::string text = "{"; - if (!keys.empty() && r(4) == 0) - { - text += repeated() + ","; - } bool first = true; for (const auto& kv : j.items()) { @@ -224,10 +223,58 @@ std::string text_with_duplicates(const ordered_json& j) return j.dump(); } -// every lookup of the edited view finds what j holds, although the view may -// have several members for a key (j has the last value, at the position of the -// first member: what parse() and materialize() make of it) -void check_lookups(const ordered_json_editable_view& v, const ordered_json& j) +// every lookup of the edited view finds the first member of its key, which +// the iteration of the view shows +void check_lookups(const ordered_json_editable_view& v) +{ + if (v.is_object()) + { + std::set seen; + for (const auto item : v.items()) + { + const std::string key(item.key().data(), item.key().size()); + if (!seen.insert(key).second) + { + continue; // (not the first member of the key) + } + const ptr_t ptr = ptr_t() / key; + CAPTURE(key) + const ordered_json expected = item.value().materialize(); + const ordered_json_editable_view m = v[key]; + REQUIRE(!m.is_discarded()); + CHECK(m.materialize() == expected); + CHECK(v.at(key).materialize() == expected); + CHECK(v[ptr].materialize() == expected); + CHECK(v.at(ptr).materialize() == expected); + CHECK(v.contains(key)); + CHECK(v.contains(ptr)); + CHECK(v.count(key) == 1); + const auto it = v.find(key); + REQUIRE(it != v.end()); + CHECK((*it).materialize() == expected); + CHECK(v.value(ptr, ordered_json(nullptr)) == expected); + if (expected.is_string()) + { + CHECK(v.value(key, std::string("-")) == expected.get()); + } + check_lookups(m); + } + CHECK(v["missing#key"].is_discarded()); + CHECK(!v.contains("missing#key")); + CHECK(v.find("missing#key") == v.end()); + } + else if (v.is_array()) + { + for (std::size_t i = 0; i < v.size(); ++i) + { + check_lookups(v[i]); + check_lookups(v.at(i)); + } + } +} + +// every lookup of the edited view finds what the first members of j hold +void check_first_members(const ordered_json_editable_view& v, const ordered_json& j) { REQUIRE(v.type() == j.type()); if (j.is_object()) @@ -237,45 +284,39 @@ void check_lookups(const ordered_json_editable_view& v, const ordered_json& j) const std::string& key = kv.key(); const ptr_t ptr = ptr_t() / key; CAPTURE(key) - const ordered_json_editable_view m = v[key]; - REQUIRE(!m.is_discarded()); - CHECK(m.materialize() == kv.value()); - CHECK(v.at(key).materialize() == kv.value()); - CHECK(v[ptr].materialize() == kv.value()); - CHECK(v.at(ptr).materialize() == kv.value()); - CHECK(v.contains(key)); + REQUIRE(v.contains(key)); CHECK(v.contains(ptr)); CHECK(v.count(key) == 1); - const auto it = v.find(key); - REQUIRE(it != v.end()); - CHECK((*it).materialize() == kv.value()); - CHECK(v.value(ptr, ordered_json(nullptr)) == kv.value()); - if (kv.value().is_string()) - { - CHECK(v.value(key, std::string("-")) == kv.value().get()); - } - check_lookups(m, kv.value()); + CHECK(v.find(key) != v.end()); + CHECK(v.at(ptr).type() == kv.value().type()); + check_first_members(v[key], kv.value()); + check_first_members(v.at(key), kv.value()); + check_first_members(v[ptr], kv.value()); } - CHECK(v["missing#key"].is_discarded()); CHECK(!v.contains("missing#key")); - CHECK(v.find("missing#key") == v.end()); } else if (j.is_array()) { + REQUIRE(v.size() == j.size()); for (std::size_t i = 0; i < j.size(); ++i) { - check_lookups(v[i], j[i]); - check_lookups(v.at(i), j[i]); + check_first_members(v[i], j[i]); } } + else + { + CHECK(v.materialize() == j); + } } -// (for documents whose objects repeat keys: dump(), size(), and iteration -// list all members, so only the lookups and materialize() are compared) +// (for documents whose objects repeat keys: j holds the first members, the +// lookups must find them; dump(), size(), and iteration list all members, but +// materialize() keeps the last value of a key, as parse() does of the dump) void check_duplicates(const ordered_json_editable_document& d, const ordered_json& j) { - CHECK(d.root().materialize() == j); - check_lookups(d.root(), j); + check_first_members(d.root(), j); + check_lookups(d.root()); + CHECK(d.root().materialize() == ordered_json::parse(d.root().dump())); } void check_all(const ordered_json_editable_document& d, const ordered_json& j, bool deep) @@ -303,17 +344,25 @@ TEST_CASE("json_view edits: differential") // materialize, and read back the same for (int n = 0; n < 250; ++n) { - // the last 100 documents repeat keys in some of their objects + // the last 100 documents repeat keys in some of their objects; j then + // holds the first member of each key, which the edits address const bool duplicates = n >= 150; ordered_json j = random_value(0); if (r(4) == 0) { j = ordered_json::object({{"a", random_value(1)}, {"b", random_value(1)}}); } + if (duplicates) + { + j = ordered_json::parse(j.dump()); // (normalized as the document reads it) + } const std::string text = duplicates ? text_with_duplicates(j) : j.dump(r(2) == 0 ? -1 : 2); CAPTURE(text) ordered_json_editable_document d = ordered_json_editable_document::parse(text); - j = ordered_json::parse(text); + if (!duplicates) + { + j = ordered_json::parse(text); + } const ordered_json_document other = ordered_json_document::parse(random_value(0).dump()); const int edits = 30; for (int e = 0; e < edits; ++e) @@ -610,7 +659,7 @@ TEST_CASE("json_view edits: views and values") SECTION("duplicate keys") { json_editable_document d = json_editable_document::parse(R"({"a": 1, "b": 2, "a": 3})"); - d.set(d.root(), "a", 4); // the last member is assigned (at the position of the first), the others dropped + d.set(d.root(), "a", 4); // the first member is assigned, the others dropped CHECK(d.root().dump() == R"({"a":4,"b":2})"); d = json_editable_document::parse(R"({"a": 1, "b": 2, "a": 3})"); CHECK(d.erase(d.root(), "a") == 2); @@ -897,10 +946,10 @@ TEST_CASE("json_view edits: replacing arrays and objects by scalars") namespace { -// reads of "a" in an object that repeats it: the last member wins, however the +// reads of "a" in an object that repeats it: the first member wins, however the // object is stored template -void check_last_wins(const View& o, int expected) +void check_first_wins(const View& o, int expected) { CAPTURE(expected) CHECK(o["a"].template get() == expected); @@ -921,35 +970,36 @@ TEST_CASE("json_view edits: duplicate keys") SECTION("an object in its parsed layout, then moved by edits") { json_editable_document d = json_editable_document::parse(R"({"a": 1, "b": 2, "a": 3})"); - check_last_wins(d.root(), 3); + check_first_wins(d.root(), 1); CHECK(d.root().size() == 3); CHECK(d.root().dump() == R"({"a":1,"b":2,"a":3})"); + CHECK(d.root().materialize()["a"] == 3); // (parse() keeps the last value) // an edit of a value does not move the object d.set(d.root()["b"], 5); - check_last_wins(d.root(), 3); - d.set(d.root()["a"], 4); // the member that reads find - check_last_wins(d.root(), 4); - CHECK(d.root().dump() == R"({"a":1,"b":5,"a":4})"); + check_first_wins(d.root(), 1); + d.set(d.root()["a"], 4); // the member that lookups find + check_first_wins(d.root(), 4); + CHECK(d.root().dump() == R"({"a":4,"b":5,"a":3})"); // an appended member moves it d.set(d.root(), "c", true); - check_last_wins(d.root(), 4); + check_first_wins(d.root(), 4); CHECK(d.root().size() == 4); - CHECK(d.root().dump() == R"({"a":1,"b":5,"a":4,"c":true})"); + CHECK(d.root().dump() == R"({"a":4,"b":5,"a":3,"c":true})"); d.set(d.root()["a"], 6); - check_last_wins(d.root(), 6); - CHECK(d.root().dump() == R"({"a":1,"b":5,"a":6,"c":true})"); + check_first_wins(d.root(), 6); + CHECK(d.root().dump() == R"({"a":6,"b":5,"a":3,"c":true})"); d.set(json::json_pointer("/a"), 7); - check_last_wins(d.root(), 7); + check_first_wins(d.root(), 7); } - SECTION("set assigns the member that reads find") + SECTION("set assigns the member that lookups find") { - // the key keeps the position of its first occurrence (as in materialize()), the later members are dropped + // the key stays where its first occurrence is, the later members are dropped ordered_json_editable_document d = ordered_json_editable_document::parse(R"({"a": 1, "b": 2, "a": 3, "c": 4, "a": 5})"); const ordered_json_editable_view held = d.root()["a"]; - CHECK(held.get() == 5); + CHECK(held.get() == 1); const ordered_json_editable_view assigned = d.set(d.root(), "a", "x"); CHECK(held.get() == "x"); CHECK(assigned.get() == "x"); @@ -965,7 +1015,7 @@ TEST_CASE("json_view edits: duplicate keys") // through a pointer, below a duplicate d = ordered_json_editable_document::parse(R"({"a": {"x": 1}, "b": 2, "a": {"x": 3}})"); d.set(ptr_t("/a/x"), 4); - CHECK(d.root().dump() == R"({"a":{"x":1},"b":2,"a":{"x":4}})"); + CHECK(d.root().dump() == R"({"a":{"x":4},"b":2,"a":{"x":3}})"); d.set(ptr_t("/a"), 0); CHECK(d.root().dump() == R"({"a":0,"b":2})"); } @@ -974,7 +1024,7 @@ TEST_CASE("json_view edits: duplicate keys") { json_editable_document d = json_editable_document::parse(R"({"a": 1, "b": 2, "a": 3})"); d.set(d.root(), "c", 4); - check_last_wins(d.root(), 3); + check_first_wins(d.root(), 1); CHECK(d.erase(d.root(), "a") == 2); CHECK(d.root().dump() == R"({"b":2,"c":4})"); CHECK(!d.root().contains("a")); @@ -994,20 +1044,20 @@ TEST_CASE("json_view edits: duplicate keys") } text += R"(,"a":1,"k7":"last","a":2})"; json_editable_document d = json_editable_document::parse(text); - check_last_wins(d.root(), 2); - CHECK(d.root()["k7"].get_string() == "last"); + check_first_wins(d.root(), 0); + CHECK(d.root()["k7"].get_string() == "first"); CHECK(d.root()["k199"].get() == 199); // a value assigned in place: the index stays in use d.set(d.root()["a"], 3); - check_last_wins(d.root(), 3); + check_first_wins(d.root(), 3); d.set(d.root()["k7"], "changed"); CHECK(d.root()["k7"].get_string() == "changed"); CHECK(d.root()["k199"].get() == 199); // an appended member moves the members: the lookup scans them d.set(d.root(), "new", 1); - check_last_wins(d.root(), 3); + check_first_wins(d.root(), 3); CHECK(d.root()["k7"].get_string() == "changed"); CHECK(d.root()["k7"].get_string() == d.root().at("k7").get_string()); CHECK(d.root()["k199"].get() == 199); @@ -1017,7 +1067,7 @@ TEST_CASE("json_view edits: duplicate keys") d = json_editable_document::parse(text); d.set(d.root()["a"], json{{"x", 1}}); CHECK(d.root()["a"]["x"].get() == 1); - CHECK(d.root()["k7"].get_string() == "last"); + CHECK(d.root()["k7"].get_string() == "first"); CHECK(d.erase(d.root(), "k7") == 3); // (the key occurs three times) CHECK(d.root()["k7"].is_discarded()); CHECK(d.root()["a"]["x"].get() == 1); @@ -1026,18 +1076,18 @@ TEST_CASE("json_view edits: duplicate keys") SECTION("objects in moved arrays and in new values") { json_editable_document d = json_editable_document::parse(R"([{"a": 1, "a": 2}, {"a": 3, "b": 0, "a": 4}])"); - check_last_wins(d.root()[0], 2); - check_last_wins(d.root()[1], 4); + check_first_wins(d.root()[0], 1); + check_first_wins(d.root()[1], 3); d.insert(d.root(), 0, json::parse(R"({"a": 0})")); d.push_back(d.root(), json::parse(R"({"a": 5, "a": 6})")); // (a basic_json value has one member) - check_last_wins(d.root()[1], 2); - check_last_wins(d.root()[2], 4); + check_first_wins(d.root()[1], 1); + check_first_wins(d.root()[2], 3); CHECK(d.root()[3]["a"].get() == 6); d.set(d.root()[1], "a", 8); - check_last_wins(d.root()[1], 8); + check_first_wins(d.root()[1], 8); CHECK(d.root()[1].dump() == R"({"a":8})"); d.erase(d.root(), 0); - check_last_wins(d.root()[1], 4); + check_first_wins(d.root()[1], 3); CHECK(d.root().dump() == R"([{"a":8},{"a":3,"b":0,"a":4},{"a":6}])"); } @@ -1049,14 +1099,14 @@ TEST_CASE("json_view edits: duplicate keys") d.set(d.root(), "o", source.root()["o"]); // (the copies keep the repeated members) CHECK(d.root().dump() == R"({"copy":[{"a":1,"a":2}],"o":{"b":{"a":3,"a":4},"a":5,"a":6}})"); - check_last_wins(d.root()["copy"][0], 2); - check_last_wins(d.root()["o"]["b"], 4); - check_last_wins(d.root()["o"], 6); + check_first_wins(d.root()["copy"][0], 1); + check_first_wins(d.root()["o"]["b"], 3); + check_first_wins(d.root()["o"], 5); d.set(d.root()["o"]["b"], "c", 0); - check_last_wins(d.root()["o"]["b"], 4); + check_first_wins(d.root()["o"]["b"], 3); d.set(d.root()["o"], "d", 0); - check_last_wins(d.root()["o"], 6); - CHECK(d.root()["o"]["b"]["a"].get() == 4); + check_first_wins(d.root()["o"], 5); + CHECK(d.root()["o"]["b"]["a"].get() == 3); CHECK(d.root()["o"]["d"].get() == 0); } } From 369a78202f51b75a6628182dcf801c4bfc734d82 Mon Sep 17 00:00:00 2001 From: Niels Lohmann Date: Fri, 9 Oct 2026 23:48:07 +0200 Subject: [PATCH 7/7] Expect first-wins lookups in the image tests Signed-off-by: Niels Lohmann --- tests/src/unit-json_view_image.cpp | 31 +++++++++++++----------------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/tests/src/unit-json_view_image.cpp b/tests/src/unit-json_view_image.cpp index 158eaba28..5d2e735a9 100644 --- a/tests/src/unit-json_view_image.cpp +++ b/tests/src/unit-json_view_image.cpp @@ -241,7 +241,7 @@ TEST_CASE("json_view images: round trips") { CHECK(l.root()["k" + std::to_string(i)] == d.root()["k" + std::to_string(i)]); } - CHECK(l.root()["k7"] == "a duplicate"); // the last of duplicate keys + CHECK(l.root()["k7"].get() == 7); // the first of duplicate keys CHECK(l.root()["inner"]["m199"].get() == -199); CHECK(!l.root().contains("k1000")); // the index is not part of the image @@ -251,18 +251,18 @@ TEST_CASE("json_view images: round trips") CHECK(node_at(image, 0).extra == 0); } - SECTION("duplicate keys of a large object: lookups return the last member") + SECTION("duplicate keys of a large object: lookups return the first member") { std::string text = "{"; for (int i = 0; i < 200; ++i) { text += (i != 0 ? ",\"k" : "\"k") + std::to_string(i) + "\":" + std::to_string(i); } - // three members for k5 (the third is the last), and duplicates of k0 and k199 + // three members for k5 (the first is a number), and duplicates of k0 and k199 text += R"(,"k5":"two","k0":null,"k199":[],"k5":"three"})"; const json_document d = json_document::parse(text); REQUIRE(d.root().size() == 204); - CHECK(d.root()["k5"] == "three"); + CHECK(d.root()["k5"] == 5); const std::vector image = d.save(); for (const image_check check : { @@ -271,13 +271,15 @@ TEST_CASE("json_view images: round trips") { const json_document l = json_document::load(image, check); CHECK(l.root().size() == 204); - CHECK(l.root()["k5"] == "three"); - CHECK(l.root().at("k5") == "three"); - CHECK(l.root().find("k5").value() == "three"); - CHECK(l.root()["k0"].is_null()); - CHECK(l.root()["k199"] == json::array()); + CHECK(l.root()["k5"] == 5); + CHECK(l.root().at("k5") == 5); + CHECK(l.root().find("k5").value() == 5); + CHECK(l.root()["k0"] == 0); + CHECK(l.root()["k199"] == 199); CHECK(l.root()["k100"] == 100); + // materialize() keeps the semantics of parse(): the last value CHECK(l.root().materialize() == d.root().materialize()); + CHECK(l.root().materialize()["k5"] == "three"); } } @@ -355,15 +357,8 @@ TEST_CASE("json_view images: round trips") for (std::size_t i = 0; i < members; ++i) { CAPTURE(i) - const bool duplicate = std::find(duplicated.begin(), duplicated.end(), keys[i]) != duplicated.end(); - if (duplicate) - { - CHECK(l.root()[keys[i]] == "last"); - } - else - { - CHECK(l.root()[keys[i]] == i); - } + // lookups find the first member, also of the duplicated keys + CHECK(l.root()[keys[i]] == i); CHECK(l.root().at(keys[i]) == l.root()[keys[i]]); CHECK(l.root().find(keys[i]).key() == keys[i]); CHECK(!l.root().contains(keys[i] + "x"));