diff --git a/composer.json b/composer.json index 0bcc147..8649623 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,7 @@ }, "require-dev": { "friendsofphp/php-cs-fixer": "^3.75", - "phpstan/phpstan": "^2.1", + "phpstan/phpstan": "^2.2", "phpstan/phpstan-strict-rules": "^2.0", "phpunit/phpunit": "^12.0" }, diff --git a/phpstan.neon b/phpstan.neon index 5ef607f..394385f 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,7 +2,7 @@ includes: - vendor/phpstan/phpstan-strict-rules/rules.neon parameters: - level: 9 + level: 10 paths: - src - scripts diff --git a/src/JsonMergePatch.php b/src/JsonMergePatch.php index def3059..662eb3a 100644 --- a/src/JsonMergePatch.php +++ b/src/JsonMergePatch.php @@ -26,7 +26,7 @@ class JsonMergePatch */ public static function apply(mixed $document, mixed $patch): mixed { - return self::mergeValue(self::deepCopy($document), self::deepCopy($patch)); + return self::mergeValue(self::import($document), self::import($patch)); } /** @@ -42,7 +42,9 @@ public static function applyJson( $document = json_decode($documentJson, false, 512, JSON_THROW_ON_ERROR); $patch = json_decode($patchJson, false, 512, JSON_THROW_ON_ERROR); - return json_encode(self::apply($document, $patch), $encodeFlags | JSON_THROW_ON_ERROR); + // Decoded JSON is structurally valid and unaliased; skip apply()'s + // validating isolation pass. + return json_encode(self::mergeValue($document, $patch), $encodeFlags | JSON_THROW_ON_ERROR); } /** @@ -51,7 +53,7 @@ public static function applyJson( private static function mergeValue(mixed $target, mixed $patch): mixed { if (! self::isJsonObject($patch)) { - return self::deepCopy($patch); + return $patch; } if (! self::isJsonObject($target)) { @@ -127,10 +129,10 @@ private static function hasObjectMember(array|stdClass $object, string $member): private static function readObjectMember(array|stdClass $object, string $member): mixed { if ($object instanceof stdClass) { - return self::deepCopy($object->{$member}); + return $object->{$member}; } - return self::deepCopy($object[$member]); + return $object[$member]; } /** @@ -141,12 +143,12 @@ private static function readObjectMember(array|stdClass $object, string $member) private static function writeObjectMember(array|stdClass &$object, string $member, mixed $value): void { if ($object instanceof stdClass) { - $object->{$member} = self::deepCopy($value); + $object->{$member} = $value; return; } - $object[$member] = self::deepCopy($value); + $object[$member] = $value; } /** @@ -166,19 +168,46 @@ private static function unsetObjectMember(array|stdClass &$object, string $membe } /** - * Copies JSON-like PHP values while rejecting values JSON cannot encode. + * Validates a JSON-like value and returns a copy that is safe to hand out. + * + * Arrays already behave as values through engine copy-on-write, so they are + * shared instead of rebuilt. stdClass instances have reference semantics + * and are cloned, including clones of every array holding one. + */ + private static function import(mixed $value): mixed + { + $containsObject = false; + + return self::importValue($value, $containsObject); + } + + /** + * Recursive worker for import() that rebuilds only object-bearing subtrees. */ - private static function deepCopy(mixed $value): mixed + private static function importValue(mixed $value, bool &$containsObject): mixed { if (is_array($value)) { - return array_map(static fn (mixed $child): mixed => self::deepCopy($child), $value); + $result = $value; + + foreach ($value as $key => $child) { + $childContainsObject = false; + $copy = self::importValue($child, $childContainsObject); + + if ($childContainsObject) { + $containsObject = true; + $result[$key] = $copy; + } + } + + return $result; } if ($value instanceof stdClass) { + $containsObject = true; $copy = new stdClass(); foreach (get_object_vars($value) as $name => $child) { - $copy->{$name} = self::deepCopy($child); + $copy->{$name} = self::import($child); } return $copy; diff --git a/src/JsonPatch.php b/src/JsonPatch.php index 96f4113..f835451 100644 --- a/src/JsonPatch.php +++ b/src/JsonPatch.php @@ -45,7 +45,7 @@ class JsonPatch public static function apply(mixed $document, array $patch): mixed { $patchOperations = self::patchOperations($patch); - $workingDocument = self::deepCopy($document); + $workingDocument = self::import($document); foreach ($patchOperations as $index => $operation) { $workingDocument = self::applyOperation($workingDocument, $operation, $index); @@ -71,7 +71,26 @@ public static function applyJson( throw new InvalidPatchException('A JSON Patch document must decode to an array.'); } - return json_encode(self::apply($document, $patch), $encodeFlags | JSON_THROW_ON_ERROR); + return json_encode(self::applyDecoded($document, $patch), $encodeFlags | JSON_THROW_ON_ERROR); + } + + /** + * Applies patch operations to a freshly decoded document. + * + * Decoded JSON cannot contain non-JSON values and is referenced by nobody + * else, so the validating isolation pass of apply() is unnecessary. + * + * @param array $patch + */ + private static function applyDecoded(mixed $document, array $patch): mixed + { + $workingDocument = $document; + + foreach (self::patchOperations($patch) as $index => $operation) { + $workingDocument = self::applyOperation($workingDocument, $operation, $index); + } + + return $workingDocument; } /** @@ -87,10 +106,10 @@ public static function applyJson( */ public static function diff(mixed $source, mixed $target): array { - $source = self::deepCopy($source); - $target = self::deepCopy($target); + self::assertJsonValue($source); + self::assertJsonValue($target); - return self::diffValue($source, $target, []); + return self::diffValue($source, $target, ''); } /** @@ -106,70 +125,65 @@ public static function diffJson( $source = json_decode($sourceJson, false, 512, JSON_THROW_ON_ERROR); $target = json_decode($targetJson, false, 512, JSON_THROW_ON_ERROR); - return json_encode(self::diff($source, $target), $encodeFlags | JSON_THROW_ON_ERROR); + return json_encode(self::diffValue($source, $target, ''), $encodeFlags | JSON_THROW_ON_ERROR); } /** * Builds patch operations for one JSON value pair. * - * @param list $pathTokens - * - * @phpstan-param JsonValue $source - * @phpstan-param JsonValue $target - * * @phpstan-return GeneratedPatchDocument */ - private static function diffValue(mixed $source, mixed $target, array $pathTokens): array + private static function diffValue(mixed $source, mixed $target, string $path): array { - if (self::jsonValuesAreEqual($source, $target)) { + if ($source === $target) { return []; } if (self::isJsonArray($source) && self::isJsonArray($target)) { - return self::diffArray($source, $target, $pathTokens); + return self::diffArray($source, $target, $path); } if (self::isJsonObject($source) && self::isJsonObject($target)) { - return self::diffObject($source, $target, $pathTokens); + return self::diffObject($source, $target, $path); + } + + if (self::jsonValuesAreEqual($source, $target)) { + return []; } return [ - self::replaceOperation($pathTokens, $target), + self::replaceOperation($path, $target), ]; } /** * Builds patch operations for one JSON array pair. * - * @param list $pathTokens - * * @phpstan-param JsonArray $source * @phpstan-param JsonArray $target * * @phpstan-return GeneratedPatchDocument */ - private static function diffArray(array $source, array $target, array $pathTokens): array + private static function diffArray(array $source, array $target, string $path): array { $operations = []; - $sharedLength = min(count($source), count($target)); + $sourceLength = count($source); + $targetLength = count($target); + $sharedLength = min($sourceLength, $targetLength); for ($index = 0; $index < $sharedLength; $index++) { array_push( $operations, - ...self::diffValue( - self::deepCopy($source[$index]), - self::deepCopy($target[$index]), - [...$pathTokens, (string) $index], - ), + ...self::diffValue($source[$index], $target[$index], "{$path}/{$index}"), ); } - for ($index = count($source) - 1; $index >= count($target); $index--) { - $operations[] = self::removeOperation([...$pathTokens, (string) $index]); + for ($index = $sourceLength - 1; $index >= $targetLength; $index--) { + $operations[] = self::removeOperation("{$path}/{$index}"); } - for ($index = count($source); $index < count($target); $index++) { - $operations[] = self::addOperation([...$pathTokens, '-'], self::deepCopy($target[$index])); + for ($index = $sourceLength; $index < $targetLength; $index++) { + $operations[] = self::addOperation("{$path}/-", $target[$index]); } return $operations; @@ -178,45 +192,43 @@ private static function diffArray(array $source, array $target, array $pathToken /** * Builds patch operations for one JSON object pair. * - * @param list $pathTokens - * * @phpstan-param JsonObject $source * @phpstan-param JsonObject $target * * @phpstan-return GeneratedPatchDocument */ - private static function diffObject(array|stdClass $source, array|stdClass $target, array $pathTokens): array + private static function diffObject(array|stdClass $source, array|stdClass $target, string $path): array { $sourceMembers = self::objectMembers($source); $targetMembers = self::objectMembers($target); - $operations = []; + $removeOperations = []; + $addOperations = []; + $memberOperations = []; foreach ($sourceMembers as $member => $value) { if (! array_key_exists($member, $targetMembers)) { - $operations[] = self::removeOperation([...$pathTokens, (string) $member]); + $removeOperations[] = self::removeOperation($path . '/' . JsonPointer::escapeToken((string) $member)); } } - foreach ($targetMembers as $member => $value) { + foreach ($targetMembers as $member => $targetValue) { if (! array_key_exists($member, $sourceMembers)) { - $operations[] = self::addOperation([...$pathTokens, (string) $member], self::deepCopy($value)); - } - } + $addOperations[] = self::addOperation($path . '/' . JsonPointer::escapeToken((string) $member), $targetValue); - foreach ($targetMembers as $member => $targetValue) { - if (array_key_exists($member, $sourceMembers)) { - array_push( - $operations, - ...self::diffValue( - self::deepCopy($sourceMembers[$member]), - self::deepCopy($targetValue), - [...$pathTokens, (string) $member], - ), - ); + continue; } + + array_push( + $memberOperations, + ...self::diffValue( + $sourceMembers[$member], + $targetValue, + $path . '/' . JsonPointer::escapeToken((string) $member), + ), + ); } - return $operations; + return [...$removeOperations, ...$addOperations, ...$memberOperations]; } /** @@ -238,71 +250,44 @@ private static function objectMembers(array|stdClass $object): array /** * Creates an add operation for a generated patch. * - * @param list $pathTokens - * - * @phpstan-param JsonValue $value - * * @phpstan-return GeneratedAddOperation */ - private static function addOperation(array $pathTokens, mixed $value): array + private static function addOperation(string $path, mixed $value): array { return [ 'op' => 'add', - 'path' => self::pathFromTokens($pathTokens), - 'value' => self::deepCopy($value), + 'path' => $path, + 'value' => self::import($value), ]; } /** * Creates a remove operation for a generated patch. * - * @param list $pathTokens - * * @phpstan-return GeneratedRemoveOperation */ - private static function removeOperation(array $pathTokens): array + private static function removeOperation(string $path): array { return [ 'op' => 'remove', - 'path' => self::pathFromTokens($pathTokens), + 'path' => $path, ]; } /** * Creates a replace operation for a generated patch. * - * @param list $pathTokens - * - * @phpstan-param JsonValue $value - * * @phpstan-return GeneratedReplaceOperation */ - private static function replaceOperation(array $pathTokens, mixed $value): array + private static function replaceOperation(string $path, mixed $value): array { return [ 'op' => 'replace', - 'path' => self::pathFromTokens($pathTokens), - 'value' => self::deepCopy($value), + 'path' => $path, + 'value' => self::import($value), ]; } - /** - * Formats parsed path tokens as a JSON Pointer string. - * - * @param list $pathTokens - */ - private static function pathFromTokens(array $pathTokens): string - { - if ($pathTokens === []) { - return ''; - } - - return '/' . implode( - '/', - array_map(static fn (string $token): string => JsonPointer::escapeToken($token), $pathTokens), - ); - } - /** * Validates the top-level JSON Patch document shape. * @@ -332,10 +317,7 @@ private static function patchOperations(array $patch): array /** * Dispatches one validated operation object by its op member. * - * @phpstan-param JsonValue $document * @phpstan-param JsonPatchOperation $operation - * - * @phpstan-return JsonValue */ private static function applyOperation(mixed $document, array|stdClass $operation, int $index): mixed { @@ -412,7 +394,7 @@ private static function operationValue(array|stdClass $operation, string $name, throw new InvalidPatchException(sprintf('Operation %d is missing "%s".', $index, $name)); } - return self::deepCopy(self::readMember($operation, $name)); + return self::import(self::readMember($operation, $name)); } /** @@ -445,11 +427,6 @@ private static function readMember(array|stdClass $operation, string $name): mix /** * Applies the add operation. - * - * @phpstan-param JsonValue $document - * @phpstan-param JsonValue $value - * - * @phpstan-return JsonValue */ private static function add(mixed $document, JsonPointer $path, mixed $value): mixed { @@ -458,10 +435,6 @@ private static function add(mixed $document, JsonPointer $path, mixed $value): m /** * Applies the remove operation. - * - * @phpstan-param JsonValue $document - * - * @phpstan-return JsonArray|JsonObject|null */ private static function remove(mixed $document, JsonPointer $path): mixed { @@ -470,11 +443,6 @@ private static function remove(mixed $document, JsonPointer $path): mixed /** * Applies the replace operation. - * - * @phpstan-param JsonValue $document - * @phpstan-param JsonValue $value - * - * @phpstan-return JsonValue */ private static function replace(mixed $document, JsonPointer $path, mixed $value): mixed { @@ -485,10 +453,6 @@ private static function replace(mixed $document, JsonPointer $path, mixed $value /** * Applies the move operation. - * - * @phpstan-param JsonValue $document - * - * @phpstan-return JsonValue */ private static function move(mixed $document, JsonPointer $from, JsonPointer $path): mixed { @@ -504,23 +468,14 @@ private static function move(mixed $document, JsonPointer $from, JsonPointer $pa /** * Applies the copy operation. - * - * @phpstan-param JsonValue $document - * - * @phpstan-return JsonValue */ private static function copy(mixed $document, JsonPointer $from, JsonPointer $path): mixed { - return self::add($document, $path, self::deepCopy(self::valueAt($document, $from))); + return self::add($document, $path, self::import(self::valueAt($document, $from))); } /** * Applies the test operation. - * - * @phpstan-param JsonValue $document - * @phpstan-param JsonValue $expected - * - * @phpstan-return JsonValue */ private static function test(mixed $document, JsonPointer $path, mixed $expected): mixed { @@ -537,21 +492,16 @@ private static function test(mixed $document, JsonPointer $path, mixed $expected * Adds a value at the provided token path. * * @param list $tokens - * - * @phpstan-param JsonValue $target - * @phpstan-param JsonValue $value - * - * @phpstan-return JsonValue */ - private static function addAt(mixed $target, array $tokens, mixed $value): mixed + private static function addAt(mixed $target, array $tokens, mixed $value, int $depth = 0): mixed { - if ($tokens === []) { + if (! isset($tokens[$depth])) { return $value; } - $token = array_shift($tokens); + $token = $tokens[$depth]; - if ($tokens === []) { + if (! isset($tokens[$depth + 1])) { if (self::isJsonArray($target)) { array_splice($target, self::arrayIndexForAdd($target, $token), 0, [$value]); @@ -568,7 +518,7 @@ private static function addAt(mixed $target, array $tokens, mixed $value): mixed } $child = self::childValue($target, $token); - $updatedChild = self::addAt($child, $tokens, $value); + $updatedChild = self::addAt($child, $tokens, $value, $depth + 1); return self::replaceChild($target, $token, $updatedChild); } @@ -577,20 +527,16 @@ private static function addAt(mixed $target, array $tokens, mixed $value): mixed * Removes a value at the provided token path. * * @param list $tokens - * - * @phpstan-param JsonValue $target - * - * @phpstan-return JsonArray|JsonObject|null */ - private static function removeAt(mixed $target, array $tokens): mixed + private static function removeAt(mixed $target, array $tokens, int $depth = 0): mixed { - if ($tokens === []) { + if (! isset($tokens[$depth])) { return null; } - $token = array_shift($tokens); + $token = $tokens[$depth]; - if ($tokens === []) { + if (! isset($tokens[$depth + 1])) { if (self::isJsonArray($target)) { array_splice($target, self::arrayIndexForExistingValue($target, $token), 1); @@ -608,7 +554,7 @@ private static function removeAt(mixed $target, array $tokens): mixed } $child = self::childValue($target, $token); - $updatedChild = self::removeAt($child, $tokens); + $updatedChild = self::removeAt($child, $tokens, $depth + 1); return self::replaceChild($target, $token, $updatedChild); } @@ -617,36 +563,27 @@ private static function removeAt(mixed $target, array $tokens): mixed * Replaces a value at the provided token path. * * @param list $tokens - * - * @phpstan-param JsonValue $target - * @phpstan-param JsonValue $value - * - * @phpstan-return JsonValue */ - private static function replaceAt(mixed $target, array $tokens, mixed $value): mixed + private static function replaceAt(mixed $target, array $tokens, mixed $value, int $depth = 0): mixed { - if ($tokens === []) { + if (! isset($tokens[$depth])) { return $value; } - $token = array_shift($tokens); + $token = $tokens[$depth]; - if ($tokens === []) { + if (! isset($tokens[$depth + 1])) { return self::replaceChild($target, $token, $value); } $child = self::childValue($target, $token); - $updatedChild = self::replaceAt($child, $tokens, $value); + $updatedChild = self::replaceAt($child, $tokens, $value, $depth + 1); return self::replaceChild($target, $token, $updatedChild); } /** * Reads the value addressed by a pointer. - * - * @phpstan-param JsonValue $document - * - * @phpstan-return JsonValue */ private static function valueAt(mixed $document, JsonPointer $path): mixed { @@ -661,15 +598,11 @@ private static function valueAt(mixed $document, JsonPointer $path): mixed /** * Reads one child from a JSON array or object container. - * - * @phpstan-param JsonValue $target - * - * @phpstan-return JsonValue */ private static function childValue(mixed $target, string $token): mixed { if (self::isJsonArray($target)) { - return self::deepCopy($target[self::arrayIndexForExistingValue($target, $token)]); + return $target[self::arrayIndexForExistingValue($target, $token)]; } if (self::isJsonObject($target)) { @@ -684,9 +617,6 @@ private static function childValue(mixed $target, string $token): mixed /** * Replaces one child in a JSON array or object container. * - * @phpstan-param JsonValue $target - * @phpstan-param JsonValue $value - * * @phpstan-return JsonArray|JsonObject */ private static function replaceChild(mixed $target, string $token, mixed $value): mixed @@ -772,7 +702,11 @@ private static function arrayIndexForExistingValue(array $array, string $token): */ private static function arrayIndex(string $token): int { - if (preg_match('/^(0|[1-9][0-9]*)$/', $token) !== 1) { + if ($token === '0') { + return 0; + } + + if ($token === '' || $token[0] === '0' || strspn($token, '0123456789') !== strlen($token)) { throw new JsonPointerException('Array index must be a non-negative integer without leading zeros.'); } @@ -803,23 +737,20 @@ private static function assertObjectMemberExists(array|stdClass $object, string * Reads an object member from stdClass or associative array objects. * * @phpstan-param JsonObject $object - * - * @phpstan-return JsonValue */ private static function readObjectMember(array|stdClass $object, string $member): mixed { if ($object instanceof stdClass) { - return self::deepCopy($object->{$member}); + return $object->{$member}; } - return self::deepCopy($object[$member]); + return $object[$member]; } /** * Writes an object member on stdClass or associative array objects. * * @phpstan-param JsonObject $object - * @phpstan-param JsonValue $value */ private static function writeObjectMember(array|stdClass &$object, string $member, mixed $value): void { @@ -850,12 +781,13 @@ private static function unsetObjectMember(array|stdClass &$object, string $membe /** * Compares two PHP values using JSON value equality rules. - * - * @phpstan-param JsonValue $left - * @phpstan-param JsonValue $right */ private static function jsonValuesAreEqual(mixed $left, mixed $right): bool { + if ($left === $right) { + return true; + } + if (self::isJsonNumber($left) && self::isJsonNumber($right)) { return (float) $left === (float) $right; } @@ -870,16 +802,8 @@ private static function jsonValuesAreEqual(mixed $left, mixed $right): bool return self::arraysAreEqual($left, $right); } - if ($left instanceof stdClass && $right instanceof stdClass) { - return self::objectsAreEqual(get_object_vars($left), get_object_vars($right)); - } - - if ($left instanceof stdClass && is_array($right) && ! array_is_list($right)) { - return self::objectsAreEqual(get_object_vars($left), $right); - } - - if (is_array($left) && ! array_is_list($left) && $right instanceof stdClass) { - return self::objectsAreEqual($left, get_object_vars($right)); + if (self::isJsonObject($left) && self::isJsonObject($right)) { + return self::objectsAreEqual($left, $right); } return $left === $right; @@ -897,9 +821,6 @@ private static function isJsonNumber(mixed $value): bool /** * Reports whether two PHP containers share the same JSON container type. - * - * @phpstan-param JsonValue $left - * @phpstan-param JsonValue $right */ private static function sameJsonContainerType(mixed $left, mixed $right): bool { @@ -926,7 +847,7 @@ private static function arraysAreEqual(array $left, array $right): bool } foreach ($left as $index => $value) { - if (! self::jsonValuesAreEqual(self::deepCopy($value), self::deepCopy($right[$index]))) { + if (! self::jsonValuesAreEqual($value, $right[$index])) { return false; } } @@ -937,21 +858,24 @@ private static function arraysAreEqual(array $left, array $right): bool /** * Compares JSON object members without depending on member order. * - * @phpstan-param array $left - * @phpstan-param array $right + * @phpstan-param JsonObject $left + * @phpstan-param JsonObject $right */ - private static function objectsAreEqual(array $left, array $right): bool + private static function objectsAreEqual(array|stdClass $left, array|stdClass $right): bool { - if (count($left) !== count($right)) { + $leftMembers = self::objectMembers($left); + $rightMembers = self::objectMembers($right); + + if (count($leftMembers) !== count($rightMembers)) { return false; } - foreach ($left as $key => $value) { - if (! array_key_exists($key, $right)) { + foreach ($leftMembers as $member => $value) { + if (! array_key_exists($member, $rightMembers)) { return false; } - if (! self::jsonValuesAreEqual(self::deepCopy($value), self::deepCopy($right[$key]))) { + if (! self::jsonValuesAreEqual($value, $rightMembers[$member])) { return false; } } @@ -960,21 +884,50 @@ private static function objectsAreEqual(array $left, array $right): bool } /** - * Copies JSON-like PHP values while rejecting values JSON cannot encode. + * Validates a JSON-like value and returns a copy that is safe to hand out. + * + * Arrays already behave as values through engine copy-on-write, so they are + * shared instead of rebuilt. stdClass instances have reference semantics + * and are cloned, including clones of every array holding one. + * + * @phpstan-return JsonValue + */ + private static function import(mixed $value): mixed + { + $containsObject = false; + + return self::importValue($value, $containsObject); + } + + /** + * Recursive worker for import() that rebuilds only object-bearing subtrees. * * @phpstan-return JsonValue */ - private static function deepCopy(mixed $value): mixed + private static function importValue(mixed $value, bool &$containsObject): mixed { if (is_array($value)) { - return array_map(static fn (mixed $child): mixed => self::deepCopy($child), $value); + $result = $value; + + foreach ($value as $key => $child) { + $childContainsObject = false; + $copy = self::importValue($child, $childContainsObject); + + if ($childContainsObject) { + $containsObject = true; + $result[$key] = $copy; + } + } + + return $result; } if ($value instanceof stdClass) { + $containsObject = true; $copy = new stdClass(); foreach (get_object_vars($value) as $name => $child) { - $copy->{$name} = self::deepCopy($child); + $copy->{$name} = self::import($child); } return $copy; @@ -990,4 +943,38 @@ private static function deepCopy(mixed $value): mixed throw new InvalidPatchException(sprintf('Unsupported JSON value of type %s.', get_debug_type($value))); } + + /** + * Validates a JSON-like value without copying any part of it. + * + * @phpstan-assert JsonValue $value + */ + private static function assertJsonValue(mixed $value): void + { + if (is_array($value)) { + foreach ($value as $child) { + self::assertJsonValue($child); + } + + return; + } + + if ($value instanceof stdClass) { + foreach (get_object_vars($value) as $child) { + self::assertJsonValue($child); + } + + return; + } + + if (is_float($value) && ! is_finite($value)) { + throw new InvalidPatchException('JSON numbers must be finite.'); + } + + if ($value === null || is_scalar($value)) { + return; + } + + throw new InvalidPatchException(sprintf('Unsupported JSON value of type %s.', get_debug_type($value))); + } } diff --git a/src/JsonPointer.php b/src/JsonPointer.php index 4673119..1784671 100644 --- a/src/JsonPointer.php +++ b/src/JsonPointer.php @@ -39,10 +39,11 @@ public static function fromString(string $pointer): static throw new JsonPointerException('JSON Pointer must be empty or start with "/".'); } - $tokens = array_map( - static fn (string $token): string => self::unescapeToken($token), - explode('/', substr($pointer, 1)), - ); + $tokens = explode('/', substr($pointer, 1)); + + foreach ($tokens as $index => $token) { + $tokens[$index] = self::unescapeToken($token); + } return new static($pointer, $tokens); } @@ -52,6 +53,10 @@ public static function fromString(string $pointer): static */ public static function escapeToken(string $token): string { + if (strpbrk($token, '~/') === false) { + return $token; + } + return str_replace(['~', '/'], ['~0', '~1'], $token); } @@ -60,6 +65,10 @@ public static function escapeToken(string $token): string */ public static function unescapeToken(string $token): string { + if (! str_contains($token, '~')) { + return $token; + } + if (preg_match('/~(?![01])/', $token) === 1) { throw new JsonPointerException('JSON Pointer contains an invalid escape sequence.'); } @@ -75,7 +84,7 @@ public static function get(mixed $document, string|self $pointer): mixed $pointer = self::coerce($pointer); try { - return self::valueAt(self::deepCopy($document), $pointer->tokens); + return self::import(self::valueAt($document, $pointer->tokens)); } catch (JsonPointerException $exception) { throw $exception->withContext(path: (string) $pointer); } @@ -89,7 +98,7 @@ public static function has(mixed $document, string|self $pointer): bool $pointer = self::coerce($pointer); try { - self::valueAt(self::deepCopy($document), $pointer->tokens); + self::assertJsonValue(self::valueAt($document, $pointer->tokens)); return true; } catch (JsonPointerException) { @@ -107,7 +116,7 @@ public static function set(mixed $document, string|self $pointer, mixed $value): $pointer = self::coerce($pointer); try { - return self::setAt(self::deepCopy($document), $pointer->tokens, self::deepCopy($value)); + return self::setAt(self::import($document), $pointer->tokens, self::import($value)); } catch (JsonPointerException $exception) { throw $exception->withContext(path: (string) $pointer); } @@ -123,7 +132,7 @@ public static function remove(mixed $document, string|self $pointer): mixed $pointer = self::coerce($pointer); try { - return self::removeAt(self::deepCopy($document), $pointer->tokens); + return self::removeAt(self::import($document), $pointer->tokens); } catch (JsonPointerException $exception) { throw $exception->withContext(path: (string) $pointer); } @@ -197,7 +206,7 @@ private static function valueAt(mixed $document, array $tokens): mixed $target = self::childValue($target, $token); } - return self::deepCopy($target); + return $target; } /** @@ -205,23 +214,23 @@ private static function valueAt(mixed $document, array $tokens): mixed * * @param list $tokens */ - private static function setAt(mixed $target, array $tokens, mixed $value): mixed + private static function setAt(mixed $target, array $tokens, mixed $value, int $depth = 0): mixed { - if ($tokens === []) { - return self::deepCopy($value); + if (! isset($tokens[$depth])) { + return $value; } - $token = array_shift($tokens); + $token = $tokens[$depth]; - if ($tokens === []) { + if (! isset($tokens[$depth + 1])) { if (self::isJsonArray($target)) { - $target[self::arrayIndexForExistingValue($target, $token)] = self::deepCopy($value); + $target[self::arrayIndexForExistingValue($target, $token)] = $value; return $target; } if (self::isJsonObject($target)) { - self::writeObjectMember($target, $token, self::deepCopy($value)); + self::writeObjectMember($target, $token, $value); return $target; } @@ -230,7 +239,7 @@ private static function setAt(mixed $target, array $tokens, mixed $value): mixed } $child = self::childValue($target, $token); - $updatedChild = self::setAt($child, $tokens, $value); + $updatedChild = self::setAt($child, $tokens, $value, $depth + 1); return self::replaceChild($target, $token, $updatedChild); } @@ -240,15 +249,15 @@ private static function setAt(mixed $target, array $tokens, mixed $value): mixed * * @param list $tokens */ - private static function removeAt(mixed $target, array $tokens): mixed + private static function removeAt(mixed $target, array $tokens, int $depth = 0): mixed { - if ($tokens === []) { + if (! isset($tokens[$depth])) { return null; } - $token = array_shift($tokens); + $token = $tokens[$depth]; - if ($tokens === []) { + if (! isset($tokens[$depth + 1])) { if (self::isJsonArray($target)) { array_splice($target, self::arrayIndexForExistingValue($target, $token), 1); @@ -266,7 +275,7 @@ private static function removeAt(mixed $target, array $tokens): mixed } $child = self::childValue($target, $token); - $updatedChild = self::removeAt($child, $tokens); + $updatedChild = self::removeAt($child, $tokens, $depth + 1); return self::replaceChild($target, $token, $updatedChild); } @@ -277,7 +286,7 @@ private static function removeAt(mixed $target, array $tokens): mixed private static function childValue(mixed $target, string $token): mixed { if (self::isJsonArray($target)) { - return self::deepCopy($target[self::arrayIndexForExistingValue($target, $token)]); + return $target[self::arrayIndexForExistingValue($target, $token)]; } if (self::isJsonObject($target)) { @@ -295,14 +304,14 @@ private static function childValue(mixed $target, string $token): mixed private static function replaceChild(mixed $target, string $token, mixed $value): mixed { if (self::isJsonArray($target)) { - $target[self::arrayIndexForExistingValue($target, $token)] = self::deepCopy($value); + $target[self::arrayIndexForExistingValue($target, $token)] = $value; return $target; } if (self::isJsonObject($target)) { self::assertObjectMemberExists($target, $token); - self::writeObjectMember($target, $token, self::deepCopy($value)); + self::writeObjectMember($target, $token, $value); return $target; } @@ -341,7 +350,7 @@ private static function arrayIndexForExistingValue(array $array, string $token): throw new JsonPointerException('"-" does not reference an existing array value.'); } - if (preg_match('/^(0|[1-9][0-9]*)$/', $token) !== 1) { + if ($token !== '0' && ($token === '' || $token[0] === '0' || strspn($token, '0123456789') !== strlen($token))) { throw new JsonPointerException('Array index must be a non-negative integer without leading zeros.'); } @@ -382,10 +391,10 @@ private static function assertObjectMemberExists(array|stdClass $object, string private static function readObjectMember(array|stdClass $object, string $member): mixed { if ($object instanceof stdClass) { - return self::deepCopy($object->{$member}); + return $object->{$member}; } - return self::deepCopy($object[$member]); + return $object[$member]; } /** @@ -396,12 +405,12 @@ private static function readObjectMember(array|stdClass $object, string $member) private static function writeObjectMember(array|stdClass &$object, string $member, mixed $value): void { if ($object instanceof stdClass) { - $object->{$member} = self::deepCopy($value); + $object->{$member} = $value; return; } - $object[$member] = self::deepCopy($value); + $object[$member] = $value; } /** @@ -421,19 +430,46 @@ private static function unsetObjectMember(array|stdClass &$object, string $membe } /** - * Copies JSON-like PHP values while rejecting values JSON cannot encode. + * Validates a JSON-like value and returns a copy that is safe to hand out. + * + * Arrays already behave as values through engine copy-on-write, so they are + * shared instead of rebuilt. stdClass instances have reference semantics + * and are cloned, including clones of every array holding one. + */ + private static function import(mixed $value): mixed + { + $containsObject = false; + + return self::importValue($value, $containsObject); + } + + /** + * Recursive worker for import() that rebuilds only object-bearing subtrees. */ - private static function deepCopy(mixed $value): mixed + private static function importValue(mixed $value, bool &$containsObject): mixed { if (is_array($value)) { - return array_map(static fn (mixed $child): mixed => self::deepCopy($child), $value); + $result = $value; + + foreach ($value as $key => $child) { + $childContainsObject = false; + $copy = self::importValue($child, $childContainsObject); + + if ($childContainsObject) { + $containsObject = true; + $result[$key] = $copy; + } + } + + return $result; } if ($value instanceof stdClass) { + $containsObject = true; $copy = new stdClass(); foreach (get_object_vars($value) as $name => $child) { - $copy->{$name} = self::deepCopy($child); + $copy->{$name} = self::import($child); } return $copy; @@ -449,4 +485,36 @@ private static function deepCopy(mixed $value): mixed throw new InvalidPatchException(sprintf('Unsupported JSON value of type %s.', get_debug_type($value))); } + + /** + * Validates a JSON-like value without copying any part of it. + */ + private static function assertJsonValue(mixed $value): void + { + if (is_array($value)) { + foreach ($value as $child) { + self::assertJsonValue($child); + } + + return; + } + + if ($value instanceof stdClass) { + foreach (get_object_vars($value) as $child) { + self::assertJsonValue($child); + } + + return; + } + + if (is_float($value) && ! is_finite($value)) { + throw new InvalidPatchException('JSON numbers must be finite.'); + } + + if ($value === null || is_scalar($value)) { + return; + } + + throw new InvalidPatchException(sprintf('Unsupported JSON value of type %s.', get_debug_type($value))); + } } diff --git a/tests/JsonMergePatchTest.php b/tests/JsonMergePatchTest.php index 6f25538..bdfaad1 100644 --- a/tests/JsonMergePatchTest.php +++ b/tests/JsonMergePatchTest.php @@ -128,4 +128,67 @@ public static function appendixCases(): iterable yield 'replace array target with object patch' => ['[1,2]', '{"a":"b","c":null}', '{"a":"b"}']; yield 'remove absent nested null' => ['{}', '{"a":{"bb":{"ccc":null}}}', '{"a":{"bb":{}}}']; } + + /** + * Confirms merges read existing object members and detach array-nested objects. + */ + public function testItMergesIntoExistingObjectMembersWithoutSharing(): void + { + $document = (object) [ + 'profile' => (object) ['name' => 'old', 'keep' => true], + 'rows' => [ + (object) ['name' => 'stable'], + ], + ]; + $patch = (object) [ + 'profile' => (object) ['name' => 'new'], + ]; + + $result = JsonMergePatch::apply($document, $patch); + + self::assertInstanceOf(\stdClass::class, $result); + + $profile = $result->profile; + self::assertInstanceOf(\stdClass::class, $profile); + self::assertSame('new', $profile->name); + self::assertTrue($profile->keep); + self::assertSame('old', $document->profile->name); + + $rows = $result->rows; + self::assertIsArray($rows); + $row = $rows[0]; + self::assertInstanceOf(\stdClass::class, $row); + + $row->name = 'mutated after apply'; + + self::assertSame('stable', $document->rows[0]->name); + } + + /** + * Confirms mutating a returned document never writes through to either input. + */ + public function testItReturnsResultsThatDoNotShareObjectsWithInputs(): void + { + $document = (object) [ + 'untouched' => (object) ['name' => 'stable'], + ]; + $patch = (object) [ + 'added' => (object) ['name' => 'from patch'], + ]; + + $result = JsonMergePatch::apply($document, $patch); + + self::assertInstanceOf(\stdClass::class, $result); + + $untouched = $result->untouched; + self::assertInstanceOf(\stdClass::class, $untouched); + $added = $result->added; + self::assertInstanceOf(\stdClass::class, $added); + + $untouched->name = 'mutated after apply'; + $added->name = 'mutated after apply'; + + self::assertSame('stable', $document->untouched->name); + self::assertSame('from patch', $patch->added->name); + } } diff --git a/tests/JsonPatchTest.php b/tests/JsonPatchTest.php index 7784ade..909cba5 100644 --- a/tests/JsonPatchTest.php +++ b/tests/JsonPatchTest.php @@ -35,7 +35,12 @@ public function testItAppliesPatchWithoutMutatingOriginalDocument(): void self::assertInstanceOf(stdClass::class, $result); self::assertSame('original', $document->items[0]->name); - self::assertSame('changed', $result->items[0]->name); + + $items = $result->items; + self::assertIsArray($items); + $item = $items[0]; + self::assertInstanceOf(stdClass::class, $item); + self::assertSame('changed', $item->name); } /** @@ -565,4 +570,110 @@ public function testItFailsTestOperationForObjectsWithDifferentValues(): void ['op' => 'test', 'path' => '', 'value' => (object) ['foo' => 'baz']], ]); } + + /** + * Confirms diff treats numerically equal leaves as equal without operations. + */ + public function testItGeneratesAnEmptyPatchForNumericallyEqualLeaves(): void + { + self::assertSame([], JsonPatch::diff([1], [1.0])); + } + + /** + * Confirms diff rejects non-finite numbers in its inputs. + */ + public function testItRejectsNonFiniteNumbersInDiffInputs(): void + { + $this->expectException(InvalidPatchException::class); + $this->expectExceptionMessage('finite'); + + JsonPatch::diff(['value' => INF], ['value' => 1]); + } + + /** + * Confirms diff rejects unsupported values in its inputs. + */ + public function testItRejectsUnsupportedValuesInDiffInputs(): void + { + $resource = fopen('php://memory', 'rb'); + self::assertIsResource($resource); + + $this->expectException(InvalidPatchException::class); + $this->expectExceptionMessage('Unsupported JSON value'); + + JsonPatch::diff(['ok' => true], ['bad' => $resource]); + } + + /** + * Confirms mutating a returned document never writes through to the input document. + */ + public function testItReturnsResultsThatDoNotShareObjectsWithTheInputDocument(): void + { + $document = (object) [ + 'touched' => (object) ['name' => 'original'], + 'untouched' => (object) ['name' => 'stable'], + ]; + + $result = JsonPatch::apply($document, [ + ['op' => 'replace', 'path' => '/touched/name', 'value' => 'changed'], + ]); + + self::assertInstanceOf(stdClass::class, $result); + + $untouched = $result->untouched; + self::assertInstanceOf(stdClass::class, $untouched); + + $untouched->name = 'mutated after apply'; + + self::assertSame('stable', $document->untouched->name); + } + + /** + * Confirms the copy operation produces independent values inside the result. + */ + public function testItCopiesValuesWithoutAliasingInsideTheResult(): void + { + $document = (object) [ + 'source' => (object) ['name' => 'original'], + ]; + + $result = JsonPatch::apply($document, [ + ['op' => 'copy', 'from' => '/source', 'path' => '/duplicate'], + ]); + + self::assertInstanceOf(stdClass::class, $result); + + $duplicate = $result->duplicate; + self::assertInstanceOf(stdClass::class, $duplicate); + + $duplicate->name = 'mutated copy'; + + $source = $result->source; + self::assertInstanceOf(stdClass::class, $source); + self::assertSame('original', $source->name); + } + + /** + * Confirms diffing deeply nested equal documents stays within linear memory. + */ + public function testItDiffsDeeplyNestedEqualDocumentsWithoutQuadraticMemory(): void + { + $source = ['leaf' => 'end']; + $target = ['leaf' => 'end']; + + for ($depth = 0; $depth < 400; $depth++) { + $source = ['child' => $source]; + $target = ['child' => $target]; + } + + memory_reset_peak_usage(); + $before = memory_get_usage(); + + $patch = JsonPatch::diff($source, $target); + + $peakBytes = memory_get_peak_usage() - $before; + + self::assertSame([], $patch); + self::assertLessThan(16 * 1048576, $peakBytes); + } } diff --git a/tests/JsonPointerTest.php b/tests/JsonPointerTest.php index d1372c4..5129aa9 100644 --- a/tests/JsonPointerTest.php +++ b/tests/JsonPointerTest.php @@ -114,7 +114,10 @@ public function testItReadsValuesFromDocuments(): void $read = JsonPointer::get($document, '/foo'); self::assertInstanceOf(stdClass::class, $read); - $read->bar[0] = 'changed'; + $bar = $read->bar; + self::assertIsArray($bar); + $bar[0] = 'changed'; + $read->bar = $bar; self::assertSame('baz', $document->foo->bar[0]); } @@ -288,6 +291,65 @@ public function testItRejectsNonFinitePointerNumbers(): void JsonPointer::set([], '', INF); } + + /** + * Confirms existence checks validate object targets recursively. + */ + public function testItReportsExistenceForObjectTargets(): void + { + $document = (object) [ + 'config' => (object) ['nested' => (object) ['flag' => true]], + ]; + + self::assertTrue(JsonPointer::has($document, '/config')); + } + + /** + * Confirms existence checks reject non-finite numbers at the target. + */ + public function testItRejectsNonFiniteNumbersInExistenceChecks(): void + { + $this->expectException(InvalidPatchException::class); + $this->expectExceptionMessage('finite'); + + JsonPointer::has(['bad' => INF], '/bad'); + } + + /** + * Confirms existence checks reject unsupported values at the target. + */ + public function testItRejectsUnsupportedValuesInExistenceChecks(): void + { + $resource = fopen('php://memory', 'rb'); + self::assertIsResource($resource); + + $this->expectException(InvalidPatchException::class); + $this->expectExceptionMessage('Unsupported JSON value'); + + JsonPointer::has(['bad' => $resource], '/bad'); + } + + /** + * Confirms mutating a returned document never writes through to the input document. + */ + public function testItSetsValuesWithoutSharingObjectsBetweenInputAndResult(): void + { + $document = (object) [ + 'touched' => (object) ['name' => 'original'], + 'untouched' => (object) ['name' => 'stable'], + ]; + + $result = JsonPointer::set($document, '/touched/name', 'changed'); + + self::assertInstanceOf(stdClass::class, $result); + + $untouched = $result->untouched; + self::assertInstanceOf(stdClass::class, $untouched); + + $untouched->name = 'mutated after set'; + + self::assertSame('stable', $document->untouched->name); + } } /** diff --git a/tests/UpstreamJsonPatchCompatibilityTest.php b/tests/UpstreamJsonPatchCompatibilityTest.php index 3412432..ca76c6e 100644 --- a/tests/UpstreamJsonPatchCompatibilityTest.php +++ b/tests/UpstreamJsonPatchCompatibilityTest.php @@ -21,11 +21,17 @@ class UpstreamJsonPatchCompatibilityTest extends TestCase */ public function testItPassesUpstreamJsonPatchTests(string $source, int $index, stdClass $case): void { - $label = sprintf('%s case %d: %s', $source, $index, $case->comment ?? 'unnamed'); + $comment = $case->comment ?? 'unnamed'; + self::assertIsString($comment); + + $label = sprintf('%s case %d: %s', $source, $index, $comment); + + $patch = $case->patch; + self::assertIsArray($patch, $label); if (property_exists($case, 'error')) { try { - JsonPatch::apply($case->doc, $case->patch); + JsonPatch::apply($case->doc, $patch); } catch (JsonPatchException $exception) { self::assertNotSame('', $exception->getMessage(), $label); @@ -35,7 +41,7 @@ public function testItPassesUpstreamJsonPatchTests(string $source, int $index, s self::fail($label . ' should have failed.'); } - self::assertEquals($case->expected, JsonPatch::apply($case->doc, $case->patch), $label); + self::assertEquals($case->expected, JsonPatch::apply($case->doc, $patch), $label); } /**