Skip to content

Commit acd5efc

Browse files
committed
JSON Patch operations are now atomic
1 parent 6c72aea commit acd5efc

File tree

2 files changed

+120
-47
lines changed

2 files changed

+120
-47
lines changed

src/FastJsonPatch.php

Lines changed: 102 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -106,31 +106,82 @@ public static function applyDecode(
106106
public static function applyByReference(array|\stdClass &$document, array $patch): void
107107
{
108108
self::validateDecodedPatch($patch);
109+
$revert = [];
109110

110-
foreach ($patch as $p) {
111-
$p = (array) $p;
112-
$path = self::pathSplitter($p['path']);
113-
114-
switch ($p['op']) {
115-
case self::OP_ADD:
116-
self::opAdd($document, $path, $p['value']);
117-
break;
118-
case self::OP_REPLACE:
119-
self::opReplace($document, $path, $p['value']);
120-
break;
121-
case self::OP_TEST:
122-
self::opTest($document, $path, $p['value']);
123-
break;
124-
case self::OP_COPY:
125-
self::opCopy($document, self::pathSplitter($p['from']), $path);
126-
break;
127-
case self::OP_MOVE:
128-
self::opMove($document, self::pathSplitter($p['from']), $path);
129-
break;
130-
case self::OP_REMOVE:
131-
self::opRemove($document, $path);
132-
break;
111+
try {
112+
foreach ($patch as $p) {
113+
$p = (array) $p;
114+
$path = self::pathSplitter($p['path']);
115+
116+
switch ($p['op']) {
117+
case self::OP_ADD:
118+
$previous = self::opAdd($document, $path, $p['value']);
119+
120+
// there was nothing before
121+
if (is_null($previous)) {
122+
$revert[] = ['op' => 'remove', 'path' => $path];
123+
break;
124+
}
125+
126+
if (is_array($previous)) {
127+
if (end($path) === '-') {
128+
array_pop($path);
129+
$path[] = (string) count($previous);
130+
}
131+
$revert[] = ['op' => 'remove', 'path' => $path];
132+
break;
133+
}
134+
135+
$revert[] = ['op' => 'replace', 'path' => $path, 'value' => $previous];
136+
break;
137+
case self::OP_REPLACE:
138+
$previous = self::opReplace($document, $path, $p['value']);
139+
$revert[] = ['op' => 'replace', 'path' => $path, 'value' => $previous];
140+
break;
141+
case self::OP_TEST:
142+
self::opTest($document, $path, $p['value']);
143+
break;
144+
case self::OP_COPY:
145+
$previous = self::opCopy($document, self::pathSplitter($p['from']), $path);
146+
147+
if (is_array($previous) && end($path) === '-') {
148+
array_pop($path);
149+
$path[] = (string) count($previous);
150+
}
151+
152+
$revert[] = ['op' => 'remove', 'path' => $path];
153+
break;
154+
case self::OP_MOVE:
155+
$from = self::pathSplitter($p['from']);
156+
self::opMove($document, $from, $path);
157+
$revert[] = ['op' => 'move', 'from' => $path, 'path' => $from];
158+
break;
159+
case self::OP_REMOVE:
160+
$previous = self::opRemove($document, $path);
161+
$revert[] = ['op' => 'add', 'path' => $path, 'value' => $previous];
162+
break;
163+
}
133164
}
165+
} catch (FastJsonPatchException $e) {
166+
// Revert patch
167+
foreach (array_reverse($revert) as $p) {
168+
switch ($p['op']) {
169+
case self::OP_ADD:
170+
self::opAdd($document, $p['path'], $p['value']);
171+
break;
172+
case self::OP_REPLACE:
173+
self::opReplace($document, $p['path'], $p['value']);
174+
break;
175+
case self::OP_MOVE:
176+
self::opMove($document, $p['from'], $p['path']);
177+
break;
178+
case self::OP_REMOVE:
179+
self::opRemove($document, $p['path']);
180+
break;
181+
}
182+
}
183+
184+
throw $e;
134185
}
135186
}
136187

@@ -194,11 +245,11 @@ public static function validatePatch(string $patch): void
194245
* @param array<int|string, mixed>|\stdClass $document
195246
* @param string[] $path
196247
* @param mixed $value
197-
* @return void
248+
* @return mixed the previous value at $path or null if there was no value before
198249
*/
199-
private static function opAdd(array|\stdClass &$document, array $path, mixed $value): void
250+
private static function opAdd(array|\stdClass &$document, array $path, mixed $value): mixed
200251
{
201-
self::documentWriter($document, $path, $value);
252+
return self::documentWriter($document, $path, $value);
202253
}
203254

204255
/**
@@ -208,11 +259,11 @@ private static function opAdd(array|\stdClass &$document, array $path, mixed $va
208259
* @link https://datatracker.ietf.org/doc/html/rfc6902/#section-4.2
209260
* @param array<int|string, mixed>|\stdClass $document
210261
* @param string[] $path
211-
* @return void
262+
* @return mixed
212263
*/
213-
private static function opRemove(array|\stdClass &$document, array $path): void
264+
private static function opRemove(array|\stdClass &$document, array $path): mixed
214265
{
215-
self::documentRemover($document, $path);
266+
return self::documentRemover($document, $path);
216267
}
217268

218269
/**
@@ -224,12 +275,13 @@ private static function opRemove(array|\stdClass &$document, array $path): void
224275
* @param array<int|string, mixed>|\stdClass $document
225276
* @param string[] $path
226277
* @param mixed $value
227-
* @return void
278+
* @return mixed
228279
*/
229-
private static function opReplace(array|\stdClass &$document, array $path, mixed $value): void
280+
private static function opReplace(array|\stdClass &$document, array $path, mixed $value): mixed
230281
{
231-
self::documentRemover($document, $path);
282+
$previous = self::documentRemover($document, $path);
232283
self::documentWriter($document, $path, $value);
284+
return $previous;
233285
}
234286

235287
/**
@@ -240,12 +292,12 @@ private static function opReplace(array|\stdClass &$document, array $path, mixed
240292
* @param array<int|string, mixed>|\stdClass $document
241293
* @param string[] $from
242294
* @param string[] $path
243-
* @return void
295+
* @return mixed
244296
*/
245-
private static function opMove(array|\stdClass &$document, array $from, array $path): void
297+
private static function opMove(array|\stdClass &$document, array $from, array $path): mixed
246298
{
247299
$value = self::documentRemover($document, $from);
248-
self::documentWriter($document, $path, $value);
300+
return self::documentWriter($document, $path, $value);
249301
}
250302

251303
/**
@@ -256,12 +308,12 @@ private static function opMove(array|\stdClass &$document, array $from, array $p
256308
* @param array<int|string, mixed>|\stdClass $document
257309
* @param string[] $from
258310
* @param string[] $path
259-
* @return void
311+
* @return mixed
260312
*/
261-
private static function opCopy(array|\stdClass &$document, array $from, array $path): void
313+
private static function opCopy(array|\stdClass &$document, array $from, array $path): mixed
262314
{
263315
$value = self::documentReader($document, $from);
264-
self::documentWriter($document, $path, $value);
316+
return self::documentWriter($document, $path, $value);
265317
}
266318

267319
/**
@@ -295,17 +347,18 @@ private static function opTest(array|\stdClass &$document, array $path, mixed $v
295347
* @param string[] $path
296348
* @param mixed $value
297349
* @param string[]|null $originalpath
298-
* @return void
350+
* @return mixed the previous value at $path location
299351
*/
300352
private static function documentWriter(
301353
array|\stdClass &$document,
302354
array $path,
303355
mixed $value,
304356
?array $originalpath = null
305-
): void {
357+
): mixed {
306358
if (count($path) === 0) {
359+
$previous = $document;
307360
$document = $value;
308-
return;
361+
return $previous;
309362
}
310363

311364
$originalpath ??= $path;
@@ -330,17 +383,19 @@ private static function documentWriter(
330383
}
331384

332385
if ($isObject) {
386+
$previous = $document->{$node} ?? null;
333387
$document->{$node} = $value;
334-
return;
388+
return $previous;
335389
}
336390

337391
/** @phpstan-ignore-next-line */
338392
$documentLength = count($document);
339393
$node = $appendToArray ? (string) $documentLength : $node;
340394

341395
if ((!empty($document) && $isAssociative) || empty($document)) {
396+
$previous = $document[$node] ?? [];
342397
$document[$node] = $value;
343-
return;
398+
return $previous;
344399
}
345400

346401
if (!is_numeric($node)) {
@@ -361,16 +416,16 @@ private static function documentWriter(
361416
);
362417
}
363418

419+
$previous = $document;
364420
array_splice($document, $nodeInt, 0, is_array($value) || is_object($value) ? [$value] : $value);
365-
return;
421+
return $previous;
366422
}
367423

368424
if ($isObject) {
369-
self::documentWriter($document->{$node}, $path, $value, $originalpath);
370-
return;
425+
return self::documentWriter($document->{$node}, $path, $value, $originalpath);
371426
}
372427

373-
self::documentWriter($document[$node], $path, $value, $originalpath);
428+
return self::documentWriter($document[$node], $path, $value, $originalpath);
374429
}
375430

376431
/**

tests/FastJsonPatchTest.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,24 @@ public function testRemoveFromAssociativeObject(): void
4040
$this->assertSame([], FastJsonPatch::applyDecode($json, $patch, true));
4141
}
4242

43+
#[DataProvider('atomicOperationsProvider')]
44+
public function testAtomicOperations(string $json, string $patches, string $expected): void
45+
{
46+
$document = json_decode($json);
47+
$patch = json_decode($patches);
48+
49+
try {
50+
FastJsonPatch::applyByReference($document, $patch);
51+
} catch (\Throwable) {
52+
// expecting some error
53+
}
54+
55+
$this->assertSame(
56+
$this->normalizeJson($expected),
57+
$this->normalizeJson(json_encode($document))
58+
);
59+
}
60+
4361
#[DataProvider('validOperationsProvider')]
4462
public function testValidJsonPatches(string $json, string $patches, string $expected): void
4563
{

0 commit comments

Comments
 (0)