From 8ac6d2781d02bef0c7ff00f73ac41c7f6f9e8ce3 Mon Sep 17 00:00:00 2001 From: acoulton Date: Sun, 30 Nov 2025 17:24:55 +0000 Subject: [PATCH 1/7] feat: Support building & using patches as a list of DTOs Build on the existing implementation to allow users to specify patches as a list of operation objects, rather than as a JSON string. Unlike the JSON form, the patch operation DTOs are strict about the parameters they accept. If a user wishes to include custom properties, they can implement a class extending the base `PatchOperation`. Patch DTOs can be serialised to and from JSON, for convenience. --- README.md | 58 +++++ src/FastJsonPatch.php | 27 ++- src/operations/Add.php | 13 + src/operations/Copy.php | 13 + src/operations/Move.php | 13 + src/operations/PatchOperation.php | 10 + src/operations/PatchOperationList.php | 77 ++++++ src/operations/Remove.php | 12 + src/operations/Replace.php | 13 + src/operations/Test.php | 13 + tests/FastJsonPatchTest.php | 55 ++++- tests/operations/PatchOperationListTest.php | 251 ++++++++++++++++++++ 12 files changed, 543 insertions(+), 12 deletions(-) create mode 100644 src/operations/Add.php create mode 100644 src/operations/Copy.php create mode 100644 src/operations/Move.php create mode 100644 src/operations/PatchOperation.php create mode 100644 src/operations/PatchOperationList.php create mode 100644 src/operations/Remove.php create mode 100644 src/operations/Replace.php create mode 100644 src/operations/Test.php create mode 100644 tests/operations/PatchOperationListTest.php diff --git a/README.md b/README.md index 2046e55..094fe1f 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,64 @@ The expected workflow is that once you got a `FastJsonPatch` instance you can ca Patch application is designed to be atomic. If any operation of a given patch fails the original document is restored, ensuring a consistent state of the document. +If you are building patches within your application, rather than receiving them from an external source, you may wish +to build them as native PHP objects. This provides strict typing of the available parameters for each operation. + +The above example could also be represented as: + +```php +use blancks\JsonPatch\FastJsonPatch; +use blancks\JsonPatch\exceptions\FastJsonPatchException; +use blancks\JsonPatch\operations\PatchOperationList; +use blancks\JsonPatch\operations\Add; +use blancks\JsonPatch\operations\Replace; +use blancks\JsonPatch\operations\Remove; + +$document = '{ + "contacts":[ + {"name":"John","number":"-"}, + {"name":"Dave","number":"+1 222 333 4444"} + ] +}'; + +$patch = new PatchOperationList( + new Add(path: '/contacts/-', value: ['name' => 'Jane', 'number' => '+1 353 644 2121']), + new Replace(path: '/contacts/0/number', value: '+1 212 555 1212'), + new Remove(path: '/contacts/1'), +); + +$FastJsonPatch = FastJsonPatch::fromJson($document); + +try { + + $FastJsonPatch->apply($patch); + +} catch (FastJsonPatchException $e) { + + // here if patch cannot be applied for some reason + echo $e->getMessage(), "\n"; + +} + +var_dump($FastJsonPatch->getDocument()); +``` + +### Should I use DTOs or JSON strings for patches? + +The exact answer will depend on your usecase, but broadly speaking: + +* If your patches are coming from an external or serialized source, keep them as JSON strings. This provides a clearer + and more forgiving validation process (for example, if the patch has missing or additional properties). It also avoids + any (limited) performance overhead to build the patch as typed objects. +* If you are building patches at runtime in your own application, consider using DTOs. This provides additional + type-safety within your code, and may be more efficient than serialising a patch to JSON and back. + +When working with DTOs, the `PatchOperationList` can be serialized to JSON using the native `json_encode` (or any method +that supports the `JsonSerializable` interface). It can also be unserialized from JSON - and you can optionally provide +a JSON handler and a mapping of `PatchOperation` classes to customise the parsing. + +Note that - unlike the JSON format - the core operation DTOs do not accept any additional parameters. If you need to +include additional parameters in your patch you can provide your own `PatchOperation` implementation(s). ## Constructor diff --git a/src/FastJsonPatch.php b/src/FastJsonPatch.php index eae4c2d..c925db2 100644 --- a/src/FastJsonPatch.php +++ b/src/FastJsonPatch.php @@ -21,9 +21,6 @@ JsonPointerHandlerAwareTrait, JsonPointerHandlerInterface }; -use blancks\JsonPatch\operations\{ - PatchValidationTrait -}; use blancks\JsonPatch\operations\handlers\{ AddHandler, CopyHandler, @@ -33,6 +30,10 @@ ReplaceHandler, TestHandler }; +use blancks\JsonPatch\operations\{ + PatchOperationList, + PatchValidationTrait +}; /** * This class allow to perform a sequence of operations to apply to a target JSON document as per RFC 6902 @@ -123,11 +124,11 @@ public function registerOperationHandler(PatchOperationHandlerInterface $PatchOp /** * Applies the patch to the referenced document. * The operation is atomic, if the patch cannot be applied the original document is restored - * @param string $patch + * @param string|PatchOperationList $patch * @return void * @throws FastJsonPatchException */ - public function apply(string $patch): void + public function apply(string|PatchOperationList $patch): void { try { $revertPatch = []; @@ -204,18 +205,22 @@ public function &getDocument(): mixed } /** - * @param string $patch + * @param string|PatchOperationList $patch * @return \Generator & iterable */ - private function patchIterator(string $patch): \Generator + private function patchIterator(string|PatchOperationList $patch): \Generator { - $decodedPatch = $this->JsonHandler->decode($patch); + if (is_string($patch)) { + $patchOperations = $this->JsonHandler->decode($patch); - if (!is_array($decodedPatch)) { - throw new InvalidPatchException('Invalid patch structure'); + if (!is_array($patchOperations)) { + throw new InvalidPatchException('Invalid patch structure'); + } + } else { + $patchOperations = $patch->operations; } - foreach ($decodedPatch as $p) { + foreach ($patchOperations as $p) { $p = (object) $p; $this->assertValidOp($p); $this->assertValidPath($p); diff --git a/src/operations/Add.php b/src/operations/Add.php new file mode 100644 index 0000000..d4cf636 --- /dev/null +++ b/src/operations/Add.php @@ -0,0 +1,13 @@ + + */ + public readonly array $operations; + + /** + * @param string $jsonOperations + * @param JsonHandlerInterface $jsonHandler + * @param array> $customClasses + * @return self + */ + public static function fromJson( + string $jsonOperations, + JsonHandlerInterface $jsonHandler = new BasicJsonHandler(), + array $customClasses = [], + ): self { + $patches = $jsonHandler->decode($jsonOperations, ['associative' => true]); + if (!(is_array($patches) && array_is_list($patches))) { + throw new InvalidPatchException( + sprintf('Invalid patch structure (expected list, got %s)', get_debug_type($patches)), + ); + } + + $classes = [ + 'add' => Add::class, + 'copy' => Copy::class, + 'move' => Move::class, + 'remove' => Remove::class, + 'replace' => Replace::class, + 'test' => Test::class, + ...$customClasses, + ]; + + return new PatchOperationList( + ...array_map( + function (array $patch) use ($classes) { + $op = $patch['op']; + unset($patch['op']); + if (!isset($classes[$op])) { + throw new InvalidPatchOperationException(sprintf('Unknown operation "%s"', $op)); + } + + return new $classes[$op](...$patch); + }, + $patches + ), + ); + } + + /** + * @no-named-arguments + */ + public function __construct( + PatchOperation ...$operations + ) { + $this->operations = $operations; + } + + /** + * @return list + */ + public function jsonSerialize(): array + { + return $this->operations; + } +} diff --git a/src/operations/Remove.php b/src/operations/Remove.php new file mode 100644 index 0000000..5f0cf5e --- /dev/null +++ b/src/operations/Remove.php @@ -0,0 +1,12 @@ + + */ + public static function validOperationsForDTOsProvider(): iterable + { + foreach (self::validOperationsProvider() as $name => $values) { + if ($name === 'Test with optional patch properties') { + // DTOs do not support arbitrary constructor parameters + continue; + } + + yield $name => $values; + } + } + + /** + * @param string $json + * @param string $patches + * @param string $expected + * @return void + * @throws \JsonException + * @throws FastJsonPatchException + */ + #[DataProvider('validOperationsForDTOsProvider')] + public function testValidJsonPatchesAsDTOs(string $json, string $patches, string $expected): void + { + $patch = PatchOperationList::fromJson($patches); + + $FastJsonPatch = FastJsonPatch::fromJson($json); + $FastJsonPatch->apply($patch); + + $this->assertSame( + $this->normalizeJson($expected), + $this->normalizeJson($this->jsonEncode($FastJsonPatch->getDocument())) + ); + } + /** * @param string $json * @param string $patches diff --git a/tests/operations/PatchOperationListTest.php b/tests/operations/PatchOperationListTest.php new file mode 100644 index 0000000..4c9cc65 --- /dev/null +++ b/tests/operations/PatchOperationListTest.php @@ -0,0 +1,251 @@ +, string}> + */ + public static function validEncodeDecodeProvider(): array + { + return [ + 'empty list' => [ + [], + '[]', + ], + 'single operation' => [ + [ + new Add(path: '/foo', value: 'World'), + ], + '[{"op": "add", "path": "/foo", "value": "World"}]', + ], + 'all supported operations' => [ + [ + new Add(path: '/bar', value: 'Worldy'), + new Copy(path: '/foo', from: '/bar'), + new Move(path: '/food', from: '/foo'), + new Remove(path: '/food'), + new Replace(path: '/bar', value: 'World'), + new Test(path: '/bar', value: 'World'), + ], + <<<'JSON' + [ + {"op":"add","path":"/bar","value":"Worldy"}, + {"op":"copy","path":"/foo","from":"/bar"}, + {"op":"move","path":"/food","from":"/foo"}, + {"op":"remove","path":"/food"}, + {"op":"replace","path":"/bar","value":"World"}, + {"op":"test","path":"/bar","value":"World"} + ] + JSON, + ], + ]; + } + + /** + * @param list $operationDTOs + * @param string $jsonOperations + * @return void + * @throws \JsonException + */ + #[DataProvider('validEncodeDecodeProvider')] + public function testItCanBeEncodedToJsonPatch(array $operationDTOs, string $jsonOperations): void + { + $this->assertSame( + $this->normalizeJson($jsonOperations), + $this->normalizeJson($this->jsonEncode(new PatchOperationList(...$operationDTOs))), + ); + } + + /** + * @param list $operationDTOs + * @param string $jsonOperations + * @return void + * @throws \JsonException + */ + #[DataProvider('validEncodeDecodeProvider')] + public function testItCanBeBuiltFromEncodedJson(array $operationDTOs, string $jsonOperations): void + { + $this->assertEquals( + new PatchOperationList(...$operationDTOs), + PatchOperationList::fromJson($jsonOperations), + ); + } + + public function testItCanUseCustomHandlerWhenDecodingFromJson(): void + { + $result = PatchOperationList::fromJson( + '[fake]', + jsonHandler: new class implements JsonHandlerInterface { + public function write(mixed &$document, string $path, mixed $value): mixed + { + throw new \BadMethodCallException(__METHOD__); + } + + public function &read(mixed &$document, string $path): mixed + { + throw new \BadMethodCallException(__METHOD__); + } + + public function update(mixed &$document, string $path, mixed $value): mixed + { + throw new \BadMethodCallException(__METHOD__); + } + + public function delete(mixed &$document, string $path): mixed + { + throw new \BadMethodCallException(__METHOD__); + } + + public function encode(mixed $document, array $options = []): string + { + throw new \BadMethodCallException(__METHOD__); + } + + public function decode(string $json, array $options = []): mixed + { + Assert::assertSame( + [ + 'json' => '[fake]', + 'options' => ['associative' => true], + ], + get_defined_vars(), + 'JSONHandler should have been called with expected args', + ); + return [ + ['op' => 'remove', 'path' => '/some/path'], + ]; + } + }, + ); + + $this->assertEquals( + new PatchOperationList(new Remove('/some/path')), + $result, + ); + } + + public function testItCanDecodeWithCustomOperationClasses(): void + { + $appendOperation = new class('/greeting', ' World') extends PatchOperation { + public function __construct( + public readonly string $path, + public readonly string $suffix, + ) { + parent::__construct('append'); + } + }; + $customAddOperation = new class('/greeting', 'Hello') extends PatchOperation { + public function __construct( + public readonly string $path, + public readonly mixed $value, + ) { + parent::__construct('add'); + } + }; + + $result = PatchOperationList::fromJson( + <<<'JSON' + [ + {"op": "add", "path": "/greeting", "value": "Hello" }, + {"op": "append", "path": "/greeting", "suffix": " World" }, + {"op": "copy", "path": "/whatever", "from": "/greeting"} + ] + JSON, + customClasses: [ + 'add' => $customAddOperation::class, + 'append' => $appendOperation::class, + ], + ); + + $this->assertEquals( + new PatchOperationList( + $customAddOperation, + $appendOperation, + new Copy(path: '/whatever', from: '/greeting'), + ), + $result, + ); + } + + /** + * @return array, string}> + */ + public static function invalidJsonProvider(): array + { + return [ + 'json is not a list (example 1)' => [ + '{"some": "field"}', + InvalidPatchException::class, + 'Invalid patch structure (expected list, got array)', + ], + 'json is not a list (example 2)' => [ + 'true', + InvalidPatchException::class, + 'Invalid patch structure (expected list, got bool)', + ], + 'unknown operation' => [ + '[{"op": "scramble", "path": "/anywhere"}]', + InvalidPatchOperationException::class, + 'Unknown operation "scramble"', + ], + ]; + } + + /** + * @param string $json + * @param class-string $expect_exception + * @param string $expect_msg + * @return void + */ + #[DataProvider('invalidJsonProvider')] + public function testItThrowsOnAttemptToCreateFromInvalidJson(string $json, string $expect_exception, string $expect_msg): void + { + $this->expectException($expect_exception); + $this->expectExceptionMessage($expect_msg); + PatchOperationList::fromJson($json); + } +} From 5cf0da319777a4622bc610006f368326150eadb5 Mon Sep 17 00:00:00 2001 From: acoulton Date: Sun, 30 Nov 2025 18:10:15 +0000 Subject: [PATCH 2/7] refactor: Use phpstan type aliases to reference operations from handlers Now that there are explicit operation classes for each standard operation, it feels appropriate to define the expected schema for unserialized operations alongside the DTO and import it to the handler. This more clearly shows the relationship between these objects. --- src/operations/Add.php | 7 +++++++ src/operations/Copy.php | 7 +++++++ src/operations/Move.php | 7 +++++++ src/operations/Remove.php | 6 ++++++ src/operations/Replace.php | 7 +++++++ src/operations/Test.php | 7 +++++++ src/operations/handlers/AddHandler.php | 20 +++++--------------- src/operations/handlers/CopyHandler.php | 20 +++++--------------- src/operations/handlers/MoveHandler.php | 21 ++++++--------------- src/operations/handlers/RemoveHandler.php | 18 ++++++------------ src/operations/handlers/ReplaceHandler.php | 21 ++++++--------------- src/operations/handlers/TestHandler.php | 20 +++++--------------- 12 files changed, 74 insertions(+), 87 deletions(-) diff --git a/src/operations/Add.php b/src/operations/Add.php index d4cf636..14dd2be 100644 --- a/src/operations/Add.php +++ b/src/operations/Add.php @@ -2,6 +2,13 @@ namespace blancks\JsonPatch\operations; +/** + * @phpstan-type TAddOperationObject object{ + * op:string, + * path: string, + * value: mixed, + * } + */ final class Add extends PatchOperation { public function __construct( diff --git a/src/operations/Copy.php b/src/operations/Copy.php index 789adf8..bf9aedb 100644 --- a/src/operations/Copy.php +++ b/src/operations/Copy.php @@ -2,6 +2,13 @@ namespace blancks\JsonPatch\operations; +/** + * @phpstan-type TCopyOperationObject object{ + * op:string, + * path: string, + * from: string, + * } + */ final class Copy extends PatchOperation { public function __construct( diff --git a/src/operations/Move.php b/src/operations/Move.php index eb07e36..bbf5b6e 100644 --- a/src/operations/Move.php +++ b/src/operations/Move.php @@ -2,6 +2,13 @@ namespace blancks\JsonPatch\operations; +/** + * @phpstan-type TMoveOperationObject object{ + * op:string, + * path: string, + * from: string, + * } + */ final class Move extends PatchOperation { public function __construct( diff --git a/src/operations/Remove.php b/src/operations/Remove.php index 5f0cf5e..b0ea3f7 100644 --- a/src/operations/Remove.php +++ b/src/operations/Remove.php @@ -2,6 +2,12 @@ namespace blancks\JsonPatch\operations; +/** + * @phpstan-type TRemoveOperationObject object{ + * op:string, + * path: string, + * } + */ final class Remove extends PatchOperation { public function __construct( diff --git a/src/operations/Replace.php b/src/operations/Replace.php index 32cf065..7ed10d1 100644 --- a/src/operations/Replace.php +++ b/src/operations/Replace.php @@ -2,6 +2,13 @@ namespace blancks\JsonPatch\operations; +/** + * @phpstan-type TReplaceOperationObject object{ + * op:string, + * path: string, + * value: mixed, + * } + */ final class Replace extends PatchOperation { public function __construct( diff --git a/src/operations/Test.php b/src/operations/Test.php index 7fce3f1..f932648 100644 --- a/src/operations/Test.php +++ b/src/operations/Test.php @@ -2,6 +2,13 @@ namespace blancks\JsonPatch\operations; +/** + * @phpstan-type TTestOperationObject object{ + * op:string, + * path: string, + * value: mixed, + * } + */ final class Test extends PatchOperation { public function __construct( diff --git a/src/operations/handlers/AddHandler.php b/src/operations/handlers/AddHandler.php index daf3d2a..8199f90 100644 --- a/src/operations/handlers/AddHandler.php +++ b/src/operations/handlers/AddHandler.php @@ -3,20 +3,18 @@ namespace blancks\JsonPatch\operations\handlers; use blancks\JsonPatch\json\accessors\UndefinedValue; +use blancks\JsonPatch\operations\Add; /** * @internal + * @phpstan-import-type TAddOperationObject from Add */ final class AddHandler extends PatchOperationHandler { private mixed $previous; /** - * @param object{ - * op:string, - * path: string, - * value: mixed, - * } $patch + * @param object&TAddOperationObject $patch * @return void */ public function validate(object $patch): void @@ -28,11 +26,7 @@ public function validate(object $patch): void /** * @param mixed $document - * @param object{ - * op:string, - * path: string, - * value: mixed, - * } $patch + * @param object&TAddOperationObject $patch * @return void */ public function apply(mixed &$document, object $patch): void @@ -41,11 +35,7 @@ public function apply(mixed &$document, object $patch): void } /** - * @param object{ - * op:string, - * path: string, - * value: mixed, - * } $patch + * @param object&TAddOperationObject $patch * @return null|array{ * op:string, * path: string, diff --git a/src/operations/handlers/CopyHandler.php b/src/operations/handlers/CopyHandler.php index f595f7b..576d945 100644 --- a/src/operations/handlers/CopyHandler.php +++ b/src/operations/handlers/CopyHandler.php @@ -3,20 +3,18 @@ namespace blancks\JsonPatch\operations\handlers; use blancks\JsonPatch\json\accessors\UndefinedValue; +use blancks\JsonPatch\operations\Copy; /** * @internal + * @phpstan-import-type TCopyOperationObject from Copy */ final class CopyHandler extends PatchOperationHandler { private mixed $previous; /** - * @param object{ - * op:string, - * path: string, - * from: string, - * } $patch + * @param object&TCopyOperationObject $patch * @return void */ public function validate(object $patch): void @@ -28,11 +26,7 @@ public function validate(object $patch): void /** * @param mixed $document - * @param object{ - * op:string, - * path: string, - * from: string, - * } $patch + * @param object&TCopyOperationObject $patch * @return void */ public function apply(mixed &$document, object $patch): void @@ -42,11 +36,7 @@ public function apply(mixed &$document, object $patch): void } /** - * @param object{ - * op:string, - * path: string, - * from: string, - * } $patch + * @param object&TCopyOperationObject $patch * @return null|array{ * op:string, * path: string, diff --git a/src/operations/handlers/MoveHandler.php b/src/operations/handlers/MoveHandler.php index d690b7c..506e4ff 100644 --- a/src/operations/handlers/MoveHandler.php +++ b/src/operations/handlers/MoveHandler.php @@ -2,17 +2,16 @@ namespace blancks\JsonPatch\operations\handlers; +use blancks\JsonPatch\operations\Move; + /** * @internal + * @phpstan-import-type TMoveOperationObject from Move */ final class MoveHandler extends PatchOperationHandler { /** - * @param object{ - * op:string, - * path: string, - * from: string, - * } $patch + * @param object&TMoveOperationObject $patch * @return void */ public function validate(object $patch): void @@ -24,11 +23,7 @@ public function validate(object $patch): void /** * @param mixed $document - * @param object{ - * op:string, - * path: string, - * from: string, - * } $patch + * @param object&TMoveOperationObject $patch * @return void */ public function apply(mixed &$document, object $patch): void @@ -38,11 +33,7 @@ public function apply(mixed &$document, object $patch): void } /** - * @param object{ - * op:string, - * path: string, - * from: string, - * } $patch + * @param object&TMoveOperationObject $patch * @return null|array{ * op:string, * path: string, diff --git a/src/operations/handlers/RemoveHandler.php b/src/operations/handlers/RemoveHandler.php index b17cd3a..c171780 100644 --- a/src/operations/handlers/RemoveHandler.php +++ b/src/operations/handlers/RemoveHandler.php @@ -2,18 +2,18 @@ namespace blancks\JsonPatch\operations\handlers; +use blancks\JsonPatch\operations\Remove; + /** * @internal + * @phpstan-import-type TRemoveOperationObject from Remove */ final class RemoveHandler extends PatchOperationHandler { private mixed $previous; /** - * @param object{ - * op:string, - * path: string, - * } $patch + * @param object&TRemoveOperationObject $patch * @return void */ public function validate(object $patch): void @@ -24,10 +24,7 @@ public function validate(object $patch): void /** * @param mixed $document - * @param object{ - * op:string, - * path: string, - * } $patch + * @param object&TRemoveOperationObject $patch * @return void */ public function apply(mixed &$document, object $patch): void @@ -36,10 +33,7 @@ public function apply(mixed &$document, object $patch): void } /** - * @param object{ - * op:string, - * path: string, - * } $patch + * @param object&TRemoveOperationObject $patch * @return null|array{ * op:string, * path: string, diff --git a/src/operations/handlers/ReplaceHandler.php b/src/operations/handlers/ReplaceHandler.php index cd0255b..db2a3d2 100644 --- a/src/operations/handlers/ReplaceHandler.php +++ b/src/operations/handlers/ReplaceHandler.php @@ -2,19 +2,18 @@ namespace blancks\JsonPatch\operations\handlers; +use blancks\JsonPatch\operations\Replace; + /** * @internal + * @phpstan-import-type TReplaceOperationObject from Replace */ final class ReplaceHandler extends PatchOperationHandler { private mixed $previous; /** - * @param object{ - * op:string, - * path: string, - * value: mixed, - * } $patch + * @param object&TReplaceOperationObject $patch * @return void */ public function validate(object $patch): void @@ -26,11 +25,7 @@ public function validate(object $patch): void /** * @param mixed $document - * @param object{ - * op:string, - * path: string, - * value: mixed, - * } $patch + * @param object&TReplaceOperationObject $patch * @return void */ public function apply(mixed &$document, object $patch): void @@ -39,11 +34,7 @@ public function apply(mixed &$document, object $patch): void } /** - * @param object{ - * op:string, - * path: string, - * value: mixed, - * } $patch + * @param object&TReplaceOperationObject $patch * @return null|array{ * op:string, * path: string, diff --git a/src/operations/handlers/TestHandler.php b/src/operations/handlers/TestHandler.php index 69f2e02..920f727 100644 --- a/src/operations/handlers/TestHandler.php +++ b/src/operations/handlers/TestHandler.php @@ -3,18 +3,16 @@ namespace blancks\JsonPatch\operations\handlers; use blancks\JsonPatch\exceptions\FailedTestException; +use blancks\JsonPatch\operations\Test; /** * @internal + * @phpstan-import-type TTestOperationObject from Test */ final class TestHandler extends PatchOperationHandler { /** - * @param object{ - * op:string, - * path: string, - * value: mixed, - * } $patch + * @param object&TTestOperationObject $patch * @return void */ public function validate(object $patch): void @@ -26,11 +24,7 @@ public function validate(object $patch): void /** * @param mixed $document - * @param object{ - * op:string, - * path: string, - * value: mixed, - * } $patch + * @param object&TTestOperationObject $patch * @return void */ public function apply(mixed &$document, object $patch): void @@ -50,11 +44,7 @@ public function apply(mixed &$document, object $patch): void } /** - * @param object{ - * op:string, - * path: string, - * value: mixed, - * } $patch + * @param object&TTestOperationObject $patch * @return null|array{ * op:string, * path: string, From c93dff3e8ba66a1e5074a9785824f1c7b650a774 Mon Sep 17 00:00:00 2001 From: Andrew Coulton Date: Thu, 11 Dec 2025 23:59:04 +0000 Subject: [PATCH 3/7] fix: PatchOperationList should decode patches as objects Because although the top-level patch entries will be equivalent as arrays or objects, properties like the `value` key can contain any type of data. --- src/operations/PatchOperationList.php | 8 ++- tests/operations/PatchOperationListTest.php | 55 ++++++++++++--------- 2 files changed, 37 insertions(+), 26 deletions(-) diff --git a/src/operations/PatchOperationList.php b/src/operations/PatchOperationList.php index 0775f90..197c346 100644 --- a/src/operations/PatchOperationList.php +++ b/src/operations/PatchOperationList.php @@ -6,6 +6,7 @@ use blancks\JsonPatch\exceptions\InvalidPatchOperationException; use blancks\JsonPatch\json\handlers\BasicJsonHandler; use blancks\JsonPatch\json\handlers\JsonHandlerInterface; +use stdClass; final class PatchOperationList implements \JsonSerializable { @@ -25,7 +26,7 @@ public static function fromJson( JsonHandlerInterface $jsonHandler = new BasicJsonHandler(), array $customClasses = [], ): self { - $patches = $jsonHandler->decode($jsonOperations, ['associative' => true]); + $patches = $jsonHandler->decode($jsonOperations); if (!(is_array($patches) && array_is_list($patches))) { throw new InvalidPatchException( sprintf('Invalid patch structure (expected list, got %s)', get_debug_type($patches)), @@ -44,7 +45,10 @@ public static function fromJson( return new PatchOperationList( ...array_map( - function (array $patch) use ($classes) { + function (stdClass $patch) use ($classes) { + // The top-level patch entries should be identical as objects or arrays, cast to array to allow + // spreading the properties into the DTO constructors (which are assumed to match the JSON objects) + $patch = (array) $patch; $op = $patch['op']; unset($patch['op']); if (!isset($classes[$op])) { diff --git a/tests/operations/PatchOperationListTest.php b/tests/operations/PatchOperationListTest.php index 4c9cc65..558cca2 100644 --- a/tests/operations/PatchOperationListTest.php +++ b/tests/operations/PatchOperationListTest.php @@ -25,6 +25,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Assert; use PHPUnit\Framework\Attributes\UsesClass; +use stdClass; use Throwable; #[CoversClass(PatchOperationList::class)] @@ -51,6 +52,10 @@ class PatchOperationListTest extends JsonPatchCompliance */ public static function validEncodeDecodeProvider(): array { + $objValue = new stdClass(); + $objValue->type = 'foo'; + $objValue->list = ['bar', 'baz']; + return [ 'empty list' => [ [], @@ -82,6 +87,20 @@ public static function validEncodeDecodeProvider(): array ] JSON, ], + 'operations with object values' => [ + [ + new Add(path: '/bar', value: $objValue), + new Replace(path: '/bar', value: $objValue), + new Test(path: '/bar', value: $objValue), + ], + <<<'JSON' + [ + {"op":"add","path":"/bar","value":{"type":"foo","list":["bar","baz"]}}, + {"op":"replace","path":"/bar","value":{"type":"foo","list":["bar","baz"]}}, + {"op":"test","path":"/bar","value":{"type":"foo","list":["bar","baz"]}} + ] + JSON, + ] ]; } @@ -150,13 +169,13 @@ public function decode(string $json, array $options = []): mixed Assert::assertSame( [ 'json' => '[fake]', - 'options' => ['associative' => true], + 'options' => [], ], get_defined_vars(), 'JSONHandler should have been called with expected args', ); return [ - ['op' => 'remove', 'path' => '/some/path'], + (object) ['op' => 'remove', 'path' => '/some/path'], ]; } }, @@ -170,23 +189,6 @@ public function decode(string $json, array $options = []): mixed public function testItCanDecodeWithCustomOperationClasses(): void { - $appendOperation = new class('/greeting', ' World') extends PatchOperation { - public function __construct( - public readonly string $path, - public readonly string $suffix, - ) { - parent::__construct('append'); - } - }; - $customAddOperation = new class('/greeting', 'Hello') extends PatchOperation { - public function __construct( - public readonly string $path, - public readonly mixed $value, - ) { - parent::__construct('add'); - } - }; - $result = PatchOperationList::fromJson( <<<'JSON' [ @@ -196,15 +198,15 @@ public function __construct( ] JSON, customClasses: [ - 'add' => $customAddOperation::class, - 'append' => $appendOperation::class, + 'add' => CustomAdd::class, + 'append' => Append::class, ], ); $this->assertEquals( new PatchOperationList( - $customAddOperation, - $appendOperation, + new CustomAdd('/greeting', 'Hello'), + new Append('/greeting', ' World'), new Copy(path: '/whatever', from: '/greeting'), ), $result, @@ -220,13 +222,18 @@ public static function invalidJsonProvider(): array 'json is not a list (example 1)' => [ '{"some": "field"}', InvalidPatchException::class, - 'Invalid patch structure (expected list, got array)', + 'Invalid patch structure (expected list, got stdClass)', ], 'json is not a list (example 2)' => [ 'true', InvalidPatchException::class, 'Invalid patch structure (expected list, got bool)', ], + 'json is not a list (example 3)' => [ + '{"2": {"op": "add", "path": "/some/path", "value": "World"}}', + InvalidPatchException::class, + 'Invalid patch structure (expected list, got stdClass)', + ], 'unknown operation' => [ '[{"op": "scramble", "path": "/anywhere"}]', InvalidPatchOperationException::class, From 028168cbbfbd6aecb603b7146df3872f5f2819ea Mon Sep 17 00:00:00 2001 From: Andrew Coulton Date: Fri, 12 Dec 2025 00:05:49 +0000 Subject: [PATCH 4/7] feat: Make all `final` patch operation classes `readonly` Will break on 8.1, to be rebased once the project drops 8.1 support. I have intentionally left the base `PatchOperation` class as *not* readonly in case end-users want to extend it with mutable operation DTOs for any reason. Instead, I have just left `op` as a readonly property in the base class. --- src/operations/Add.php | 6 +++--- src/operations/Copy.php | 6 +++--- src/operations/Move.php | 6 +++--- src/operations/PatchOperation.php | 4 ++-- src/operations/PatchOperationList.php | 4 ++-- src/operations/Remove.php | 4 ++-- src/operations/Replace.php | 6 +++--- src/operations/Test.php | 6 +++--- tests/operations/PatchOperationListTest.php | 22 ++++++++++++++++++++- 9 files changed, 42 insertions(+), 22 deletions(-) diff --git a/src/operations/Add.php b/src/operations/Add.php index 14dd2be..8e54237 100644 --- a/src/operations/Add.php +++ b/src/operations/Add.php @@ -9,11 +9,11 @@ * value: mixed, * } */ -final class Add extends PatchOperation +final readonly class Add extends PatchOperation { public function __construct( - public readonly string $path, - public readonly mixed $value, + public string $path, + public mixed $value, ) { parent::__construct('add'); } diff --git a/src/operations/Copy.php b/src/operations/Copy.php index bf9aedb..06d834a 100644 --- a/src/operations/Copy.php +++ b/src/operations/Copy.php @@ -9,11 +9,11 @@ * from: string, * } */ -final class Copy extends PatchOperation +final readonly class Copy extends PatchOperation { public function __construct( - public readonly string $path, - public readonly string $from, + public string $path, + public string $from, ) { parent::__construct('copy'); } diff --git a/src/operations/Move.php b/src/operations/Move.php index bbf5b6e..e9dcdfe 100644 --- a/src/operations/Move.php +++ b/src/operations/Move.php @@ -9,11 +9,11 @@ * from: string, * } */ -final class Move extends PatchOperation +final readonly class Move extends PatchOperation { public function __construct( - public readonly string $path, - public readonly string $from, + public string $path, + public string $from, ) { parent::__construct('move'); } diff --git a/src/operations/PatchOperation.php b/src/operations/PatchOperation.php index 08f36c5..29d69e6 100644 --- a/src/operations/PatchOperation.php +++ b/src/operations/PatchOperation.php @@ -2,9 +2,9 @@ namespace blancks\JsonPatch\operations; -abstract class PatchOperation +abstract readonly class PatchOperation { public function __construct( - public readonly string $op, + public string $op, ) {} } diff --git a/src/operations/PatchOperationList.php b/src/operations/PatchOperationList.php index 197c346..8ed5615 100644 --- a/src/operations/PatchOperationList.php +++ b/src/operations/PatchOperationList.php @@ -8,12 +8,12 @@ use blancks\JsonPatch\json\handlers\JsonHandlerInterface; use stdClass; -final class PatchOperationList implements \JsonSerializable +final readonly class PatchOperationList implements \JsonSerializable { /** * @phpstan-var list */ - public readonly array $operations; + public array $operations; /** * @param string $jsonOperations diff --git a/src/operations/Remove.php b/src/operations/Remove.php index b0ea3f7..0a9450f 100644 --- a/src/operations/Remove.php +++ b/src/operations/Remove.php @@ -8,10 +8,10 @@ * path: string, * } */ -final class Remove extends PatchOperation +final readonly class Remove extends PatchOperation { public function __construct( - public readonly string $path, + public string $path, ) { parent::__construct('remove'); } diff --git a/src/operations/Replace.php b/src/operations/Replace.php index 7ed10d1..0c6d16d 100644 --- a/src/operations/Replace.php +++ b/src/operations/Replace.php @@ -9,11 +9,11 @@ * value: mixed, * } */ -final class Replace extends PatchOperation +final readonly class Replace extends PatchOperation { public function __construct( - public readonly string $path, - public readonly mixed $value, + public string $path, + public mixed $value, ) { parent::__construct('replace'); } diff --git a/src/operations/Test.php b/src/operations/Test.php index f932648..143350e 100644 --- a/src/operations/Test.php +++ b/src/operations/Test.php @@ -9,11 +9,11 @@ * value: mixed, * } */ -final class Test extends PatchOperation +final readonly class Test extends PatchOperation { public function __construct( - public readonly string $path, - public readonly mixed $value, + public string $path, + public mixed $value, ) { parent::__construct('test'); } diff --git a/tests/operations/PatchOperationListTest.php b/tests/operations/PatchOperationListTest.php index 558cca2..2eebfde 100644 --- a/tests/operations/PatchOperationListTest.php +++ b/tests/operations/PatchOperationListTest.php @@ -23,8 +23,8 @@ use blancks\JsonPatchTest\JsonPatchCompliance; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; -use PHPUnit\Framework\Assert; use PHPUnit\Framework\Attributes\UsesClass; +use PHPUnit\Framework\Assert; use stdClass; use Throwable; @@ -256,3 +256,23 @@ public function testItThrowsOnAttemptToCreateFromInvalidJson(string $json, strin PatchOperationList::fromJson($json); } } + +readonly class Append extends PatchOperation +{ + public function __construct( + public string $path, + public string $suffix, + ) { + parent::__construct('append'); + } +} + +readonly class CustomAdd extends PatchOperation +{ + public function __construct( + public string $path, + public mixed $value, + ) { + parent::__construct('add'); + } +} From 7658f79950d2fdd63f6afeb16ac5c9f4e4aae17a Mon Sep 17 00:00:00 2001 From: acoulton Date: Tue, 16 Dec 2025 09:44:03 +0000 Subject: [PATCH 5/7] feat: More strictly check patch structural validity during fromJson Improve test coverage of attempting to create a PatchOperationList from JSON with invalid data. Update the implementation so that the majority of potential issues will be detected and thrown as InvalidPatchException or InvalidPatchOperationException. --- src/operations/PatchOperationList.php | 90 +++++++++++++++++++-- tests/operations/PatchOperationListTest.php | 83 ++++++++++++++++--- 2 files changed, 156 insertions(+), 17 deletions(-) diff --git a/src/operations/PatchOperationList.php b/src/operations/PatchOperationList.php index 8ed5615..62cb839 100644 --- a/src/operations/PatchOperationList.php +++ b/src/operations/PatchOperationList.php @@ -6,7 +6,10 @@ use blancks\JsonPatch\exceptions\InvalidPatchOperationException; use blancks\JsonPatch\json\handlers\BasicJsonHandler; use blancks\JsonPatch\json\handlers\JsonHandlerInterface; +use ArgumentCountError; +use Error; use stdClass; +use TypeError; final readonly class PatchOperationList implements \JsonSerializable { @@ -45,23 +48,94 @@ public static function fromJson( return new PatchOperationList( ...array_map( - function (stdClass $patch) use ($classes) { - // The top-level patch entries should be identical as objects or arrays, cast to array to allow - // spreading the properties into the DTO constructors (which are assumed to match the JSON objects) - $patch = (array) $patch; - $op = $patch['op']; - unset($patch['op']); + function (mixed $patch) use ($classes) { + [$op, $values] = self::extractPatchOpAndValues($patch); if (!isset($classes[$op])) { throw new InvalidPatchOperationException(sprintf('Unknown operation "%s"', $op)); } - - return new $classes[$op](...$patch); + return self::createPatchDtoFromValues($op, $classes[$op], $values); }, $patches ), ); } + /** + * @phpstan-return array{string, array} + */ + private static function extractPatchOpAndValues(mixed $patch): array + { + if (!$patch instanceof stdClass) { + throw new InvalidPatchOperationException( + sprintf('Each patch item must be an object, got %s', get_debug_type($patch)) + ); + } + + if (!isset($patch->op)) { + throw new InvalidPatchOperationException('Each patch item must specify "op"'); + } + + if (!is_string($patch->op)) { + throw new InvalidPatchOperationException( + sprintf('Patch "op" must be a string, got %s', get_debug_type($patch->op)) + ); + } + + $op = $patch->op; + unset($patch->op); + + $values = []; + foreach ((array) $patch as $key => $value) { + // We rely on the properties being strings, to ensure that PHP will enforce that all named properties are + // present / defined when creating the arbitrary DTO object. Ensure this is the case + if (!is_string($key)) { + throw new InvalidPatchOperationException('All patch operation properties must have string names'); + } + $values[$key] = $value; + } + + return [$op, $values]; + } + + /** + * @phpstan-param class-string $class + * @param array $values + * @return PatchOperation + * + * @throws InvalidPatchOperationException + */ + private static function createPatchDtoFromValues(string $op, string $class, array $values): PatchOperation + { + try { + return new $class(...$values); + } catch (ArgumentCountError $e) { + // We can be relatively confident that this was triggered for the DTO constructor. If the DTO was calling a + // method (e.g. a parent method / internal helper method) with incorrect argument counts that should have + // been detected by its own tests. + throw new InvalidPatchOperationException( + sprintf('Missing required param(s) for %s operation as %s: %s', $op, $class, $e->getMessage()), + previous: $e, + ); + } catch (TypeError $e) { + // We can be relatively confident that this relates to the property types passed to the DTO constructor. + // If the DTO constructor has a type signature that does not match the expected supported values, that + // should have been detected by its own tests. + throw new InvalidPatchOperationException( + sprintf('Invalid param(s) for %s operation as %s: %s', $op, $class, $e->getMessage()), + previous: $e, + ); + } catch (Error $e) { + if (str_contains($e->getMessage(), 'Unknown named parameter')) { + // This does not have a dedicated error type so we have to match on the message. + throw new InvalidPatchOperationException( + sprintf('Unexpected param(s) for %s operation as %s: %s', $op, $class, $e->getMessage()), + previous: $e, + ); + } + throw $e; + } + } + /** * @no-named-arguments */ diff --git a/tests/operations/PatchOperationListTest.php b/tests/operations/PatchOperationListTest.php index 2eebfde..1e65794 100644 --- a/tests/operations/PatchOperationListTest.php +++ b/tests/operations/PatchOperationListTest.php @@ -29,13 +29,13 @@ use Throwable; #[CoversClass(PatchOperationList::class)] -#[UsesClass(Add::class)] -#[UsesClass(Copy::class)] -#[UsesClass(Move::class)] -#[UsesClass(Remove::class)] -#[UsesClass(Replace::class)] -#[UsesClass(Test::class)] -#[UsesClass(PatchOperation::class)] +#[CoversClass(Add::class)] +#[CoversClass(Copy::class)] +#[CoversClass(Move::class)] +#[CoversClass(Remove::class)] +#[CoversClass(Replace::class)] +#[CoversClass(Test::class)] +#[CoversClass(PatchOperation::class)] #[UsesClass(FastJsonPatchExceptionTrait::class)] #[UsesClass(InvalidPatchException::class)] #[UsesClass(InvalidPatchOperationException::class)] @@ -192,7 +192,7 @@ public function testItCanDecodeWithCustomOperationClasses(): void $result = PatchOperationList::fromJson( <<<'JSON' [ - {"op": "add", "path": "/greeting", "value": "Hello" }, + {"op": "add", "path": "/greeting", "value": "Hello", "custom": "my own var" }, {"op": "append", "path": "/greeting", "suffix": " World" }, {"op": "copy", "path": "/whatever", "from": "/greeting"} ] @@ -205,7 +205,7 @@ public function testItCanDecodeWithCustomOperationClasses(): void $this->assertEquals( new PatchOperationList( - new CustomAdd('/greeting', 'Hello'), + new CustomAdd('/greeting', 'Hello', 'my own var'), new Append('/greeting', ' World'), new Copy(path: '/whatever', from: '/greeting'), ), @@ -234,11 +234,75 @@ public static function invalidJsonProvider(): array InvalidPatchException::class, 'Invalid patch structure (expected list, got stdClass)', ], + 'patch item is not an object (example 1)' => [ + '[["foo"]]', + InvalidPatchOperationException::class, + 'Each patch item must be an object, got array', + ], + 'patch item is not an object (example 2)' => [ + '[{"op": "remove", "path": "/some/path"}, true]', + InvalidPatchOperationException::class, + 'Each patch item must be an object, got bool', + ], + 'patch without op property' => [ + '[{"path": "/some/path", "value": "anything"}]', + InvalidPatchOperationException::class, + 'Each patch item must specify "op"', + ], + 'op is invalid type' => [ + '[{"op": true}]', + InvalidPatchOperationException::class, + 'Patch "op" must be a string, got bool', + ], 'unknown operation' => [ '[{"op": "scramble", "path": "/anywhere"}]', InvalidPatchOperationException::class, 'Unknown operation "scramble"', ], + 'unexpected extra operation property' => [ + '[{"op":"add", "path": "/foo", "value": "bar", "custom": "things"}]', + InvalidPatchOperationException::class, + // The message also contains the original PHP message but this varies between versions so we do not + // include it in the assertion. + sprintf( + 'Unexpected param(s) for add operation as %s', + Add::class, + ), + ], + 'missing operation property' => [ + '[{"op":"add", "path": "/foo"}]', + InvalidPatchOperationException::class, + // The message also contains the original PHP message but this varies between versions so we do not + // include it in the assertion. + sprintf( + 'Missing required param(s) for add operation as %s', + Add::class, + ), + ], + 'operation property has incorrect type' => [ + '[{"op":"add", "path": true, "value": "bar"}]', + InvalidPatchOperationException::class, + // The message also contains the original PHP message but this varies between versions so we do not + // include it in the assertion. + sprintf( + 'Invalid param(s) for add operation as %s', + Add::class, + ), + ], + 'operation with mixed string and int keys (example 1)' => [ + // PHP would internally convert this into positional args and accept it even though there's no guarantee + // we have the right keys in the right sequence / have no extra properties. + '[{"op":"add", "0": "bar", "1": "/from"}]', + InvalidPatchOperationException::class, + 'All patch operation properties must have string names' + ], + 'operation with mixed string and int keys (example 2)' => [ + // PHP would internally convert this into positional args and accept it even though there's no guarantee + // we have the right keys in the right sequence / have no extra properties. + '[{"op":"add", "1": "bar", "2": "/from"}]', + InvalidPatchOperationException::class, + 'All patch operation properties must have string names' + ] ]; } @@ -272,6 +336,7 @@ public function __construct( public function __construct( public string $path, public mixed $value, + public mixed $custom, ) { parent::__construct('add'); } From ab22ffa9050c14a610ae79705acf55fc9d0ac4d6 Mon Sep 17 00:00:00 2001 From: acoulton Date: Tue, 16 Dec 2025 10:21:14 +0000 Subject: [PATCH 6/7] test: Prove that PatchOperationList::fromJson bubbles unexpected errors It's essentially impossible for our own DTOs to trigger PHP errors other than the ones related to creating them with incorrect parameters. However, because our code has to catch `Error` to check for certain types of error, we need to prove that it rethrows if the error is not one that it expects to handle. --- tests/operations/PatchOperationListTest.php | 34 ++++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/tests/operations/PatchOperationListTest.php b/tests/operations/PatchOperationListTest.php index 1e65794..ef227c6 100644 --- a/tests/operations/PatchOperationListTest.php +++ b/tests/operations/PatchOperationListTest.php @@ -25,6 +25,7 @@ use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\Assert; +use DivisionByZeroError; use stdClass; use Throwable; @@ -100,7 +101,7 @@ public static function validEncodeDecodeProvider(): array {"op":"test","path":"/bar","value":{"type":"foo","list":["bar","baz"]}} ] JSON, - ] + ], ]; } @@ -294,15 +295,15 @@ public static function invalidJsonProvider(): array // we have the right keys in the right sequence / have no extra properties. '[{"op":"add", "0": "bar", "1": "/from"}]', InvalidPatchOperationException::class, - 'All patch operation properties must have string names' + 'All patch operation properties must have string names', ], 'operation with mixed string and int keys (example 2)' => [ // PHP would internally convert this into positional args and accept it even though there's no guarantee // we have the right keys in the right sequence / have no extra properties. '[{"op":"add", "1": "bar", "2": "/from"}]', InvalidPatchOperationException::class, - 'All patch operation properties must have string names' - ] + 'All patch operation properties must have string names', + ], ]; } @@ -319,6 +320,18 @@ public function testItThrowsOnAttemptToCreateFromInvalidJson(string $json, strin $this->expectExceptionMessage($expect_msg); PatchOperationList::fromJson($json); } + + public function testItsFromJsonBubblesGenericPhpErrors(): void + { + // Very contrived example to prove that our code rethrows any unexpected PHP errors during DTO creation + $this->expectException(DivisionByZeroError::class); + PatchOperationList::fromJson( + <<<'JSON' + [{"op": "fraction", "numerator": 1, "denominator": 0}] + JSON, + customClasses: ['fraction' => Fraction::class] + ); + } } readonly class Append extends PatchOperation @@ -341,3 +354,16 @@ public function __construct( parent::__construct('add'); } } + +readonly class Fraction extends PatchOperation +{ + public float $fraction; + + public function __construct( + public int $numerator, + public int $denominator, + ) { + parent::__construct('fraction'); + $this->fraction = $this->numerator / $this->denominator; + } +} From 3747bcea50555cf54c56c69ab382a41d0924d57d Mon Sep 17 00:00:00 2001 From: acoulton Date: Mon, 12 Jan 2026 09:41:45 +0000 Subject: [PATCH 7/7] feat: `isValidPatch` should also accept `PatchOperationList` Internally, this is already implemented - it just required a change to the typehint for `isValidPatch`. I've refactored the existing `isValidPatch` tests to use a DataProvider to make the overlap between examples for strings and DTOs clearer. As part of this I added a couple of new examples of valid / invalid string patches. This is because some of the existing string cases (missing params / structural issues) cannot happen with DTOs. --- src/FastJsonPatch.php | 4 +- tests/FastJsonPatchTest.php | 73 +++++++++++++++++++++++++++++-------- 2 files changed, 59 insertions(+), 18 deletions(-) diff --git a/src/FastJsonPatch.php b/src/FastJsonPatch.php index c925db2..097ecb1 100644 --- a/src/FastJsonPatch.php +++ b/src/FastJsonPatch.php @@ -166,10 +166,10 @@ public function apply(string|PatchOperationList $patch): void /** * Tells if the json patch is syntactically valid - * @param string $patch + * @param string|PatchOperationList $patch * @return bool */ - public function isValidPatch(string $patch): bool + public function isValidPatch(string|PatchOperationList $patch): bool { try { foreach ($this->patchIterator($patch) as $op => $p) { diff --git a/tests/FastJsonPatchTest.php b/tests/FastJsonPatchTest.php index 124bf98..a17ae47 100644 --- a/tests/FastJsonPatchTest.php +++ b/tests/FastJsonPatchTest.php @@ -2,13 +2,12 @@ namespace blancks\JsonPatchTest; -use blancks\JsonPatch\exceptions\{ - FastJsonPatchException, +use blancks\JsonPatch\exceptions\{FastJsonPatchException, InvalidPatchException, InvalidPatchOperationException, InvalidPatchPathException, - UnknownPathException -}; + MalformedPathException, + UnknownPathException}; use blancks\JsonPatch\json\{ accessors\ArrayAccessor, accessors\ArrayAccessorAwareTrait, @@ -47,6 +46,7 @@ #[CoversClass(FastJsonPatch::class)] #[CoversClass(FastJsonPatchException::class)] #[UsesClass(InvalidPatchException::class)] +#[UsesClass(MalformedPathException::class)] #[UsesClass(UnknownPathException::class)] #[UsesClass(InvalidPatchOperationException::class)] #[UsesClass(InvalidPatchPathException::class)] @@ -76,23 +76,64 @@ #[UsesClass(Test::class)] final class FastJsonPatchTest extends JsonPatchCompliance { - public function testValidPatch(): void - { - $FastJsonPatch = FastJsonPatch::fromJson('{"foo":"bar"}'); - $this->assertTrue($FastJsonPatch->isValidPatch('[{"op":"test","path":"/foo","value":"bar"}]')); - } - - public function testInvalidPatch(): void + /** + * @return array + */ + public static function isValidPatchProvider(): array { - $FastJsonPatch = FastJsonPatch::fromJson('{"foo":"bar"}'); - $this->assertFalse($FastJsonPatch->isValidPatch('{"op":"test","path":"/foo","value":"bar"}')); - $this->assertFalse($FastJsonPatch->isValidPatch('[{"op":"add"}]')); + return [ + 'string patch - valid' => [ + '[{"op":"test","path":"/foo","value":"bar"}]', + true, + ], + 'string patch - valid (despite test not matching)' => [ + '[{"op":"test","path":"/foo","value":"not this"}]', + true, + ], + 'string patch - valid (despite unknown path)' => [ + '[{"op":"test","path":"/nonexistent-path","value":"any"}]', + true, + ], + 'string patch - invalid (is not list)' => [ + '{"op":"test","path":"/foo","value":"bar"}', + false, + ], + 'string patch - invalid (missing parameter for op)' => [ + '[{"op":"add"}]', + false, + ], + 'string patch - invalid (unknown op)' => [ + '[{"op":"unknown","path":"/foo","value":"bar"}]', + false, + ], + 'string patch - invalid (invalid path)' => [ + '[{"op":"remove","path":"not a path"}]', + false, + ], + 'DTO patch - valid' => [ + new PatchOperationList(new Test(path: '/foo', value: 'bar')), + true, + ], + 'DTO patch - valid (despite test not matching)' => [ + new PatchOperationList(new Test(path: '/foo', value: 'not this')), + true, + ], + 'DTO patch - valid (despite unknown path)' => [ + new PatchOperationList(new Test(path: '/nonexistent-path', value: 'any')), + true, + ], + 'DTO patch - invalid (invalid path)' => [ + new PatchOperationList(new Remove(path: 'not a path')), + false, + ], + ]; } - public function testUnknownPatchOperation(): void + #[DataProvider('isValidPatchProvider')] + public function testIsValidPatch(string|PatchOperationList $patch, bool $expect): void { $FastJsonPatch = FastJsonPatch::fromJson('{"foo":"bar"}'); - $this->assertFalse($FastJsonPatch->isValidPatch('[{"op":"unknown","path":"/foo","value":"bar"}]')); + $this->assertSame($expect, $FastJsonPatch->isValidPatch($patch)); } /**