From 0fdfe403bf7d31a69453a8f03bffb8746ea7459c Mon Sep 17 00:00:00 2001 From: megasteve19 Date: Fri, 24 Jul 2026 12:14:42 +0300 Subject: [PATCH 1/7] Eliminate redundant deep copies via engine copy-on-write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Benchmarking showed every hot path was dominated by deepCopy calls: whole-document copies in JsonPointer::get/has, per-traversal-step copies in childValue, and per-comparison copies in the equality helpers (the latter made diff quadratic in nesting depth for both time and memory — diffing two identical 800-level documents exhausted 128M). Arrays are PHP value types with engine copy-on-write, so sharing them is already copying. The new import() validates and clones only stdClass nodes (the sole reference-semantics JSON values), rebuilding just the object-bearing subtrees. Read paths (traversal, has, get, equality) now copy nothing. Token descent is index-based instead of array_shift, and diff builds pointer strings incrementally. Isolation contract is unchanged and now pinned by tests, including a memory bound on deep diffs that failed before this change. --- src/JsonMergePatch.php | 47 ++++-- src/JsonPatch.php | 271 +++++++++++++++-------------------- src/JsonPointer.php | 117 +++++++++++---- tests/JsonMergePatchTest.php | 23 +++ tests/JsonPatchTest.php | 65 +++++++++ tests/JsonPointerTest.php | 19 +++ 6 files changed, 347 insertions(+), 195 deletions(-) diff --git a/src/JsonMergePatch.php b/src/JsonMergePatch.php index def3059..b3e5268 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)); } /** @@ -51,7 +51,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 +127,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 +141,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 +166,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..f2cc89b 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); @@ -87,10 +87,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, ''); } /** @@ -112,43 +112,36 @@ public static function diffJson( /** * 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)) { 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); } 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)); @@ -156,20 +149,16 @@ private static function diffArray(array $source, array $target, array $pathToken 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]); + $operations[] = self::removeOperation("{$path}/{$index}"); } for ($index = count($source); $index < count($target); $index++) { - $operations[] = self::addOperation([...$pathTokens, '-'], self::deepCopy($target[$index])); + $operations[] = self::addOperation("{$path}/-", $target[$index]); } return $operations; @@ -178,14 +167,12 @@ 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); @@ -193,13 +180,13 @@ private static function diffObject(array|stdClass $source, array|stdClass $targe foreach ($sourceMembers as $member => $value) { if (! array_key_exists($member, $targetMembers)) { - $operations[] = self::removeOperation([...$pathTokens, (string) $member]); + $operations[] = self::removeOperation($path . '/' . JsonPointer::escapeToken((string) $member)); } } foreach ($targetMembers as $member => $value) { if (! array_key_exists($member, $sourceMembers)) { - $operations[] = self::addOperation([...$pathTokens, (string) $member], self::deepCopy($value)); + $operations[] = self::addOperation($path . '/' . JsonPointer::escapeToken((string) $member), $value); } } @@ -208,9 +195,9 @@ private static function diffObject(array|stdClass $source, array|stdClass $targe array_push( $operations, ...self::diffValue( - self::deepCopy($sourceMembers[$member]), - self::deepCopy($targetValue), - [...$pathTokens, (string) $member], + $sourceMembers[$member], + $targetValue, + $path . '/' . JsonPointer::escapeToken((string) $member), ), ); } @@ -238,71 +225,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 +292,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 +369,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 +402,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 +410,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 +418,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 +428,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 +443,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 +467,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 +493,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 +502,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 +529,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 +538,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 +573,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 +592,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 @@ -803,23 +708,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,9 +752,6 @@ 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 { @@ -897,9 +796,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 +822,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; } } @@ -951,7 +847,7 @@ private static function objectsAreEqual(array $left, array $right): bool return false; } - if (! self::jsonValuesAreEqual(self::deepCopy($value), self::deepCopy($right[$key]))) { + if (! self::jsonValuesAreEqual($value, $right[$key])) { return false; } } @@ -960,21 +856,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 +915,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..cb2dfa3 100644 --- a/src/JsonPointer.php +++ b/src/JsonPointer.php @@ -75,7 +75,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 +89,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 +107,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 +123,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 +197,7 @@ private static function valueAt(mixed $document, array $tokens): mixed $target = self::childValue($target, $token); } - return self::deepCopy($target); + return $target; } /** @@ -205,23 +205,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 +230,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 +240,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 +266,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 +277,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 +295,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; } @@ -382,10 +382,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 +396,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 +421,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 +476,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..4c9d37b 100644 --- a/tests/JsonMergePatchTest.php +++ b/tests/JsonMergePatchTest.php @@ -128,4 +128,27 @@ 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 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); + + $result->untouched->name = 'mutated after apply'; + $result->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..54d8477 100644 --- a/tests/JsonPatchTest.php +++ b/tests/JsonPatchTest.php @@ -565,4 +565,69 @@ public function testItFailsTestOperationForObjectsWithDifferentValues(): void ['op' => 'test', 'path' => '', 'value' => (object) ['foo' => 'baz']], ]); } + + /** + * 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); + + $result->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); + + $result->duplicate->name = 'mutated copy'; + + self::assertSame('original', $result->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..4bda5f6 100644 --- a/tests/JsonPointerTest.php +++ b/tests/JsonPointerTest.php @@ -288,6 +288,25 @@ public function testItRejectsNonFinitePointerNumbers(): void JsonPointer::set([], '', INF); } + + /** + * 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); + + $result->untouched->name = 'mutated after set'; + + self::assertSame('stable', $document->untouched->name); + } } /** From 92d48292a9379ba24559e04b6bdd277fc6062bf7 Mon Sep 17 00:00:00 2001 From: megasteve19 Date: Fri, 24 Jul 2026 12:14:42 +0300 Subject: [PATCH 2/7] Short-circuit equality and make diff single-pass jsonValuesAreEqual now tries strict equality first: it is sound as a positive-only check, and the engine compares identical copy-on-write hashtables by pointer, so subtrees shared between a document and a patched derivative of it compare in constant time. diffValue previously ran a full deep-equality check at every node before recursing, so each ancestor re-compared all of its descendants. It now recurses directly into same-type containers and only falls back to the equality helper for leaves and mixed representations (1 vs 1.0, assoc array vs stdClass), which keeps emitted operations byte-identical while dropping the O(size x depth) factor. --- src/JsonPatch.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/JsonPatch.php b/src/JsonPatch.php index f2cc89b..6ab8168 100644 --- a/src/JsonPatch.php +++ b/src/JsonPatch.php @@ -116,7 +116,7 @@ public static function diffJson( */ private static function diffValue(mixed $source, mixed $target, string $path): array { - if (self::jsonValuesAreEqual($source, $target)) { + if ($source === $target) { return []; } @@ -128,6 +128,10 @@ private static function diffValue(mixed $source, mixed $target, string $path): a return self::diffObject($source, $target, $path); } + if (self::jsonValuesAreEqual($source, $target)) { + return []; + } + return [ self::replaceOperation($path, $target), ]; @@ -755,6 +759,10 @@ private static function unsetObjectMember(array|stdClass &$object, string $membe */ 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; } From d26e314bfc26cbe314f38f6d4ed1fb594d51fce7 Mon Sep 17 00:00:00 2001 From: megasteve19 Date: Fri, 24 Jul 2026 12:14:42 +0300 Subject: [PATCH 3/7] Skip validation and isolation for freshly decoded JSON input Values produced by json_decode cannot contain resources, closures, or non-finite floats, and no other code holds references into them. The JSON-string entry points (applyJson, diffJson, merge applyJson) now bypass the validating isolation pass and operate on the decoded values directly; results are encoded immediately, so nothing observable leaks. --- src/JsonMergePatch.php | 4 +++- src/JsonPatch.php | 23 +++++++++++++++++++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/src/JsonMergePatch.php b/src/JsonMergePatch.php index b3e5268..662eb3a 100644 --- a/src/JsonMergePatch.php +++ b/src/JsonMergePatch.php @@ -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); } /** diff --git a/src/JsonPatch.php b/src/JsonPatch.php index 6ab8168..0811e89 100644 --- a/src/JsonPatch.php +++ b/src/JsonPatch.php @@ -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; } /** @@ -106,7 +125,7 @@ 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); } /** From a924c35b0c9ae351375833b63024007ede066aef Mon Sep 17 00:00:00 2001 From: megasteve19 Date: Fri, 24 Jul 2026 12:14:42 +0300 Subject: [PATCH 4/7] Replace regex token parsing with ctype checks and escape guards Array-index tokens went through preg_match on every traversal step; a ctype_digit check with an explicit leading-zero guard accepts and rejects exactly the same token set. Escape handling now bails out before scanning when a token contains no escapable characters, and fromString unescapes in place instead of mapping through a closure. This targets the per-call constant of pointer-heavy workloads. --- src/JsonPatch.php | 6 +++++- src/JsonPointer.php | 19 ++++++++++++++----- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/src/JsonPatch.php b/src/JsonPatch.php index 0811e89..51cbdc2 100644 --- a/src/JsonPatch.php +++ b/src/JsonPatch.php @@ -700,7 +700,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' || ! ctype_digit($token)) { throw new JsonPointerException('Array index must be a non-negative integer without leading zeros.'); } diff --git a/src/JsonPointer.php b/src/JsonPointer.php index cb2dfa3..e087474 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.'); } @@ -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' || ! ctype_digit($token))) { throw new JsonPointerException('Array index must be a non-negative integer without leading zeros.'); } From 14d0c644198ab1d8ebdacc81ae3bfa749ceede88 Mon Sep 17 00:00:00 2001 From: megasteve19 Date: Fri, 24 Jul 2026 12:18:41 +0300 Subject: [PATCH 5/7] Streamline diff object traversal and equality helpers diffObject now walks members in two passes instead of three: the target pass buffers add and member-diff operations separately and concatenates in the documented remove/add/replace order, saving a full pass and one membership lookup per common member. Array diff hoists count() out of loop conditions, and the three stdClass equality branches collapse into one objectsAreEqual entry that accepts both object representations. Emitted operations are unchanged. --- src/JsonPatch.php | 71 +++++++++++++++++++++++------------------------ 1 file changed, 34 insertions(+), 37 deletions(-) diff --git a/src/JsonPatch.php b/src/JsonPatch.php index 51cbdc2..e5c3279 100644 --- a/src/JsonPatch.php +++ b/src/JsonPatch.php @@ -167,7 +167,9 @@ private static function diffValue(mixed $source, mixed $target, string $path): a 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( @@ -176,11 +178,11 @@ private static function diffArray(array $source, array $target, string $path): a ); } - for ($index = count($source) - 1; $index >= count($target); $index--) { + for ($index = $sourceLength - 1; $index >= $targetLength; $index--) { $operations[] = self::removeOperation("{$path}/{$index}"); } - for ($index = count($source); $index < count($target); $index++) { + for ($index = $sourceLength; $index < $targetLength; $index++) { $operations[] = self::addOperation("{$path}/-", $target[$index]); } @@ -199,34 +201,34 @@ private static function diffObject(array|stdClass $source, array|stdClass $targe { $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($path . '/' . JsonPointer::escapeToken((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($path . '/' . JsonPointer::escapeToken((string) $member), $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( - $sourceMembers[$member], - $targetValue, - $path . '/' . JsonPointer::escapeToken((string) $member), - ), - ); + continue; } + + array_push( + $memberOperations, + ...self::diffValue( + $sourceMembers[$member], + $targetValue, + $path . '/' . JsonPointer::escapeToken((string) $member), + ), + ); } - return $operations; + return [...$removeOperations, ...$addOperations, ...$memberOperations]; } /** @@ -800,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; @@ -864,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($value, $right[$key])) { + if (! self::jsonValuesAreEqual($value, $rightMembers[$member])) { return false; } } From 7cf4e1c7ea2f088dc8422c1ff6eee921a9b08b49 Mon Sep 17 00:00:00 2001 From: megasteve19 Date: Fri, 24 Jul 2026 12:29:20 +0300 Subject: [PATCH 6/7] Cover rerouted validation paths and drop the ctype dependency The copy-on-write rework moved validation onto new entry paths that the suite no longer exercised: diff() input validation, has() target validation for objects and invalid values, merge imports of objects nested inside arrays, and diff's equal-leaf fallback for numerically equal values. New tests pin each of those, restoring the 100% coverage gate. Array-index tokens now use strspn() instead of ctype_digit() so the package keeps working when the ctype extension is disabled, without declaring an ext-ctype requirement. --- src/JsonPatch.php | 2 +- src/JsonPointer.php | 2 +- tests/JsonMergePatchTest.php | 27 ++++++++++++++++++++++++++ tests/JsonPatchTest.php | 33 ++++++++++++++++++++++++++++++++ tests/JsonPointerTest.php | 37 ++++++++++++++++++++++++++++++++++++ 5 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/JsonPatch.php b/src/JsonPatch.php index e5c3279..f835451 100644 --- a/src/JsonPatch.php +++ b/src/JsonPatch.php @@ -706,7 +706,7 @@ private static function arrayIndex(string $token): int return 0; } - if ($token === '' || $token[0] === '0' || ! ctype_digit($token)) { + if ($token === '' || $token[0] === '0' || strspn($token, '0123456789') !== strlen($token)) { throw new JsonPointerException('Array index must be a non-negative integer without leading zeros.'); } diff --git a/src/JsonPointer.php b/src/JsonPointer.php index e087474..1784671 100644 --- a/src/JsonPointer.php +++ b/src/JsonPointer.php @@ -350,7 +350,7 @@ private static function arrayIndexForExistingValue(array $array, string $token): throw new JsonPointerException('"-" does not reference an existing array value.'); } - if ($token !== '0' && ($token === '' || $token[0] === '0' || ! ctype_digit($token))) { + 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.'); } diff --git a/tests/JsonMergePatchTest.php b/tests/JsonMergePatchTest.php index 4c9d37b..0d7063c 100644 --- a/tests/JsonMergePatchTest.php +++ b/tests/JsonMergePatchTest.php @@ -129,6 +129,33 @@ public static function appendixCases(): iterable 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); + self::assertSame('new', $result->profile->name); + self::assertTrue($result->profile->keep); + self::assertSame('old', $document->profile->name); + + $result->rows[0]->name = 'mutated after apply'; + + self::assertSame('stable', $document->rows[0]->name); + } + /** * Confirms mutating a returned document never writes through to either input. */ diff --git a/tests/JsonPatchTest.php b/tests/JsonPatchTest.php index 54d8477..eb8206f 100644 --- a/tests/JsonPatchTest.php +++ b/tests/JsonPatchTest.php @@ -566,6 +566,39 @@ public function testItFailsTestOperationForObjectsWithDifferentValues(): void ]); } + /** + * 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. */ diff --git a/tests/JsonPointerTest.php b/tests/JsonPointerTest.php index 4bda5f6..e1aa4a0 100644 --- a/tests/JsonPointerTest.php +++ b/tests/JsonPointerTest.php @@ -289,6 +289,43 @@ 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. */ From 6036ce3160085a8d77910e25f76b9673d5b1f6d4 Mon Sep 17 00:00:00 2001 From: megasteve19 Date: Fri, 24 Jul 2026 12:36:18 +0300 Subject: [PATCH 7/7] Upgrade PHPStan to 2.2 and raise analysis to level 10 Level 10 treats implicit mixed strictly. The src narrowing style already satisfies it; the gap was in tests, which accessed properties and offsets on mixed API results. Those sites now extract values and assert their structure first, which both narrows for analysis (through PHPUnit's own type assertions) and makes the tests assert the shapes they previously assumed. --- composer.json | 2 +- phpstan.neon | 2 +- tests/JsonMergePatchTest.php | 23 +++++++++++++++----- tests/JsonPatchTest.php | 21 ++++++++++++++---- tests/JsonPointerTest.php | 10 +++++++-- tests/UpstreamJsonPatchCompatibilityTest.php | 12 +++++++--- 6 files changed, 54 insertions(+), 16 deletions(-) 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/tests/JsonMergePatchTest.php b/tests/JsonMergePatchTest.php index 0d7063c..bdfaad1 100644 --- a/tests/JsonMergePatchTest.php +++ b/tests/JsonMergePatchTest.php @@ -147,11 +147,19 @@ public function testItMergesIntoExistingObjectMembersWithoutSharing(): void $result = JsonMergePatch::apply($document, $patch); self::assertInstanceOf(\stdClass::class, $result); - self::assertSame('new', $result->profile->name); - self::assertTrue($result->profile->keep); + + $profile = $result->profile; + self::assertInstanceOf(\stdClass::class, $profile); + self::assertSame('new', $profile->name); + self::assertTrue($profile->keep); self::assertSame('old', $document->profile->name); - $result->rows[0]->name = 'mutated after apply'; + $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); } @@ -172,8 +180,13 @@ public function testItReturnsResultsThatDoNotShareObjectsWithInputs(): void self::assertInstanceOf(\stdClass::class, $result); - $result->untouched->name = 'mutated after apply'; - $result->added->name = 'mutated after apply'; + $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 eb8206f..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); } /** @@ -615,7 +620,10 @@ public function testItReturnsResultsThatDoNotShareObjectsWithTheInputDocument(): self::assertInstanceOf(stdClass::class, $result); - $result->untouched->name = 'mutated after apply'; + $untouched = $result->untouched; + self::assertInstanceOf(stdClass::class, $untouched); + + $untouched->name = 'mutated after apply'; self::assertSame('stable', $document->untouched->name); } @@ -635,9 +643,14 @@ public function testItCopiesValuesWithoutAliasingInsideTheResult(): void self::assertInstanceOf(stdClass::class, $result); - $result->duplicate->name = 'mutated copy'; + $duplicate = $result->duplicate; + self::assertInstanceOf(stdClass::class, $duplicate); + + $duplicate->name = 'mutated copy'; - self::assertSame('original', $result->source->name); + $source = $result->source; + self::assertInstanceOf(stdClass::class, $source); + self::assertSame('original', $source->name); } /** diff --git a/tests/JsonPointerTest.php b/tests/JsonPointerTest.php index e1aa4a0..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]); } @@ -340,7 +343,10 @@ public function testItSetsValuesWithoutSharingObjectsBetweenInputAndResult(): vo self::assertInstanceOf(stdClass::class, $result); - $result->untouched->name = 'mutated after set'; + $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); } /**