Skip to content

Commit 41a8c28

Browse files
committed
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.
1 parent ce77941 commit 41a8c28

12 files changed

Lines changed: 540 additions & 9 deletions

README.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,64 @@ The expected workflow is that once you got a `FastJsonPatch` instance you can ca
9090

9191
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.
9292

93+
If you are building patches within your application, rather than receiving them from an external source, you may wish
94+
to build them as native PHP objects. This provides strict typing of the available parameters for each operation.
95+
96+
The above example could also be represented as:
97+
98+
```php
99+
use blancks\JsonPatch\FastJsonPatch;
100+
use blancks\JsonPatch\exceptions\FastJsonPatchException;
101+
use blancks\JsonPatch\operations\PatchOperationList;
102+
use blancks\JsonPatch\operations\Add;
103+
use blancks\JsonPatch\operations\Replace;
104+
use blancks\JsonPatch\operations\Remove;
105+
106+
$document = '{
107+
"contacts":[
108+
{"name":"John","number":"-"},
109+
{"name":"Dave","number":"+1 222 333 4444"}
110+
]
111+
}';
112+
113+
$patch = new PatchOperationList(
114+
new Add(path: '/contacts/-', value: ['name' => 'Jane', 'number' => '+1 353 644 2121']),
115+
new Replace(path: '/contacts/0/number', value: '+1 212 555 1212'),
116+
new Remove(path: '/contacts/1'),
117+
);
118+
119+
$FastJsonPatch = FastJsonPatch::fromJson($document);
120+
121+
try {
122+
123+
$FastJsonPatch->apply($patch);
124+
125+
} catch (FastJsonPatchException $e) {
126+
127+
// here if patch cannot be applied for some reason
128+
echo $e->getMessage(), "\n";
129+
130+
}
131+
132+
var_dump($FastJsonPatch->getDocument());
133+
```
134+
135+
### Should I use DTOs or JSON strings for patches?
136+
137+
The exact answer will depend on your usecase, but broadly speaking:
138+
139+
* If your patches are coming from an external or serialized source, keep them as JSON strings. This provides a clearer
140+
and more forgiving validation process (for example, if the patch has missing or additional properties). It also avoids
141+
any (limited) performance overhead to build the patch as typed objects.
142+
* If you are building patches at runtime in your own application, consider using DTOs. This provides additional
143+
type-safety within your code, and may be more efficient than serialising a patch to JSON and back.
144+
145+
When working with DTOs, the `PatchOperationList` can be serialized to JSON using the native `json_encode` (or any method
146+
that supports the `JsonSerializable` interface). It can also be unserialized from JSON - and you can optionally provide
147+
a JSON handler and a mapping of `PatchOperation` classes to customise the parsing.
148+
149+
Note that - unlike the JSON format - the core operation DTOs do not accept any additional parameters. If you need to
150+
include additional parameters in your patch you can provide your own `PatchOperation` implementation(s).
93151

94152
## Constructor
95153

src/FastJsonPatch.php

