diff --git a/.github/workflows/flawfinder.yml b/.github/workflows/flawfinder.yml index 2aebc6306..0c6e5fa9c 100644 --- a/.github/workflows/flawfinder.yml +++ b/.github/workflows/flawfinder.yml @@ -43,8 +43,8 @@ jobs: - name: flawfinder_scan uses: david-a-wheeler/flawfinder@c4216b74cf2639ffa98503768bd6e4299b5440c9 # v2.0.20 with: - # single_include/ is omitted: it is generated from include/ and would report every finding twice - arguments: '--sarif ./cmake ./docs ./include ./src ./tests ./tools' + # single_include/ is generated from include/ and would report every finding twice + arguments: "--sarif --exclude '*/single_include/*' ./" output: 'flawfinder_results.sarif' - name: Upload analysis results to GitHub Security tab diff --git a/docs/mkdocs/docs/api/basic_json/contains.md b/docs/mkdocs/docs/api/basic_json/contains.md index a06f37f47..65ccca142 100644 --- a/docs/mkdocs/docs/api/basic_json/contains.md +++ b/docs/mkdocs/docs/api/basic_json/contains.md @@ -61,11 +61,18 @@ Strong exception safety: if an exception occurs, the original value stays intact ## Complexity -Logarithmic in the size of the JSON object. +1. Logarithmic in the size of the JSON object. +2. Logarithmic in the size of the JSON object. +3. At most one lookup per reference token of `ptr`, each logarithmic in the size of the container at that level: + O(*n* log *m*) for *n* reference tokens and containers of size at most *m*. +4. Deleted; a call with an integral argument does not compile. ## Notes -- This method always returns `#!cpp false` when executed on a JSON type that is not an object. +- Overloads (1) and (2) always return `#!cpp false` when executed on a JSON type that is not an object. Overload (3) + also works on arrays and on primitive values: the empty JSON pointer resolves in every JSON value (so + `#!cpp json(1).contains(""_json_pointer)` returns `#!cpp true`), and `#!cpp json({1, 2}).contains("/1"_json_pointer)` + returns `#!cpp true`. - This method can be executed on any JSON value type. - Calling this function with an integer argument (for example, `#!cpp contains(0)`) does not compile: such an argument would otherwise implicitly convert to a null `#!cpp const char*` and, from there, cause undefined behavior when diff --git a/docs/mkdocs/docs/api/basic_json/diff.md b/docs/mkdocs/docs/api/basic_json/diff.md index 681134308..655261a3d 100644 --- a/docs/mkdocs/docs/api/basic_json/diff.md +++ b/docs/mkdocs/docs/api/basic_json/diff.md @@ -2,17 +2,27 @@ ```cpp static basic_json diff(const basic_json& source, - const basic_json& target); + const basic_json& target, + const string_t& path = ""); ``` Creates a [JSON Patch](http://jsonpatch.com) so that value `source` can be changed into the value `target` by calling [`patch`](patch.md) function. -For two JSON values `source` and `target`, the following code always yields `#!cpp true`: +For two JSON values `source` and `target`, the following code yields `#!cpp true`: ```cpp source.patch(diff(source, target)) == target; ``` +The equality holds up to [`operator==`](operator_eq.md), so it has the following exceptions: + +- A value that is not equal to itself cannot round-trip. This applies to `NaN` (also nested in an array or object) and + to [discarded](is_discarded.md) values: `diff` emits a `replace` operation for them. A `NaN` in the patch is + serialized as `#!json null`, so a patch that was dumped and parsed again would set `#!json null`. +- Numbers that compare equal produce no operation, even if their types or signs differ: `diff(1, 1.0)`, + `diff(1u, 1)` (unsigned and signed integer) and `diff(0.0, -0.0)` all return `#!json []`. The patched value is then + the `source` and keeps its number type. + ## Parameters `source` (in) @@ -21,6 +31,12 @@ source.patch(diff(source, target)) == target; `target` (in) : JSON value to compare against +`path` (in) +: a prefix that is put verbatim in front of the `path` member of every operation (default: `""`, which addresses the + root of the value). The prefix is neither escaped nor validated: `#!cpp diff(1, 2, "abc")` yields an operation with + the `path` `abc`, which is not a valid JSON Pointer. Use it with a valid JSON Pointer such as `/root` and escape + `~` and `/` in its reference tokens (see [JSON Pointer](../../features/json_pointer.md)). + ## Return value a JSON patch to convert the `source` to `target` @@ -31,11 +47,32 @@ Strong guarantee: `source` and `target` are never modified. ## Complexity -Linear in the lengths of `source` and `target`. +Linear in the total number of values (including nested ones) in `source` and `target`, times the cost of a key-based +object operation (looking up a key in an object): logarithmic for [`json`](../json.md), and linear for +[`ordered_json`](../ordered_json.md) (see [`ordered_map` complexity](../ordered_map.md#complexity)). For +`ordered_json`, diffing two objects with `n` and `m` members therefore takes O((n+m)²) when their keys do not line up +(e.g., a key was added or the keys are in a different order). The size of the patch is proportional to the number of +operations times the length of their paths, so a small change deep inside a value yields a long `path`. ## Notes -Currently, only `remove`, `add`, and `replace` operations are generated. +- Currently, only `remove`, `add`, and `replace` operations are generated. +- The patch is correct, but not guaranteed to be minimal. For instance, `diff([1,2,3], [0,1,2,3])` yields three + `replace` operations and one `add` operation instead of a single insertion. +- Differences between two arrays are reported index by index. Elements that were added at the end of the array are added + with `add` operations on the path `/-` (appended in order), and elements that were removed are removed with `remove` + operations from the highest index down to the lowest, so that the remaining indices stay valid. +- For objects, the operations for the members of `source` come first (`replace`, `remove`, or the operations for nested + values, in the iteration order of `source`), followed by `add` operations for the members that only `target` has. +- For [`ordered_json`](../ordered_json.md), objects with the same keys in a different order are different. As a patch + cannot reorder members, `diff` removes all keys of the `source` object and adds them again in the order of the + `target`. +- If `source` and `target` differ as a whole (e.g., different types), the patch consists of a single `replace` + operation for the root of the value, i.e., with the `path` `""` (or the given `path` prefix). +- The `value` members of the patch hold copies of values of `target`. A [binary](../../features/binary_values.md) value + has no JSON representation: when a patch that contains one is serialized, it becomes an object such as + `#!json {"bytes":[1,3],"subtype":null}`, so a parsed patch no longer holds a binary value (and would set that object + instead). ## Examples diff --git a/docs/mkdocs/docs/api/basic_json/merge_patch.md b/docs/mkdocs/docs/api/basic_json/merge_patch.md index 5d34508b9..fe83f6cc3 100644 --- a/docs/mkdocs/docs/api/basic_json/merge_patch.md +++ b/docs/mkdocs/docs/api/basic_json/merge_patch.md @@ -39,13 +39,33 @@ Basic guarantee: if an exception is thrown during the operation, the JSON value ## Complexity -Linear in the lengths of `apply_patch`. +Linear in the total number of values (including nested ones) in `apply_patch`, times the cost of a key-based object +operation (looking up, adding, or erasing a member): logarithmic for [`json`](../json.md), and linear for +[`ordered_json`](../ordered_json.md) (see [`ordered_map` complexity](../ordered_map.md#complexity)). For +`ordered_json`, patching an object that has `n` members with a patch that has `m` members therefore takes O(n·m). The +cost also includes one copy of `apply_patch` (see the notes below). ## Notes -`apply_patch` may be `#!cpp *this` itself or refer to a value contained in `#!cpp *this` (for example, a subobject -returned by `#!cpp (*this)[key]`); it is read as it was when `merge_patch()` was called, before any modification of -`#!cpp *this`. +- `apply_patch` may be `#!cpp *this` itself or refer to a value contained in `#!cpp *this` (for example, a subobject + returned by `#!cpp (*this)[key]`); it is read as it was when `merge_patch()` was called, before any modification of + `#!cpp *this`. To make this possible, the function copies `apply_patch` before it modifies `#!cpp *this`, so the cost + includes one copy of the patch. +- A merge patch uses `#!json null` to remove a member, so it cannot set a member to `#!json null`: applying + `#!json {"a":null}` to `#!json {"a":1}` gives `#!json {}`. +- Arrays are not merged: an array in the patch replaces the target value as a whole, and a `#!json null` inside an + array is kept as an element. `#!json null` members of the target that the patch does not mention are preserved. +- A `#!json null` in the patch only removes a member; it does not prevent the creation of the enclosing objects. + Applying `#!json {"a":{"b":null}}` to `#!json {}` gives `#!json {"a":{}}` (see example 15 in Appendix A of the RFC). +- If `apply_patch` is not an object, it replaces the target as a whole. In particular, a `#!json null` patch turns the + target into `#!json null`. If `apply_patch` is an object and the target is not, the target is first replaced by an + empty object, so applying `#!json {}` to `#!json 1` gives `#!json {}`. +- A merge patch cannot express every change; for example, it cannot reorder array elements or remove a specific array + element. Use [JSON Patch](../../features/json_patch.md) ([RFC 6902](https://tools.ietf.org/html/rfc6902)) for that. + [`merge_diff`](merge_diff.md) has a matching limitation, see its notes. +- A [discarded](is_discarded.md) value is treated like any other non-object value: a discarded + `apply_patch` turns the target into a discarded value, and an object patch replaces a discarded target by an object + as described above. ## Examples diff --git a/docs/mkdocs/docs/api/basic_json/operator[].md b/docs/mkdocs/docs/api/basic_json/operator[].md index 81972e0f7..59e48d984 100644 --- a/docs/mkdocs/docs/api/basic_json/operator[].md +++ b/docs/mkdocs/docs/api/basic_json/operator[].md @@ -137,10 +137,13 @@ Strong exception safety: if an exception occurs, the original value stays intact When the JSON pointer traverses intermediate levels that don't exist at all yet (not just a missing leaf), each missing level is created as an array or an object depending on whether the corresponding - pointer token parses as a non-negative integer: a numeric token creates an array, a non-numeric token - creates an object. For example, on an initially `#!json null` value, `/foo/0/0/0` creates nested arrays, - while `/foo/one/one/one` creates nested objects. This is not specified by the JSON Pointer RFC; it is - this library's own, intentional disambiguation rule. See also [JSON Pointer](../../features/json_pointer.md). + pointer token is a valid array index: the token `0`, a sequence of digits that does not begin with `0`, + or the token `-` creates an array, and every other token creates an object. For example, on an + initially `#!json null` value, `/foo/0/0/0` creates nested arrays, while `/foo/one/one/one` creates + nested objects. Tokens such as `01` or the empty token cannot be array indices (cf. RFC 6901, Sect. 4) + and therefore create objects, just as they would if the level already existed as an object. This is not + specified by the JSON Pointer RFC; it is this library's own, intentional disambiguation rule. See also + [JSON Pointer](../../features/json_pointer.md). !!! warning "Deprecation" diff --git a/docs/mkdocs/docs/api/basic_json/patch.md b/docs/mkdocs/docs/api/basic_json/patch.md index 4bb63a59d..cd553553e 100644 --- a/docs/mkdocs/docs/api/basic_json/patch.md +++ b/docs/mkdocs/docs/api/basic_json/patch.md @@ -40,7 +40,7 @@ Strong guarantee: if an exception is thrown, there are no changes in the JSON va where an existing element is required (the "path" of "replace", the "from" of "move" and "copy"); example: `"array index '-' (3) is out of range"`. - Throws [`out_of_range.403`](../../home/exceptions.md#jsonexceptionout_of_range403) if a JSON pointer inside the patch - could not be resolved successfully in the current JSON value; example: `"key baz not found"`. + could not be resolved successfully in the current JSON value; example: `"key 'baz' not found"`. - Throws [`out_of_range.404`](../../home/exceptions.md#jsonexceptionout_of_range404) if a reference token of a JSON pointer inside the patch cannot be resolved, e.g., `-` in a "remove" operation or `1a` for an array; example: `"unresolved reference token '-'"`. @@ -53,7 +53,10 @@ Strong guarantee: if an exception is thrown, there are no changes in the JSON va - Throws [`out_of_range.414`](../../home/exceptions.md#jsonexceptionout_of_range414) if a "move" operation's "from" location is a proper prefix of its "path" location. - Throws [`other_error.501`](../../home/exceptions.md#jsonexceptionother_error501) if "test" operation was - unsuccessful. + unsuccessful. A "test" operation whose "path" does not exist is unsuccessful, too: the failed lookup is reported as a + failed test, even if the operation has no "value" member (which would otherwise be + [`parse_error.105`](../../home/exceptions.md#jsonexceptionparse_error105)). Which of the two errors is reported for + this combination depends on the order in which the operands of a comparison are evaluated, so do not rely on it. ## Complexity @@ -65,6 +68,10 @@ the JSON value, the complexity can usually be neglected. The application of a patch is atomic: Either all operations succeed and the patched document is returned or an exception is thrown. In any case, the original value is not changed: the patch is applied to a copy of the value. +Duplicate member names inside an operation object are resolved by the parser before the patch is applied (the last one +is kept, see [`object_t`](object_t.md#behavior)), so `#!json {"op": "add", "op": "remove", "path": "/a"}` is a +`remove` operation. + ## Examples ??? example "Example: apply a JSON patch" @@ -110,6 +117,7 @@ is thrown. In any case, the original value is not changed: the patch is applied - Added in version 2.0.0. - Added [`out_of_range.411`](../../home/exceptions.md#jsonexceptionout_of_range411) and stopped relying on an internal assertion when an "add" operation's target location has a non-object/non-array parent in version 3.13.0. + This includes a `null` parent, which was previously silently converted to an object, in version 3.13.0. - Added [`out_of_range.413`](../../home/exceptions.md#jsonexceptionout_of_range413) and stopped silently ignoring a "remove" operation whose target location has a non-object/non-array parent in version 3.13.0. - Added [`out_of_range.414`](../../home/exceptions.md#jsonexceptionout_of_range414) and rejected a "move" operation whose "from" location is a proper diff --git a/docs/mkdocs/docs/api/basic_json/patch_inplace.md b/docs/mkdocs/docs/api/basic_json/patch_inplace.md index 56b1bb329..67c6e8814 100644 --- a/docs/mkdocs/docs/api/basic_json/patch_inplace.md +++ b/docs/mkdocs/docs/api/basic_json/patch_inplace.md @@ -36,7 +36,7 @@ No guarantees, value may be corrupted by an unsuccessful patch operation. where an existing element is required (the "path" of "replace", the "from" of "move" and "copy"); example: `"array index '-' (3) is out of range"`. - Throws [`out_of_range.403`](../../home/exceptions.md#jsonexceptionout_of_range403) if a JSON pointer inside the patch - could not be resolved successfully in the current JSON value; example: `"key baz not found"`. + could not be resolved successfully in the current JSON value; example: `"key 'baz' not found"`. - Throws [`out_of_range.404`](../../home/exceptions.md#jsonexceptionout_of_range404) if a reference token of a JSON pointer inside the patch cannot be resolved, e.g., `-` in a "remove" operation or `1a` for an array; example: `"unresolved reference token '-'"`. @@ -49,7 +49,10 @@ No guarantees, value may be corrupted by an unsuccessful patch operation. - Throws [`out_of_range.414`](../../home/exceptions.md#jsonexceptionout_of_range414) if a "move" operation's "from" location is a proper prefix of its "path" location. - Throws [`other_error.501`](../../home/exceptions.md#jsonexceptionother_error501) if "test" operation was - unsuccessful. + unsuccessful. A "test" operation whose "path" does not exist is unsuccessful, too: the failed lookup is reported as a + failed test, even if the operation has no "value" member (which would otherwise be + [`parse_error.105`](../../home/exceptions.md#jsonexceptionparse_error105)). Which of the two errors is reported for + this combination depends on the order in which the operands of a comparison are evaluated, so do not rely on it. ## Complexity @@ -62,6 +65,10 @@ Unlike [`patch`](patch.md), `patch_inplace` applies the operation "in place" and That makes it faster for large documents by avoiding the copy. However, the JSON value might be corrupted if the function throws an exception. +Duplicate member names inside an operation object are resolved by the parser before the patch is applied (the last one +is kept, see [`object_t`](object_t.md#behavior)), so `#!json {"op": "add", "op": "remove", "path": "/a"}` is a +`remove` operation. + ## Examples ??? example "Example: apply a JSON patch in place" @@ -107,6 +114,7 @@ function throws an exception. - Added in version 3.11.0. - Added [`out_of_range.411`](../../home/exceptions.md#jsonexceptionout_of_range411) and stopped relying on an internal assertion when an "add" operation's target location has a non-object/non-array parent in version 3.13.0. + This includes a `null` parent, which was previously silently converted to an object, in version 3.13.0. - Added [`out_of_range.413`](../../home/exceptions.md#jsonexceptionout_of_range413) and stopped silently ignoring a "remove" operation whose target location has a non-object/non-array parent in version 3.13.0. - Added [`out_of_range.414`](../../home/exceptions.md#jsonexceptionout_of_range414) and rejected a "move" operation whose "from" location is a proper diff --git a/docs/mkdocs/docs/api/basic_json/unflatten.md b/docs/mkdocs/docs/api/basic_json/unflatten.md index cae2723b0..8b9f2cbc1 100644 --- a/docs/mkdocs/docs/api/basic_json/unflatten.md +++ b/docs/mkdocs/docs/api/basic_json/unflatten.md @@ -36,9 +36,9 @@ The function can throw the following exceptions: - Throws [`parse_error.109`](../../home/exceptions.md#jsonexceptionparse_error109) if an array index in a key is not a number; example: `"array index 'one' is not a number"` - Throws [`out_of_range.404`](../../home/exceptions.md#jsonexceptionout_of_range404) if a level becomes an array - (because one of its keys is `0`) and another key at that level begins with a digit but is not a valid array index - (such as `1a`), or is `-`; example: - `"unresolved reference token '-'"` + (because one of its keys is `0`) and another key at that level is empty (as in `/a/`), is `-`, or begins with a digit + but is not a valid array index (such as `1a`); examples: `"unresolved reference token '-'"`, + `"unresolved reference token ''"` ## Complexity @@ -46,7 +46,7 @@ Linear in the size of the JSON value. ## Notes -Empty objects and arrays are flattened by [`flatten()`](flatten.md) to `#!json null` values and cannot unflattened to +Empty objects and arrays are flattened by [`flatten()`](flatten.md) to `#!json null` values and cannot be unflattened to their original type. A flattened array and a flattened object whose keys are array indices are indistinguishable, because both are @@ -54,7 +54,12 @@ described by the same JSON pointers. A value is therefore restored as an array i reference token `0`, and as an object otherwise: `#!json {"2": 1}` is restored unchanged, whereas `#!json {"0": 1}` is restored as `#!json [1]`. This decision does not depend on the order in which the flattened object is iterated. -Apart from these two cases, for a JSON value `j`, the following is always true: +A third case is an object that has the key `0` next to a key that is not a valid array index: `#!json {"0": 1, "b": 2}` +is flattened to `#!json {"/0": 1, "/b": 2}`, and unflatten treats the level as an array and throws +[`parse_error.109`](../../home/exceptions.md#jsonexceptionparse_error109) because `b` is not a number. (For the keys +`-`, `1a`, or the empty key instead of `b`, it throws [`out_of_range.404`](../../home/exceptions.md#jsonexceptionout_of_range404).) + +Apart from these three cases, for a JSON value `j`, the following is always true: `#!cpp j == j.flatten().unflatten()`. ## Examples diff --git a/docs/mkdocs/docs/api/basic_json/value.md b/docs/mkdocs/docs/api/basic_json/value.md index 164ea70ae..1f0bb2a97 100644 --- a/docs/mkdocs/docs/api/basic_json/value.md +++ b/docs/mkdocs/docs/api/basic_json/value.md @@ -45,6 +45,9 @@ This is equivalent to Python's `dict.get(key, default)`. return default_value; } ``` + with one exception: if the JSON value is neither an array nor an object, the function throws + [`type_error.306`](../../home/exceptions.md#jsonexceptiontype_error306) before looking at `ptr`, even for the empty + pointer, whereas `at` returns the value itself for the empty pointer. !!! note "Differences to `at` and `operator[]`" diff --git a/docs/mkdocs/docs/api/json_pointer/parent_pointer.md b/docs/mkdocs/docs/api/json_pointer/parent_pointer.md index fdc4a35fd..1cb26c920 100644 --- a/docs/mkdocs/docs/api/json_pointer/parent_pointer.md +++ b/docs/mkdocs/docs/api/json_pointer/parent_pointer.md @@ -12,7 +12,8 @@ Parent of this JSON pointer; in case this JSON pointer is the root, the root its ## Exception safety -No-throw guarantee: this function never throws exceptions. +Strong exception safety: if an exception occurs, the original value stays intact. The function is not `#!cpp noexcept` +and allocates a new JSON pointer, so it can throw `#!cpp std::bad_alloc`; it throws no library exceptions. ## Complexity diff --git a/docs/mkdocs/docs/api/operator_ltlt.md b/docs/mkdocs/docs/api/operator_ltlt.md index 823f209fb..813409abd 100644 --- a/docs/mkdocs/docs/api/operator_ltlt.md +++ b/docs/mkdocs/docs/api/operator_ltlt.md @@ -37,7 +37,8 @@ the stream `o` 1. Throws [`type_error.316`](../home/exceptions.md#jsonexceptiontype_error316) if a string stored inside the JSON value is not UTF-8 encoded. Note that unlike the [`dump`](basic_json/dump.md) member functions, no `error_handler` can be set. -2. None. +2. Throws no library exceptions. As the string representation is built with [`to_string`](json_pointer/to_string.md), + `#!cpp std::bad_alloc` is possible. ## Complexity diff --git a/docs/mkdocs/docs/features/json_patch.md b/docs/mkdocs/docs/features/json_patch.md index d00e3eaca..3329c6cba 100644 --- a/docs/mkdocs/docs/features/json_patch.md +++ b/docs/mkdocs/docs/features/json_patch.md @@ -31,6 +31,12 @@ other three continue on its result. If the document is large and the original value is no longer needed, [`patch_inplace`](../api/basic_json/patch_inplace.md) applies the same operations in place instead. +!!! note "Duplicate member names" + + Duplicate member names inside an operation object are resolved by the parser before the patch is applied (the last + one is kept, see [`object_t`](../api/basic_json/object_t.md#behavior)), so + `#!json {"op": "add", "op": "remove", "path": "/a"}` is a `remove` operation. + ??? example "Example: apply a JSON Patch" The following code shows how a JSON patch is applied to a value. @@ -61,12 +67,23 @@ flowchart LR !!! success "Invariant" - For two JSON values *source* and *target*, the following code yields always true: + For two JSON values *source* and *target*, the following code yields true: ```cpp source.patch(diff(source, target)) == target; ``` + The equality holds up to [`operator==`](../api/basic_json/operator_eq.md), with these exceptions: + + - `NaN` and [discarded](../api/basic_json/is_discarded.md) values are not equal to themselves, so the invariant + cannot hold for them. `diff` emits a `replace` operation for such a value, and a `NaN` in a patch is serialized + as `#!json null`. + - Numbers that compare equal produce no operation even if their types differ: `diff(1, 1.0)`, `diff(1u, 1)`, and + `diff(0.0, -0.0)` return `#!json []`, so the patched value keeps the number type of *source*. + - A [binary value](binary_values.md) in a patch is serialized as an object + (`#!json {"bytes":[...],"subtype":null}`), so a patch that was dumped and parsed again no longer holds a binary + value. + ??? example "Example: create a JSON Patch from the difference of two values" The following code shows how a JSON patch is created as a diff for two JSON values. diff --git a/docs/mkdocs/docs/features/json_pointer.md b/docs/mkdocs/docs/features/json_pointer.md index e7b2f1da7..1c3b5bf21 100644 --- a/docs/mkdocs/docs/features/json_pointer.md +++ b/docs/mkdocs/docs/features/json_pointer.md @@ -71,6 +71,10 @@ Then every value inside the JSON document can be identified as follows: Note `/` does not identify the root (i.e., the whole document), but an object entry with empty key `""`. See [RFC 6901](https://tools.ietf.org/html/rfc6901) for more information. +[RFC 6901, section 4](https://tools.ietf.org/html/rfc6901#section-4) states that evaluation fails if an object has +non-unique member names. A `basic_json` object holds one value per key (see [`object_t`](../api/basic_json/object_t.md) +for how duplicate keys are handled while parsing), so evaluating a JSON Pointer never encounters duplicates. + ## JSON Pointer creation JSON Pointers can be created from a string: @@ -88,6 +92,13 @@ auto p = "/nested/one"_json_pointer; The escaping rules of [RFC 6901](https://tools.ietf.org/html/rfc6901) are implemented. See the [constructor documentation](../api/json_pointer/json_pointer.md) for more information. +!!! note "URI fragment representation" + + [RFC 6901, section 6](https://tools.ietf.org/html/rfc6901#section-6) also defines a URI fragment identifier + representation (`#/a%20b`). It is not supported: `#!cpp json::json_pointer("#/a")` throws + [`parse_error.107`](../home/exceptions.md#jsonexceptionparse_error107). Strip the leading `#` and + percent-decode the remainder before creating the JSON pointer. + ## Value access JSON Pointers can be used in the [`at`](../api/basic_json/at.md), [`operator[]`](../api/basic_json/operator%5B%5D.md), diff --git a/docs/mkdocs/docs/features/merge_patch.md b/docs/mkdocs/docs/features/merge_patch.md index 2d2c8ae51..0d2151835 100644 --- a/docs/mkdocs/docs/features/merge_patch.md +++ b/docs/mkdocs/docs/features/merge_patch.md @@ -2,7 +2,7 @@ The library supports JSON Merge Patch ([RFC 7396](https://tools.ietf.org/html/rfc7396)) as a patch format. The merge patch format is primarily intended for use with the HTTP PATCH method as a means of describing a set of -modifications to a target resource's content. This function applies a merge patch to the current JSON value. +modifications to a target resource's content. Applying a merge patch changes the JSON value the patch is applied to. Instead of using [JSON Pointer](json_pointer.md) to specify values to be manipulated, it describes the changes using a syntax that closely mimics the document being modified. Unlike [JSON Patch](json_patch.md), a JSON Merge Patch cannot @@ -48,6 +48,18 @@ flowchart LR [`update`](../api/basic_json/update.md) with `merge_objects` set to `#!cpp true`; see [Merging objects](modifying_values.md#merging-objects). +!!! note "Limitations of RFC 7396" + + - A patch cannot set a member to `#!json null`, because `#!json null` removes the member: + `#!json {"a":1}` patched with `#!json {"a":null}` gives `#!json {}`. + - Arrays are replaced as a whole, never merged, and a `#!json null` inside an array is kept. + - `#!json null` members of the target that the patch does not mention are preserved. + - A `#!json null` that would remove a member of a not yet existing object still creates that object: + `#!json {}` patched with `#!json {"a":{"b":null}}` gives `#!json {"a":{}}`. + - A patch that is not an object replaces the whole target (a `#!json null` patch makes the target + `#!json null`); an object patch applied to a non-object target starts from an empty object. + - [`merge_diff`](../api/basic_json/merge_diff.md#notes) has a matching limitation for `#!json null` members. + ??? example The following code shows how a JSON Merge Patch is applied to a JSON document. diff --git a/docs/mkdocs/docs/features/types/template_parameters.md b/docs/mkdocs/docs/features/types/template_parameters.md index 3488060ea..087a81432 100644 --- a/docs/mkdocs/docs/features/types/template_parameters.md +++ b/docs/mkdocs/docs/features/types/template_parameters.md @@ -390,6 +390,7 @@ using array_t = ArrayType>; described in RFC 6901. Neither `find(const StringType&, size_type)`, nor `substr(pos, count)`, nor `replace(pos, count, const StringType&)` is required. - `empty()`. +- `resize(n)` (one argument) -- used by `to_string` to size the result before the reference tokens are copied into it. - `begin()` and `end()` -- used by [`operator[](const json_pointer&)`](../../api/basic_json/operator%5B%5D.md) to decide whether a reference token denotes an array index. diff --git a/docs/mkdocs/docs/home/exceptions.md b/docs/mkdocs/docs/home/exceptions.md index fc8c127eb..baaea09cf 100644 --- a/docs/mkdocs/docs/home/exceptions.md +++ b/docs/mkdocs/docs/home/exceptions.md @@ -1083,9 +1083,14 @@ a JSON pointer exceeds the range of `size_type` (e.g., on 32-bit platforms). ``` enum value out of range - array index 18446744073709551616 exceeds size_type + array index 18446744073709551615 exceeds size_type ``` + The second message is thrown, for instance, by `#!cpp json::parse("[1, 2]").at("/18446744073709551615"_json_pointer)` + on a 64-bit platform, where `size_type` is `#!cpp std::size_t` and this index is `SIZE_MAX`. An index that does not + fit into 64 bits (such as `18446744073709551616`) is not reported as 410 but as + [`out_of_range.404`](#jsonexceptionout_of_range404). + ### json.exception.out_of_range.411 A JSON Patch `add` operation cannot be applied because the target location's parent is neither an object nor an array. Per [RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902), an `add` target must reference a member of an existing object or an element of an existing array; a primitive value (string, number, boolean, etc.) cannot receive a new member or element. @@ -1100,6 +1105,8 @@ A JSON Patch `add` operation cannot be applied because the target location's par This exception was added in version 3.13.0. Before that, this situation hit an internal assertion (aborting the program in debug builds) or was silently ignored when assertions were disabled. + A `null` parent is rejected as well. Before version 3.13.0, an `add` into a `null` parent silently converted it to an object (e.g., `add` at `/foo/bar` turned `{"foo":null}` into `{"foo":{"bar":1}}`). + ### json.exception.out_of_range.412 BSON stores the length of documents, arrays, strings, and binary values in a signed 32-bit integer, and MessagePack diff --git a/include/nlohmann/detail/json_pointer.hpp b/include/nlohmann/detail/json_pointer.hpp index 4560714ab..457985480 100644 --- a/include/nlohmann/detail/json_pointer.hpp +++ b/include/nlohmann/detail/json_pointer.hpp @@ -984,15 +984,19 @@ class json_pointer // convert null values to arrays or objects before continuing if (ptr->is_null()) { - // check if the reference token is a number - const bool nums = - std::all_of(reference_token.begin(), reference_token.end(), - [](const unsigned char x) + // check if the reference token is a valid array index, that is + // a nonempty sequence of digits without a leading '0' + // (cf. RFC 6901, Sect. 4); tokens that could never be a valid + // array index (such as "01" or "") are treated as object keys + const bool nums = !reference_token.empty() + && (reference_token.size() == 1 || reference_token[0] != '0') + && std::all_of(reference_token.begin(), reference_token.end(), + [](const unsigned char x) { return std::isdigit(x); }); - // change value to an array for numbers or "-" or to object otherwise + // change value to an array for array indices or "-" or to object otherwise *ptr = (nums || reference_token == "-") ? detail::value_t::array : detail::value_t::object; diff --git a/include/nlohmann/json.hpp b/include/nlohmann/json.hpp index 3d40682d9..c2dddd55a 100644 --- a/include/nlohmann/json.hpp +++ b/include/nlohmann/json.hpp @@ -7003,7 +7003,6 @@ public: switch (parent.m_data.m_type) { - case value_t::null: case value_t::object: { // use operator[] to add value @@ -7035,6 +7034,7 @@ public: // the parent of an "add" target must be an object or array // (see #4292) + case value_t::null: case value_t::string: case value_t::boolean: case value_t::number_integer: diff --git a/single_include/nlohmann/json.hpp b/single_include/nlohmann/json.hpp index 1beba149b..f0b788c9e 100644 --- a/single_include/nlohmann/json.hpp +++ b/single_include/nlohmann/json.hpp @@ -24569,15 +24569,19 @@ class json_pointer // convert null values to arrays or objects before continuing if (ptr->is_null()) { - // check if the reference token is a number - const bool nums = - std::all_of(reference_token.begin(), reference_token.end(), - [](const unsigned char x) + // check if the reference token is a valid array index, that is + // a nonempty sequence of digits without a leading '0' + // (cf. RFC 6901, Sect. 4); tokens that could never be a valid + // array index (such as "01" or "") are treated as object keys + const bool nums = !reference_token.empty() + && (reference_token.size() == 1 || reference_token[0] != '0') + && std::all_of(reference_token.begin(), reference_token.end(), + [](const unsigned char x) { return std::isdigit(x); }); - // change value to an array for numbers or "-" or to object otherwise + // change value to an array for array indices or "-" or to object otherwise *ptr = (nums || reference_token == "-") ? detail::value_t::array : detail::value_t::object; @@ -42793,7 +42797,6 @@ public: switch (parent.m_data.m_type) { - case value_t::null: case value_t::object: { // use operator[] to add value @@ -42825,6 +42828,7 @@ public: // the parent of an "add" target must be an object or array // (see #4292) + case value_t::null: case value_t::string: case value_t::boolean: case value_t::number_integer: diff --git a/tests/src/unit-json_patch.cpp b/tests/src/unit-json_patch.cpp index 67ffee6dd..b6c78532d 100644 --- a/tests/src/unit-json_patch.cpp +++ b/tests/src/unit-json_patch.cpp @@ -15,6 +15,7 @@ using nlohmann::json; #endif #include +#include #include #include #include "make_test_data_available.hpp" @@ -490,7 +491,9 @@ TEST_CASE("JSON patch") } // A.13. Invalid JSON Patch Document - // not applicable + // not applicable: the library cannot tell the duplicate "op" members + // apart, because the parser resolves them (the last one is kept); see + // "duplicate member names in an operation" below SECTION("A.14. Escape Ordering") { @@ -1515,6 +1518,24 @@ TEST_CASE("JSON patch - add to a primitive parent (regression #4292)") #endif } + SECTION("null parent") + { + json const doc = {{"foo", nullptr}}; + json const patch = {{{"op", "add"}, {"path", "/foo/bar"}, {"value", 1}}}; +#if JSON_DIAGNOSTICS + CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.411] (/foo) cannot add value: the JSON Patch 'add' target's parent is of type null, but must be an object or array", json::out_of_range&); +#else + CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.411] cannot add value: the JSON Patch 'add' target's parent is of type null, but must be an object or array", json::out_of_range&); +#endif + } + + SECTION("null document") + { + json const doc = nullptr; + json const patch = {{{"op", "add"}, {"path", "/-"}, {"value", 1}}}; + CHECK_THROWS_WITH_AS(doc.patch(patch), "[json.exception.out_of_range.411] cannot add value: the JSON Patch 'add' target's parent is of type null, but must be an object or array", json::out_of_range&); + } + SECTION("number parent") { json const doc = {{"foo", 1}}; @@ -2103,3 +2124,204 @@ TEST_CASE_TEMPLATE("JSON patch - diff round trip on hand-made documents", Json, roundtrip(Json(1), Json::parse("[1]")); } } + +TEST_CASE("JSON patch - behavior documented in the API reference") +{ + SECTION("duplicate member names in an operation") + { + // RFC 6902, A.13: the parser resolves the duplicates (the last one + // is kept), so this is a "remove" and not an "add" + const json patch = json::parse(R"([{"op": "add", "op": "remove", "path": "/a"}])"); + CHECK(patch == json::parse(R"([{"op": "remove", "path": "/a"}])")); + CHECK(json({{"a", 1}, {"b", 2}}).patch(patch) == json({{"b", 2}})); + } + + SECTION("add with a leading zero in an array index") + { + const json patch = R"([{"op": "add", "path": "/01", "value": 1}])"_json; + json doc = json::array(); +#if !defined(JSON_NOEXCEPTION) + CHECK_THROWS_AS(doc.patch(patch), json::parse_error&); + CHECK_THROWS_AS(doc.patch_inplace(patch), json::parse_error&); +#endif + CHECK(doc == json::array()); + } + + SECTION("test compares numbers by value") + { + const json doc = R"({"a": 1, "b": 0, "c": 1.5})"_json; + CHECK(doc.patch(R"([{"op": "test", "path": "/a", "value": 1.0}])"_json) == doc); + CHECK(doc.patch(R"([{"op": "test", "path": "/b", "value": -0.0}])"_json) == doc); + CHECK(doc.patch(R"([{"op": "test", "path": "/c", "value": 1.5}])"_json) == doc); + CHECK_THROWS_AS(doc.patch(R"([{"op": "test", "path": "/a", "value": 2}])"_json), json::other_error&); + CHECK_THROWS_AS(doc.patch(R"([{"op": "test", "path": "/a", "value": "1"}])"_json), json::other_error&); + } + + SECTION("test ignores the order of object members, but not the order of array elements") + { + const json doc = R"({"o": {"x": 1, "y": 2}, "a": [1, 2]})"_json; + CHECK(doc.patch(R"([{"op": "test", "path": "/o", "value": {"y": 2, "x": 1}}])"_json) == doc); + CHECK(doc.patch(R"([{"op": "test", "path": "/a", "value": [1, 2]}])"_json) == doc); + CHECK_THROWS_AS(doc.patch(R"([{"op": "test", "path": "/a", "value": [2, 1]}])"_json), json::other_error&); + } + + SECTION("test on a path that does not exist") + { + const json doc = R"({"a": 1})"_json; + // with a "value" member, the failed lookup is a failed test + CHECK_THROWS_AS(doc.patch(R"([{"op": "test", "path": "/b", "value": 1}])"_json), json::other_error&); + CHECK_THROWS_AS(doc.patch(R"([{"op": "test", "path": "/b", "value": null}])"_json), json::other_error&); + // without it, an exception is thrown, too; which one depends on the + // order of evaluation of the operands of ==, so it is not checked + CHECK_THROWS(doc.patch(R"([{"op": "test", "path": "/b"}])"_json)); + } + + SECTION("patch() has the strong guarantee") + { + const json doc = R"({"a": [1, 2], "o": {"k": "v"}})"_json; + const json before = doc; + + const std::vector failing = + { + // remove of a member that does not exist + R"([{"op": "add", "path": "/n", "value": 1}, {"op": "remove", "path": "/missing"}])"_json, + // replace of a member that does not exist + R"([{"op": "remove", "path": "/a/0"}, {"op": "replace", "path": "/missing", "value": 1}])"_json, + // move from a member that does not exist + R"([{"op": "add", "path": "/n", "value": 1}, {"op": "move", "from": "/missing", "path": "/m"}])"_json, + // copy from a member that does not exist + R"([{"op": "add", "path": "/n", "value": 1}, {"op": "copy", "from": "/missing", "path": "/m"}])"_json, + // out-of-range array index + R"([{"op": "add", "path": "/n", "value": 1}, {"op": "add", "path": "/a/5", "value": 1}])"_json, + // failing test after a successful operation + R"([{"op": "remove", "path": "/o"}, {"op": "test", "path": "/a", "value": []}])"_json, + }; + for (const auto& patch : failing) + { + CAPTURE(patch.dump()) + CHECK_THROWS_AS(doc.patch(patch), json::exception&); + CHECK(doc == before); + + json copy = doc; + CHECK_THROWS_AS(copy.patch(patch), json::exception&); + CHECK(copy == before); + } + } +} + +TEST_CASE("JSON patch - diff behavior documented in the API reference") +{ + SECTION("path is a verbatim prefix") + { + // not escaped and not validated + CHECK(json::diff(1, 2) == R"([{"op": "replace", "path": "", "value": 2}])"_json); + CHECK(json::diff(1, 2, "abc") == R"([{"op": "replace", "path": "abc", "value": 2}])"_json); + CHECK(json::diff(1, 2, "/root") == R"([{"op": "replace", "path": "/root", "value": 2}])"_json); + // an escaped token in a non-root prefix is kept as given + CHECK(json::diff(R"({"a": 1})"_json, R"({"a": 2})"_json, "/x~1y/~0") + == R"([{"op": "replace", "path": "/x~1y/~0/a", "value": 2}])"_json); + CHECK(json::diff(R"({"a": 1})"_json, R"({"a": 2})"_json, "/p") + == R"([{"op": "replace", "path": "/p/a", "value": 2}])"_json); + } + + SECTION("keys are escaped in the paths") + { + const json source = json::object(); + const json target = + { + {"", 0}, {"/", 1}, {"~", 2}, {"~1", 3}, {"~01", 4} + }; + const json patch = json::diff(source, target); + REQUIRE(patch.size() == 5); + + // the order of the operations is not checked + json paths = json::object(); + for (const auto& op : patch) + { + CHECK(op["op"] == "add"); + paths[op["path"].get()] = op["value"]; + } + CHECK(paths == json({{"/", 0}, {"/~1", 1}, {"/~0", 2}, {"/~01", 3}, {"/~001", 4}})); + CHECK(source.patch(patch) == target); + } + + SECTION("NaN is not equal to itself") + { + const json nan = std::numeric_limits::quiet_NaN(); + const json patch = json::diff(nan, nan); + CHECK(patch.size() == 1); + CHECK(patch[0]["op"] == "replace"); + CHECK(patch[0]["path"] == ""); + CHECK(patch[0]["value"].is_number_float()); + CHECK(nan.patch(patch) != nan); + + // a NaN in a patch is serialized as null + CHECK(json::parse(patch.dump())[0]["value"].is_null()); + + const json nested = {{"a", {1, nan}}}; + const json nested_patch = json::diff(nested, nested); + CHECK(nested_patch.size() == 1); + CHECK(nested_patch[0]["path"] == "/a/1"); + + // infinity is equal to itself + const json inf = std::numeric_limits::infinity(); + CHECK(json::diff(inf, inf).empty()); + } + + SECTION("discarded values are not equal to themselves") + { + const json discarded(json::value_t::discarded); + const json patch = json::diff(discarded, discarded); + CHECK(patch.size() == 1); + CHECK(patch[0]["op"] == "replace"); + CHECK(patch[0]["path"] == ""); + CHECK(patch[0]["value"].is_discarded()); + CHECK(discarded.patch(patch) != discarded); + } + + SECTION("numbers that compare equal produce no operation") + { + CHECK(json::diff(json(1), json(1.0)).empty()); + CHECK(json::diff(json(1.0), json(1)).empty()); + CHECK(json::diff(json(1U), json(1)).empty()); + CHECK(json::diff(json(1), json(1U)).empty()); + CHECK(json::diff(json(0.0), json(-0.0)).empty()); + + // the patched value keeps the number type of the source + const json source = 1; + const json patched = source.patch(json::diff(source, json(1.0))); + CHECK(patched.is_number_integer()); + CHECK(patched == json(1.0)); + + // different values are replaced + CHECK(json::diff(json(1), json(2.0)) == R"([{"op": "replace", "path": "", "value": 2.0}])"_json); + CHECK(json::diff(json(1), json(-1)) == R"([{"op": "replace", "path": "", "value": -1}])"_json); + CHECK(json::diff(json(1.0), json(1.5)) == R"([{"op": "replace", "path": "", "value": 1.5}])"_json); + CHECK(json::diff(json(1), json("1")) == R"([{"op": "replace", "path": "", "value": "1"}])"_json); + } + + SECTION("binary values") + { + const json a = json::binary({1, 2}); + const json b = json::binary({1, 3}); + CHECK(json::diff(a, a).empty()); + + const json patch = json::diff(a, b); + CHECK(patch.size() == 1); + CHECK(patch[0]["op"] == "replace"); + CHECK(patch[0]["path"] == ""); + CHECK(patch[0]["value"].is_binary()); + CHECK(a.patch(patch) == b); + + // serialized, the binary value is an object + const json parsed = json::parse(patch.dump()); + CHECK(parsed[0]["value"].is_object()); + CHECK(parsed[0]["value"] == R"({"bytes": [1, 3], "subtype": null})"_json); + } + + SECTION("a difference as a whole is a replace of the root") + { + CHECK(json::diff(json::array({1}), json::object()) == R"([{"op": "replace", "path": "", "value": {}}])"_json); + CHECK(json::diff(json(nullptr), json::array()) == R"([{"op": "replace", "path": "", "value": []}])"_json); + } +} diff --git a/tests/src/unit-json_pointer.cpp b/tests/src/unit-json_pointer.cpp index 7748a375a..1a4e726ed 100644 --- a/tests/src/unit-json_pointer.cpp +++ b/tests/src/unit-json_pointer.cpp @@ -493,6 +493,72 @@ TEST_CASE("JSON pointers") } } + SECTION("creating intermediate levels") + { + SECTION("tokens that are valid array indices create arrays") + { + json j; + j["/0"_json_pointer] = 1; + CHECK(j == json({1})); + + json j2; + j2["/2"_json_pointer] = 1; + CHECK(j2 == json({nullptr, nullptr, 1})); + + json j3; + j3["/-"_json_pointer] = 1; + CHECK(j3 == json({1})); + + json j4; + j4["/foo/0/0"_json_pointer] = 1; + CHECK(j4 == json({{"foo", {{1}}}})); + } + + SECTION("tokens that are no valid array indices create objects") + { + json j; + j["/one"_json_pointer] = 1; + CHECK(j == json({{"one", 1}})); + + // leading '0' can never be a valid array index (RFC 6901, Sect. 4) + json j2; + j2["/01"_json_pointer] = 1; + CHECK(j2 == json({{"01", 1}})); + + // the empty token is a valid object key, but no valid array index + json j3; + j3["/"_json_pointer] = 1; + CHECK(j3 == json({{"", 1}})); + } + + SECTION("creating a level yields the same result as reusing it (#5357)") + { + json j; + j["/a/b/01/d"_json_pointer] = "value"; + + json j_init = json::object(); + j_init["/a/b"_json_pointer] = json::object(); + j_init["/a/b/01/d"_json_pointer] = "value"; + + const json expected = json::parse(R"({"a":{"b":{"01":{"d":"value"}}}})"); + CHECK(j == expected); + CHECK(j_init == expected); + + // unflatten uses the same key + const json flat = {{"/a/b/01/d", "value"}}; + CHECK(flat.unflatten() == expected); + } + + SECTION("existing arrays still reject invalid indices") + { + json j = {1, 2, 3}; + CHECK_THROWS_WITH_AS(j["/01"_json_pointer], + "[json.exception.parse_error.106] parse error: array index '01' must not begin with '0'", json::parse_error&); + CHECK_THROWS_WITH_AS(j.at("/01"_json_pointer), + "[json.exception.parse_error.106] parse error: array index '01' must not begin with '0'", json::parse_error&); + } + } + SECTION("flatten") { json j = @@ -1044,6 +1110,56 @@ TEST_CASE("flatten of structured values") CHECK(flat_object.unflatten() == nlohmann::ordered_json::parse(R"({"b": {"1": 2}})")); } +#if !defined(JSON_NOEXCEPTION) + SECTION("an object with the key \"0\" next to a non-index key does not round-trip") + { + // the third documented case in which j != j.flatten().unflatten() + const json value = {{"a", {{"0", 1}, {"b", 2}}}}; + const json flat = value.flatten(); + CHECK(flat == json({{"/a/0", 1}, {"/a/b", 2}})); + CHECK_THROWS_WITH_AS(flat.unflatten(), + "[json.exception.parse_error.109] parse error: array index 'b' is not a number", json::parse_error&); + + // the same for other keys that are not valid array indices + CHECK_THROWS_WITH_AS(json({{"/a/0", 1}, {"/a/1a", 2}}).unflatten(), + "[json.exception.out_of_range.404] unresolved reference token '1a'", json::out_of_range&); + CHECK_THROWS_WITH_AS(json({{"/a/0", 1}, {"/a/-", 2}}).unflatten(), + "[json.exception.out_of_range.404] unresolved reference token '-'", json::out_of_range&); + } + + SECTION("unflatten with the empty token among array indices") + { + CHECK_THROWS_WITH_AS(json({{"/a/0", 1}, {"/a/", 2}}).unflatten(), + "[json.exception.out_of_range.404] unresolved reference token ''", json::out_of_range&); + CHECK_THROWS_WITH_AS(json({{"/a/0", 1}, {"/a/1", 2}, {"/a/", 3}}).unflatten(), + "[json.exception.out_of_range.404] unresolved reference token ''", json::out_of_range&); + + // without an index "0", the empty token is an ordinary object key + CHECK(json({{"/a/", 2}}).unflatten() == json({{"a", {{"", 2}}}})); + } + + SECTION("value() with the empty pointer on a primitive root") + { + // unlike at(), value() rejects primitive values before it looks at the pointer + const json number = 1; + CHECK(number.at(""_json_pointer) == 1); + CHECK_THROWS_WITH_AS(number.value(""_json_pointer, 7), + "[json.exception.type_error.306] cannot use value() with number", json::type_error&); + + const json string = "s"; + CHECK_THROWS_WITH_AS(string.value(""_json_pointer, 7), + "[json.exception.type_error.306] cannot use value() with string", json::type_error&); + + // contains() accepts the empty pointer on every value + CHECK(number.contains(""_json_pointer)); + CHECK_FALSE(number.contains("/a"_json_pointer)); + + // arrays and objects return the default for a missing location + CHECK(json::array().value(""_json_pointer, json(7)) == json::array()); + CHECK(json::array().value("/0"_json_pointer, 7) == 7); + } +#endif + SECTION("objects and arrays interleaved") { const json value = diff --git a/tests/src/unit-merge_patch.cpp b/tests/src/unit-merge_patch.cpp index 927eb8773..3f5d79b3c 100644 --- a/tests/src/unit-merge_patch.cpp +++ b/tests/src/unit-merge_patch.cpp @@ -423,3 +423,86 @@ TEST_CASE("merge_patch() with an argument that aliases *this (#5641)") } } } + +TEST_CASE("JSON Merge Patch on non-object, discarded, and binary values") +{ + SECTION("an empty object patch turns a non-object target into an empty object") + { + json target = 1; + target.merge_patch(json::object()); + CHECK(target == json::object()); + + json from_null; + from_null.merge_patch(json::object()); + CHECK(from_null == json::object()); + } + + SECTION("a non-object patch replaces a deeply nested target") + { + // checked by type, since == recurses + SECTION("number") + { + json target = json::parse(nested_objects(1000, 0)); + target.merge_patch(json(42)); + CHECK(target.is_number_integer()); + CHECK(target.get() == 42); + } + + SECTION("array") + { + json target = json::parse(nested_objects(1000, 0)); + target.merge_patch(json({1, nullptr})); + CHECK(target.is_array()); + CHECK(target.size() == 2); + CHECK(target[1].is_null()); + } + + SECTION("null") + { + json target = json::parse(nested_objects(1000, 0)); + target.merge_patch(json(nullptr)); + CHECK(target.is_null()); + } + } + + SECTION("discarded values") + { + const json discarded(json::value_t::discarded); + + // a discarded patch is not an object and replaces the target + json target = {{"a", 1}}; + target.merge_patch(discarded); + CHECK(target.is_discarded()); + + // a discarded target is replaced by an empty object first + json discarded_target(json::value_t::discarded); + discarded_target.merge_patch(json({{"a", 1}})); + CHECK(discarded_target == json({{"a", 1}})); + + json both(json::value_t::discarded); + both.merge_patch(discarded); + CHECK(both.is_discarded()); + } + + SECTION("binary values") + { + const json bin = json::binary({1, 2, 3}, 7); + + // a binary patch is not an object and replaces the target + json target = {{"a", 1}}; + target.merge_patch(bin); + CHECK(target.is_binary()); + CHECK(target.get_binary() == bin.get_binary()); + + // a binary target is replaced by an object + json binary_target = bin; + binary_target.merge_patch(json({{"a", 1}})); + CHECK(binary_target == json({{"a", 1}})); + + // binary values nested in the patch are kept + json nested = json::object(); + nested.merge_patch(json({{"a", bin}})); + CHECK(nested["a"].is_binary()); + CHECK(nested["a"].get_binary() == bin.get_binary()); + } +}