diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..80f5c7e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+.idea
+vendor
+.phpunit.result.cache
+php_errors.log
+composer.lock
\ No newline at end of file
diff --git a/LICENSE.md b/LICENSE.md
new file mode 100644
index 0000000..e3ecf5c
--- /dev/null
+++ b/LICENSE.md
@@ -0,0 +1,9 @@
+The MIT License (MIT)
+
+Copyright (c) 2024 - Salvatore Rotondo
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..f7a6cec
--- /dev/null
+++ b/README.md
@@ -0,0 +1,97 @@
+PHP Fast JSON Patch
+=====================
+
+FastJsonPatch is designed to handle JSON Patch operations in accordance with the [RFC 6902](http://tools.ietf.org/html/rfc6902) specification.
+
+JSON Patch is a format for expressing a sequence of operations to be applied to a JSON document. This class provides methods to parse, validate, and apply these operations, allowing you to modify JSON objects or arrays programmatically.
+
+
+## Installation via Composer
+
+``` bash
+composer require blancks/fast-jsonpatch-php
+```
+
+## Key features
+
+1. **Apply JSON Patch Operations:**
+ - The class can apply a series of JSON Patch operations to a target JSON document.
+ - The operations are performed sequentially, modifying the document as specified in the patch.
+
+
+2. **Operation Types:**
+ - **add**: Adds a value to a specific location in the JSON document.
+ - **copy**: Copies a value from one location to another within the JSON document.
+ - **move**: Moves a value from one location to another within the JSON document.
+ - **remove**: Removes a value from a specific location in the JSON document.
+ - **replace**: Replaces the value at a specific location with a new value.
+ - **test**: Tests whether a specified value is present at a specific location in the JSON document.
+
+
+3. **Path Parsing:**
+ - The class uses JSON Pointer ([RFC 6901](https://datatracker.ietf.org/doc/html/rfc6901)) notation to identify locations within the JSON document. It correctly handles the path syntax, including edge cases such as escaping special characters.
+
+
+4. **Validation:**
+ - The class ensures that the provided patch document conforms to the JSON Patch specification, validating the structure and types of operations before applying them.
+
+
+5. **Performance:**
+ - The class is optimized for performance, ensuring that operations are applied efficiently even on large JSON documents.
+
+
+6. **Tests:**
+ - Extensive unit testing ensures that everything is robust and works as intended.
+
+## Basic Usage
+
+``` php
+= 8.1
+- JSON extension enabled in PHP
+
+## Running tests
+
+``` bash
+composer test
+```
+
+Test cases comes from [json-patch/json-patch-tests](https://github.com/json-patch/json-patch-tests) and extended furthermore.
+
+## License
+
+This software is licensed under the [MIT License](LICENSE.md).
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..5080078
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,33 @@
+{
+ "name": "blancks/fast-jsonpatch-php",
+ "description": "PHP implementation of JSON Patch that runs fast with optimized memory usage",
+ "keywords": ["php","json", "json patch"],
+ "homepage": "https://github.com/blancks/fast-jsonpatch-php",
+ "type": "library",
+ "license": "MIT",
+ "authors": [
+ {
+ "name": "Salvatore Rotondo",
+ "email": "s.rotondo90@gmail.com"
+ }
+ ],
+ "support": {
+ "issues": "https://github.com/blancks/fast-jsonpatch-php/issues"
+ },
+ "autoload": {
+ "psr-4": {
+ "blancks\\JsonPatch\\": "src"
+ }
+ },
+ "require": {
+ "php": ">=8.1"
+ },
+ "require-dev": {
+ "phpunit/phpunit": "^11",
+ "phpstan/phpstan": "^1.11"
+ },
+ "scripts": {
+ "test": "phpunit --no-configuration --test-suffix FastJsonPatchTest.php tests/ --colors=always",
+ "static-analyse": "phpstan analyse --configuration phpstan.neon.dist"
+ }
+}
\ No newline at end of file
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644
index 0000000..88b40d7
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,11 @@
+parameters:
+ level: max
+ excludePaths:
+ - test
+ paths:
+ - src
+ reportUnmatchedIgnoredErrors: false
+ ignoreErrors:
+ - '#recursiveKeySort#'
+ - '#array_splice#'
+ - '#Cannot access offset string on array#'
\ No newline at end of file
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..3def6de
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/FastJsonPatch.php b/src/FastJsonPatch.php
new file mode 100644
index 0000000..de6a005
--- /dev/null
+++ b/src/FastJsonPatch.php
@@ -0,0 +1,570 @@
+|\stdClass $document decoded json document passed by reference
+ * @param array $patch decoded list of patches that must be applied to $document
+ * @throws FastJsonPatchException
+ */
+ public static function applyByReference(array|\stdClass &$document, array $patch): void
+ {
+ self::validateDecodedPatch($patch);
+ $optionalNodes = ['from', 'path', 'value'];
+
+ foreach ($patch as $p) {
+ $p = (array) $p;
+ $parameters = [];
+
+ foreach ($optionalNodes as $key) {
+ if (array_key_exists($key, $p)) {
+ $parameters[$key] = $key === 'path' || $key === 'from'
+ ? self::pathSplitter($p[$key])
+ : $p[$key];
+ }
+ }
+
+ self::{'op' . ucfirst($p['op'])}($document, ...$parameters);
+ }
+ }
+
+ /**
+ * Parses a $jsonpointer path against $json and returns the value
+ *
+ * @param string $json
+ * @param string $pointer JSON Pointer
+ * @return mixed
+ * @throws FastJsonPatchException
+ */
+ public static function parsePath(string $json, string $pointer): mixed
+ {
+ $document = json_decode($json);
+ return self::parsePathByReference($document, $pointer);
+ }
+
+ /**
+ * Parses a $jsonpointer path against $json and returns the value
+ *
+ * @param array|\stdClass $document
+ * @param string $pointer JSON Pointer
+ * @return mixed
+ * @throws FastJsonPatchException
+ */
+ public static function parsePathByReference(array|\stdClass &$document, string $pointer): mixed
+ {
+ self::assertValidJsonPointer($pointer);
+ return self::documentReader($document, self::pathSplitter($pointer));
+ }
+
+ /**
+ * Validates the JSON Patch document structure.
+ * throws a FastJsonPatchException if the $patch is invalid
+ *
+ * @param string $patch decoded list of patches that must be applied to $document
+ * @return void
+ * @throws InvalidPatchOperationException
+ * @throws InvalidPatchPathException
+ * @throws InvalidPatchValueException
+ * @throws InvalidPatchFromException
+ * @throws UnknownPatchOperationException
+ * @throws MalformedPathException
+ */
+ public static function validatePatch(string $patch): void
+ {
+ /** @var array $decoded */
+ $decoded = json_decode($patch);
+ self::validateDecodedPatch($decoded);
+ }
+
+ /**
+ * ADD Operation
+ *
+ * Performs one of the following functions, depending upon what the target location references:
+ * * If the target location specifies an array index, a new value is inserted into the array at the specified index.
+ * * If the target location specifies an object member that does not already exist, a new member is added to the object.
+ * * If the target location specifies an object member that does exist, that member's value is replaced.
+ *
+ * @link https://datatracker.ietf.org/doc/html/rfc6902/#section-4.1
+ * @param array|\stdClass $document
+ * @param string[] $path
+ * @param mixed $value
+ * @return void
+ */
+ private static function opAdd(array|\stdClass &$document, array $path, mixed $value): void
+ {
+ self::documentWriter($document, $path, $value);
+ }
+
+ /**
+ * REMOVE Operation
+ * Removes the value at the target location. The target location MUST exist for the operation to be successful.
+ *
+ * @link https://datatracker.ietf.org/doc/html/rfc6902/#section-4.2
+ * @param array|\stdClass $document
+ * @param string[] $path
+ * @return void
+ */
+ private static function opRemove(array|\stdClass &$document, array $path): void
+ {
+ self::documentRemover($document, $path);
+ }
+
+ /**
+ * REPLACE Operation
+ * Replaces the value at the target location with a new value.
+ * The target location MUST exist for the operation to be successful.
+ *
+ * @link https://datatracker.ietf.org/doc/html/rfc6902/#section-4.3
+ * @param array|\stdClass $document
+ * @param string[] $path
+ * @param mixed $value
+ * @return void
+ */
+ private static function opReplace(array|\stdClass &$document, array $path, mixed $value): void
+ {
+ self::documentRemover($document, $path);
+ self::documentWriter($document, $path, $value);
+ }
+
+ /**
+ * MOVE Operation
+ * Removes the value at a specified location and adds it to the target location.
+ *
+ * @link https://datatracker.ietf.org/doc/html/rfc6902/#section-4.4
+ * @param array|\stdClass $document
+ * @param string[] $from
+ * @param string[] $path
+ * @return void
+ */
+ private static function opMove(array|\stdClass &$document, array $from, array $path): void
+ {
+ $value = self::documentRemover($document, $from);
+ self::documentWriter($document, $path, $value);
+ }
+
+ /**
+ * COPY Operation
+ * Copies the value at a specified location to the target location.
+ *
+ * @link https://datatracker.ietf.org/doc/html/rfc6902/#section-4.5
+ * @param array|\stdClass $document
+ * @param string[] $from
+ * @param string[] $path
+ * @return void
+ */
+ private static function opCopy(array|\stdClass &$document, array $from, array $path): void
+ {
+ $value = self::documentReader($document, $from);
+ self::documentWriter($document, $path, $value);
+ }
+
+ /**
+ * TEST Operation
+ * Tests that a value at the target location is equal to a specified value
+ *
+ * @link https://datatracker.ietf.org/doc/html/rfc6902/#section-4.6
+ * @param array|\stdClass $document
+ * @param string[] $path
+ * @param mixed $value
+ * @return void
+ */
+ private static function opTest(array|\stdClass &$document, array $path, mixed $value): void
+ {
+ $item = self::documentReader($document, $path);
+
+ if (!self::isJsonEquals($item, $value)) {
+ throw new FailedTestException(
+ sprintf(
+ 'Test operation failed asserting that %s equals %s',
+ json_encode($item),
+ json_encode($value)
+ )
+ );
+ }
+ }
+
+ /**
+ * Adds $value at the $path location in the $document
+ *
+ * @param array|\stdClass $document
+ * @param string[] $path
+ * @param mixed $value
+ * @param string[]|null $originalpath
+ * @return void
+ */
+ private static function documentWriter(
+ array|\stdClass &$document,
+ array $path,
+ mixed $value,
+ ?array $originalpath = null
+ ): void {
+ if (count($path) === 0) {
+ $document = $value;
+ return;
+ }
+
+ $originalpath ??= $path;
+ $node = array_shift($path);
+ $pathLength = count($path);
+ $isObject = is_object($document);
+
+ if (
+ $pathLength > 0 &&
+ (($isObject && !property_exists($document, $node)) || (!$isObject && !array_key_exists($node, $document)))
+ ) {
+ throw new UnknownPathException(sprintf('Unknown document path "/%s"', implode('/', $originalpath)));
+ }
+
+ if ($pathLength === 0) {
+ $appendToArray = $node === '-';
+ $isAssociative = !$isObject && !array_is_list($document);
+
+ if ($appendToArray && ($isObject || $isAssociative)) {
+ throw new AppendToObjectException(
+ sprintf(
+ 'Appending value ("-" symbol) against an object is not allowed at path /%s for item %s',
+ implode('/', $originalpath),
+ json_encode($document)
+ )
+ );
+ }
+
+ if ($isObject) {
+ $document->{$node} = $value;
+ return;
+ }
+
+ /** @phpstan-ignore-next-line */
+ $documentLength = count($document);
+ $node = $appendToArray ? (string) $documentLength : $node;
+
+ if ((!empty($document) && $isAssociative) || empty($document)) {
+ $document[$node] = $value;
+ return;
+ }
+
+ if (!is_numeric($node)) {
+ throw new UnknownPathException(
+ sprintf(
+ 'Can\'t add object property "%s" to value "%s" at "/%s"',
+ $node,
+ json_encode($value),
+ implode('/', $originalpath)
+ )
+ );
+ }
+
+ $nodeInt = (int) $node;
+
+ if ((string) $nodeInt !== $node || $nodeInt < 0 || $nodeInt > $documentLength) {
+ throw new ArrayBoundaryException(
+ sprintf(
+ 'Exceeding array boundaries with index %d at path /%s for item %s',
+ $nodeInt,
+ implode('/', $originalpath),
+ json_encode($document)
+ )
+ );
+ }
+
+ array_splice($document, $nodeInt, 0, is_array($value) || is_object($value) ? [$value] : $value);
+ return;
+ }
+
+ if ($isObject) {
+ self::documentWriter($document->{$node}, $path, $value, $originalpath);
+ return;
+ }
+
+ self::documentWriter($document[$node], $path, $value, $originalpath);
+ }
+
+ /**
+ * Removes the value at the provided $path in the $document
+ *
+ * @param array|\stdClass $document
+ * @param string[] $path
+ * @param string[]|null $originalpath
+ * @return mixed the value removed from the document
+ */
+ private static function documentRemover(array|\stdClass &$document, array $path, ?array $originalpath = null): mixed
+ {
+ if (count($path) === 0) {
+ return null;
+ }
+
+ $originalpath ??= $path;
+ $node = array_shift($path);
+ $isObject = is_object($document);
+
+ if (($isObject && !property_exists($document, $node)) || (!$isObject && !array_key_exists($node, $document))) {
+ throw new UnknownPathException(
+ sprintf('Unknown document path "/%s"', implode('/', $originalpath))
+ );
+ }
+
+ if (count($path) === 0) {
+ $isAssociative = !$isObject && !array_is_list($document);
+
+ if ($isObject) {
+ $value = $document->{$node};
+ unset($document->{$node});
+ return $value;
+ } elseif ($isAssociative) {
+ $value = $document[$node];
+ unset($document[$node]);
+ return $value;
+ }
+
+ $value = $document[$node];
+ array_splice($document, (int) $node, 1);
+ return $value;
+ }
+
+ if ($isObject) {
+ return self::documentRemover($document->{$node}, $path, $originalpath);
+ }
+
+ return self::documentRemover($document[$node], $path, $originalpath);
+ }
+
+ /**
+ * Finds and returns the value at the provided $path in the $document
+ *
+ * @param array|\stdClass $document
+ * @param string[] $path
+ * @param string[]|null $originalpath
+ * @return mixed
+ */
+ private static function documentReader(array|\stdClass &$document, array $path, ?array $originalpath = null): mixed
+ {
+ if (count($path) === 0) {
+ return $document;
+ }
+
+ $originalpath ??= $path;
+ $node = array_shift($path);
+ $isObject = is_object($document);
+
+ if (($isObject && !property_exists($document, $node)) || (!$isObject && !array_key_exists($node, $document))) {
+ throw new UnknownPathException(
+ sprintf('Unknown document path "/%s"', implode('/', $originalpath))
+ );
+ }
+
+ if ($isObject) {
+ return count($path) === 0
+ ? $document->{$node}
+ : self::documentReader($document->{$node}, $path, $originalpath);
+ }
+
+ return count($path) === 0
+ ? $document[$node]
+ : self::documentReader($document[$node], $path, $originalpath);
+ }
+
+ /**
+ * Validates the JSON Patch document structure.
+ * throws a FastJsonPatchException if the $patch is invalid
+ *
+ * @param array $patch decoded list of patches that must be applied to $document
+ * @return void
+ * @throws InvalidPatchOperationException
+ * @throws InvalidPatchPathException
+ * @throws InvalidPatchValueException
+ * @throws InvalidPatchFromException
+ * @throws UnknownPatchOperationException
+ * @throws MalformedPathException
+ */
+ private static function validateDecodedPatch(array $patch): void
+ {
+ foreach ($patch as $p) {
+ $p = (array) $p;
+
+ if (!isset($p['op'])) {
+ throw new InvalidPatchOperationException(
+ sprintf('"op" is missing in patch %s', json_encode($p))
+ );
+ }
+
+ if (!isset($p['path'])) {
+ throw new InvalidPatchPathException(
+ sprintf('"path" is missing in patch %s', json_encode($p))
+ );
+ }
+
+ self::assertValidJsonPointer($p['path']);
+
+ switch ($p['op']) {
+ case 'add':
+ case 'replace':
+ case 'test':
+ if (!array_key_exists('value', $p)) {
+ throw new InvalidPatchValueException(sprintf('"value" is missing in patch %s', json_encode($p)));
+ }
+ break;
+ case 'copy':
+ case 'move':
+ if (!isset($p['from'])) {
+ throw new InvalidPatchFromException(sprintf('"from" is missing in patch %s', json_encode($p)));
+ }
+
+ self::assertValidJsonPointer($p['from']);
+ break;
+ case 'remove':
+ break; // only needs "op" and "path" as mandatory properties
+ default:
+ throw new UnknownPatchOperationException(
+ sprintf('Unknown operation "%s" in patch %s', $p['op'], json_encode($p))
+ );
+ }
+ }
+ }
+
+ /**
+ * Returns the $path tokens as array
+ *
+ * @link https://datatracker.ietf.org/doc/html/rfc6901#section-3
+ * @param string $path
+ * @return string[]
+ * @throws MalformedPathException
+ */
+ private static function pathSplitter(string $path): array
+ {
+ return $path === '' ? [] : array_map(
+ fn(string $part): string => strtr($part, ['~1' => '/', '~0' => '~']),
+ explode('/', ltrim($path, '/'))
+ );
+ }
+
+ /**
+ * Ensures that $pointer is a valid JSON Pointer
+ * @param string $pointer
+ * @return void
+ */
+ private static function assertValidJsonPointer(string $pointer): void
+ {
+ if ($pointer !== '' && !str_starts_with($pointer, '/')) {
+ throw new MalformedPathException(sprintf('path "%s" does not start with a slash', $pointer));
+ }
+ }
+
+ /**
+ * Tells if $a and $b are of the same JSON type
+ *
+ * @link https://datatracker.ietf.org/doc/html/rfc6902/#section-4.6
+ * @param mixed $a
+ * @param mixed $b
+ * @return bool true if $a and $b are JSON equal, false otherwise
+ */
+ private static function isJsonEquals(mixed $a, mixed $b): bool
+ {
+ if (is_array($a) || is_object($a)) {
+ $a = (array) $a;
+ self::recursiveKeySort($a);
+ }
+ if (is_array($b) || is_object($b)) {
+ $b = (array) $b;
+ self::recursiveKeySort($b);
+ }
+
+ return json_encode($a) === json_encode($b);
+ }
+
+ /**
+ * Applies ksort to each array element recursively
+ *
+ * @param array $a
+ * @return void
+ */
+ private static function recursiveKeySort(array &$a): void
+ {
+ foreach ($a as &$item) {
+ if (is_array($item) || is_object($item)) {
+ $item = (array) $item;
+ self::recursiveKeySort($item);
+ }
+ }
+
+ ksort($a, SORT_STRING);
+ }
+}
diff --git a/src/exceptions/AppendToObjectException.php b/src/exceptions/AppendToObjectException.php
new file mode 100644
index 0000000..992a7e4
--- /dev/null
+++ b/src/exceptions/AppendToObjectException.php
@@ -0,0 +1,5 @@
+expectException(InvalidPatchOperationException::class);
+ FastJsonPatch::apply('{}', '[{"path": "/foo", "value": "bar"}]');
+ }
+
+ public function testPatchWithUnknownOpShouldFail(): void
+ {
+ $this->expectException(UnknownPatchOperationException::class);
+ FastJsonPatch::apply('{"foo":"bar"}', '[{"op":"read", "path": "/foo"}]');
+ }
+
+ public function testPatchWithMissingPathParameterShouldFail(): void
+ {
+ $this->expectException(InvalidPatchPathException::class);
+ FastJsonPatch::apply('{}', '[{"op":"add", "value": "bar"}]');
+ }
+
+ public function testPatchWithMalformedPathParameterShouldFail(): void
+ {
+ $this->expectException(MalformedPathException::class);
+ FastJsonPatch::apply('{}', '[{"op":"add", "path": "foo", "value": "bar"}]');
+ }
+
+ public function testPatchWithMissingValueParameterShouldFail(): void
+ {
+ $this->expectException(InvalidPatchValueException::class);
+ FastJsonPatch::apply('{}', '[{"op":"add", "path": "/foo"}]');
+ }
+
+ public function testPatchWithMissingFromParameterShouldFail(): void
+ {
+ $this->expectException(InvalidPatchFromException::class);
+ FastJsonPatch::apply('{"bar":1}', '[{"op": "copy", "path": "/foo"}]');
+ }
+
+ #[DataProvider('validOperationsProvider')]
+ public function testValidJsonPatches(string $json, string $patches, string $expected): void
+ {
+ $this->assertSame(
+ json_encode(json_decode($expected, false, 512, JSON_THROW_ON_ERROR)), // normalizes expected json
+ FastJsonPatch::apply($json, $patches)
+ );
+ }
+
+ #[DataProvider('outOfBoundsProvider')]
+ public function testAddingOutOfArrayBoundariesShouldFail(string $json, string $patches): void
+ {
+ $this->expectException(ArrayBoundaryException::class);
+ echo FastJsonPatch::apply($json, $patches);
+ }
+
+ #[DataProvider('unknownPathsProvider')]
+ public function testOperationsOnUnknownPathShouldFail(string $json, string $patches): void
+ {
+ $this->expectException(UnknownPathException::class);
+ echo FastJsonPatch::apply($json, $patches);
+ }
+
+ #[DataProvider('failedTestsProvider')]
+ public function testOperationsWithFailureCases(string $json, string $patches): void
+ {
+ $this->expectException(FailedTestException::class);
+ echo FastJsonPatch::apply($json, $patches);
+ }
+
+ public function testAppendingValueToAnObjectShouldFail(): void
+ {
+ $this->expectException(AppendToObjectException::class);
+ FastJsonPatch::apply('{"foo":"bar"}', '[{"op":"add", "path": "/-", "value":"biz"}]');
+ }
+
+ public static function validOperationsProvider(): array
+ {
+ return [
+ 'Empty patch against empty document' => [
+ '{}',
+ '[]',
+ '{}'
+ ],
+ 'Empty patch against non-empty document' => [
+ '{"foo": 1}',
+ '[]',
+ '{"foo": 1}'
+ ],
+ 'Empty patch against top-level array document' => [
+ '["foo"]',
+ '[]',
+ '["foo"]'
+ ],
+ 'Add patch replaces existing value' => [
+ '{"foo": 1}',
+ '[{"op": "add", "path": "/foo", "value": "Hello World"}]',
+ '{"foo": "Hello World"}'
+ ],
+ 'Add item to index zero into top-level array document' => [
+ '[]',
+ '[{"op": "add", "path": "/0", "value": "foo"}]',
+ '["foo"]'
+ ],
+ 'Add item to index one into top-level array document' => [
+ '["foo"]',
+ '[{"op": "add", "path": "/1", "value": "bar"}]',
+ '["foo","bar"]'
+ ],
+ 'Add item ahead of existing ones into top-level array document' => [
+ '["foo","bar"]',
+ '[{"op": "add", "path": "/0", "value": "first"}]',
+ '["first","foo","bar"]'
+ ],
+ 'Add item in the middle of two existing ones into top-level array document' => [
+ '["foo","bar"]',
+ '[{"op": "add", "path": "/1", "value": "inbetween"}]',
+ '["foo","inbetween","bar"]'
+ ],
+ 'Add item at the end of existing ones into top-level array document' => [
+ '["foo","bar"]',
+ '[{"op": "add", "path": "/2", "value": "last"}]',
+ '["foo","bar","last"]'
+ ],
+ 'Add new property with zero as object property name' => [
+ '{"foo": 1}',
+ '[{"op": "add", "path": "/0", "value": "bar"}]',
+ '{"foo": 1, "0": "bar" }'
+ ],
+ 'Add item into top-level array document with the append symbol "-"' => [
+ '[]',
+ '[{"op": "add", "path": "/-", "value": "foo"}]',
+ '["foo"]'
+ ],
+ 'Add null into top-level array document with the append symbol "-"' => [
+ '[]',
+ '[{"op": "add", "path": "/-", "value": null}]',
+ '[null]'
+ ],
+ 'Add object into top-level array document with the append symbol "-"' => [
+ '[]',
+ '[{"op": "add", "path": "/-", "value":{"foo":"bar"}}]',
+ '[{"foo":"bar"}]'
+ ],
+ 'Add object into nested array with the append symbol "-"' => [
+ '[ 1, 2, [ 3, [ 4, 5 ] ] ]',
+ '[ { "op": "add", "path": "/2/1/-", "value": { "foo": [ "bar", "baz" ] } } ]',
+ '[ 1, 2, [ 3, [ 4, 5, { "foo": [ "bar", "baz" ] } ] ] ]'
+ ],
+ 'Add test against unexpected flattened values in document array' => [
+ '["foo", "sil"]',
+ '[{"op": "add", "path": "/1", "value": ["bar", "baz"]}]',
+ '["foo", ["bar", "baz"], "sil"]'
+ ],
+ 'Add numeric string into top-level object' => [
+ '{}',
+ '[{"op": "add", "path": "/foo", "value": "1"}]',
+ '{"foo":"1"}'
+ ],
+ 'Add integer into top-level object' => [
+ '{}',
+ '[{"op": "add", "path": "/foo", "value": 1}]',
+ '{"foo":1}'
+ ],
+ 'Add integer into top-level object with an empty string key' => [
+ '{}',
+ '[{"op": "add", "path": "/", "value": 1}]',
+ '{"":1}'
+ ],
+ 'Add integer into top-level object with a numeric key' => [
+ '{}',
+ '[{"op": "add", "path": "/0", "value": 1}]',
+ '{"0":1}'
+ ],
+ 'Add new array value property at top-level object document' => [
+ '{"foo": 1}',
+ '[{"op": "add", "path": "/bar", "value": [1, 2]}]',
+ '{"foo": 1, "bar": [1,2]}'
+ ],
+ 'Add item into existing array' => [
+ '{"foo": 1, "baz": [{"qux": "hello"}]}',
+ '[{"op": "add", "path": "/baz/0/foo", "value": "world"}]',
+ '{"foo": 1, "baz": [{"qux": "hello", "foo": "world"}]}'
+ ],
+ 'Add new boolean value property (true) into object document' => [
+ '{"foo": 1}',
+ '[{"op": "add", "path": "/bar", "value": true}]',
+ '{"foo": 1, "bar": true}'
+ ],
+ 'Add new boolean value property (false) into object document' => [
+ '{"foo": 1}',
+ '[{"op": "add", "path": "/bar", "value": false}]',
+ '{"foo": 1, "bar": false}'
+ ],
+ 'Add new NULL value property into object document' => [
+ '{"foo": 1}',
+ '[{"op": "add", "path": "/bar", "value": null}]',
+ '{"foo": 1, "bar": null}'
+ ],
+ 'Add can replace the root of the document' => [
+ '{"foo": "bar"}',
+ '[{"op": "add", "path": "", "value": {"baz": "qux"}}]',
+ '{"baz":"qux"}'
+ ],
+ 'Add multiple patches at once' => [
+ '{}',
+ '[{"op": "add", "path": "/foo", "value": "Hello"},
+ {"op": "add", "path": "/bar", "value": "World"},
+ {"op": "add", "path": "/array", "value": []},
+ {"op": "add", "path": "/array/-", "value": "one"},
+ {"op": "add", "path": "/array/1", "value": "three"},
+ {"op": "add", "path": "/array/1", "value": "two"}]',
+ '{"foo":"Hello", "bar":"World", "array":["one","two","three"]}'
+ ],
+ 'Move to same location has no effect' => [
+ '{"foo": 1}',
+ '[{"op": "move", "from": "/foo", "path": "/foo"}]',
+ '{"foo": 1}'
+ ],
+ 'Move property into the same object' => [
+ '{"foo": 1, "baz": [{"qux": "hello"}]}',
+ '[{"op": "move", "from": "/foo", "path": "/bar"}]',
+ '{"baz": [{"qux": "hello"}], "bar": 1}'
+ ],
+ 'Move an object property value into an array' => [
+ '{"baz": [{"qux": "hello"}], "bar": 1}',
+ '[{"op": "move", "from": "/baz/0/qux", "path": "/baz/1"}]',
+ '{"baz": [{}, "hello"], "bar": 1}'
+ ],
+ 'Move entire object into an array' => [
+ '{"baz": [], "bar": {"qux": "hello"}}',
+ '[{"op": "move", "from": "/bar", "path": "/baz/0"}]',
+ '{"baz": [{"qux": "hello"}]}'
+ ],
+ 'Copy a null value' => [
+ '{"baz": null}',
+ '[{"op": "copy", "from": "/baz", "path": "/foo"}]',
+ '{"baz": null, "foo": null}'
+ ],
+ 'Copy a boolean true value' => [
+ '{"baz": true}',
+ '[{"op": "copy", "from": "/baz", "path": "/foo"}]',
+ '{"baz": true, "foo": true}'
+ ],
+ 'Copy a boolean false value' => [
+ '{"baz": true}',
+ '[{"op": "copy", "from": "/baz", "path": "/foo"}]',
+ '{"baz": true, "foo": true}'
+ ],
+ 'Copy a integer value' => [
+ '{"baz": 1}',
+ '[{"op": "copy", "from": "/baz", "path": "/foo"}]',
+ '{"baz": 1, "foo": 1}'
+ ],
+ 'Copy a string value' => [
+ '{"baz": "Hello World"}',
+ '[{"op": "copy", "from": "/baz", "path": "/foo"}]',
+ '{"baz": "Hello World", "foo": "Hello World"}'
+ ],
+ 'Copy an object to a different nesting level' => [
+ '{"baz": [{"qux": "hello"}], "bar": 1}',
+ '[{"op": "copy", "from": "/baz/0", "path": "/boo"}]',
+ '{"baz":[{"qux":"hello"}],"bar":1,"boo":{"qux":"hello"}}'
+ ],
+ 'Copy an array to a different nesting level' => [
+ '{"baz": [], "bar": 1, "qux": ["hello", "world"]}',
+ '[{"op": "copy", "from": "/qux", "path": "/baz/0"}]',
+ '{"baz": [["hello", "world"]], "bar": 1, "qux": ["hello", "world"]}'
+ ],
+ 'Remove null value' => [
+ '{"foo": null}',
+ '[{"op": "remove", "path": "/foo"}]',
+ '{}'
+ ],
+ 'Remove boolean true value' => [
+ '{"foo": true}',
+ '[{"op": "remove", "path": "/foo"}]',
+ '{}'
+ ],
+ 'Remove boolean false value' => [
+ '{"foo": false}',
+ '[{"op": "remove", "path": "/foo"}]',
+ '{}'
+ ],
+ 'Remove integer value' => [
+ '{"foo": 1}',
+ '[{"op": "remove", "path": "/foo"}]',
+ '{}'
+ ],
+ 'Remove string value' => [
+ '{"foo": "Hello World"}',
+ '[{"op": "remove", "path": "/foo"}]',
+ '{}'
+ ],
+ 'Remove object property from document' => [
+ '{"foo": 1, "bar": [1, 2, 3, 4]}',
+ '[{"op": "remove", "path": "/bar"}]',
+ '{"foo": 1}'
+ ],
+ 'Remove object property leaving an empty object' => [
+ '{"foo": 1, "baz": [{"qux": "hello"}]}',
+ '[{"op": "remove", "path": "/baz/0/qux"}]',
+ '{"foo": 1, "baz": [{}]}'
+ ],
+ 'Remove on array items' => [
+ '[1, 2, 3, 4]',
+ '[{"op": "remove", "path": "/0"}]',
+ '[2, 3, 4]'
+ ],
+ 'Remove entire array' => [
+ '[1, 2, 3, 4, [1,2]]',
+ '[{"op": "remove", "path": "/4"}]',
+ '[1, 2, 3, 4]'
+ ],
+ 'Replace object property with a different value type' => [
+ '{"foo": 1, "baz": [{"qux": "hello"}]}',
+ '[{"op": "replace", "path": "/foo", "value": [1, 2, 3, 4]}]',
+ '{"baz": [{"qux": "hello"}], "foo": [1, 2, 3, 4]}'
+ ],
+ 'Replace a more nested object property' => [
+ '{"foo": [1, 2, 3, 4], "baz": [{"qux": "hello"}]}',
+ '[{"op": "replace", "path": "/baz/0/qux", "value": "world"}]',
+ '{"foo": [1, 2, 3, 4], "baz": [{"qux": "world"}]}'
+ ],
+ 'Replace an indexed array item' => [
+ '["foo"]',
+ '[{"op": "replace", "path": "/0", "value": "bar"}]',
+ '["bar"]'
+ ],
+ 'Replace an empty string item with a zero' => [
+ '[""]',
+ '[{"op": "replace", "path": "/0", "value": 0}]',
+ '[0]'
+ ],
+ 'Replace an empty string item with boolean true' => [
+ '[""]',
+ '[{"op": "replace", "path": "/0", "value": true}]',
+ '[true]'
+ ],
+ 'Replace an empty string item with boolean false' => [
+ '[""]',
+ '[{"op": "replace", "path": "/0", "value": false}]',
+ '[false]'
+ ],
+ 'Replace an empty string item with a null value' => [
+ '[""]',
+ '[{"op": "replace", "path": "/0", "value": null}]',
+ '[null]'
+ ],
+ 'Replace value in array without flattening' => [
+ '["foo", "sil"]',
+ '[{"op": "replace", "path": "/1", "value": ["bar", "baz"]}]',
+ '["foo", ["bar", "baz"]]'
+ ],
+ 'Replace whole document' => [
+ '{"foo": "bar"}',
+ '[{"op": "replace", "path": "", "value": {"baz": "qux"}}]',
+ '{"baz": "qux"}'
+ ],
+ 'Test against implementation-specific numeric parsing' => [
+ '{"1e0": "foo"}',
+ '[{"op": "test", "path": "/1e0", "value": "foo"}]',
+ '{"1e0": "foo"}'
+ ],
+ 'Test with optional patch properties' => [
+ '{"foo": 1}',
+ '[{"op": "test", "path": "/foo", "value": 1, "eeeew": 1}]',
+ '{"foo": 1}'
+ ],
+ 'Test null properties are still valid' => [
+ '{"foo": null}',
+ '[{"op": "test", "path": "/foo", "value": null}]',
+ '{"foo": null}'
+ ],
+ 'Test should pass despite different arrangement' => [
+ '{"foo": {"foo": 1, "bar": 2}}',
+ '[{"op": "test", "path": "/foo", "value": {"bar": 2, "foo": 1}}]',
+ '{"foo": {"foo": 1, "bar": 2}}'
+ ],
+ 'Test should pass despite different arrangement (array nested)' => [
+ '{"foo": [{"foo": 1, "bar": 2}]}',
+ '[{"op": "test", "path": "/foo", "value": [{"bar": 2, "foo": 1}]}]',
+ '{"foo": [{"foo": 1, "bar": 2}]}'
+ ],
+ 'Test indexed array' => [
+ '{"foo": {"bar": [1, 2, 5, 4]}}',
+ '[{"op": "test", "path": "/foo", "value": {"bar": [1, 2, 5, 4]}}]',
+ '{"foo": {"bar": [1, 2, 5, 4]}}'
+ ],
+ 'Test whole document' => [
+ '{ "foo": 1 }',
+ '[{"op": "test", "path": "", "value": {"foo": 1}}]',
+ '{ "foo": 1 }'
+ ],
+ 'Test empty string element' => [
+ '{ "": 1 }',
+ '[{"op": "test", "path": "/", "value": 1}]',
+ '{ "": 1 }'
+ ],
+ // https://datatracker.ietf.org/doc/html/rfc6901#section-5
+ 'Test valid JSON pointers' => [
+ '{
+ "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
+ }',
+ '[
+ {"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}
+ ]',
+ '{
+ "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
+ }'
+ ],
+ ];
+ }
+
+ public static function outOfBoundsProvider(): array
+ {
+ return [
+ 'Add to array index with bad number should fail' => [
+ '["foo", "sil"]',
+ '[{"op": "add", "path": "/1e0", "value": "bar"}]'
+ ],
+ 'Add item out of upper array bounds should fail' => [
+ '{"bar": [1, 2]}',
+ '[{"op": "add", "path": "/bar/8", "value": "5"}]'
+ ],
+ 'Add item out of lower array bounds should fail' => [
+ '{"bar": [1, 2]}',
+ '[{"op": "add", "path": "/bar/-1", "value": "5"}]'
+ ],
+ ];
+ }
+
+ public static function unknownPathsProvider(): array
+ {
+ return [
+ 'Add Object operation on array target should fail' => [
+ '["foo", "sil"]',
+ '[{"op": "add", "path": "/bar", "value": 42}]'
+ ],
+ 'Add to a bad array index should fail' => [
+ '["foo", "sil"]',
+ '[{"op": "add", "path": "/bar", "value": "bar"}]'
+ ],
+ 'Copy with bad array index should fail' => [
+ '{"baz": [1,2,3], "bar": 1}',
+ '[{"op": "copy", "from": "/baz/1e0", "path": "/boo"}]'
+ ],
+ 'Move with bad array index should fail' => [
+ '{"foo": 1, "baz": [1,2,3,4]}',
+ '[{"op": "move", "from": "/baz/1e0", "path": "/foo"}]'
+ ],
+ 'Remove with bad array index should fail' => [
+ '[1, 2, 3, 4]',
+ '[{"op": "remove", "path": "/1e0"}]'
+ ],
+ 'Remove existing property with bad array index should fail' => [
+ '{"foo": 1, "baz": [{"qux": "hello"}]}',
+ '[{"op": "remove", "path": "/baz/1e0/qux"}]'
+ ],
+ 'Replace with bad array index should fail' => [
+ '[""]',
+ '[{"op": "replace", "path": "/1e0", "value": false}]'
+ ],
+ 'Test against undefined path should fail' => [
+ '["foo", "bar"]',
+ '[{"op": "test", "path": "/1e0", "value": "bar"}]'
+ ],
+ ];
+ }
+
+ public static function failedTestsProvider(): array
+ {
+ return [
+ 'Test null case against non-null value should fail' => [
+ '{"foo": "non-null"}',
+ '[{"op": "test", "path": "/foo", "value": null}]'
+ ],
+ 'Test string case against null value should fail' => [
+ '{"foo": null}',
+ '[{"op": "test", "path": "/foo", "value": "non-null"}]'
+ ],
+ 'Test boolean false case against null value should fail' => [
+ '{"foo": null}',
+ '[{"op": "test", "path": "/foo", "value": false}]'
+ ],
+ 'Test null case against boolean false value should fail' => [
+ '{"foo": false}',
+ '[{"op": "test", "path": "/foo", "value": null}]'
+ ],
+ 'Test invalid array should fail' => [
+ '{"foo": {"bar": [1, 2, 5, 4]}}',
+ '[{"op": "test", "path": "/foo", "value": [1, 2]}]'
+ ],
+ 'Test same value with different type should fail' => [
+ '{"foo": "1"}',
+ '[{"op": "test", "path": "/foo", "value": 1}]'
+ ],
+ ];
+ }
+}