Lines changed: 13 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
JsonPointerHandlerInterface
2323
};
2424
use blancks\JsonPatch\operations\{
25+
PatchOperationList,
2526
PatchValidationTrait
2627
};
2728
use blancks\JsonPatch\operations\handlers\{
@@ -123,11 +124,11 @@ public function registerOperationHandler(PatchOperationHandlerInterface $PatchOp
123124
/**
124125
* Applies the patch to the referenced document.
125126
* The operation is atomic, if the patch cannot be applied the original document is restored
126-
* @param string $patch
127+
* @param string|PatchOperationList $patch
127128
* @return void
128129
* @throws FastJsonPatchException
129130
*/
130-
public function apply(string $patch): void
131+
public function apply(string|PatchOperationList $patch): void
131132
{
132133
try {
133134
$revertPatch = [];
@@ -204,18 +205,22 @@ public function &getDocument(): mixed
204205
}
205206

206207
/**
207-
* @param string $patch
208+
* @param string|PatchOperationList $patch
208209
* @return \Generator & iterable<string, object{op: string, path: string, value?: mixed, from?: string}>
209210
*/
210-
private function patchIterator(string $patch): \Generator
211+
private function patchIterator(string|PatchOperationList $patch): \Generator
211212
{
212-
$decodedPatch = $this->JsonHandler->decode($patch);
213+
if (is_string($patch)) {
214+
$patchOperations = $this->JsonHandler->decode($patch);
213215

214-
if (!is_array($decodedPatch)) {
215-
throw new InvalidPatchException('Invalid patch structure');
216+
if (!is_array($patchOperations)) {
217+
throw new InvalidPatchException('Invalid patch structure');
218+
}
219+
} else {
220+
$patchOperations = $patch->operations;
216221
}
217222

218-
foreach ($decodedPatch as $p) {
223+
foreach ($patchOperations as $p) {
219224
$p = (object) $p;
220225
$this->assertValidOp($p);
221226
$this->assertValidPath($p);

src/operations/Add.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace blancks\JsonPatch\operations;
4+
5+
final class Add extends PatchOperation
6+
{
7+
public function __construct(
8+
public readonly string $path,
9+
public readonly mixed $value,
10+
) {
11+
parent::__construct('add');
12+
}
13+
}

src/operations/Copy.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace blancks\JsonPatch\operations;
4+
5+
final class Copy extends PatchOperation
6+
{
7+
public function __construct(
8+
public readonly string $path,
9+
public readonly string $from,
10+
) {
11+
parent::__construct('copy');
12+
}
13+
}

src/operations/Move.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace blancks\JsonPatch\operations;
4+
5+
final class Move extends PatchOperation
6+
{
7+
public function __construct(
8+
public readonly string $path,
9+
public readonly string $from,
10+
) {
11+
parent::__construct('move');
12+
}
13+
}

src/operations/PatchOperation.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace blancks\JsonPatch\operations;
4+
5+
abstract class PatchOperation
6+
{
7+
public function __construct(
8+
public readonly string $op,
9+
) {}
10+
}
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace blancks\JsonPatch\operations;
4+
5+
use blancks\JsonPatch\exceptions\InvalidPatchException;
6+
use blancks\JsonPatch\exceptions\InvalidPatchOperationException;
7+
use blancks\JsonPatch\json\handlers\BasicJsonHandler;
8+
use blancks\JsonPatch\json\handlers\JsonHandlerInterface;
9+
10+
final class PatchOperationList implements \JsonSerializable
11+
{
12+
/**
13+
* @phpstan-var list<PatchOperation>
14+
*/
15+
public readonly array $operations;
16+
17+
/**
18+
* @param string $jsonOperations
19+
* @param JsonHandlerInterface $jsonHandler
20+
* @param array<string, class-string<PatchOperation>> $customClasses
21+
* @return self
22+
*/
23+
public static function fromJson(
24+
string $jsonOperations,
25+
JsonHandlerInterface $jsonHandler = new BasicJsonHandler(),
26+
array $customClasses = [],
27+
): self {
28+
$patches = $jsonHandler->decode($jsonOperations, ['associative' => true]);
29+
if (!(is_array($patches) && array_is_list($patches))) {
30+
throw new InvalidPatchException(
31+
sprintf('Invalid patch structure (expected list, got %s)', get_debug_type($patches)),
32+
);
33+
}
34+
35+
$classes = [
36+
'add' => Add::class,
37+
'copy' => Copy::class,
38+
'move' => Move::class,
39+
'remove' => Remove::class,
40+
'replace' => Replace::class,
41+
'test' => Test::class,
42+
...$customClasses,
43+
];
44+
45+
return new PatchOperationList(
46+
...array_map(
47+
function (array $patch) use ($classes) {
48+
$op = $patch['op'];
49+
unset($patch['op']);
50+
if (!isset($classes[$op])) {
51+
throw new InvalidPatchOperationException(sprintf('Unknown operation "%s"', $op));
52+
}
53+
54+
return new $classes[$op](...$patch);
55+
},
56+
$patches
57+
),
58+
);
59+
}
60+
61+
/**
62+
* @no-named-arguments
63+
*/
64+
public function __construct(
65+
PatchOperation ...$operations
66+
) {
67+
$this->operations = $operations;
68+
}
69+
70+
/**
71+
* @return list<PatchOperation>
72+
*/
73+
public function jsonSerialize(): array
74+
{
75+
return $this->operations;
76+
}
77+
}

src/operations/Remove.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace blancks\JsonPatch\operations;
4+
5+
final class Remove extends PatchOperation
6+
{
7+
public function __construct(
8+
public readonly string $path,
9+
) {
10+
parent::__construct('remove');
11+
}
12+
}

src/operations/Replace.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace blancks\JsonPatch\operations;
4+
5+
final class Replace extends PatchOperation
6+
{
7+
public function __construct(
8+
public readonly string $path,
9+
public readonly mixed $value,
10+
) {
11+
parent::__construct('replace');
12+
}
13+
}

src/operations/Test.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace blancks\JsonPatch\operations;
4+
5+
final class Test extends PatchOperation
6+
{
7+
public function __construct(
8+
public readonly string $path,
9+
public readonly mixed $value,
10+
) {
11+
parent::__construct('test');
12+
}
13+
}

0 commit comments

Comments
 (0)