Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
27 changes: 16 additions & 11 deletions src/FastJsonPatch.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,6 @@
JsonPointerHandlerAwareTrait,
JsonPointerHandlerInterface
};
use blancks\JsonPatch\operations\{
PatchValidationTrait
};
use blancks\JsonPatch\operations\handlers\{
AddHandler,
CopyHandler,
Expand All @@ -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
Expand Down Expand Up @@ -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 = [];
Expand Down Expand Up @@ -204,18 +205,22 @@ public function &getDocument(): mixed
}

/**
* @param string $patch
* @param string|PatchOperationList $patch
* @return \Generator & iterable<string, object{op: string, path: string, value?: mixed, from?: string}>
*/
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);
Expand Down
20 changes: 20 additions & 0 deletions src/operations/Add.php
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');
}
}
20 changes: 20 additions & 0 deletions src/operations/Copy.php
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');
}
}
20 changes: 20 additions & 0 deletions src/operations/Move.php
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');
}
}
10 changes: 10 additions & 0 deletions src/operations/PatchOperation.php
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,
) {}
}
155 changes: 155 additions & 0 deletions src/operations/PatchOperationList.php
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) {
Comment on lines +109 to +111
Copy link
Author

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 $values array 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?

// 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;
}
}
18 changes: 18 additions & 0 deletions src/operations/Remove.php
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');
}
}
20 changes: 20 additions & 0 deletions src/operations/Replace.php
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');
}
}
Loading