Skip to content

Commit

Permalink
JSON Patch operations are now atomic
Browse files Browse the repository at this point in the history
  • Loading branch information
blancks committed Sep 9, 2024
1 parent 6c72aea commit acd5efc
Show file tree
Hide file tree
Showing 2 changed files with 120 additions and 47 deletions.
149 changes: 102 additions & 47 deletions src/FastJsonPatch.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,31 +106,82 @@ public static function applyDecode(
public static function applyByReference(array|\stdClass &$document, array $patch): void
{
self::validateDecodedPatch($patch);
$revert = [];

foreach ($patch as $p) {
$p = (array) $p;
$path = self::pathSplitter($p['path']);

switch ($p['op']) {
case self::OP_ADD:
self::opAdd($document, $path, $p['value']);
break;
case self::OP_REPLACE:
self::opReplace($document, $path, $p['value']);
break;
case self::OP_TEST:
self::opTest($document, $path, $p['value']);
break;
case self::OP_COPY:
self::opCopy($document, self::pathSplitter($p['from']), $path);
break;
case self::OP_MOVE:
self::opMove($document, self::pathSplitter($p['from']), $path);
break;
case self::OP_REMOVE:
self::opRemove($document, $path);
break;
try {
foreach ($patch as $p) {
$p = (array) $p;
$path = self::pathSplitter($p['path']);

switch ($p['op']) {
case self::OP_ADD:
$previous = self::opAdd($document, $path, $p['value']);

// there was nothing before
if (is_null($previous)) {
$revert[] = ['op' => 'remove', 'path' => $path];
break;
}

if (is_array($previous)) {
if (end($path) === '-') {
array_pop($path);
$path[] = (string) count($previous);
}
$revert[] = ['op' => 'remove', 'path' => $path];
break;
}

$revert[] = ['op' => 'replace', 'path' => $path, 'value' => $previous];
break;
case self::OP_REPLACE:
$previous = self::opReplace($document, $path, $p['value']);
$revert[] = ['op' => 'replace', 'path' => $path, 'value' => $previous];
break;
case self::OP_TEST:
self::opTest($document, $path, $p['value']);
break;
case self::OP_COPY:
$previous = self::opCopy($document, self::pathSplitter($p['from']), $path);

if (is_array($previous) && end($path) === '-') {
array_pop($path);
$path[] = (string) count($previous);
}

$revert[] = ['op' => 'remove', 'path' => $path];
break;
case self::OP_MOVE:
$from = self::pathSplitter($p['from']);
self::opMove($document, $from, $path);
$revert[] = ['op' => 'move', 'from' => $path, 'path' => $from];
break;
case self::OP_REMOVE:
$previous = self::opRemove($document, $path);
$revert[] = ['op' => 'add', 'path' => $path, 'value' => $previous];
break;
}
}
} catch (FastJsonPatchException $e) {
// Revert patch
foreach (array_reverse($revert) as $p) {
switch ($p['op']) {
case self::OP_ADD:
self::opAdd($document, $p['path'], $p['value']);
break;
case self::OP_REPLACE:
self::opReplace($document, $p['path'], $p['value']);
break;
case self::OP_MOVE:
self::opMove($document, $p['from'], $p['path']);
break;
case self::OP_REMOVE:
self::opRemove($document, $p['path']);
break;
}
}

throw $e;
}
}

Expand Down Expand Up @@ -194,11 +245,11 @@ public static function validatePatch(string $patch): void
* @param array<int|string, mixed>|\stdClass $document
* @param string[] $path
* @param mixed $value
* @return void
* @return mixed the previous value at $path or null if there was no value before
*/
private static function opAdd(array|\stdClass &$document, array $path, mixed $value): void
private static function opAdd(array|\stdClass &$document, array $path, mixed $value): mixed
{
self::documentWriter($document, $path, $value);
return self::documentWriter($document, $path, $value);
}

/**
Expand All @@ -208,11 +259,11 @@ private static function opAdd(array|\stdClass &$document, array $path, mixed $va
* @link https://datatracker.ietf.org/doc/html/rfc6902/#section-4.2
* @param array<int|string, mixed>|\stdClass $document
* @param string[] $path
* @return void
* @return mixed
*/
private static function opRemove(array|\stdClass &$document, array $path): void
private static function opRemove(array|\stdClass &$document, array $path): mixed
{
self::documentRemover($document, $path);
return self::documentRemover($document, $path);
}

/**
Expand All @@ -224,12 +275,13 @@ private static function opRemove(array|\stdClass &$document, array $path): void
* @param array<int|string, mixed>|\stdClass $document
* @param string[] $path
* @param mixed $value
* @return void
* @return mixed
*/
private static function opReplace(array|\stdClass &$document, array $path, mixed $value): void
private static function opReplace(array|\stdClass &$document, array $path, mixed $value): mixed
{
self::documentRemover($document, $path);
$previous = self::documentRemover($document, $path);
self::documentWriter($document, $path, $value);
return $previous;
}

/**
Expand All @@ -240,12 +292,12 @@ private static function opReplace(array|\stdClass &$document, array $path, mixed
* @param array<int|string, mixed>|\stdClass $document
* @param string[] $from
* @param string[] $path
* @return void
* @return mixed
*/
private static function opMove(array|\stdClass &$document, array $from, array $path): void
private static function opMove(array|\stdClass &$document, array $from, array $path): mixed
{
$value = self::documentRemover($document, $from);
self::documentWriter($document, $path, $value);
return self::documentWriter($document, $path, $value);
}

/**
Expand All @@ -256,12 +308,12 @@ private static function opMove(array|\stdClass &$document, array $from, array $p
* @param array<int|string, mixed>|\stdClass $document
* @param string[] $from
* @param string[] $path
* @return void
* @return mixed
*/
private static function opCopy(array|\stdClass &$document, array $from, array $path): void
private static function opCopy(array|\stdClass &$document, array $from, array $path): mixed
{
$value = self::documentReader($document, $from);
self::documentWriter($document, $path, $value);
return self::documentWriter($document, $path, $value);
}

/**
Expand Down Expand Up @@ -295,17 +347,18 @@ private static function opTest(array|\stdClass &$document, array $path, mixed $v
* @param string[] $path
* @param mixed $value
* @param string[]|null $originalpath
* @return void
* @return mixed the previous value at $path location
*/
private static function documentWriter(
array|\stdClass &$document,
array $path,
mixed $value,
?array $originalpath = null
): void {
): mixed {
if (count($path) === 0) {
$previous = $document;
$document = $value;
return;
return $previous;
}

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

if ($isObject) {
$previous = $document->{$node} ?? null;
$document->{$node} = $value;
return;
return $previous;
}

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

if ((!empty($document) && $isAssociative) || empty($document)) {
$previous = $document[$node] ?? [];
$document[$node] = $value;
return;
return $previous;
}

if (!is_numeric($node)) {
Expand All @@ -361,16 +416,16 @@ private static function documentWriter(
);
}

$previous = $document;
array_splice($document, $nodeInt, 0, is_array($value) || is_object($value) ? [$value] : $value);
return;
return $previous;
}

if ($isObject) {
self::documentWriter($document->{$node}, $path, $value, $originalpath);
return;
return self::documentWriter($document->{$node}, $path, $value, $originalpath);
}

self::documentWriter($document[$node], $path, $value, $originalpath);
return self::documentWriter($document[$node], $path, $value, $originalpath);
}

/**
Expand Down
18 changes: 18 additions & 0 deletions tests/FastJsonPatchTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,24 @@ public function testRemoveFromAssociativeObject(): void
$this->assertSame([], FastJsonPatch::applyDecode($json, $patch, true));
}

#[DataProvider('atomicOperationsProvider')]
public function testAtomicOperations(string $json, string $patches, string $expected): void
{
$document = json_decode($json);
$patch = json_decode($patches);

try {
FastJsonPatch::applyByReference($document, $patch);
} catch (\Throwable) {
// expecting some error
}

$this->assertSame(
$this->normalizeJson($expected),
$this->normalizeJson(json_encode($document))
);
}

#[DataProvider('validOperationsProvider')]
public function testValidJsonPatches(string $json, string $patches, string $expected): void
{
Expand Down

0 comments on commit acd5efc

Please sign in to comment.