From be6136fdf2f935396010af8eeb45a5fe464e2acc Mon Sep 17 00:00:00 2001 From: Matthias Leuffen Date: Fri, 11 Sep 2026 00:16:34 +0200 Subject: [PATCH] Extract atomic JSON Patch package with stable arrays and upstream edge-case coverage --- .ai-usage-info.md | 33 +- .github/workflows/tests.yml | 33 +- .gitignore | 3 + README.md | 177 ++++++- composer.json | 45 +- docs/test-coverage.md | 82 +++ examples/atomic-failure.php | 32 ++ examples/basic-patch.php | 21 + examples/stable-arrays.php | 28 + phpunit.xml.dist | 2 +- src/JsonPatch.php | 50 ++ src/JsonPatchApplier.php | 198 +++++++ src/JsonPatchOperation.php | 66 +++ src/JsonPointer.php | 62 +++ src/JsonValue.php | 86 +++ src/PatchApplyOptions.php | 54 ++ src/PatchApplyResult.php | 20 + src/PatchConflictException.php | 7 + src/PatchLimitException.php | 7 + src/PatchTestFailedException.php | 7 + src/PatchValidationException.php | 23 + src/StableArrayView.php | 115 ++++ test/ConformanceTest.php | 49 ++ test/EdgeCaseTest.php | 88 +++ test/JsonPatchTest.php | 167 ++++++ test/StableArrayViewTest.php | 68 +++ test/fixtures/json-patch-tests/LICENSE | 202 +++++++ test/fixtures/json-patch-tests/README.md | 75 +++ .../fixtures/json-patch-tests/spec_tests.json | 233 ++++++++ test/fixtures/json-patch-tests/tests.json | 500 ++++++++++++++++++ 30 files changed, 2490 insertions(+), 43 deletions(-) create mode 100644 docs/test-coverage.md create mode 100644 examples/atomic-failure.php create mode 100644 examples/basic-patch.php create mode 100644 examples/stable-arrays.php create mode 100644 src/JsonPatch.php create mode 100644 src/JsonPatchApplier.php create mode 100644 src/JsonPatchOperation.php create mode 100644 src/JsonPointer.php create mode 100644 src/JsonValue.php create mode 100644 src/PatchApplyOptions.php create mode 100644 src/PatchApplyResult.php create mode 100644 src/PatchConflictException.php create mode 100644 src/PatchLimitException.php create mode 100644 src/PatchTestFailedException.php create mode 100644 src/PatchValidationException.php create mode 100644 src/StableArrayView.php create mode 100644 test/ConformanceTest.php create mode 100644 test/EdgeCaseTest.php create mode 100644 test/JsonPatchTest.php create mode 100644 test/StableArrayViewTest.php create mode 100644 test/fixtures/json-patch-tests/LICENSE create mode 100644 test/fixtures/json-patch-tests/README.md create mode 100644 test/fixtures/json-patch-tests/spec_tests.json create mode 100644 test/fixtures/json-patch-tests/tests.json diff --git a/.ai-usage-info.md b/.ai-usage-info.md index b2bf077..875be35 100644 --- a/.ai-usage-info.md +++ b/.ai-usage-info.md @@ -1,14 +1,35 @@ # AI Usage Info -## Sinn der Library +## Purpose -[hier einfügen] +`phore/json-patch` is a provider-independent PHP value library for atomic RFC 6902 +patches and RFC 6901 pointers. It includes optional stable-ID array transport, +operation/path policies, size/depth limits and SHA-256 conflict checks. It has no +AI, HTTP, filesystem persistence or `phore/schema` dependency. -## Beispiele +## Entry points -[hier beispiele in ./examples/ verlinken] +- Namespace: `Phore\JsonPatch` (PSR-4 under `src/`). +- `JsonPatch::fromArray()` creates a native RFC patch with `value` and `from`. +- `JsonPatchApplier::apply()` returns `PatchApplyResult`; read the new document + from `value`. The original stays unchanged, including on failure. +- `JsonPointer::get()` reads a pointer; `escape()` escapes one path segment. +- `StableArrayView::encode()` / `decode()` transform lists to `$values`/`$order` + views. Both map entries and ordering references must be updated in one batch. +- `JsonValue::hash()` hashes a normalized snapshot for `expectedHash` policies. +- No global functions, provider wire formats or PHP struct hydration are defined. -## Globale Funktionen +## Examples -[hier links auf beispiele von speziellen funktionen einfügen] +- [Basic patch and escaping](examples/basic-patch.php) +- [Atomic failure and conflict detection](examples/atomic-failure.php) +- [Stable-ID edits, deletion, insertion and ordering](examples/stable-arrays.php) +Use `stdClass` for JSON objects and PHP lists for JSON arrays. `{}` and `[]` are +different values. Null is a valid explicit `value`. Paths are sequential, not +relative to the original snapshot. The default allowlist is add/remove/replace/test; +move/copy and root mutations require explicit opt-in. Errors provide machine-readable +codes and operation metadata without patch values. + +Run `composer test` and `composer examples`. The comprehensive API and package +publication instructions are in [README.md](README.md). diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2f5d5ec..2336c82 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,16 +1,29 @@ name: tests -on: [push] +on: [push, pull_request] -jobs: - build: +permissions: + contents: read +jobs: + test: runs-on: ubuntu-latest - + container: + image: php:8.5-cli steps: - - uses: actions/checkout@v1 - - name: Download kickstart - run: curl -o ./kickstart.sh 'https://raw.githubusercontent.com/nfra-project/nfra-kickstart/master/dist/kickstart.sh' && chmod +x ./kickstart.sh - - name: UnitTests - run: ./kickstart.sh :test - + - uses: actions/checkout@v4 + - name: Install Composer + run: | + apt-get update + apt-get install -y --no-install-recommends git unzip + php -r "copy('https://getcomposer.org/installer', 'composer-setup.php');" + php composer-setup.php --install-dir=/usr/local/bin --filename=composer + rm composer-setup.php + - name: Validate package + run: composer validate --strict + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + - name: Unit tests + run: composer test + - name: Executable examples + run: composer examples diff --git a/.gitignore b/.gitignore index 04ce9b6..be801ad 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,6 @@ Desktop.ini $RECYCLE.BIN/ .Trashes/ + +# PHPUnit generated cache +/.phpunit.cache/ diff --git a/README.md b/README.md index e2c1ff0..7e36f79 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,176 @@ -# phore-project-template -Template Repository for phore library projects +# Phore JSON Patch -## Git Submodules +Atomic JSON Patch for PHP 8.5+, with RFC 6901 pointers, all six RFC 6902 +operations, configurable limits, optimistic conflict checks and an optional +stable-ID representation for editing arrays. Runtime dependencies: PHP and JSON +only. No AI provider, HTTP client or schema library is required. -Beim Klonen direkt mit auschecken: +Extracted from the deterministic patch core of `phore/ai-harness`. Typed object +hydration, schema validation and AI-generated patch envelopes remain in that +package. Public classes now use `Phore\JsonPatch\`. -```bash -git clone --recurse-submodules +## Installation + +The initial package is being prepared for publication. After it is available on +Packagist: + +```sh +composer require phore/json-patch +``` + +For development before publication, clone this repository and run +`composer install`, `composer test` and `composer examples`. + +## Apply a patch + +```php +require 'vendor/autoload.php'; + +use Phore\JsonPatch\{JsonPatch, JsonPatchApplier}; + +$original = (object) ['title' => 'Draft', 'tags' => ['php']]; +$patch = JsonPatch::fromArray([ + ['op' => 'test', 'path' => '/title', 'value' => 'Draft'], + ['op' => 'replace', 'path' => '/title', 'value' => 'Published'], + ['op' => 'add', 'path' => '/tags/-', 'value' => 'json-patch'], +]); +$result = (new JsonPatchApplier())->apply($original, $patch); +$edited = $result->value; +// $edited: {"title":"Published","tags":["php","json-patch"]} +// $original still contains "Draft" and ["php"]. +``` + +Operations run sequentially against a detached candidate. Any error aborts the +batch without changing the input. Operation values are also copied on construction +and access, and `copy` creates independent values. An optional `validator` callback +receives a separate copy of the final candidate once; returning `false` or throwing +rejects it. This does not roll back external side effects in application callbacks. + +JSON objects are `stdClass`, arrays are PHP lists. `JsonValue::decode()` preserves +that distinction; use `(object) []` for `{}` and `[]` for `[]`. Associative PHP +arrays normalize to objects. Null is a value, distinct from a missing member. +`test` compares object members without ordering, arrays with ordering and numbers +numerically; strings and booleans are distinct from numbers. + +## Pointers and operations + +| Operation | Meaning | Required fields | +|---|---|---| +| `add` | Insert into a list or create/overwrite an object member | `op`, `path`, `value` | +| `remove` | Remove an existing value | `op`, `path` | +| `replace` | Replace an existing value | `op`, `path`, `value` | +| `move` | Remove source, then add at destination | `op`, `path`, `from` | +| `copy` | Copy source by value into destination | `op`, `path`, `from` | +| `test` | Reject the batch unless the existing value is equal | `op`, `path`, `value` | + +`JsonPointer::get($document, $pointer)` reads a value; `JsonPointer::escape($key)` +escapes a property name. The empty pointer `''` addresses the root, `/` the +empty-string property, `~0` a tilde and `~1` a slash. URI fragments and percent +decoding are not supported. Array indices must be canonical nonnegative decimal +integers without leading zeros. `-` appends for add destinations, including those +of move/copy. Parents must already exist. Array indices always refer to the state +after preceding operations; move resolves the destination after removal. + +All six operations are implemented. Defaults deliberately allow only +`add`, `remove`, `replace`, `test`, and forbid root mutations. To enable the full +operation set and root changes: + +```php +use Phore\JsonPatch\PatchApplyOptions; + +$options = new PatchApplyOptions( + allowedOperations: ['add', 'remove', 'replace', 'move', 'copy', 'test'], + allowRootReplacement: true, +); +$result = (new JsonPatchApplier())->apply($original, $patch, $options); ``` -Nachträglich initialisieren oder aktualisieren: +Removing the root sets `documentExists` to `false`; this differs from JSON null. +Only a subsequent root `add` can recreate it. Moving a path into its own descendant +is rejected, including array paths whose indices would shift. `fromArray()` ignores +unrecognized extension members; the typed operation contract rejects a non-null +`from` on add/remove/replace/test and non-null `value` on remove/move/copy. + +## Limits and conflict protection + +| `PatchApplyOptions` property | Default | Effect | +|---|---|---| +| `atomic` | `true` | Non-atomic application is unsupported. | +| `maxOperations` | `100` | Bounds batch length. | +| `maxPatchBytes` | `65536` | Bounds serialized native patch size. | +| `maxDocumentBytes` | `4194304` | Bounds input and each intermediate document. | +| `maxDepth` | `64` | Bounds documents and pointers. | +| `allowedOperations` | add, remove, replace, test | Explicit operation allowlist. | +| `allowRootReplacement` | `false` | Enables root mutations. | +| `forbiddenPaths` | `[]` | Protects overlapping paths and indirect array shifts. | +| `requireTests` | `'none'` | `'arrays'` guards positional edits; `'all'` guards every remove/replace/move source. | +| `expectedHash` | `null` | Requires the current input hash to match. | +| `dryRun` | `false` | Returns a validated candidate with `dry_run` status. | + +A required guard must be the immediately preceding successful `test`. It must +cover the mutation or an identity/value inside the exact array element being +removed/replaced. Guards do not make numeric indices stable. Protected paths block +reads as well as mutations, including ancestors and copy/move sources. + +Use `JsonValue::hash($snapshot)` as `expectedHash`. Hashes sort object keys +recursively and preserve list order; this is a local format, not RFC 8785. +A missing document hashes as SHA-256 of `undefined`. Persistence code must compare +and swap the expected version/hash in its own transaction; this library only +checks the supplied in-memory document. Dry run also leaves the input unchanged. + +`PatchApplyResult` exposes `value`, `documentExists`, `status`, `oldHash`, `newHash`, +`operationCount`, `changedPaths` and `patch`. Changed paths include explicit +no-op replacements and move sources; they are not a minimal semantic diff. + +Exceptions derive from `PatchValidationException`: `PatchTestFailedException`, +`PatchLimitException` and `PatchConflictException`. Operation failures expose +`errorCode`, `operationIndex` (zero-based), `op` and `path`, without embedding +values in messages. Configuration errors throw `InvalidArgumentException`. + +## Stable array addressing -```bash -git submodule update --init --recursive -git submodule update --remote --merge +`StableArrayView` encodes lists into ordinary JSON objects with `$order` and +`$values`. Patches remain standard JSON Patch; IDs replace positional addressing +inside this transport view. + +```json +{"items":{"$order":["item_a","item_b"],"$values":{"item_a":{"id":"a"},"item_b":{"id":"b"}}}} ``` +The keys above illustrate the shape. Actual encoding produces opaque deterministic +keys from scalar `id`, `key` or `uuid` values. Missing/duplicate automatic identities +get request-local ephemeral keys. `new StableArrayView(['/items' => '/id'])` +requires a present, unique scalar identity at that pointer for each item. Explicit +identities may use nested pointers such as `/metadata/uuid`. + +1. Encode the snapshot and retain the generated keys. +2. Edit `/items/$values//name` to change a particular item. +3. Add/remove both the value entry and its `$order` reference in the batch. +4. Replace `$order` to reorder items, then decode the final candidate. + +Business-ID changes do not retarget existing transport keys during a batch. +Ephemeral keys have no cross-request meaning. Every nested/new list uses the same +wrapper, except `$order` itself. Decoding rejects duplicate references, missing +values, orphan values, malformed wrappers and unwrapped lists. Original objects +with reserved `$order` or `$values` members cannot use this mode. + +Validate the final transport in a callback that calls `$codec->decode($candidate)` +and returns void, as in the executable example. Intermediate steps may temporarily +have unmatched references; validation belongs after the batch. + +## Executable examples and tests + +- [Basic patch and escaped pointer](examples/basic-patch.php) +- [Atomic failure and stale-hash conflict](examples/atomic-failure.php) +- [Combined stable-array deletion, edit, addition and reorder](examples/stable-arrays.php) +- [Coverage comparison, upstream sources and deliberate differences](docs/test-coverage.md) + +```sh +composer validate --strict +composer test +composer examples +``` +CI executes the suite and examples on PHP 8.5. The package includes the unchanged +Apache-2.0 JSON Patch test corpus with its attribution and license; see +`test/fixtures/json-patch-tests`. Runtime source retains the MIT package license. diff --git a/composer.json b/composer.json index 2e808bc..04075f7 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,7 @@ { - "name": "phore/project", + "name": "phore/json-patch", "type": "library", - "description": "", + "description": "Atomic RFC 6902 JSON Patch and RFC 6901 JSON Pointer for PHP, with stable-ID array views, limits and conflict protection.", "license": "MIT", "authors": [ { @@ -11,27 +11,40 @@ ], "autoload": { "psr-4": { - "Phore\\FileSystem\\": "src/" - }, - "files": [ - "src/functions.php" - ] + "Phore\\JsonPatch\\": "src/" + } }, "require": { - "php" : ">=8.3", - "ext-yaml": "*", + "php": ">=8.5", "ext-json": "*" }, "require-dev": { "phpunit/phpunit": "^13.2.2" }, - "suggest": { - }, "config": { - "preferred-install": "source", - "allow-plugins": { - "php-http/discovery": false - } + "preferred-install": "dist", + "sort-packages": true }, - "minimum-stability": "dev" + "keywords": [ + "json", + "json-patch", + "json-pointer", + "rfc6902", + "rfc6901", + "atomic", + "stable-id" + ], + "homepage": "https://github.com/phore/phore-json-patch", + "support": { + "issues": "https://github.com/phore/phore-json-patch/issues", + "source": "https://github.com/phore/phore-json-patch" + }, + "scripts": { + "test": "phpunit -c phpunit.xml.dist", + "examples": [ + "@php examples/basic-patch.php", + "@php examples/atomic-failure.php", + "@php examples/stable-arrays.php" + ] + } } diff --git a/docs/test-coverage.md b/docs/test-coverage.md new file mode 100644 index 0000000..c62ddc9 --- /dev/null +++ b/docs/test-coverage.md @@ -0,0 +1,82 @@ +# Test coverage and upstream comparison + +The extracted baseline contained 13 PHPUnit tests in `JsonPatchTest` and +`StableArrayViewTest`, with several assertions/cases inside each method. It already +covered RFC appendix examples, object-copy isolation, null vs missing values, +escaping, atomic failure, policies, root deletion/recreation and stable transport. + +The comparison adds 112 upstream data sets and 75 independently written edge-case +data sets. Total: **200 tests, 628 assertions, 4 upstream-disabled cases skipped** +with PHPUnit 13.3.3 on PHP 8.5.10. No additional runtime changes were needed to +pass these cases. Counts describe this revision, not a promise of complete RFC +conformance or exhaustive input coverage. + +## Sources pinned for reproducibility + +- [JSON Patch Tests, revision 2a928f9](https://github.com/json-patch/json-patch-tests/tree/2a928f9044aad35c74e2788d498bcf2c6b91adea): + unchanged `tests.json` (95 records) and `spec_tests.json` (17 records), plus its + README attribution and Apache-2.0 license, in `test/fixtures/json-patch-tests/`. + `ConformanceTest` executes each enabled case with all six operations and root + mutations enabled, checks the expected result/error and verifies unchanged input + on both success and failure. Test runs require no network access. +- [python-json-patch tests, revision d8e1a6e](https://github.com/stefankoegl/python-json-patch/blob/d8e1a6e244728c04229d601bc9a384d9b034c603/tests.py): + reviewed `ApplyPatchTestCase`, `ConflictTests`, `InvalidInputTests` and operation + structure tests. No Python source is copied into this package. +- [fast-json-patch core tests, revision 9d313ac](https://github.com/Starcounter-Jack/JSON-Patch/blob/9d313ac01916e525e9204074f06e5295edec491b/test/spec/coreSpec.mjs): + reviewed root operations, copy-reference isolation, sequential application and + JavaScript-specific behavior. No JavaScript source is copied into this package. + +## Gaps closed + +| Previously absent or only partially tested | Evidence reviewed | Added coverage | +|---|---|---| +| Empty patches, top-level object/list transitions | Shared corpus | `ConformanceTest` | +| Add at array length vs beyond length; replace/remove at length | Shared corpus; Python `ConflictTests` | Corpus and `EdgeCaseTest::rejectedCases` | +| Repeated removals and index shifts | Shared corpus; Python array operations | Corpus and `sequential removals reindex arrays` | +| Move backwards, append, same location, overwrite, destination bounds after removal | Shared corpus; Python move tests | `EdgeCaseTest` success/rejection data sets | +| Move/copy child object or array to root; self-root operations | fast-json-patch root tests | Six root move/copy data sets | +| Top-level scalar replacement and whole-document test | Disabled shared cases; Python/fast-json-patch root tests | Two enabled independent data sets | +| Copy root into child; nested-list isolation when either branch changes | Python `test_copy_mutable`; fast-json-patch copy-reference test | Two independent copy regressions | +| Null source for move/copy/remove, missing values and malformed operations | Shared corpus; Python operation structure tests | Corpus plus missing-wrapper and missing-test regressions | +| Numeric-looking object keys vs array indices | Shared corpus | Literal-key test and 45 rejection combinations across five operations | +| Unicode, literal percent sequences, prefix vs segment ancestry | Shared pointer cases; Python escape/unicode tests | Independent pointer and move regressions | +| Boolean/number mismatch, reordered arrays, unequal object member sets | Shared tests; fast-json-patch equality checks | Explicit rejection data sets with error codes | +| Scalar parent traversal and invalid same-source move | Python conflict tests | Explicit rejection data sets and unchanged-input checks | + +All 75 `EdgeCaseTest` data sets are independently expressed in PHP with local +examples. Rejection tests assert the precise local `errorCode`; the shared corpus +only requires rejection because its error strings are descriptive, not an API. + +## Intentional differences and scope + +Four shared records are disabled upstream, and the runner preserves those flags: +`tests.json` indices 10 (scalar root), 56 (whole-document test), 85 (duplicate `op`) +and `spec_tests.json` index 13 (duplicate `op`). Indices are zero-based. Our own +tests cover the first two successfully. Duplicate JSON member detection is not +provided by the native PHP JSON decoder; those two raw-syntax tests remain skipped. + +Python's `test_move_array_item_into_other_item` accepts moving `/0` into `/0/bar/0` +because the destination would refer to the next element after removal. We reject +it: [RFC 6902 section 4.4](https://www.rfc-editor.org/rfc/rfc6902#section-4.4) prohibits `from` being a proper prefix of `path`. +`move descendant remains forbidden despite array shift` records this difference. +Moves to non-descendant paths still resolve destinations after source removal. + +Defaults are stricter than unrestricted RFC execution: move/copy and root mutations +need opt-in; limits and protected paths also apply. The existing typed operation +contract rejects non-null unused `from`/`value` fields. Unknown extension members +are ignored. The corpus does not establish acceptance of every possible extension +member combination. Missing-path tests use local `missing_path` errors, not Python's +exception taxonomy. + +Diff generation, minimal-patch optimization, Python custom types and JavaScript +`undefined`, prototypes, observers and in-place mutation APIs are outside this +package's API. Stable-ID transport and its policies are package-specific and +remain covered by the transferred local tests. External libraries' behavior is +evidence for useful test cases, not an authority overriding the RFC or local API. + +## Updating the corpus + +Review a new upstream revision and license, replace both JSON files byte-for-byte, +retain README attribution/license, update the pinned link and disabled-case notes, +then run `composer test`. Do not silently change expected outputs to make a case +pass. Re-run `composer examples` to validate the documented workflows. diff --git a/examples/atomic-failure.php b/examples/atomic-failure.php new file mode 100644 index 0000000..4ab3a06 --- /dev/null +++ b/examples/atomic-failure.php @@ -0,0 +1,32 @@ +'Draft', 'version'=>1]; +$hash = JsonValue::hash($original); +$applier = new JsonPatchApplier(); +try { + $applier->apply($original, JsonPatch::fromArray([ + ['op'=>'replace', 'path'=>'/title', 'value'=>'Published'], + ['op'=>'test', 'path'=>'/version', 'value'=>2], + ])); + throw new RuntimeException('Expected test failure.'); +} catch (PatchTestFailedException $exception) { + if ($exception->operationIndex !== 1 || JsonValue::hash($original) !== $hash) { + throw new RuntimeException('Atomicity check failed.'); + } + echo $exception->errorCode, ': original unchanged', PHP_EOL; +} + +$newer = JsonValue::copy($original); +$newer->version = 2; +try { + $applier->apply($newer, new JsonPatch([]), new PatchApplyOptions(expectedHash: $hash)); + throw new RuntimeException('Expected hash conflict.'); +} catch (PatchConflictException $exception) { + echo $exception->errorCode, ': stale snapshot rejected', PHP_EOL; +} diff --git a/examples/basic-patch.php b/examples/basic-patch.php new file mode 100644 index 0000000..cff846d --- /dev/null +++ b/examples/basic-patch.php @@ -0,0 +1,21 @@ +'test', 'path'=>'/title', 'value'=>'Draft'], + ['op'=>'replace', 'path'=>'/title', 'value'=>'Published'], + ['op'=>'add', 'path'=>'/tags/-', 'value'=>'json-patch'], + ['op'=>'replace', 'path'=>'/settings/a~1b', 'value'=>true], +]); +$result = (new JsonPatchApplier())->apply($original, $patch); +$expected = JsonValue::decode('{"title":"Published","tags":["php","json-patch"],"settings":{"a/b":true}}'); +if (!JsonValue::equal($expected, $result->value) || $original->title !== 'Draft') { + throw new RuntimeException('Unexpected patch result or mutated input.'); +} +echo JsonValue::encode($result->value), PHP_EOL; diff --git a/examples/stable-arrays.php b/examples/stable-arrays.php new file mode 100644 index 0000000..9f6dd19 --- /dev/null +++ b/examples/stable-arrays.php @@ -0,0 +1,28 @@ +'/id']); +$view = $codec->encode($original); +[$a, $b] = $view->items->{'$order'}; +$patch = JsonPatch::fromArray([ + ['op'=>'test', 'path'=>'/items/$values/' . $a . '/id', 'value'=>'a'], + ['op'=>'remove', 'path'=>'/items/$values/' . $a], + ['op'=>'replace', 'path'=>'/items/$values/' . $b . '/name', 'value'=>'Lina'], + ['op'=>'add', 'path'=>'/items/$values/new_c', 'value'=>(object) ['id'=>'c', 'name'=>'Chris']], + ['op'=>'replace', 'path'=>'/items/$order', 'value'=>['new_c', $b]], +]); +$result = (new JsonPatchApplier())->apply($view, $patch, validator: static function (mixed $candidate) use ($codec): void { + $codec->decode($candidate); // Validate references after the entire batch. +}); +$edited = $codec->decode($result->value); +$expected = JsonValue::decode('{"items":[{"id":"c","name":"Chris"},{"id":"b","name":"Lina"}]}'); +if (!JsonValue::equal($edited, $expected) || count($original->items) !== 2 || $original->items[0]->id !== 'a') { + throw new RuntimeException('Unexpected stable-array result or mutated input.'); +} +echo JsonValue::encode($edited), PHP_EOL; diff --git a/phpunit.xml.dist b/phpunit.xml.dist index d773cf1..9b24398 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,7 +1,7 @@ - + test diff --git a/src/JsonPatch.php b/src/JsonPatch.php new file mode 100644 index 0000000..7a07bc9 --- /dev/null +++ b/src/JsonPatch.php @@ -0,0 +1,50 @@ + $operations */ + public function __construct(public array $operations) + { + if (!array_is_list($operations)) { + throw new PatchValidationException('invalid_patch'); + } + foreach ($operations as $operation) { + if (!$operation instanceof JsonPatchOperation) { + throw new PatchValidationException('invalid_patch'); + } + } + } + + public static function fromArray(array $operations): self + { + if (!array_is_list($operations)) { + throw new PatchValidationException('invalid_patch'); + } + $result = []; + foreach ($operations as $index => $operation) { + if ($operation instanceof \stdClass) { + $operation = get_object_vars($operation); + } + if (!is_array($operation)) { + throw new PatchValidationException('invalid_operation', $index); + } + try { + $result[] = JsonPatchOperation::fromArray($operation); + } catch (PatchValidationException $exception) { + throw new PatchValidationException($exception->errorCode, $index, + is_string($operation['op'] ?? null) ? $operation['op'] : null, + is_string($operation['path'] ?? null) ? $operation['path'] : null); + } + } + return new self($result); + } + + public function jsonSerialize(): array + { + return $this->operations; + } +} diff --git a/src/JsonPatchApplier.php b/src/JsonPatchApplier.php new file mode 100644 index 0000000..81ea359 --- /dev/null +++ b/src/JsonPatchApplier.php @@ -0,0 +1,198 @@ +operations) > $options->maxOperations || strlen(JsonValue::encode($patch)) > $options->maxPatchBytes) { + throw new PatchLimitException('patch_limit'); + } + $document = JsonValue::copy($target); + self::checkDocument($document, $options); + $oldHash = JsonValue::hash($document); + if ($options->expectedHash !== null && !hash_equals($options->expectedHash, $oldHash)) { + throw new PatchConflictException('hash_conflict'); + } + $exists = true; + $protected = []; + foreach ($options->forbiddenPaths as $path) { + $protected[$path] = $this->protectedValue($document, $path); + } + $changed = []; + foreach ($patch->operations as $index => $operation) { + try { + $this->checkPolicy($document, $operation, $options, $patch->operations[$index - 1] ?? null); + if (!$exists && !($operation->op === 'add' && $operation->path === '')) { + throw new PatchValidationException('missing_document'); + } + switch ($operation->op) { + case 'test': + if (!JsonValue::equal(JsonPointer::get($document, $operation->path), $operation->value())) { + throw new PatchTestFailedException('test_failed'); + } + break; + case 'move': + case 'copy': + if ($operation->op === 'move' && $operation->from !== $operation->path && JsonPointer::contains($operation->from, $operation->path)) { + throw new PatchValidationException('move_into_descendant'); + } + $value = JsonValue::copy(JsonPointer::get($document, $operation->from)); + if ($operation->op === 'move') { + if ($operation->from === $operation->path) { + break; + } + $this->mutate($document, JsonPointer::tokens($operation->from), 'remove', null, $exists); + $changed[] = $operation->from; + } + $this->mutate($document, JsonPointer::tokens($operation->path), 'add', $value, $exists); + $changed[] = $operation->path; + break; + default: + $this->mutate($document, JsonPointer::tokens($operation->path), $operation->op, $operation->value(), $exists); + $changed[] = $operation->path; + } + self::checkDocument($document, $options); + foreach ($protected as $path => $before) { + if (!JsonValue::equal($before, $this->protectedValue($document, $path))) { + throw new PatchValidationException('path_forbidden'); + } + } + } catch (PatchValidationException $exception) { + throw $exception->atOperation($index, $operation); + } + } + if ($validator !== null) { + if (!$exists || $validator(JsonValue::copy($document)) === false) { + throw new PatchValidationException('final_validation_failed'); + } + } + return new PatchApplyResult( + $options->dryRun ? 'dry_run' : 'applied', $oldHash, + $exists ? JsonValue::hash($document) : hash('sha256', 'undefined'), + count($patch->operations), array_values(array_unique($changed)), $document, $exists, $patch, + ); + } + + private function protectedValue(mixed $document, string $path): array + { + try { + return [true, JsonValue::copy(JsonPointer::get($document, $path))]; + } catch (PatchValidationException) { + return [false]; + } + } + + public static function checkDocument(mixed $value, PatchApplyOptions $options, int $depth = 0): void + { + if ($depth > $options->maxDepth) { + throw new PatchLimitException('depth_limit'); + } + if ($depth === 0 && strlen(JsonValue::encode($value)) > $options->maxDocumentBytes) { + throw new PatchLimitException('document_limit'); + } + if (is_array($value) || $value instanceof \stdClass) { + foreach ($value as $child) { + self::checkDocument($child, $options, $depth + 1); + } + } + } + + private function checkPolicy(mixed $document, JsonPatchOperation $operation, PatchApplyOptions $options, ?JsonPatchOperation $previous): void + { + if (!in_array($operation->op, $options->allowedOperations, true)) { + throw new PatchValidationException('operation_forbidden'); + } + foreach (array_filter([$operation->path, $operation->from], static fn ($p) => $p !== null) as $path) { + if (count(JsonPointer::tokens($path)) > $options->maxDepth) { + throw new PatchLimitException('depth_limit'); + } + foreach ($options->forbiddenPaths as $forbidden) { + // Also prevent a parent replacement/copy/test from bypassing protection. + if (JsonPointer::contains($forbidden, $path) || JsonPointer::contains($path, $forbidden)) { + throw new PatchValidationException('path_forbidden'); + } + } + } + if (!$options->allowRootReplacement && (($operation->path === '' && $operation->op !== 'test') + || ($operation->op === 'move' && $operation->from === ''))) { + throw new PatchValidationException('root_change_forbidden'); + } + if ($options->requireTests === 'none' || !in_array($operation->op, ['remove', 'replace', 'move'], true)) { + return; + } + $path = $operation->op === 'move' ? $operation->from : $operation->path; + $arrayElement = $this->arrayElementPath($document, $path); + if ($options->requireTests === 'arrays' && $arrayElement === null) { + return; + } + if ($previous === null || $previous->op !== 'test') { + throw new PatchValidationException('test_required'); + } + // A test may cover the whole mutation, or an identity inside the exact array element. + if (!JsonPointer::contains($previous->path, $path) + && !($arrayElement === $path && JsonPointer::contains($path, $previous->path))) { + throw new PatchValidationException('test_required'); + } + } + + private function arrayElementPath(mixed $document, string $path): ?string + { + $prefix = ''; + $element = null; + foreach (JsonPointer::tokens($path) as $token) { + $prefix .= '/' . JsonPointer::escape($token); + if (is_array($document)) { + $element = $prefix; + } + $document = JsonPointer::get($document, '/' . JsonPointer::escape($token)); + } + return $element; + } + + /** @param list $tokens */ + private function mutate(mixed &$document, array $tokens, string $op, mixed $value, bool &$exists): void + { + if ($tokens === []) { + $document = $op === 'remove' ? null : $value; + $exists = $op !== 'remove'; + return; + } + $last = array_pop($tokens); + $parent =& $document; + foreach ($tokens as $token) { + if ($parent instanceof \stdClass && property_exists($parent, $token)) { + $parent =& $parent->{$token}; + } elseif (is_array($parent)) { + $index = JsonPointer::index($token, count($parent)); + $parent =& $parent[$index]; + } else { + throw new PatchValidationException('missing_parent'); + } + } + if ($parent instanceof \stdClass) { + if ($op !== 'add' && !property_exists($parent, $last)) { + throw new PatchValidationException('missing_path'); + } + if ($op === 'remove') { + unset($parent->{$last}); + } else { + $parent->{$last} = $value; + } + } elseif (is_array($parent)) { + $index = JsonPointer::index($last, count($parent), $op === 'add'); + if ($op === 'replace') { + $parent[$index] = $value; + } else { + array_splice($parent, $index, $op === 'remove' ? 1 : 0, $op === 'remove' ? [] : [$value]); + } + } else { + throw new PatchValidationException('missing_parent'); + } + } +} diff --git a/src/JsonPatchOperation.php b/src/JsonPatchOperation.php new file mode 100644 index 0000000..e71e310 --- /dev/null +++ b/src/JsonPatchOperation.php @@ -0,0 +1,66 @@ +storedValue = JsonValue::copy($value); + } + + public function value(): mixed + { + return JsonValue::copy($this->storedValue); + } + + public static function fromArray(array $operation): self + { + if (!isset($operation['op'], $operation['path']) || !is_string($operation['op']) || !is_string($operation['path'])) { + throw new PatchValidationException('invalid_operation_fields'); + } + if (in_array($operation['op'], ['add', 'replace', 'test'], true) && !array_key_exists('value', $operation)) { + throw new PatchValidationException('missing_value'); + } + if (isset($operation['from']) && !is_string($operation['from'])) { + throw new PatchValidationException('invalid_operation_fields'); + } + // Unrecognized members are ignored as required by RFC 6902. + return new self($operation['op'], $operation['path'], $operation['value'] ?? null, $operation['from'] ?? null); + } + + public function jsonSerialize(): array + { + $result = ['op' => $this->op, 'path' => $this->path]; + if (in_array($this->op, ['add', 'replace', 'test'], true)) { + $result['value'] = $this->value(); + } + if ($this->from !== null) { + $result['from'] = $this->from; + } + return $result; + } +} diff --git a/src/JsonPointer.php b/src/JsonPointer.php new file mode 100644 index 0000000..28db71b --- /dev/null +++ b/src/JsonPointer.php @@ -0,0 +1,62 @@ + */ + public static function tokens(string $pointer): array + { + if ($pointer === '') { + return []; + } + if ($pointer[0] !== '/' || preg_match('/~(?![01])/', $pointer)) { + throw new PatchValidationException('invalid_pointer'); + } + return array_map(static fn (string $part): string => str_replace(['~1', '~0'], ['/', '~'], $part), explode('/', substr($pointer, 1))); + } + + public static function escape(string $token): string + { + return str_replace(['~', '/'], ['~0', '~1'], $token); + } + + /** Segment-based ancestry, including equality. */ + public static function contains(string $parent, string $child): bool + { + $a = self::tokens($parent); + return array_slice(self::tokens($child), 0, count($a)) === $a; + } + + public static function index(string $token, int $length, bool $adding = false): int + { + if ($adding && $token === '-') { + return $length; + } + if (!preg_match('/^(0|[1-9][0-9]*)$/D', $token) || strlen($token) > strlen((string) PHP_INT_MAX) + || (strlen($token) === strlen((string) PHP_INT_MAX) && strcmp($token, (string) PHP_INT_MAX) > 0)) { + throw new PatchValidationException('invalid_array_index'); + } + $index = (int) $token; + if ($index > $length || (!$adding && $index === $length)) { + throw new PatchValidationException('missing_path'); + } + return $index; + } + + public static function get(mixed $document, string $pointer): mixed + { + foreach (self::tokens($pointer) as $token) { + if ($document instanceof \stdClass && property_exists($document, $token)) { + $document = $document->{$token}; + } elseif (is_array($document)) { + $document = $document[self::index($token, count($document))]; + } else { + throw new PatchValidationException('missing_path'); + } + } + return $document; + } +} diff --git a/src/JsonValue.php b/src/JsonValue.php new file mode 100644 index 0000000..48ba4a8 --- /dev/null +++ b/src/JsonValue.php @@ -0,0 +1,86 @@ + $child) { + $result->{(string) $key} = self::canonical($child); + } + return $result; + } + return is_array($value) ? array_map(self::canonical(...), $value) : $value; + } + + public static function equal(mixed $a, mixed $b): bool + { + if ((is_int($a) && is_float($b)) || (is_float($a) && is_int($b))) { + $integer = is_int($a) ? $a : $b; + $float = is_float($a) ? $a : $b; + // PHP's loose comparison rounds large integers to float first. + return floor($float) === $float && $float >= PHP_INT_MIN && $float < -(float) PHP_INT_MIN && (int) $float === $integer; + } + if ($a instanceof \stdClass && $b instanceof \stdClass) { + $a = get_object_vars($a); + $b = get_object_vars($b); + if (count($a) !== count($b)) { + return false; + } + foreach ($a as $key => $value) { + if (!array_key_exists($key, $b) || !self::equal($value, $b[$key])) { + return false; + } + } + return true; + } + if (is_array($a) && is_array($b)) { + if (array_keys($a) !== array_keys($b)) { + return false; + } + foreach ($a as $key => $value) { + if (!self::equal($value, $b[$key])) { + return false; + } + } + return true; + } + return $a === $b; + } +} diff --git a/src/PatchApplyOptions.php b/src/PatchApplyOptions.php new file mode 100644 index 0000000..1064529 --- /dev/null +++ b/src/PatchApplyOptions.php @@ -0,0 +1,54 @@ + $allowedOperations @param list $forbiddenPaths */ + public function __construct( + public bool $atomic = true, + public int $maxOperations = 100, + public array $allowedOperations = ['add', 'remove', 'replace', 'test'], + public int $maxPatchBytes = 65536, + public int $maxDocumentBytes = 4194304, + public int $maxDepth = 64, + public bool $allowRootReplacement = false, + public array $forbiddenPaths = [], + public string $requireTests = 'none', + public ?string $expectedHash = null, + public bool $dryRun = false, + ) { + if (!$atomic || $maxOperations < 0 || $maxPatchBytes < 1 || $maxDocumentBytes < 1 || $maxDepth < 1 + || !in_array($requireTests, ['none', 'arrays', 'all'], true) + || !array_is_list($allowedOperations) || array_diff($allowedOperations, ['add', 'remove', 'replace', 'move', 'copy', 'test']) !== []) { + throw new \InvalidArgumentException('Invalid patch policy. Non-atomic application is not supported.'); + } + if ($expectedHash !== null && !preg_match('/^[a-f0-9]{64}$/D', $expectedHash)) { + throw new \InvalidArgumentException('expected_hash must be a lowercase SHA-256 hash.'); + } + foreach ($forbiddenPaths as $path) { + if (!is_string($path)) { + throw new \InvalidArgumentException('Forbidden paths must be JSON Pointers.'); + } + JsonPointer::tokens($path); + } + } + + public static function fromArray(array $options): self + { + return new self( + maxOperations: $options['max_operations'] ?? 100, + allowedOperations: $options['allowed_operations'] ?? ['add', 'remove', 'replace', 'test'], + maxPatchBytes: $options['max_patch_bytes'] ?? 65536, + maxDocumentBytes: $options['max_document_bytes'] ?? 4194304, + maxDepth: $options['max_depth'] ?? 64, + allowRootReplacement: $options['allow_root_replacement'] ?? false, + forbiddenPaths: $options['forbidden_paths'] ?? [], + requireTests: $options['require_tests'] ?? 'none', + expectedHash: $options['expected_hash'] ?? null, + dryRun: $options['dry_run'] ?? false, + ); + } +} diff --git a/src/PatchApplyResult.php b/src/PatchApplyResult.php new file mode 100644 index 0000000..2788380 --- /dev/null +++ b/src/PatchApplyResult.php @@ -0,0 +1,20 @@ + $changedPaths Paths are in the patched representation. */ + public function __construct( + public string $status, + public string $oldHash, + public string $newHash, + public int $operationCount, + public array $changedPaths, + public mixed $value, + public bool $documentExists = true, + public ?JsonPatch $patch = null, + ) {} +} diff --git a/src/PatchConflictException.php b/src/PatchConflictException.php new file mode 100644 index 0000000..aca42b2 --- /dev/null +++ b/src/PatchConflictException.php @@ -0,0 +1,7 @@ +errorCode, $index, $operation->op, $operation->path); + } +} diff --git a/src/StableArrayView.php b/src/StableArrayView.php new file mode 100644 index 0000000..603e959 --- /dev/null +++ b/src/StableArrayView.php @@ -0,0 +1,115 @@ + $identityPointers Original array pointer => identity pointer within each element. */ + public function __construct(private readonly array $identityPointers = []) + { + foreach ($identityPointers as $path => $identity) { + JsonPointer::tokens((string) $path); + JsonPointer::tokens($identity); + } + } + + public function encode(mixed $document): mixed + { + $this->sequence = 0; + return $this->encodeValue(JsonValue::copy($document), ''); + } + + private function encodeValue(mixed $value, string $path): mixed + { + if (is_array($value)) { + $order = []; + $values = new \stdClass(); + foreach ($value as $index => $item) { + $id = $this->identity($item, $path); + if ($id === null || property_exists($values, $id)) { + if ($id !== null && isset($this->identityPointers[$path])) { + throw new PatchValidationException('duplicate_identity'); + } + do { + $id = 'e_' . ++$this->sequence; + } while (property_exists($values, $id)); + } + $order[] = $id; + $values->{$id} = $this->encodeValue($item, $path . '/' . $index); + } + return (object) ['$order' => $order, '$values' => $values]; + } + if ($value instanceof \stdClass) { + if (property_exists($value, '$order') || property_exists($value, '$values')) { + throw new PatchValidationException('reserved_stable_property'); + } + foreach ($value as $key => $child) { + $value->{$key} = $this->encodeValue($child, $path . '/' . JsonPointer::escape((string) $key)); + } + } + return $value; + } + + private function identity(mixed $item, string $path): ?string + { + if (isset($this->identityPointers[$path])) { + $identity = JsonPointer::get($item, $this->identityPointers[$path]); + if (!is_scalar($identity)) { + throw new PatchValidationException('invalid_identity'); + } + return 'i_' . substr(hash('sha256', JsonValue::encode($identity)), 0, 24); + } + if ($item instanceof \stdClass) { + foreach (['id', 'key', 'uuid'] as $key) { + if (property_exists($item, $key) && is_scalar($item->{$key})) { + return 'i_' . substr(hash('sha256', $key . ':' . JsonValue::encode($item->{$key})), 0, 24); + } + } + } + return null; + } + + public function decode(mixed $view): mixed + { + return $this->decodeValue(JsonValue::copy($view)); + } + + private function decodeValue(mixed $value): mixed + { + // Every list in the transport must be wrapped, except its own $order. + if (is_array($value)) { + throw new PatchValidationException('unwrapped_stable_array'); + } + if (!$value instanceof \stdClass) { + return $value; + } + if (property_exists($value, '$order') || property_exists($value, '$values')) { + if (count(get_object_vars($value)) !== 2 || !isset($value->{'$order'}, $value->{'$values'}) + || !is_array($value->{'$order'}) || !array_is_list($value->{'$order'}) || !$value->{'$values'} instanceof \stdClass) { + throw new PatchValidationException('invalid_stable_view'); + } + $seen = []; + $result = []; + foreach ($value->{'$order'} as $id) { + if (!is_string($id) || $id === '' || isset($seen[$id]) || !property_exists($value->{'$values'}, $id)) { + throw new PatchValidationException('invalid_stable_order'); + } + $seen[$id] = true; + $result[] = $this->decodeValue($value->{'$values'}->{$id}); + } + if (count($seen) !== count(get_object_vars($value->{'$values'}))) { + throw new PatchValidationException('orphan_stable_value'); + } + return $result; + } + foreach ($value as $key => $child) { + $value->{$key} = $this->decodeValue($child); + } + return $value; + } +} diff --git a/test/ConformanceTest.php b/test/ConformanceTest.php new file mode 100644 index 0000000..1a26ebc --- /dev/null +++ b/test/ConformanceTest.php @@ -0,0 +1,49 @@ + $case) { + if (!property_exists($case, 'doc') || !property_exists($case, 'patch')) { + continue; // The upstream format permits comment-only records. + } + yield $file . ':' . $index . ' ' . ($case->comment ?? '') => [$case]; + } + } + } + + #[DataProvider('cases')] + public function testUpstreamCase(stdClass $case): void + { + if ($case->disabled ?? false) { + self::markTestSkipped('Disabled upstream; see docs/test-coverage.md.'); + } + $original = JsonValue::encode($case->doc); + try { + try { + $result = (new JsonPatchApplier())->apply($case->doc, JsonPatch::fromArray($case->patch), new PatchApplyOptions( + allowedOperations: ['add', 'remove', 'replace', 'move', 'copy', 'test'], + allowRootReplacement: true, + )); + } catch (PatchValidationException $exception) { + self::assertTrue(property_exists($case, 'error'), 'Unexpected error: ' . $exception->errorCode); + return; + } + self::assertFalse(property_exists($case, 'error'), 'Expected rejection: ' . ($case->error ?? '')); + if (property_exists($case, 'expected')) { + self::assertTrue($result->documentExists); + self::assertTrue(JsonValue::equal($case->expected, $result->value), JsonValue::encode($result->value)); + } + } finally { + self::assertSame($original, JsonValue::encode($case->doc), 'Input must remain unchanged, including on failure.'); + } + } +} diff --git a/test/EdgeCaseTest.php b/test/EdgeCaseTest.php new file mode 100644 index 0000000..7a3b7bc --- /dev/null +++ b/test/EdgeCaseTest.php @@ -0,0 +1,88 @@ + ['{"child":{"x":3},"other":9}', [['op'=>$op,'from'=>'/child','path'=>'']], '{"x":3}']; + yield "$op child array to root" => ['{"child":[3,4]}', [['op'=>$op,'from'=>'/child','path'=>'']], '[3,4]']; + yield "$op root to itself" => ['{"x":3}', [['op'=>$op,'from'=>'','path'=>'']], '{"x":3}']; + yield "$op appends to array" => ['[3,4,5]', [['op'=>$op,'from'=>'/0','path'=>'/-']], $op === 'move' ? '[4,5,3]' : '[3,4,5,3]']; + } + yield 'scalar root replacement' => ['"before"', [['op'=>'replace','path'=>'','value'=>false]], 'false']; + yield 'test entire root' => ['{"x":[3,null]}', [['op'=>'test','path'=>'','value'=>(object)['x'=>[3,null]]]], '{"x":[3,null]}']; + yield 'copy root into child is finite and detached' => ['{"x":3}', [['op'=>'copy','from'=>'','path'=>'/snapshot'],['op'=>'replace','path'=>'/x','value'=>4]], '{"x":4,"snapshot":{"x":3}}']; + yield 'move backwards uses post-removal positions' => ['[3,4,5,6]', [['op'=>'move','from'=>'/3','path'=>'/1']], '[3,6,4,5]']; + yield 'move overwrites existing object member' => ['{"a":{"x":3},"b":9}', [['op'=>'move','from'=>'/a','path'=>'/b']], '{"b":{"x":3}}']; + yield 'copy nested lists isolates both directions' => ['{"a":[{"x":3}]}', [['op'=>'copy','from'=>'/a','path'=>'/b'],['op'=>'replace','path'=>'/a/0/x','value'=>4],['op'=>'add','path'=>'/b/-','value'=>5]], '{"a":[{"x":4}],"b":[{"x":3},5]}']; + yield 'sequential removals reindex arrays' => ['[3,4,5,6]', [['op'=>'remove','path'=>'/1'],['op'=>'remove','path'=>'/2']], '[3,5]']; + yield 'numeric looking object keys stay literal' => ['{"01":1,"1e0":2,"-":3}', [['op'=>'replace','path'=>'/01','value'=>4],['op'=>'move','from'=>'/1e0','path'=>'/-']], '{"01":4,"-":2}']; + yield 'unicode and percent sequences stay literal' => ['{"ä/雪":1,"%2F":2,"/":3}', [['op'=>'replace','path'=>'/ä~1雪','value'=>4],['op'=>'test','path'=>'/%2F','value'=>2]], '{"ä/雪":4,"%2F":2,"/":3}']; + yield 'similar prefix is not descendant' => ['{"a":3,"ab":{}}', [['op'=>'move','from'=>'/a','path'=>'/ab/x']], '{"ab":{"x":3}}']; + } + + #[DataProvider('successfulCases')] + public function testSuccessfulEdgeCase(string $before, array $operations, string $expected): void + { + $target = JsonValue::decode($before); + $snapshot = JsonValue::encode($target); + $patch = JsonPatch::fromArray($operations); + $serialized = JsonValue::encode($patch); + $result = (new JsonPatchApplier())->apply($target, $patch, self::allOperations()); + self::assertTrue(JsonValue::equal(JsonValue::decode($expected), $result->value), JsonValue::encode($result->value)); + self::assertTrue($result->documentExists); + self::assertSame($snapshot, JsonValue::encode($target)); + self::assertSame($serialized, JsonValue::encode($patch)); + } + + public static function rejectedCases(): iterable + { + yield 'replace at array length' => ['[3,4]', [['op'=>'replace','path'=>'/2','value'=>5]], 'missing_path']; + yield 'remove at array length' => ['[3,4]', [['op'=>'remove','path'=>'/2']], 'missing_path']; + yield 'move destination bounds after removal' => ['[3,4]', [['op'=>'move','from'=>'/0','path'=>'/2']], 'missing_path']; + yield 'same missing source and destination is invalid' => ['{}', [['op'=>'move','from'=>'/absent','path'=>'/absent']], 'missing_path']; + yield 'move descendant remains forbidden despite array shift' => ['[{"a":[]},{"b":[]}]', [['op'=>'move','from'=>'/0','path'=>'/0/b/0']], 'move_into_descendant']; + yield 'move root into child' => ['{"a":{}}', [['op'=>'move','from'=>'','path'=>'/a/b']], 'move_into_descendant']; + yield 'missing test is not null' => ['{}', [['op'=>'test','path'=>'/absent','value'=>null]], 'missing_path']; + yield 'boolean is not a number' => ['{"x":true}', [['op'=>'test','path'=>'/x','value'=>1]], 'test_failed']; + yield 'array order matters' => ['[3,4]', [['op'=>'test','path'=>'','value'=>[4,3]]], 'test_failed']; + yield 'object extra member matters' => ['{"a":3,"b":4}', [['op'=>'test','path'=>'','value'=>(object)['a'=>3]]], 'test_failed']; + yield 'cannot traverse a scalar' => ['{"x":3}', [['op'=>'add','path'=>'/x/y','value'=>4]], 'missing_parent']; + yield 'missing patch array wrapper' => ['{}', ['op'=>'add','path'=>'/x','value'=>3], 'invalid_patch']; + foreach (['01', '-1', '1.0', '1e0', '+1', ' 1', '1 ', '-', '999999999999999999999999'] as $index) { + foreach (['test', 'replace', 'remove', 'copy', 'move'] as $op) { + $operation = ['op'=>$op,'path'=>'/' . $index]; + if (in_array($op, ['test','replace'], true)) $operation['value'] = 4; + if (in_array($op, ['copy','move'], true)) $operation = ['op'=>$op,'from'=>'/' . $index,'path'=>'/0']; + yield "$op rejects index [$index]" => ['[3,4]', [$operation], 'invalid_array_index']; + } + } + } + + #[DataProvider('rejectedCases')] + public function testRejectedEdgeCase(string $before, array $operations, string $errorCode): void + { + $target = JsonValue::decode($before); + $snapshot = JsonValue::encode($target); + try { + (new JsonPatchApplier())->apply($target, JsonPatch::fromArray($operations), self::allOperations()); + self::fail('Expected ' . $errorCode); + } catch (PatchValidationException $exception) { + self::assertSame($errorCode, $exception->errorCode); + } + self::assertSame($snapshot, JsonValue::encode($target)); + } + + private static function allOperations(): PatchApplyOptions + { + return new PatchApplyOptions(allowedOperations: ['add','remove','replace','move','copy','test'], allowRootReplacement: true); + } +} diff --git a/test/JsonPatchTest.php b/test/JsonPatchTest.php new file mode 100644 index 0000000..9a71436 --- /dev/null +++ b/test/JsonPatchTest.php @@ -0,0 +1,167 @@ +apply(JsonValue::decode($document), JsonPatch::fromArray($operations), $options)->value; + } + + public function testRfc6902AppendixExamples(): void + { + $all = new PatchApplyOptions(allowedOperations: ['add', 'remove', 'replace', 'move', 'copy', 'test'], allowRootReplacement: true); + $cases = [ + ['{"foo":"bar"}', [['op'=>'add','path'=>'/baz','value'=>'qux']], '{"foo":"bar","baz":"qux"}'], + ['{"foo":["bar","baz"]}', [['op'=>'add','path'=>'/foo/1','value'=>'qux']], '{"foo":["bar","qux","baz"]}'], + ['{"baz":"qux","foo":"bar"}', [['op'=>'remove','path'=>'/baz']], '{"foo":"bar"}'], + ['{"foo":["bar","qux","baz"]}', [['op'=>'remove','path'=>'/foo/1']], '{"foo":["bar","baz"]}'], + ['{"baz":"qux","foo":"bar"}', [['op'=>'replace','path'=>'/baz','value'=>'boo']], '{"baz":"boo","foo":"bar"}'], + ['{"foo":{"bar":"baz","waldo":"fred"},"qux":{"corge":"grault"}}', [['op'=>'move','from'=>'/foo/waldo','path'=>'/qux/thud']], '{"foo":{"bar":"baz"},"qux":{"corge":"grault","thud":"fred"}}'], + ['{"foo":["all","grass","cows","eat"]}', [['op'=>'move','from'=>'/foo/1','path'=>'/foo/3']], '{"foo":["all","cows","eat","grass"]}'], + ['{"baz":"qux","foo":["a",2,"c"]}', [['op'=>'test','path'=>'/baz','value'=>'qux'],['op'=>'test','path'=>'/foo/1','value'=>2]], '{"baz":"qux","foo":["a",2,"c"]}'], + ['{"foo":"bar"}', [['op'=>'add','path'=>'/child','value'=>(object)['grandchild'=>(object)[]]]], '{"foo":"bar","child":{"grandchild":{}}}'], + ['{"foo":"bar"}', [['op'=>'add','path'=>'/baz','value'=>'qux','xyz'=>123]], '{"foo":"bar","baz":"qux"}'], + ['{"/":9,"~1":10}', [['op'=>'test','path'=>'/~01','value'=>10]], '{"/":9,"~1":10}'], + ['{"foo":["bar"]}', [['op'=>'add','path'=>'/foo/-','value'=>['abc','def']]], '{"foo":["bar",["abc","def"]]}'], + ['{"a":{"x":1}}', [['op'=>'copy','from'=>'/a','path'=>'/b'],['op'=>'replace','path'=>'/b/x','value'=>2]], '{"a":{"x":1},"b":{"x":2}}'], + ['{"a":1}', [['op'=>'replace','path'=>'','value'=>null]], 'null'], + ['[]', [['op'=>'add','path'=>'/-','value'=>(object)[]]], '[{}]'], + ]; + foreach ($cases as [$before, $patch, $after]) { + self::assertTrue(JsonValue::equal(JsonValue::decode($after), $this->apply($before, $patch, $all)), JsonValue::encode($patch)); + } + } + + public function testPointerEscapingAndEmptyKeys(): void + { + $document = JsonValue::decode('{"":0,"a/b":1,"m~n":2,"0":"object key"}'); + self::assertSame(0, JsonPointer::get($document, '/')); + self::assertSame(1, JsonPointer::get($document, '/a~1b')); + self::assertSame(2, JsonPointer::get($document, '/m~0n')); + self::assertSame('object key', JsonPointer::get($document, '/0')); + self::assertSame($document, JsonPointer::get($document, '')); + } + + public function testAtomicFailureIncludesIndexWithoutValues(): void + { + $target = JsonValue::decode('{"a":{"secret":"original"}}'); + try { + (new JsonPatchApplier())->apply($target, JsonPatch::fromArray([ + ['op'=>'replace','path'=>'/a/secret','value'=>'sensitive-new'], + ['op'=>'test','path'=>'/a/secret','value'=>'wrong'], + ])); + self::fail('Expected failure'); + } catch (PatchTestFailedException $exception) { + self::assertSame(1, $exception->operationIndex); + self::assertSame('test', $exception->op); + self::assertSame('/a/secret', $exception->path); + self::assertStringNotContainsString('sensitive', $exception->getMessage()); + self::assertNull($exception->getPrevious()); + } + self::assertSame('original', $target->a->secret); + } + + public function testInvalidOperationsAndPointersFailClosed(): void + { + $cases = [ + [['op'=>'add','path'=>'/a']], + [['op'=>'remove','path'=>'/missing']], + [['op'=>'replace','path'=>'/missing','value'=>null]], + [['op'=>'add','path'=>'/missing/child','value'=>1]], + [['op'=>'remove','path'=>'/a/01']], + [['op'=>'remove','path'=>'/a/-']], + [['op'=>'remove','path'=>'/a/+1']], + [['op'=>'remove','path'=>'/a/999999999999999999999']], + [['op'=>'remove','path'=>'/bad~2']], + [['op'=>'remove','path'=>'bad']], + [['op'=>'move','from'=>'/a','path'=>'/a/0/child']], + [['op'=>'copy','from'=>'/missing','path'=>'/b']], + [['op'=>'test','path'=>'/a/0','value'=>'1']], + [['op'=>'remove','path'=>'/a','value'=>1]], + ]; + foreach ($cases as $operations) { + try { + $this->apply('{"a":[1,2]}', $operations, new PatchApplyOptions(allowedOperations: ['add','remove','replace','move','copy','test'])); + self::fail('Expected invalid patch: ' . JsonValue::encode($operations)); + } catch (PatchValidationException $exception) { + self::assertSame(0, $exception->operationIndex); + } + } + } + + public function testNullValuesAndObjectArrayDistinction(): void + { + self::assertNull($this->apply('{"x":1}', [['op'=>'replace','path'=>'/x','value'=>null]])->x); + self::assertTrue(JsonValue::equal(JsonValue::decode('{"b":2,"a":1}'), JsonValue::decode('{"a":1.0,"b":2}'))); + self::assertFalse(JsonValue::equal((object)[], [])); + if (PHP_INT_SIZE === 8) { + self::assertFalse(JsonValue::equal(PHP_INT_MAX, (float) PHP_INT_MAX)); + } + self::assertTrue(JsonValue::equal(1, 1.0)); + self::assertSame(JsonValue::hash((object)['b'=>2,'a'=>1]), JsonValue::hash((object)['a'=>1,'b'=>2])); + self::assertNotSame(JsonValue::hash((object)[]), JsonValue::hash([])); + } + + public function testOperationValuesAreDetached(): void + { + $value = (object)['x'=>1]; + $operation = new JsonPatchOperation('add', '/a', $value); + $value->x = 2; + $exposed = $operation->value(); + $exposed->x = 3; + self::assertSame(1, $operation->value()->x); + } + + public function testRootDeletionAndRecreation(): void + { + $policy = new PatchApplyOptions(allowRootReplacement: true); + $result = (new JsonPatchApplier())->apply((object)[], JsonPatch::fromArray([['op'=>'remove','path'=>'']]), $policy); + self::assertFalse($result->documentExists); + self::assertNull($result->value); + self::assertSame([1], $this->apply('{}', [['op'=>'remove','path'=>''],['op'=>'add','path'=>'','value'=>[1]]], $policy)); + $this->expectException(PatchValidationException::class); + $this->apply('{}', [['op'=>'remove','path'=>'']]); + } + + public function testPoliciesLimitsAndProtectedArrayShifts(): void + { + $cases = [ + [new PatchApplyOptions(maxOperations: 0), [['op'=>'test','path'=>'/items/0','value'=>1]], PatchLimitException::class], + [new PatchApplyOptions(maxPatchBytes: 5), [], PatchLimitException::class], + [new PatchApplyOptions(expectedHash: str_repeat('0', 64)), [], PatchConflictException::class], + [new PatchApplyOptions(forbiddenPaths: ['/items/1']), [['op'=>'add','path'=>'/items/0','value'=>3]], PatchValidationException::class], + [new PatchApplyOptions(forbiddenPaths: ['/items/1']), [['op'=>'replace','path'=>'/items','value'=>[]]], PatchValidationException::class], + [new PatchApplyOptions(requireTests: 'arrays'), [['op'=>'remove','path'=>'/items/0']], PatchValidationException::class], + [new PatchApplyOptions(maxDocumentBytes: 5), [], PatchLimitException::class], + [new PatchApplyOptions(maxDepth: 1), [], PatchLimitException::class], + ]; + // Use a nonempty patch in the byte-limit case. + $cases[1][1] = [['op'=>'test','path'=>'','value'=>null]]; + foreach ($cases as [$policy, $operations, $exceptionClass]) { + try { + $this->apply('{"items":[1,2]}', $operations, $policy); + self::fail('Expected policy failure'); + } catch (PatchValidationException $exception) { + self::assertInstanceOf($exceptionClass, $exception); + } + } + self::assertSame([2], $this->apply('{"items":[1,2]}', [ + ['op'=>'test','path'=>'/items/0','value'=>1], ['op'=>'remove','path'=>'/items/0'], + ], new PatchApplyOptions(requireTests: 'arrays'))->items); + } + + public function testIdentityGuardAndValidationCallback(): void + { + $result = $this->apply('{"items":[{"id":"a"},{"id":"b"}]}', [ + ['op'=>'test','path'=>'/items/0/id','value'=>'a'], ['op'=>'remove','path'=>'/items/0'], + ], new PatchApplyOptions(requireTests: 'arrays')); + self::assertSame('b', $result->items[0]->id); + $this->expectException(PatchValidationException::class); + (new JsonPatchApplier())->apply((object)['a'=>1], new JsonPatch([]), validator: static fn () => false); + } +} diff --git a/test/StableArrayViewTest.php b/test/StableArrayViewTest.php new file mode 100644 index 0000000..87398ba --- /dev/null +++ b/test/StableArrayViewTest.php @@ -0,0 +1,68 @@ +encode($value); + self::assertTrue(JsonValue::equal($value, $codec->decode($view))); + self::assertCount(3, array_unique($view->items->{'$order'})); + self::assertSame(JsonValue::encode($view), JsonValue::encode($codec->encode($value))); + } + + public function testCombinedStableEditsDoNotShiftOtherReferences(): void + { + $codec = new StableArrayView(); + $view = $codec->encode(JsonValue::decode('{"items":[{"id":"a","name":"A"},{"id":"b","name":"B"},{"id":"c","name":"C"}]}')); + [$a, $b, $c] = $view->items->{'$order'}; + $patch = JsonPatch::fromArray([ + ['op'=>'remove','path'=>'/items/$values/' . $a], + ['op'=>'remove','path'=>'/items/$values/' . $c], + ['op'=>'replace','path'=>'/items/$values/' . $b . '/name','value'=>'changed'], + ['op'=>'add','path'=>'/items/$values/new','value'=>(object)['id'=>'d','name'=>'D']], + ['op'=>'replace','path'=>'/items/$order','value'=>['new',$b]], + ]); + $result = $codec->decode((new JsonPatchApplier())->apply($view, $patch)->value); + self::assertSame(['d','b'], array_map(static fn ($item) => $item->id, $result->items)); + self::assertSame('changed', $result->items[1]->name); + self::assertCount(3, $codec->decode($view)->items); + } + + public function testExplicitIdentityPointer(): void + { + $codec = new StableArrayView(['/items'=>'/meta/key']); + $a = $codec->encode(JsonValue::decode('{"items":[{"meta":{"key":"a"}}]}')); + $b = $codec->encode(JsonValue::decode('{"items":[{"meta":{"key":"b"}},{"meta":{"key":"a"}}]}')); + self::assertSame($a->items->{'$order'}[0], $b->items->{'$order'}[1]); + $this->expectException(PatchValidationException::class); + $codec->encode(JsonValue::decode('{"items":[{"meta":{"key":"a"}},{"meta":{"key":"a"}}]}')); + } + + public function testInvalidTransportFailsClosed(): void + { + foreach ([ + '{"$order":["a","a"],"$values":{"a":1}}', + '{"$order":["a"],"$values":{}}', + '{"$order":[],"$values":{"a":1}}', + '{"$order":[],"$values":[]}', + '{"$order":[],"$values":{},"extra":1}', + '{"items":[1]}', + ] as $json) { + try { + (new StableArrayView())->decode(JsonValue::decode($json)); + self::fail('Expected invalid view'); + } catch (PatchValidationException $exception) { + self::assertNotSame('', $exception->errorCode); + } + } + $this->expectException(PatchValidationException::class); + (new StableArrayView())->encode((object)['$order'=>[]]); + } +} diff --git a/test/fixtures/json-patch-tests/LICENSE b/test/fixtures/json-patch-tests/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/test/fixtures/json-patch-tests/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/test/fixtures/json-patch-tests/README.md b/test/fixtures/json-patch-tests/README.md new file mode 100644 index 0000000..fb9e447 --- /dev/null +++ b/test/fixtures/json-patch-tests/README.md @@ -0,0 +1,75 @@ +JSON Patch Tests +================ + +These are test cases for implementations of [IETF JSON Patch (RFC6902)](http://tools.ietf.org/html/rfc6902). + +Some implementations can be found at [jsonpatch.com](http://jsonpatch.com). + + +Test Format +----------- + +Each test file is a JSON document that contains an array of test records. A +test record is an object with the following members: + +- doc: The JSON document to test against +- patch: The patch(es) to apply +- expected: The expected resulting document, OR +- error: A string describing an expected error +- comment: A string describing the test +- disabled: True if the test should be skipped + +All fields except 'doc' and 'patch' are optional. Test records consisting only +of a comment are also OK. + + +Files +----- + +- tests.json: the main test file +- spec_tests.json: tests from the RFC6902 spec + + +Writing Tests +------------- + +All tests should have a descriptive comment. Tests should be as +simple as possible - just what's required to test a specific piece of +behavior. If you want to test interacting behaviors, create tests for +each behavior as well as the interaction. + +If an 'error' member is specified, the error text should describe the +error the implementation should raise - *not* what's being tested. +Implementation error strings will vary, but the suggested error should +be easily matched to the implementation error string. Try to avoid +creating error tests that might pass because an incorrect error was +reported. + +Please feel free to contribute! + + +Credits +------- + +The seed test set was adapted from Byron Ruth's +[jsonpatch-js](https://github.com/bruth/jsonpatch-js/blob/master/test.js) and +extended by [Mike McCabe](https://github.com/mikemccabe). + + +License +------- + + Copyright 2014 The Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/test/fixtures/json-patch-tests/spec_tests.json b/test/fixtures/json-patch-tests/spec_tests.json new file mode 100644 index 0000000..c160535 --- /dev/null +++ b/test/fixtures/json-patch-tests/spec_tests.json @@ -0,0 +1,233 @@ +[ + { + "comment": "4.1. add with missing object", + "doc": { "q": { "bar": 2 } }, + "patch": [ {"op": "add", "path": "/a/b", "value": 1} ], + "error": + "path /a does not exist -- missing objects are not created recursively" + }, + + { + "comment": "A.1. Adding an Object Member", + "doc": { + "foo": "bar" +}, + "patch": [ + { "op": "add", "path": "/baz", "value": "qux" } +], + "expected": { + "baz": "qux", + "foo": "bar" +} + }, + + { + "comment": "A.2. Adding an Array Element", + "doc": { + "foo": [ "bar", "baz" ] +}, + "patch": [ + { "op": "add", "path": "/foo/1", "value": "qux" } +], + "expected": { + "foo": [ "bar", "qux", "baz" ] +} + }, + + { + "comment": "A.3. Removing an Object Member", + "doc": { + "baz": "qux", + "foo": "bar" +}, + "patch": [ + { "op": "remove", "path": "/baz" } +], + "expected": { + "foo": "bar" +} + }, + + { + "comment": "A.4. Removing an Array Element", + "doc": { + "foo": [ "bar", "qux", "baz" ] +}, + "patch": [ + { "op": "remove", "path": "/foo/1" } +], + "expected": { + "foo": [ "bar", "baz" ] +} + }, + + { + "comment": "A.5. Replacing a Value", + "doc": { + "baz": "qux", + "foo": "bar" +}, + "patch": [ + { "op": "replace", "path": "/baz", "value": "boo" } +], + "expected": { + "baz": "boo", + "foo": "bar" +} + }, + + { + "comment": "A.6. Moving a Value", + "doc": { + "foo": { + "bar": "baz", + "waldo": "fred" + }, + "qux": { + "corge": "grault" + } +}, + "patch": [ + { "op": "move", "from": "/foo/waldo", "path": "/qux/thud" } +], + "expected": { + "foo": { + "bar": "baz" + }, + "qux": { + "corge": "grault", + "thud": "fred" + } +} + }, + + { + "comment": "A.7. Moving an Array Element", + "doc": { + "foo": [ "all", "grass", "cows", "eat" ] +}, + "patch": [ + { "op": "move", "from": "/foo/1", "path": "/foo/3" } +], + "expected": { + "foo": [ "all", "cows", "eat", "grass" ] +} + + }, + + { + "comment": "A.8. Testing a Value: Success", + "doc": { + "baz": "qux", + "foo": [ "a", 2, "c" ] +}, + "patch": [ + { "op": "test", "path": "/baz", "value": "qux" }, + { "op": "test", "path": "/foo/1", "value": 2 } +], + "expected": { + "baz": "qux", + "foo": [ "a", 2, "c" ] + } + }, + + { + "comment": "A.9. Testing a Value: Error", + "doc": { + "baz": "qux" +}, + "patch": [ + { "op": "test", "path": "/baz", "value": "bar" } +], + "error": "string not equivalent" + }, + + { + "comment": "A.10. Adding a nested Member Object", + "doc": { + "foo": "bar" +}, + "patch": [ + { "op": "add", "path": "/child", "value": { "grandchild": { } } } +], + "expected": { + "foo": "bar", + "child": { + "grandchild": { + } + } +} + }, + + { + "comment": "A.11. Ignoring Unrecognized Elements", + "doc": { + "foo":"bar" +}, + "patch": [ + { "op": "add", "path": "/baz", "value": "qux", "xyz": 123 } +], + "expected": { + "foo":"bar", + "baz":"qux" +} + }, + + { + "comment": "A.12. Adding to a Non-existent Target", + "doc": { + "foo": "bar" +}, + "patch": [ + { "op": "add", "path": "/baz/bat", "value": "qux" } +], + "error": "add to a non-existent target" + }, + + { + "comment": "A.13 Invalid JSON Patch Document", + "doc": { + "foo": "bar" + }, + "patch": [ + { "op": "add", "path": "/baz", "value": "qux", "op": "remove" } +], + "error": "operation has two 'op' members", + "disabled": true + }, + + { + "comment": "A.14. ~ Escape Ordering", + "doc": { + "/": 9, + "~1": 10 + }, + "patch": [{"op": "test", "path": "/~01", "value": 10}], + "expected": { + "/": 9, + "~1": 10 + } + }, + + { + "comment": "A.15. Comparing Strings and Numbers", + "doc": { + "/": 9, + "~1": 10 + }, + "patch": [{"op": "test", "path": "/~01", "value": "10"}], + "error": "number is not equal to string" + }, + + { + "comment": "A.16. Adding an Array Value", + "doc": { + "foo": ["bar"] + }, + "patch": [{ "op": "add", "path": "/foo/-", "value": ["abc", "def"] }], + "expected": { + "foo": ["bar", ["abc", "def"]] + } + } + +] diff --git a/test/fixtures/json-patch-tests/tests.json b/test/fixtures/json-patch-tests/tests.json new file mode 100644 index 0000000..ae1f7f0 --- /dev/null +++ b/test/fixtures/json-patch-tests/tests.json @@ -0,0 +1,500 @@ +[ + { "comment": "empty list, empty docs", + "doc": {}, + "patch": [], + "expected": {} }, + + { "comment": "empty patch list", + "doc": {"foo": 1}, + "patch": [], + "expected": {"foo": 1} }, + + { "comment": "rearrangements OK?", + "doc": {"foo": 1, "bar": 2}, + "patch": [], + "expected": {"bar":2, "foo": 1} }, + + { "comment": "rearrangements OK? How about one level down ... array", + "doc": [{"foo": 1, "bar": 2}], + "patch": [], + "expected": [{"bar":2, "foo": 1}] }, + + { "comment": "rearrangements OK? How about one level down...", + "doc": {"foo":{"foo": 1, "bar": 2}}, + "patch": [], + "expected": {"foo":{"bar":2, "foo": 1}} }, + + { "comment": "add replaces any existing field", + "doc": {"foo": null}, + "patch": [{"op": "add", "path": "/foo", "value":1}], + "expected": {"foo": 1} }, + + { "comment": "toplevel array", + "doc": [], + "patch": [{"op": "add", "path": "/0", "value": "foo"}], + "expected": ["foo"] }, + + { "comment": "toplevel array, no change", + "doc": ["foo"], + "patch": [], + "expected": ["foo"] }, + + { "comment": "toplevel object, numeric string", + "doc": {}, + "patch": [{"op": "add", "path": "/foo", "value": "1"}], + "expected": {"foo":"1"} }, + + { "comment": "toplevel object, integer", + "doc": {}, + "patch": [{"op": "add", "path": "/foo", "value": 1}], + "expected": {"foo":1} }, + + { "comment": "Toplevel scalar values OK?", + "doc": "foo", + "patch": [{"op": "replace", "path": "", "value": "bar"}], + "expected": "bar", + "disabled": true }, + + { "comment": "replace object document with array document?", + "doc": {}, + "patch": [{"op": "add", "path": "", "value": []}], + "expected": [] }, + + { "comment": "replace array document with object document?", + "doc": [], + "patch": [{"op": "add", "path": "", "value": {}}], + "expected": {} }, + + { "comment": "append to root array document?", + "doc": [], + "patch": [{"op": "add", "path": "/-", "value": "hi"}], + "expected": ["hi"] }, + + { "comment": "Add, / target", + "doc": {}, + "patch": [ {"op": "add", "path": "/", "value":1 } ], + "expected": {"":1} }, + + { "comment": "Add, /foo/ deep target (trailing slash)", + "doc": {"foo": {}}, + "patch": [ {"op": "add", "path": "/foo/", "value":1 } ], + "expected": {"foo":{"": 1}} }, + + { "comment": "Add composite value at top level", + "doc": {"foo": 1}, + "patch": [{"op": "add", "path": "/bar", "value": [1, 2]}], + "expected": {"foo": 1, "bar": [1, 2]} }, + + { "comment": "Add into composite value", + "doc": {"foo": 1, "baz": [{"qux": "hello"}]}, + "patch": [{"op": "add", "path": "/baz/0/foo", "value": "world"}], + "expected": {"foo": 1, "baz": [{"qux": "hello", "foo": "world"}]} }, + + { "doc": {"bar": [1, 2]}, + "patch": [{"op": "add", "path": "/bar/8", "value": "5"}], + "error": "Out of bounds (upper)" }, + + { "doc": {"bar": [1, 2]}, + "patch": [{"op": "add", "path": "/bar/-1", "value": "5"}], + "error": "Out of bounds (lower)" }, + + { "doc": {"foo": 1}, + "patch": [{"op": "add", "path": "/bar", "value": true}], + "expected": {"foo": 1, "bar": true} }, + + { "doc": {"foo": 1}, + "patch": [{"op": "add", "path": "/bar", "value": false}], + "expected": {"foo": 1, "bar": false} }, + + { "doc": {"foo": 1}, + "patch": [{"op": "add", "path": "/bar", "value": null}], + "expected": {"foo": 1, "bar": null} }, + + { "comment": "0 can be an array index or object element name", + "doc": {"foo": 1}, + "patch": [{"op": "add", "path": "/0", "value": "bar"}], + "expected": {"foo": 1, "0": "bar" } }, + + { "doc": ["foo"], + "patch": [{"op": "add", "path": "/1", "value": "bar"}], + "expected": ["foo", "bar"] }, + + { "doc": ["foo", "sil"], + "patch": [{"op": "add", "path": "/1", "value": "bar"}], + "expected": ["foo", "bar", "sil"] }, + + { "doc": ["foo", "sil"], + "patch": [{"op": "add", "path": "/0", "value": "bar"}], + "expected": ["bar", "foo", "sil"] }, + + { "comment": "push item to array via last index + 1", + "doc": ["foo", "sil"], + "patch": [{"op":"add", "path": "/2", "value": "bar"}], + "expected": ["foo", "sil", "bar"] }, + + { "comment": "add item to array at index > length should fail", + "doc": ["foo", "sil"], + "patch": [{"op":"add", "path": "/3", "value": "bar"}], + "error": "index is greater than number of items in array" }, + + { "comment": "test against implementation-specific numeric parsing", + "doc": {"1e0": "foo"}, + "patch": [{"op": "test", "path": "/1e0", "value": "foo"}], + "expected": {"1e0": "foo"} }, + + { "comment": "test with bad number should fail", + "doc": ["foo", "bar"], + "patch": [{"op": "test", "path": "/1e0", "value": "bar"}], + "error": "test op shouldn't get array element 1" }, + + { "doc": ["foo", "sil"], + "patch": [{"op": "add", "path": "/bar", "value": 42}], + "error": "Object operation on array target" }, + + { "doc": ["foo", "sil"], + "patch": [{"op": "add", "path": "/1", "value": ["bar", "baz"]}], + "expected": ["foo", ["bar", "baz"], "sil"], + "comment": "value in array add not flattened" }, + + { "doc": {"foo": 1, "bar": [1, 2, 3, 4]}, + "patch": [{"op": "remove", "path": "/bar"}], + "expected": {"foo": 1} }, + + { "doc": {"foo": 1, "baz": [{"qux": "hello"}]}, + "patch": [{"op": "remove", "path": "/baz/0/qux"}], + "expected": {"foo": 1, "baz": [{}]} }, + + { "doc": {"foo": 1, "baz": [{"qux": "hello"}]}, + "patch": [{"op": "replace", "path": "/foo", "value": [1, 2, 3, 4]}], + "expected": {"foo": [1, 2, 3, 4], "baz": [{"qux": "hello"}]} }, + + { "doc": {"foo": [1, 2, 3, 4], "baz": [{"qux": "hello"}]}, + "patch": [{"op": "replace", "path": "/baz/0/qux", "value": "world"}], + "expected": {"foo": [1, 2, 3, 4], "baz": [{"qux": "world"}]} }, + + { "doc": ["foo"], + "patch": [{"op": "replace", "path": "/0", "value": "bar"}], + "expected": ["bar"] }, + + { "doc": [""], + "patch": [{"op": "replace", "path": "/0", "value": 0}], + "expected": [0] }, + + { "doc": [""], + "patch": [{"op": "replace", "path": "/0", "value": true}], + "expected": [true] }, + + { "doc": [""], + "patch": [{"op": "replace", "path": "/0", "value": false}], + "expected": [false] }, + + { "doc": [""], + "patch": [{"op": "replace", "path": "/0", "value": null}], + "expected": [null] }, + + { "doc": ["foo", "sil"], + "patch": [{"op": "replace", "path": "/1", "value": ["bar", "baz"]}], + "expected": ["foo", ["bar", "baz"]], + "comment": "value in array replace not flattened" }, + + { "comment": "replace whole document", + "doc": {"foo": "bar"}, + "patch": [{"op": "replace", "path": "", "value": {"baz": "qux"}}], + "expected": {"baz": "qux"} }, + + { "comment": "test replace with missing parent key should fail", + "doc": {"bar": "baz"}, + "patch": [{"op": "replace", "path": "/foo/bar", "value": false}], + "error": "replace op should fail with missing parent key" }, + + { "comment": "spurious patch properties", + "doc": {"foo": 1}, + "patch": [{"op": "test", "path": "/foo", "value": 1, "spurious": 1}], + "expected": {"foo": 1} }, + + { "doc": {"foo": null}, + "patch": [{"op": "test", "path": "/foo", "value": null}], + "expected": {"foo": null}, + "comment": "null value should be valid obj property" }, + + { "doc": {"foo": null}, + "patch": [{"op": "replace", "path": "/foo", "value": "truthy"}], + "expected": {"foo": "truthy"}, + "comment": "null value should be valid obj property to be replaced with something truthy" }, + + { "doc": {"foo": null}, + "patch": [{"op": "move", "from": "/foo", "path": "/bar"}], + "expected": {"bar": null}, + "comment": "null value should be valid obj property to be moved" }, + + { "doc": {"foo": null}, + "patch": [{"op": "copy", "from": "/foo", "path": "/bar"}], + "expected": {"foo": null, "bar": null}, + "comment": "null value should be valid obj property to be copied" }, + + { "doc": {"foo": null}, + "patch": [{"op": "remove", "path": "/foo"}], + "expected": {}, + "comment": "null value should be valid obj property to be removed" }, + + { "doc": {"foo": "bar"}, + "patch": [{"op": "replace", "path": "/foo", "value": null}], + "expected": {"foo": null}, + "comment": "null value should still be valid obj property replace other value" }, + + { "doc": {"foo": {"foo": 1, "bar": 2}}, + "patch": [{"op": "test", "path": "/foo", "value": {"bar": 2, "foo": 1}}], + "expected": {"foo": {"foo": 1, "bar": 2}}, + "comment": "test should pass despite rearrangement" }, + + { "doc": {"foo": [{"foo": 1, "bar": 2}]}, + "patch": [{"op": "test", "path": "/foo", "value": [{"bar": 2, "foo": 1}]}], + "expected": {"foo": [{"foo": 1, "bar": 2}]}, + "comment": "test should pass despite (nested) rearrangement" }, + + { "doc": {"foo": {"bar": [1, 2, 5, 4]}}, + "patch": [{"op": "test", "path": "/foo", "value": {"bar": [1, 2, 5, 4]}}], + "expected": {"foo": {"bar": [1, 2, 5, 4]}}, + "comment": "test should pass - no error" }, + + { "doc": {"foo": {"bar": [1, 2, 5, 4]}}, + "patch": [{"op": "test", "path": "/foo", "value": [1, 2]}], + "error": "test op should fail" }, + + { "comment": "Whole document", + "doc": { "foo": 1 }, + "patch": [{"op": "test", "path": "", "value": {"foo": 1}}], + "disabled": true }, + + { "comment": "Empty-string element", + "doc": { "": 1 }, + "patch": [{"op": "test", "path": "/", "value": 1}], + "expected": { "": 1 } }, + + { "doc": { + "foo": ["bar", "baz"], + "": 0, + "a/b": 1, + "c%d": 2, + "e^f": 3, + "g|h": 4, + "i\\j": 5, + "k\"l": 6, + " ": 7, + "m~n": 8 + }, + "patch": [{"op": "test", "path": "/foo", "value": ["bar", "baz"]}, + {"op": "test", "path": "/foo/0", "value": "bar"}, + {"op": "test", "path": "/", "value": 0}, + {"op": "test", "path": "/a~1b", "value": 1}, + {"op": "test", "path": "/c%d", "value": 2}, + {"op": "test", "path": "/e^f", "value": 3}, + {"op": "test", "path": "/g|h", "value": 4}, + {"op": "test", "path": "/i\\j", "value": 5}, + {"op": "test", "path": "/k\"l", "value": 6}, + {"op": "test", "path": "/ ", "value": 7}, + {"op": "test", "path": "/m~0n", "value": 8}], + "expected": { + "": 0, + " ": 7, + "a/b": 1, + "c%d": 2, + "e^f": 3, + "foo": [ + "bar", + "baz" + ], + "g|h": 4, + "i\\j": 5, + "k\"l": 6, + "m~n": 8 + } + }, + { "comment": "Move to same location has no effect", + "doc": {"foo": 1}, + "patch": [{"op": "move", "from": "/foo", "path": "/foo"}], + "expected": {"foo": 1} }, + + { "doc": {"foo": 1, "baz": [{"qux": "hello"}]}, + "patch": [{"op": "move", "from": "/foo", "path": "/bar"}], + "expected": {"baz": [{"qux": "hello"}], "bar": 1} }, + + { "doc": {"baz": [{"qux": "hello"}], "bar": 1}, + "patch": [{"op": "move", "from": "/baz/0/qux", "path": "/baz/1"}], + "expected": {"baz": [{}, "hello"], "bar": 1} }, + + { "doc": {"baz": [{"qux": "hello"}], "bar": 1}, + "patch": [{"op": "copy", "from": "/baz/0", "path": "/boo"}], + "expected": {"baz":[{"qux":"hello"}],"bar":1,"boo":{"qux":"hello"}} }, + + { "comment": "replacing the root of the document is possible with add", + "doc": {"foo": "bar"}, + "patch": [{"op": "add", "path": "", "value": {"baz": "qux"}}], + "expected": {"baz":"qux"}}, + + { "comment": "Adding to \"/-\" adds to the end of the array", + "doc": [ 1, 2 ], + "patch": [ { "op": "add", "path": "/-", "value": { "foo": [ "bar", "baz" ] } } ], + "expected": [ 1, 2, { "foo": [ "bar", "baz" ] } ]}, + + { "comment": "Adding to \"/-\" adds to the end of the array, even n levels down", + "doc": [ 1, 2, [ 3, [ 4, 5 ] ] ], + "patch": [ { "op": "add", "path": "/2/1/-", "value": { "foo": [ "bar", "baz" ] } } ], + "expected": [ 1, 2, [ 3, [ 4, 5, { "foo": [ "bar", "baz" ] } ] ] ]}, + + { "comment": "test remove with bad number should fail", + "doc": {"foo": 1, "baz": [{"qux": "hello"}]}, + "patch": [{"op": "remove", "path": "/baz/1e0/qux"}], + "error": "remove op shouldn't remove from array with bad number" }, + + { "comment": "test remove on array", + "doc": [1, 2, 3, 4], + "patch": [{"op": "remove", "path": "/0"}], + "expected": [2, 3, 4] }, + + { "comment": "test repeated removes", + "doc": [1, 2, 3, 4], + "patch": [{ "op": "remove", "path": "/1" }, + { "op": "remove", "path": "/2" }], + "expected": [1, 3] }, + + { "comment": "test remove with bad index should fail", + "doc": [1, 2, 3, 4], + "patch": [{"op": "remove", "path": "/1e0"}], + "error": "remove op shouldn't remove from array with bad number" }, + + { "comment": "test replace with bad number should fail", + "doc": [""], + "patch": [{"op": "replace", "path": "/1e0", "value": false}], + "error": "replace op shouldn't replace in array with bad number" }, + + { "comment": "test copy with bad number should fail", + "doc": {"baz": [1,2,3], "bar": 1}, + "patch": [{"op": "copy", "from": "/baz/1e0", "path": "/boo"}], + "error": "copy op shouldn't work with bad number" }, + + { "comment": "test move with bad number should fail", + "doc": {"foo": 1, "baz": [1,2,3,4]}, + "patch": [{"op": "move", "from": "/baz/1e0", "path": "/foo"}], + "error": "move op shouldn't work with bad number" }, + + { "comment": "test add with bad number should fail", + "doc": ["foo", "sil"], + "patch": [{"op": "add", "path": "/1e0", "value": "bar"}], + "error": "add op shouldn't add to array with bad number" }, + + { "comment": "missing 'path' parameter", + "doc": {}, + "patch": [ { "op": "add", "value": "bar" } ], + "error": "missing 'path' parameter" }, + + { "comment": "'path' parameter with null value", + "doc": {}, + "patch": [ { "op": "add", "path": null, "value": "bar" } ], + "error": "null is not valid value for 'path'" }, + + { "comment": "invalid JSON Pointer token", + "doc": {}, + "patch": [ { "op": "add", "path": "foo", "value": "bar" } ], + "error": "JSON Pointer should start with a slash" }, + + { "comment": "missing 'value' parameter to add", + "doc": [ 1 ], + "patch": [ { "op": "add", "path": "/-" } ], + "error": "missing 'value' parameter" }, + + { "comment": "missing 'value' parameter to replace", + "doc": [ 1 ], + "patch": [ { "op": "replace", "path": "/0" } ], + "error": "missing 'value' parameter" }, + + { "comment": "missing 'value' parameter to test", + "doc": [ null ], + "patch": [ { "op": "test", "path": "/0" } ], + "error": "missing 'value' parameter" }, + + { "comment": "missing value parameter to test - where undef is falsy", + "doc": [ false ], + "patch": [ { "op": "test", "path": "/0" } ], + "error": "missing 'value' parameter" }, + + { "comment": "missing from parameter to copy", + "doc": [ 1 ], + "patch": [ { "op": "copy", "path": "/-" } ], + "error": "missing 'from' parameter" }, + + { "comment": "missing from location to copy", + "doc": { "foo": 1 }, + "patch": [ { "op": "copy", "from": "/bar", "path": "/foo" } ], + "error": "missing 'from' location" }, + + { "comment": "missing from parameter to move", + "doc": { "foo": 1 }, + "patch": [ { "op": "move", "path": "" } ], + "error": "missing 'from' parameter" }, + + { "comment": "missing from location to move", + "doc": { "foo": 1 }, + "patch": [ { "op": "move", "from": "/bar", "path": "/foo" } ], + "error": "missing 'from' location" }, + + { "comment": "duplicate ops", + "doc": { "foo": "bar" }, + "patch": [ { "op": "add", "path": "/baz", "value": "qux", + "op": "move", "from":"/foo" } ], + "error": "patch has two 'op' members", + "disabled": true }, + + { "comment": "unrecognized op should fail", + "doc": {"foo": 1}, + "patch": [{"op": "spam", "path": "/foo", "value": 1}], + "error": "Unrecognized op 'spam'" }, + + { "comment": "test with bad array number that has leading zeros", + "doc": ["foo", "bar"], + "patch": [{"op": "test", "path": "/00", "value": "foo"}], + "error": "test op should reject the array value, it has leading zeros" }, + + { "comment": "test with bad array number that has leading zeros", + "doc": ["foo", "bar"], + "patch": [{"op": "test", "path": "/01", "value": "bar"}], + "error": "test op should reject the array value, it has leading zeros" }, + + { "comment": "Removing nonexistent field", + "doc": {"foo" : "bar"}, + "patch": [{"op": "remove", "path": "/baz"}], + "error": "removing a nonexistent field should fail" }, + + { "comment": "Removing deep nonexistent path", + "doc": {"foo" : "bar"}, + "patch": [{"op": "remove", "path": "/missing1/missing2"}], + "error": "removing a nonexistent field should fail" }, + + { "comment": "Removing nonexistent index", + "doc": ["foo", "bar"], + "patch": [{"op": "remove", "path": "/2"}], + "error": "removing a nonexistent index should fail" }, + + { "comment": "Patch with different capitalisation than doc", + "doc": {"foo":"bar"}, + "patch": [{"op": "add", "path": "/FOO", "value": "BAR"}], + "expected": {"foo": "bar", "FOO": "BAR"} }, + + { "comment": "test copy object then change destination", + "doc": {"foo": {"bar": {"baz": [{"boo": "net"}]}}}, + "patch": [ + {"op": "copy", "from": "/foo", "path": "/bak"}, + {"op": "replace", "path": "/bak/bar/baz/0/boo", "value": "qux"} + ], + "expected": {"foo": {"bar": {"baz": [{"boo": "net"}]}}, "bak": {"bar": {"baz": [{"boo":"qux"}]}}} }, + + { "comment": "test copy object then change source", + "doc": {"foo": {"bar": {"baz": [{"boo": "net"}]}}}, + "patch": [ + {"op": "copy", "from": "/foo", "path": "/bak"}, + {"op": "replace", "path": "/foo/bar/baz/0/boo", "value": "qux"} + ], + "expected": {"foo": {"bar": {"baz": [{"boo": "qux"}]}}, "bak": {"bar": {"baz": [{"boo":"net"}]}}} + } + +]