-
Notifications
You must be signed in to change notification settings - Fork 1
feat: Support building & using patches as a list of DTOs #14
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
acoulton
wants to merge
6
commits into
blancks:dev
Choose a base branch
from
acoulton:3.x-patch-dtos
base: dev
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
8ac6d27
feat: Support building & using patches as a list of DTOs
acoulton 5cf0da3
refactor: Use phpstan type aliases to reference operations from handlers
acoulton c93dff3
fix: PatchOperationList should decode patches as objects
acoulton 028168c
feat: Make all `final` patch operation classes `readonly`
acoulton 7658f79
feat: More strictly check patch structural validity during fromJson
acoulton ab22ffa
test: Prove that PatchOperationList::fromJson bubbles unexpected errors
acoulton File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| <?php declare(strict_types=1); | ||
|
|
||
| namespace blancks\JsonPatch\operations; | ||
|
|
||
| /** | ||
| * @phpstan-type TAddOperationObject object{ | ||
| * op:string, | ||
| * path: string, | ||
| * value: mixed, | ||
| * } | ||
| */ | ||
| final readonly class Add extends PatchOperation | ||
| { | ||
| public function __construct( | ||
| public string $path, | ||
| public mixed $value, | ||
| ) { | ||
| parent::__construct('add'); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| <?php declare(strict_types=1); | ||
|
|
||
| namespace blancks\JsonPatch\operations; | ||
|
|
||
| /** | ||
| * @phpstan-type TCopyOperationObject object{ | ||
| * op:string, | ||
| * path: string, | ||
| * from: string, | ||
| * } | ||
| */ | ||
| final readonly class Copy extends PatchOperation | ||
| { | ||
| public function __construct( | ||
| public string $path, | ||
| public string $from, | ||
| ) { | ||
| parent::__construct('copy'); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| <?php declare(strict_types=1); | ||
|
|
||
| namespace blancks\JsonPatch\operations; | ||
|
|
||
| /** | ||
| * @phpstan-type TMoveOperationObject object{ | ||
| * op:string, | ||
| * path: string, | ||
| * from: string, | ||
| * } | ||
| */ | ||
| final readonly class Move extends PatchOperation | ||
| { | ||
| public function __construct( | ||
| public string $path, | ||
| public string $from, | ||
| ) { | ||
| parent::__construct('move'); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| <?php declare(strict_types=1); | ||
|
|
||
| namespace blancks\JsonPatch\operations; | ||
|
|
||
| abstract readonly class PatchOperation | ||
| { | ||
| public function __construct( | ||
| public string $op, | ||
| ) {} | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,155 @@ | ||
| <?php declare(strict_types=1); | ||
|
|
||
| namespace blancks\JsonPatch\operations; | ||
|
|
||
| use blancks\JsonPatch\exceptions\InvalidPatchException; | ||
| 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 | ||
| { | ||
| /** | ||
| * @phpstan-var list<PatchOperation> | ||
| */ | ||
| public array $operations; | ||
|
|
||
| /** | ||
| * @param string $jsonOperations | ||
| * @param JsonHandlerInterface $jsonHandler | ||
| * @param array<string, class-string<PatchOperation>> $customClasses | ||
| * @return self | ||
| */ | ||
| public static function fromJson( | ||
| string $jsonOperations, | ||
| JsonHandlerInterface $jsonHandler = new BasicJsonHandler(), | ||
| array $customClasses = [], | ||
| ): self { | ||
| $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)), | ||
| ); | ||
| } | ||
|
|
||
| $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 (mixed $patch) use ($classes) { | ||
| [$op, $values] = self::extractPatchOpAndValues($patch); | ||
| if (!isset($classes[$op])) { | ||
| throw new InvalidPatchOperationException(sprintf('Unknown operation "%s"', $op)); | ||
| } | ||
| return self::createPatchDtoFromValues($op, $classes[$op], $values); | ||
| }, | ||
| $patches | ||
| ), | ||
| ); | ||
| } | ||
|
|
||
| /** | ||
| * @phpstan-return array{string, array<string, mixed>} | ||
| */ | ||
| 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<PatchOperation> $class | ||
| * @param array<string, mixed> $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 | ||
| */ | ||
| public function __construct( | ||
| PatchOperation ...$operations | ||
| ) { | ||
| $this->operations = $operations; | ||
| } | ||
|
|
||
| /** | ||
| * @return list<PatchOperation> | ||
| */ | ||
| public function jsonSerialize(): array | ||
| { | ||
| return $this->operations; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| <?php declare(strict_types=1); | ||
|
|
||
| namespace blancks\JsonPatch\operations; | ||
|
|
||
| /** | ||
| * @phpstan-type TRemoveOperationObject object{ | ||
| * op:string, | ||
| * path: string, | ||
| * } | ||
| */ | ||
| final readonly class Remove extends PatchOperation | ||
| { | ||
| public function __construct( | ||
| public string $path, | ||
| ) { | ||
| parent::__construct('remove'); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| <?php declare(strict_types=1); | ||
|
|
||
| namespace blancks\JsonPatch\operations; | ||
|
|
||
| /** | ||
| * @phpstan-type TReplaceOperationObject object{ | ||
| * op:string, | ||
| * path: string, | ||
| * value: mixed, | ||
| * } | ||
| */ | ||
| final readonly class Replace extends PatchOperation | ||
| { | ||
| public function __construct( | ||
| public string $path, | ||
| public mixed $value, | ||
| ) { | ||
| parent::__construct('replace'); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I have gone for catching & rethrowing (even though it is a little verbose) because it minimises runtime performance impact in the "happy case" which is likely the most common.
Alternatively we could use Reflection to get the list of expected constructor parameter names and types and strictly compare that to the
$valuesarray before we attempt to create the object.However this would create a performance overhead and would likely need to be cached (at least in process, perhaps between processes). That would complicate the implementation & likely introduce dependencies.
Given we know that the objects are expected to be very simple value objects, that will almost always have the right data (and that are properly validated when they get to FastJsonPatch and the relevant handler) I think this is an OK solution?