diff --git a/src/DeepClone/DeepClone.php b/src/DeepClone/DeepClone.php index e2465cda..8c7d4aaa 100644 --- a/src/DeepClone/DeepClone.php +++ b/src/DeepClone/DeepClone.php @@ -35,6 +35,7 @@ final class DeepClone private static array $hydrators = []; private static array $simpleHydrators = []; private static array $scopeMaps = []; + private static array $propertyScopes = []; // [class][key] = [declaring class, real name]; key is bare name, "\0*\0name", or "\0class\0name" private static array $protos = []; private static array $classInfo = []; private static \stdClass $sentinel; @@ -321,6 +322,10 @@ public static function deepclone_from_array(array $data, ?array $allowed_classes } } + // $expectedStates maps ids that flag a state replay to their wakeup + // sign (+1 / -1 is enough; the raw value is fine). Stays empty in + // the common no-wakeup case so the later validation pass is O(1). + $expectedStates = []; if (\is_int($meta)) { if ($meta < 0) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "objectMeta" count must be non-negative, '.$meta.' given'); @@ -353,6 +358,9 @@ public static function deepclone_from_array(array $data, ?array $allowed_classes throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "objectMeta" entry '.$id.' has out-of-range class index '.$cidx); } $objectMeta[$id] = [$classes[$cidx], $wakeup]; + if (0 !== $wakeup) { + $expectedStates[$id] = $wakeup; + } } } @@ -367,12 +375,13 @@ public static function deepclone_from_array(array $data, ?array $allowed_classes $data['mask'] ?? null, $data['refMasks'] ?? [], $allowed_classes, + $expectedStates, ); } public static function deepclone_hydrate(object|string $object_or_class, array $vars = [], int $flags = 0): object { - if ($flags & ~(\DEEPCLONE_HYDRATE_CALL_HOOKS | \DEEPCLONE_HYDRATE_NO_LAZY_INIT | \DEEPCLONE_HYDRATE_MANGLED_VARS | \DEEPCLONE_HYDRATE_PRESERVE_REFS)) { + if ($flags & ~(\DEEPCLONE_HYDRATE_CALL_HOOKS | \DEEPCLONE_HYDRATE_NO_LAZY_INIT | \DEEPCLONE_HYDRATE_PRESERVE_REFS)) { throw new \ValueError('deepclone_hydrate(): Argument #3 ($flags) contains unknown bits'); } if (($flags & \DEEPCLONE_HYDRATE_CALL_HOOKS) && ($flags & \DEEPCLONE_HYDRATE_NO_LAZY_INIT)) { @@ -398,122 +407,178 @@ public static function deepclone_hydrate(object|string $object_or_class, array $ $class = $object::class; } - // Fast path: scoped mode, single scope equal to the object's class, no flags - // other than (optionally) PRESERVE_REFS. Most common shape for DTO hydration. - if (!($flags & ~\DEEPCLONE_HYDRATE_PRESERVE_REFS) && isset($vars[$class]) && 1 === \count($vars)) { - $properties = $vars[$class]; - if (\is_array($properties) && $properties && !isset($properties["\0"])) { - $hasRefs = false; - if ($flags & \DEEPCLONE_HYDRATE_PRESERVE_REFS) { - foreach ($properties as $k => $_) { - if (\ReflectionReference::fromArrayElement($properties, $k)) { - $hasRefs = true; - break; - } - } - } - (self::$simpleHydrators[$class] ??= self::getSimpleHydrator($class))($properties, $object, $hasRefs); + if (!$vars) { + return $object; + } + // "\0" = SPL internal state (SplObjectStorage / ArrayObject / ArrayIterator). + if ($hasSpecial = \array_key_exists("\0", $vars)) { + if (!\is_array($special = $vars["\0"])) { + throw new \ValueError('deepclone_hydrate(): Argument #2 ($vars) special key must be of type array, '.self::valueName($vars["\0"]).' given'); + } + if ($object instanceof \SplObjectStorage) { + for ($i = 0, $c = \count($special); $i + 1 < $c; $i += 2) { + $object[$special[$i]] = $special[$i + 1]; + } + } elseif ($object instanceof \ArrayObject || $object instanceof \ArrayIterator) { + $r ??= self::$reflectors[$class] ?? new \ReflectionClass($class); + $r->getConstructor()->invokeArgs($object, $special); + } else { + throw new \ValueError(\sprintf('deepclone_hydrate(): Argument #2 ($vars) uses the special "\\0" key, which is only supported for SplObjectStorage, ArrayObject, and ArrayIterator; got "%s"', $class)); + } + if (1 === \count($vars)) { return $object; } } - if ($flags & \DEEPCLONE_HYDRATE_MANGLED_VARS) { - $mangled_vars = $vars; - $scoped_vars = []; - } else { - $scoped_vars = $vars; - $mangled_vars = []; + // Look up each key in the pre-built (propertyScopes) index, which + // handles all three mangled-key shapes uniformly — bare "foo", + // "\0*\0foo", "\0Class\0foo" — with a single hash lookup. Bare + // "foo" resolves to the most-derived declaring class; shadowed + // parent-privates are reachable via "\0Parent\0foo". Unknown + // keys fall through to the object's own class scope. + // + // Scan first: if every key maps to $class, hand $vars straight + // to the $class hydrator and skip the intermediate grouping. + // When $hasSpecial is set, the "\0" key is still in $vars and we + // must go through the grouping path to skip it. + $r ??= self::$reflectors[$class] ?? new \ReflectionClass($class); + $propertyScopes = self::$propertyScopes[$class] ??= self::getPropertyScopes($r); + + if (!$needsGroup = $hasSpecial) { + foreach ($vars as $name => $_) { + if (\array_key_exists($name, $propertyScopes) ? $class !== $propertyScopes[$name][0] : "\0" === ($name[0] ?? '')) { + $needsGroup = true; + break; + } + } } - if ($mangled_vars) { - // self::$reflectors must be populated via getClassReflector() (companion caches), so read with ??. + $effectiveFlags = $flags & \DEEPCLONE_HYDRATE_CALL_HOOKS; + if (\PHP_VERSION_ID >= 80400 && ($flags & \DEEPCLONE_HYDRATE_NO_LAZY_INIT)) { $r ??= self::$reflectors[$class] ?? new \ReflectionClass($class); + if ($r->isUninitializedLazyObject($object)) { + $effectiveFlags |= \DEEPCLONE_HYDRATE_NO_LAZY_INIT; + } + } - foreach ($mangled_vars as $name => &$value) { - if (!\is_string($name)) { - throw new \ValueError('deepclone_hydrate(): Argument #2 ($vars) in MANGLED_VARS mode must have only string keys'); - } - if ("\0" === $name) { - $scoped_vars[$class][$name] = &$value; - continue; + $hasRefs = false; + if ($flags & \DEEPCLONE_HYDRATE_PRESERVE_REFS) { + foreach ($vars as $k => $_) { + if (\ReflectionReference::fromArrayElement($vars, $k)) { + $hasRefs = true; + break; } - if (str_starts_with($name, "\0")) { - $sep = strpos($name, "\0", 1); - // Reject: no second NUL, or empty class name (second NUL right after first) - if (false === $sep || 1 === $sep) { - throw new \ValueError('deepclone_hydrate(): Argument #2 ($vars) in MANGLED_VARS mode contains an invalid mangled key'); - } - $scopeName = substr($name, 1, $sep - 1); - $realName = substr($name, $sep + 1); + } + } - if (str_contains($realName, "\0")) { - throw new \ValueError('deepclone_hydrate(): Argument #2 ($vars) in MANGLED_VARS mode contains an invalid mangled key'); - } + if (!$needsGroup) { + // Fast path: every key resolves to $class. Hand $vars straight + // to the $class hydrator — no grouping, no re-keying. + $cacheKey = $effectiveFlags ? $effectiveFlags.$class : $class; + (self::$simpleHydrators[$cacheKey] ??= self::getSimpleHydrator($class, $effectiveFlags))($vars, $object, $hasRefs); - if ('*' === $scopeName) { - $scopeName = $r->hasProperty($realName) ? $r->getProperty($realName)->class : $class; - } - } else { - $realName = $name; - $scopeName = $r->hasProperty($name) ? $r->getProperty($name)->class : $class; - } + return $object; + } - $scoped_vars[$scopeName][$realName] = &$value; + // Full parse: group values by their write scope and real name. A + // NUL-prefixed key not in $propertyScopes cannot resolve to a + // declared property, so we reject it here — which in turn means + // every scope we hand to the hydrator is $class or a real parent. + $scoped = []; + foreach ($vars as $name => &$value) { + if ([$scopeName, $realName] = $propertyScopes[$name] ?? null) { + $scoped[$scopeName][$realName] = &$value; + continue; + } + if (!\is_string($name) || "\0" !== ($name[0] ?? '')) { + $scoped[$class][$name] = &$value; + continue; } - unset($value); + if ("\0" === $name) { + // Already handled above as the SPL special key. + continue; + } + // NUL-prefixed key that isn't in $propertyScopes: either malformed + // syntax, an unknown scope class, or a valid scope + unknown prop. + // Differentiate to match the ext's error messages. + $sep = strpos($name, "\0", 1); + if (false === $sep || 1 === $sep || str_contains(substr($name, $sep + 1), "\0")) { + throw new \ValueError('deepclone_hydrate(): Argument #2 ($vars) contains an invalid mangled key'); + } + $scopeName = substr($name, 1, $sep - 1); + $realName = substr($name, $sep + 1); + if ('*' !== $scopeName && 'stdClass' !== $scopeName + && $scopeName !== $class + && (!is_a($class, $scopeName, true) || interface_exists($scopeName, false)) + ) { + throw new \ValueError(\sprintf('deepclone_hydrate(): Argument #2 ($vars) key scope "%s" is not a parent of "%s"', $scopeName, $class)); + } + // Valid scope, but the targeted slot isn't declared — reject + // instead of silently creating a dynamic property, since the + // mangled form specifically targets a declared protected/private slot. + throw new \ValueError(\sprintf('deepclone_hydrate(): Argument #2 ($vars) key scope "%s" does not declare a "%s" property', '*' === $scopeName ? $class : $scopeName, $realName)); } + unset($value); - foreach ($scoped_vars as $scope => $properties) { - if (!\is_array($properties)) { - throw new \ValueError(\sprintf('deepclone_hydrate(): Argument #2 ($vars) must have only array values, %s given for key "%s"', get_debug_type($properties), $scope)); - } - if ('stdClass' !== $scope && $scope !== $class && (!is_a($class, $scope, true) || interface_exists($scope, false))) { - throw new \ValueError(\sprintf('deepclone_hydrate(): Argument #2 ($vars) scope "%s" is not a parent of "%s"', $scope, $class)); + foreach ($scoped as $scope => $properties) { + $cacheKey = $effectiveFlags ? $effectiveFlags.$scope : $scope; + (self::$simpleHydrators[$cacheKey] ??= self::getSimpleHydrator($scope, $effectiveFlags))($properties, $object, $hasRefs); + } + + return $object; + } + + /** + * Builds a per-class map keyed by every shape a caller might use to + * target a declared property: + * - bare "name" (public/protected inherited or own; for private + * declared on $class; also set to the most-derived + * private when a parent-private shares the name) + * - "\0*\0name" (for protected props — points to the declaring + * class entry) + * - "\0ClassName\0name" (for private props declared on ClassName, where + * ClassName is $class or a parent) + * + * Each entry is [$declaringClass, $propertyName] — the scope and real + * name to use for grouping + dispatch. Adapted from VarExporter's + * LazyObjectRegistry::getPropertyScopes(). + */ + private static function getPropertyScopes(\ReflectionClass $class): array + { + $propertyScopes = []; + foreach ($class->getProperties() as $property) { + if ($property->isStatic()) { + continue; } - // Footgun: NUL-prefixed scope key almost certainly means a missing DEEPCLONE_HYDRATE_MANGLED_VARS flag. - if ('' !== $scope && "\0" === $scope[0]) { - throw new \ValueError('deepclone_hydrate(): Argument #2 ($vars) contains a NUL-prefixed key — pass DEEPCLONE_HYDRATE_MANGLED_VARS in the $flags argument to interpret $vars as a flat mangled-key array'); + $name = $property->name; + $entry = [$property->class, $name]; + + if ($property->isPrivate()) { + $propertyScopes["\0".$property->class."\0".$name] = $propertyScopes[$name] = $entry; + + continue; } - if (isset($properties["\0"]) && \is_array($properties["\0"])) { - $special = $properties["\0"]; - unset($properties["\0"]); - if ($object instanceof \SplObjectStorage) { - for ($i = 0, $c = \count($special); $i + 1 < $c; $i += 2) { - $object[$special[$i]] = $special[$i + 1]; - } - } elseif ($object instanceof \ArrayObject || $object instanceof \ArrayIterator) { - $r ??= self::$reflectors[$class] ?? new \ReflectionClass($class); - $r->getConstructor()->invokeArgs($object, $special); - } - } - if ($properties) { - // Under PRESERVE_REFS, probe once to preserve PHP & references only - // when the input carries any. Otherwise skip ref handling entirely. - $hasRefs = false; - if ($flags & \DEEPCLONE_HYDRATE_PRESERVE_REFS) { - foreach ($properties as $k => $_) { - if (\ReflectionReference::fromArrayElement($properties, $k)) { - $hasRefs = true; - break; - } - } - } + $propertyScopes[$name] = $entry; - $effectiveFlags = $flags & \DEEPCLONE_HYDRATE_CALL_HOOKS; - if (\PHP_VERSION_ID >= 80400 && ($flags & \DEEPCLONE_HYDRATE_NO_LAZY_INIT)) { - $r ??= self::$reflectors[$class] ?? new \ReflectionClass($class); - if ($r->isUninitializedLazyObject($object)) { - $effectiveFlags |= \DEEPCLONE_HYDRATE_NO_LAZY_INIT; - } + if ($property->isProtected()) { + $propertyScopes["\0*\0".$name] = $entry; + } + } + + while ($class = $class->getParentClass()) { + foreach ($class->getProperties(\ReflectionProperty::IS_PRIVATE) as $property) { + if ($property->isStatic()) { + continue; } - $cacheKey = $effectiveFlags ? $effectiveFlags.$scope : $scope; - (self::$simpleHydrators[$cacheKey] ??= self::getSimpleHydrator($scope, $effectiveFlags))($properties, $object, $hasRefs); + $entry = [$property->class, $property->name]; + $propertyScopes["\0".$property->class."\0".$property->name] = $entry; + $propertyScopes[$property->name] ??= $entry; } } - return $object; + return $propertyScopes; } /** @@ -787,7 +852,7 @@ private static function getArrayObjectProperties($value, $proto): array return [$arrayValue, $properties]; } - private static function reconstruct($prepared, $objectMeta, $numObjects, $properties, $resolve, $states, $refs, $preparedMask = null, $refMasks = [], ?array $allowedClasses = null) + private static function reconstruct($prepared, $objectMeta, $numObjects, $properties, $resolve, $states, $refs, $preparedMask = null, $refMasks = [], ?array $allowedClasses = null, array $expectedStates = []) { $objects = []; @@ -908,6 +973,10 @@ private static function reconstruct($prepared, $objectMeta, $numObjects, $proper if ($zid < 0 || $zid >= $numObjects) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "states" entry references unknown object id '.$zid); } + if (!isset($expectedStates[$zid]) || $expectedStates[$zid] >= 0) { + throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "states" has an __unserialize entry for object id '.$zid.' but "objectMeta" does not flag it for __unserialize'); + } + unset($expectedStates[$zid]); if (null === $obj = $objects[$zid]) { // Internal final class with __unserialize that rejects empty unserialize // reconstruct via the full O: serialization form (same as PHP's unserialize). @@ -930,6 +999,10 @@ private static function reconstruct($prepared, $objectMeta, $numObjects, $proper if ($state < 0 || $state >= $numObjects) { throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "states" entry references unknown object id '.$state); } + if (!isset($expectedStates[$state]) || $expectedStates[$state] <= 0) { + throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "states" has a __wakeup entry for object id '.$state.' but "objectMeta" does not flag it for __wakeup'); + } + unset($expectedStates[$state]); if (method_exists($objects[$state], '__wakeup')) { $objects[$state]->__wakeup(); } @@ -938,6 +1011,11 @@ private static function reconstruct($prepared, $objectMeta, $numObjects, $proper } } + if ($expectedStates) { + $id = array_key_first($expectedStates); + throw new \ValueError('deepclone_from_array(): Argument #1 ($data) "objectMeta" entry '.$id.' flags object for state replay but no matching "states" entry was found'); + } + if (\is_int($prepared)) { if ($prepared >= 0) { if ($prepared >= $numObjects) { @@ -1242,10 +1320,6 @@ private static function getClassReflector($class, $instantiableWithoutConstructo return $reflector; } - /** - * Port of {@see \Symfony\Component\VarExporter\Internal\Hydrator::getHydrator()}. - * Builds a scoped closure that sets properties on a list of objects. - */ private static function getHydrator($class) { $baseHydrator = self::$hydrators['stdClass'] ??= static function ($properties, $objects) { @@ -1256,56 +1330,52 @@ private static function getHydrator($class) } }; - switch ($class) { - case 'stdClass': - return $baseHydrator; - - case 'TypeError': - $class = 'Error'; - break; - - case 'ErrorException': - $class = 'Exception'; - break; + if ('stdClass' === $class) { + return $baseHydrator; + } - case 'SplObjectStorage': - return static function ($properties, $objects) { - foreach ($properties as $name => $values) { - if ("\0" === $name) { - foreach ($values as $i => $v) { - for ($j = 0; $j < \count($v); ++$j) { - $objects[$i][$v[$j]] = $v[++$j]; - } - } - continue; - } + if ('SplObjectStorage' === $class) { + return static function ($properties, $objects) { + foreach ($properties as $name => $values) { + if ("\0" === $name) { foreach ($values as $i => $v) { - $objects[$i]->$name = $v; + for ($j = 0; $j < \count($v); ++$j) { + $objects[$i][$v[$j]] = $v[++$j]; + } } + continue; } - }; + foreach ($values as $i => $v) { + $objects[$i]->$name = $v; + } + } + }; + } + + if ('TypeError' === $class) { + $class = 'Error'; + } elseif ('ErrorException' === $class) { + $class = 'Exception'; } // self::$reflectors must be populated via getClassReflector() (companion caches), so read with ??. $classReflector = self::$reflectors[$class] ?? new \ReflectionClass($class); - switch ($class) { - case 'ArrayIterator': - case 'ArrayObject': - $constructor = $classReflector->getConstructor()->invokeArgs(...); + if (\in_array($class, ['ArrayIterator', 'ArrayObject'], true)) { + $constructor = $classReflector->getConstructor()->invokeArgs(...); - return static function ($properties, $objects) use ($constructor) { - foreach ($properties as $name => $values) { - if ("\0" !== $name) { - foreach ($values as $i => $v) { - $objects[$i]->$name = $v; - } + return static function ($properties, $objects) use ($constructor) { + foreach ($properties as $name => $values) { + if ("\0" !== $name) { + foreach ($values as $i => $v) { + $objects[$i]->$name = $v; } } - foreach ($properties["\0"] ?? [] as $i => $v) { - $constructor($objects[$i], $v); - } - }; + } + foreach ($properties["\0"] ?? [] as $i => $v) { + $constructor($objects[$i], $v); + } + }; } if (!$classReflector->isInternal()) { @@ -1359,191 +1429,165 @@ private static function getSimpleHydrator(string $class, int $flags = 0): \Closu } }; - switch ($class) { - case 'stdClass': - return $baseHydrator; - - case 'TypeError': - $class = 'Error'; - break; - - case 'ErrorException': - $class = 'Exception'; - break; - - case 'SplObjectStorage': - return static function ($properties, $object) { - foreach ($properties as $name => &$value) { - if ("\0" !== $name) { - $object->$name = $value; - $object->$name = &$value; - continue; - } - for ($i = 0; $i < \count($value); ++$i) { - $object[$value[$i]] = $value[++$i]; - } + if ('stdClass' === $class) { + return $baseHydrator; + } + if ('SplObjectStorage' === $class) { + return static function ($properties, $object) { + foreach ($properties as $name => &$value) { + if ("\0" !== $name) { + $object->$name = $value; + $object->$name = &$value; + continue; } - }; + for ($i = 0; $i < \count($value); ++$i) { + $object[$value[$i]] = $value[++$i]; + } + } + }; } - if (!class_exists($class) && !interface_exists($class, false) && !trait_exists($class, false)) { + if ('TypeError' === $class) { + $class = 'Error'; + } elseif ('ErrorException' === $class) { + $class = 'Exception'; + } elseif (!class_exists($class, false)) { throw new \DeepClone\ClassNotFoundException('Class "'.$class.'" not found.'); } - $classReflector = new \ReflectionClass($class); + $classReflector = self::$reflectors[$class] ?? new \ReflectionClass($class); - switch ($class) { - case 'ArrayIterator': - case 'ArrayObject': - $constructor = $classReflector->getConstructor()->invokeArgs(...); + if (\in_array($class, ['ArrayIterator', 'ArrayObject'], true)) { + $constructor = $classReflector->getConstructor()->invokeArgs(...); - return static function ($properties, $object) use ($constructor) { - foreach ($properties as $name => &$value) { - if ("\0" === $name) { - $constructor($object, $value); - } else { - $object->$name = $value; - $object->$name = &$value; - } + return static function ($properties, $object) use ($constructor) { + foreach ($properties as $name => &$value) { + if ("\0" === $name) { + $constructor($object, $value); + } else { + $object->$name = $value; + $object->$name = &$value; } - }; + } + }; } - if (!$classReflector->isInternal()) { - $notByRef = []; - $unsetOnNull = []; - $backedEnum = []; + if ($classReflector->isInternal()) { + if ($classReflector->name !== $class) { + return self::$simpleHydrators[$classReflector->name] ??= self::getSimpleHydrator($classReflector->name); + } + + $propertySetters = []; foreach ($classReflector->getProperties() as $propertyReflector) { - if ($propertyReflector->isStatic()) { - continue; + if (!$propertyReflector->isStatic()) { + $propertySetters[$propertyReflector->name] = $propertyReflector->setValue(...); } - if ($noLazyInit && !$propertyReflector->isVirtual()) { - // Virtual hooked props fall through — setRawValueWithoutLazyInitialization rejects them. - $notByRef[$propertyReflector->name] = $propertyReflector->setRawValueWithoutLazyInitialization(...); - continue; - } - if (\PHP_VERSION_ID >= 80400 && !$propertyReflector->isAbstract() && $propertyReflector->getHooks()) { - if ($propertyReflector->isVirtual()) { - $notByRef[$propertyReflector->name] = true; - } else { - $notByRef[$propertyReflector->name] = $callHooks - ? $propertyReflector->setValue(...) - : $propertyReflector->setRawValue(...); + } + + if (!$propertySetters) { + return $baseHydrator; + } + + return static function ($properties, $object) use ($propertySetters) { + foreach ($properties as $name => $value) { + if ($setValue = $propertySetters[$name] ?? null) { + $setValue($object, $value); + continue; } - } elseif ($propertyReflector->isReadOnly()) { - $notByRef[$propertyReflector->name] = static function ($object, $value) use ($propertyReflector) { - // Idempotent rehydrate: skip same-value writes that the engine would reject. - if ($propertyReflector->isInitialized($object) - && $propertyReflector->getValue($object) === $value) { - return; - } - $propertyReflector->setValue($object, $value); - }; - } elseif (($type = $propertyReflector->getType()) && !$type->allowsNull()) { - // null → uninitialized; hooked props are already filtered above. - $unsetOnNull[$propertyReflector->name] = true; - } - - // Property-type-only decision: hook presence and CALL_HOOKS don't influence it. - if (($t = $propertyReflector->getType()) instanceof \ReflectionNamedType - && !$t->isBuiltin() - && enum_exists($enumName = $t->getName()) - && null !== ($backingType = (new \ReflectionEnum($enumName))->getBackingType()) - ) { - $backedEnum[$propertyReflector->name] = $enumName; - } - } - - $scope = $class; - - // Four variants. When the class has no hooked/readonly/unsetOnNull/backedEnum - // props, the hottest path skips the $notByRef table lookup entirely. Each - // hasRefs branch is hoisted out of the loop so it's branch-predicted once. - if (!$unsetOnNull && !$backedEnum) { - if (!$notByRef) { - return \Closure::bind(static function ($properties, $object, $hasRefs): void { - if ($hasRefs) { - foreach ($properties as $name => &$value) { - $object->$name = $value; - $object->$name = &$value; - } - } else { - foreach ($properties as $name => $value) { - $object->$name = $value; - } - } - }, null, $class); + $object->$name = $value; } + }; + } - return \Closure::bind(static function ($properties, $object, $hasRefs) use ($notByRef, $scope): void { - if ($hasRefs) { - foreach ($properties as $name => &$value) { - if (!$noRef = $notByRef[$name] ?? false) { - $object->$name = $value; - $object->$name = &$value; - } elseif (true !== $noRef) { - $noRef($object, $value); - } else { - $object->$name = $value; - } - } - } else { - foreach ($properties as $name => $value) { - if (!$noRef = $notByRef[$name] ?? false) { - $object->$name = $value; - } elseif (true !== $noRef) { - $noRef($object, $value); - } else { - $object->$name = $value; - } - } + $notByRef = []; + $unsetOnNull = []; + $backedEnum = []; + foreach ($classReflector->getProperties() as $propertyReflector) { + if ($propertyReflector->isStatic()) { + continue; + } + if ($noLazyInit && !$propertyReflector->isVirtual()) { + $notByRef[$propertyReflector->name] = $propertyReflector->setRawValueWithoutLazyInitialization(...); + continue; + } + $t = null; + if (\PHP_VERSION_ID >= 80400 && !$propertyReflector->isAbstract() && $propertyReflector->getHooks()) { + if ($propertyReflector->isVirtual()) { + $notByRef[$propertyReflector->name] = true; + } else { + $notByRef[$propertyReflector->name] = $callHooks ? $propertyReflector->setValue(...) : $propertyReflector->setRawValue(...); + } + } elseif ($propertyReflector->isReadOnly()) { + $notByRef[$propertyReflector->name] = static function ($object, $value) use ($propertyReflector) { + if (!$propertyReflector->isInitialized($object) || $propertyReflector->getValue($object) !== $value) { + $propertyReflector->setValue($object, $value); } - }, null, $class); + }; + } elseif (($t = $propertyReflector->getType()) && !$t->allowsNull()) { + $unsetOnNull[$propertyReflector->name] = true; + } + + // Property-type-only decision: hook presence and CALL_HOOKS don't influence it. + if (($t ??= $propertyReflector->getType()) instanceof \ReflectionNamedType + && !$t->isBuiltin() + && enum_exists($enumName = $t->getName()) + && (new \ReflectionEnum($enumName))->getBackingType() + ) { + $backedEnum[$propertyReflector->name] = $enumName; } - if (!$backedEnum) { - return \Closure::bind(static function ($properties, $object, $hasRefs) use ($notByRef, $unsetOnNull, $scope): void { + } + + // Four variants. When the class has no hooked/readonly/unsetOnNull/backedEnum + // props, the hottest path skips the $notByRef table lookup entirely. Each + // hasRefs branch is hoisted out of the loop so it's branch-predicted once. + if (!$unsetOnNull && !$backedEnum) { + if (!$notByRef) { + return \Closure::bind(static function ($properties, $object, $hasRefs): void { if ($hasRefs) { foreach ($properties as $name => &$value) { - if (null === $value && isset($unsetOnNull[$name])) { - unset($object->$name); - continue; - } - if (!$noRef = $notByRef[$name] ?? false) { - $object->$name = $value; - $object->$name = &$value; - } elseif (true !== $noRef) { - $noRef($object, $value); - } else { - $object->$name = $value; - } + $object->$name = $value; + $object->$name = &$value; } } else { foreach ($properties as $name => $value) { - if (null === $value && isset($unsetOnNull[$name])) { - unset($object->$name); - continue; - } - if (!$noRef = $notByRef[$name] ?? false) { - $object->$name = $value; - } elseif (true !== $noRef) { - $noRef($object, $value); - } else { - $object->$name = $value; - } + $object->$name = $value; } } }, null, $class); } - return \Closure::bind(static function ($properties, $object, $hasRefs) use ($notByRef, $unsetOnNull, $backedEnum, $scope): void { + return \Closure::bind(static function ($properties, $object, $hasRefs) use ($notByRef): void { + if ($hasRefs) { + foreach ($properties as $name => &$value) { + if (!$noRef = $notByRef[$name] ?? false) { + $object->$name = $value; + $object->$name = &$value; + } elseif (true !== $noRef) { + $noRef($object, $value); + } else { + $object->$name = $value; + } + } + } else { + foreach ($properties as $name => $value) { + if (!$noRef = $notByRef[$name] ?? false) { + $object->$name = $value; + } elseif (true !== $noRef) { + $noRef($object, $value); + } else { + $object->$name = $value; + } + } + } + }, null, $class); + } + if (!$backedEnum) { + return \Closure::bind(static function ($properties, $object, $hasRefs) use ($notByRef, $unsetOnNull): void { if ($hasRefs) { foreach ($properties as $name => &$value) { if (null === $value && isset($unsetOnNull[$name])) { unset($object->$name); continue; } - if (isset($backedEnum[$name]) && (\is_int($value) || \is_string($value))) { - $value = $backedEnum[$name]::from($value); - } if (!$noRef = $notByRef[$name] ?? false) { $object->$name = $value; $object->$name = &$value; @@ -1559,9 +1603,6 @@ private static function getSimpleHydrator(string $class, int $flags = 0): \Closu unset($object->$name); continue; } - if (isset($backedEnum[$name]) && (\is_int($value) || \is_string($value))) { - $value = $backedEnum[$name]::from($value); - } if (!$noRef = $notByRef[$name] ?? false) { $object->$name = $value; } elseif (true !== $noRef) { @@ -1574,30 +1615,44 @@ private static function getSimpleHydrator(string $class, int $flags = 0): \Closu }, null, $class); } - if ($classReflector->name !== $class) { - return self::$simpleHydrators[$classReflector->name] ??= self::getSimpleHydrator($classReflector->name); - } - - $propertySetters = []; - foreach ($classReflector->getProperties() as $propertyReflector) { - if (!$propertyReflector->isStatic()) { - $propertySetters[$propertyReflector->name] = $propertyReflector->setValue(...); - } - } - - if (!$propertySetters) { - return $baseHydrator; - } - - return static function ($properties, $object) use ($propertySetters) { - foreach ($properties as $name => $value) { - if ($setValue = $propertySetters[$name] ?? null) { - $setValue($object, $value); - continue; + return \Closure::bind(static function ($properties, $object, $hasRefs) use ($notByRef, $unsetOnNull, $backedEnum): void { + if ($hasRefs) { + foreach ($properties as $name => &$value) { + if (null === $value && isset($unsetOnNull[$name])) { + unset($object->$name); + continue; + } + if (isset($backedEnum[$name]) && (\is_int($value) || \is_string($value))) { + $value = $backedEnum[$name]::from($value); + } + if (!$noRef = $notByRef[$name] ?? false) { + $object->$name = $value; + $object->$name = &$value; + } elseif (true !== $noRef) { + $noRef($object, $value); + } else { + $object->$name = $value; + } + } + } else { + foreach ($properties as $name => $value) { + if (null === $value && isset($unsetOnNull[$name])) { + unset($object->$name); + continue; + } + if (isset($backedEnum[$name]) && (\is_int($value) || \is_string($value))) { + $value = $backedEnum[$name]::from($value); + } + if (!$noRef = $notByRef[$name] ?? false) { + $object->$name = $value; + } elseif (true !== $noRef) { + $noRef($object, $value); + } else { + $object->$name = $value; + } } - $object->$name = $value; } - }; + }, null, $class); } } diff --git a/src/DeepClone/bootstrap81.php b/src/DeepClone/bootstrap81.php index 20cdfd90..9b6555b8 100644 --- a/src/DeepClone/bootstrap81.php +++ b/src/DeepClone/bootstrap81.php @@ -27,11 +27,8 @@ function deepclone_from_array(array $data, ?array $allowed_classes = null): mixe if (!defined('DEEPCLONE_HYDRATE_NO_LAZY_INIT')) { define('DEEPCLONE_HYDRATE_NO_LAZY_INIT', 1 << 1); } -if (!defined('DEEPCLONE_HYDRATE_MANGLED_VARS')) { - define('DEEPCLONE_HYDRATE_MANGLED_VARS', 1 << 2); -} if (!defined('DEEPCLONE_HYDRATE_PRESERVE_REFS')) { - define('DEEPCLONE_HYDRATE_PRESERVE_REFS', 1 << 3); + define('DEEPCLONE_HYDRATE_PRESERVE_REFS', 1 << 2); } if (!function_exists('deepclone_hydrate')) { function deepclone_hydrate(object|string $object_or_class, array $vars = [], int $flags = 0): object { return p\DeepClone::deepclone_hydrate($object_or_class, $vars, $flags); } diff --git a/tests/DeepClone/DeepCloneTest.php b/tests/DeepClone/DeepCloneTest.php index 47368b54..50478294 100644 --- a/tests/DeepClone/DeepCloneTest.php +++ b/tests/DeepClone/DeepCloneTest.php @@ -418,6 +418,41 @@ public function testFromArrayRejectsPreparedObjectIdOutOfRange() deepclone_from_array(['classes' => 'stdClass', 'objectMeta' => 1, 'prepared' => 99]); } + public function testFromArrayRejectsWakeupAdvertisedButNoStates() + { + $this->expectException(\ValueError::class); + $this->expectExceptionMessage('"objectMeta" entry 0 flags object for state replay but no matching "states" entry was found'); + deepclone_from_array(['classes' => 'stdClass', 'objectMeta' => [[0, 1]], 'prepared' => 0]); + } + + public function testFromArrayRejectsUnserializeAdvertisedButNoStates() + { + $this->expectException(\ValueError::class); + $this->expectExceptionMessage('"objectMeta" entry 0 flags object for state replay but no matching "states" entry was found'); + deepclone_from_array(['classes' => 'stdClass', 'objectMeta' => [[0, -1]], 'prepared' => 0]); + } + + public function testFromArrayRejectsStatesWakeupWithoutPositiveMeta() + { + $this->expectException(\ValueError::class); + $this->expectExceptionMessage('"states" has a __wakeup entry for object id 0 but "objectMeta" does not flag it for __wakeup'); + deepclone_from_array(['classes' => 'stdClass', 'objectMeta' => [[0, -1]], 'prepared' => 0, 'states' => [1 => 0]]); + } + + public function testFromArrayRejectsStatesUnserializeWithoutNegativeMeta() + { + $this->expectException(\ValueError::class); + $this->expectExceptionMessage('"states" has an __unserialize entry for object id 0 but "objectMeta" does not flag it for __unserialize'); + deepclone_from_array(['classes' => 'stdClass', 'objectMeta' => [[0, 1]], 'prepared' => 0, 'states' => [1 => [0, []]]]); + } + + public function testFromArrayRejectsStatesWakeupEntryButMetaZero() + { + $this->expectException(\ValueError::class); + $this->expectExceptionMessage('"states" has a __wakeup entry for object id 0 but "objectMeta" does not flag it for __wakeup'); + deepclone_from_array(['classes' => 'stdClass', 'objectMeta' => 1, 'prepared' => 0, 'states' => [1 => 0]]); + } + public function testFromArrayRejectsPreparedRefIdUnknown() { $this->expectException(\ValueError::class); @@ -1004,6 +1039,19 @@ public function testRoundTripWithAllowedClasses() $this->assertSame(1, $c->x); } + public function testAllowedChildCarriesParentDeclaredPrivateState() + { + $c = new AllowedChild(); + (function () { $this->secret = 'inherited'; })->bindTo($c, AllowedParent::class)(); + $c->pub = 'visible'; + + $d = deepclone_to_array($c, ['Symfony\Polyfill\Tests\DeepClone\AllowedChild']); + $r = deepclone_from_array($d, ['Symfony\Polyfill\Tests\DeepClone\AllowedChild']); + $this->assertInstanceOf(AllowedChild::class, $r); + $this->assertSame('inherited', $r->getSecret()); + $this->assertSame('visible', $r->pub); + } + /** * @requires extension mongodb */ @@ -1063,9 +1111,9 @@ public function testTidyNodeRoundTrip() public function testHydrateScopedInstantiate() { $obj = deepclone_hydrate(HydrateChild::class, [ - HydrateBase::class => ['secret' => 'hidden'], - HydrateChild::class => ['num' => 42], - 'stdClass' => ['pub' => 'visible'], + "\0".HydrateBase::class."\0secret" => 'hidden', + 'num' => 42, + 'pub' => 'visible', ]); $this->assertInstanceOf(HydrateChild::class, $obj); @@ -1078,8 +1126,8 @@ public function testHydrateScopedExistingObject() { $existing = new HydrateChild(); $result = deepclone_hydrate($existing, [ - HydrateBase::class => ['secret' => 'updated'], - 'stdClass' => ['pub' => 'changed'], + "\0".HydrateBase::class."\0secret" => 'updated', + 'pub' => 'changed', ]); $this->assertSame($existing, $result); @@ -1090,7 +1138,7 @@ public function testHydrateScopedExistingObject() public function testHydrateScopedStdClass() { - $o = deepclone_hydrate('stdClass', ['stdClass' => ['x' => 1, 'y' => 'hi']]); + $o = deepclone_hydrate('stdClass', ['x' => 1, 'y' => 'hi']); $this->assertSame(1, $o->x); $this->assertSame('hi', $o->y); @@ -1102,7 +1150,7 @@ public function testHydrateSplObjectStorage() $o1 = new \stdClass(); $o2 = new \stdClass(); - $result = deepclone_hydrate($s, ['stdClass' => ["\0" => [$o1, 'info1', $o2, 'info2']]]); + $result = deepclone_hydrate($s, ["\0" => [$o1, 'info1', $o2, 'info2']]); $this->assertSame($s, $result); $this->assertSame(2, $result->count()); @@ -1114,7 +1162,7 @@ public function testHydrateSplObjectStorage() public function testHydrateArrayObject() { $ao = deepclone_hydrate('ArrayObject', [ - 'stdClass' => ["\0" => [['x' => 1, 'y' => 2], \ArrayObject::ARRAY_AS_PROPS]], + "\0" => [['x' => 1, 'y' => 2], \ArrayObject::ARRAY_AS_PROPS], ]); $this->assertInstanceOf(\ArrayObject::class, $ao); @@ -1125,7 +1173,7 @@ public function testHydrateArrayObject() public function testHydrateArrayIterator() { $ai = deepclone_hydrate('ArrayIterator', [ - 'stdClass' => ["\0" => [['a', 'b', 'c']]], + "\0" => [['a', 'b', 'c']], ]); $this->assertInstanceOf(\ArrayIterator::class, $ai); @@ -1134,30 +1182,30 @@ public function testHydrateArrayIterator() public function testHydrateFlatStdClass() { - $this->assertEquals((object) ['p' => 123], deepclone_hydrate('stdClass', ['p' => 123], \DEEPCLONE_HYDRATE_MANGLED_VARS)); + $this->assertEquals((object) ['p' => 123], deepclone_hydrate('stdClass', ['p' => 123])); } public function testHydrateFlatCaseInsensitiveClass() { - $this->assertEquals((object) ['p' => 123], deepclone_hydrate('STDcLASS', ['p' => 123], \DEEPCLONE_HYDRATE_MANGLED_VARS)); + $this->assertEquals((object) ['p' => 123], deepclone_hydrate('STDcLASS', ['p' => 123])); } public function testHydrateFlatArrayObject() { - $this->assertEquals(new \ArrayObject([123]), deepclone_hydrate(\ArrayObject::class, ["\0" => [[123]]], \DEEPCLONE_HYDRATE_MANGLED_VARS)); + $this->assertEquals(new \ArrayObject([123]), deepclone_hydrate(\ArrayObject::class, ["\0" => [[123]]])); } public function testHydrateFlatSplObjectStorage() { $o1 = new \stdClass(); - $s = deepclone_hydrate('SplObjectStorage', ["\0" => [$o1, 'data']], \DEEPCLONE_HYDRATE_MANGLED_VARS); + $s = deepclone_hydrate('SplObjectStorage', ["\0" => [$o1, 'data']]); $this->assertSame(1, $s->count()); } public function testHydrateScopedArrayObjectOwnClass() { - $ao = deepclone_hydrate('ArrayObject', ['ArrayObject' => ["\0" => [[456]]]]); + $ao = deepclone_hydrate('ArrayObject', ["\0" => [[456]]]); $this->assertSame(456, $ao[0]); } @@ -1165,7 +1213,7 @@ public function testHydrateScopedArrayObjectOwnClass() public function testHydrateScopedSplObjectStorageOwnClass() { $o1 = new \stdClass(); - $s = deepclone_hydrate('SplObjectStorage', ['SplObjectStorage' => ["\0" => [$o1, 'info']]]); + $s = deepclone_hydrate('SplObjectStorage', ["\0" => [$o1, 'info']]); $this->assertSame(1, $s->count()); } @@ -1180,7 +1228,7 @@ public function testHydrateFlatMangledKeys() 'ro' => 567, ]; - $actual = (array) deepclone_hydrate(HydrateBar::class, $expected, \DEEPCLONE_HYDRATE_MANGLED_VARS); + $actual = (array) deepclone_hydrate(HydrateBar::class, $expected); ksort($actual); $this->assertSame($expected, $actual); @@ -1188,7 +1236,7 @@ public function testHydrateFlatMangledKeys() public function testHydrateFlatExceptionTrace() { - $e = deepclone_hydrate('Exception', ['trace' => [234]], \DEEPCLONE_HYDRATE_MANGLED_VARS); + $e = deepclone_hydrate('Exception', ['trace' => [234]]); $this->assertSame([234], $e->getTrace()); } @@ -1197,7 +1245,7 @@ public function testHydrateFlatReadonlyInitialized() { $obj = new HydrateReadonly(123); try { - deepclone_hydrate($obj, ['value' => 456], \DEEPCLONE_HYDRATE_MANGLED_VARS); + deepclone_hydrate($obj, ['value' => 456]); $this->fail('Expected Error on readonly overwrite'); } catch (\Error $e) { $this->assertStringContainsString('readonly', $e->getMessage()); @@ -1207,7 +1255,7 @@ public function testHydrateFlatReadonlyInitialized() public function testHydrateFlatReadonlyUninitialized() { - $obj = deepclone_hydrate(HydrateReadonly::class, [HydrateReadonly::class => ['value' => 456]]); + $obj = deepclone_hydrate(HydrateReadonly::class, ['value' => 456]); $this->assertSame(456, $obj->getValue()); } @@ -1217,7 +1265,7 @@ public function testHydratePhpReferences() $properties = ['p1' => 1]; $properties['p2'] = &$properties['p1']; - $obj = deepclone_hydrate('stdClass', $properties, \DEEPCLONE_HYDRATE_MANGLED_VARS | \DEEPCLONE_HYDRATE_PRESERVE_REFS); + $obj = deepclone_hydrate('stdClass', $properties, \DEEPCLONE_HYDRATE_PRESERVE_REFS); $this->assertSame(1, $obj->p1); $this->assertSame(1, $obj->p2); @@ -1232,7 +1280,7 @@ public function testHydratePhpReferencesNotPreservedByDefault() $properties = ['p1' => 1]; $properties['p2'] = &$properties['p1']; - $obj = deepclone_hydrate('stdClass', $properties, \DEEPCLONE_HYDRATE_MANGLED_VARS); + $obj = deepclone_hydrate('stdClass', $properties); $this->assertSame(1, $obj->p1); $this->assertSame(1, $obj->p2); @@ -1245,7 +1293,7 @@ public function testHydratePhpReferencesNotPreservedByDefault() public function testHydrateScopedReferences() { $v = 'hello'; - $obj = deepclone_hydrate('stdClass', ['stdClass' => ['x' => &$v, 'y' => &$v]], \DEEPCLONE_HYDRATE_PRESERVE_REFS); + $obj = deepclone_hydrate('stdClass', ['x' => &$v, 'y' => &$v], \DEEPCLONE_HYDRATE_PRESERVE_REFS); $v = 'world'; $this->assertSame('world', $obj->x); @@ -1255,7 +1303,7 @@ public function testHydrateScopedReferences() public function testHydrateScopedReferencesNotPreservedByDefault() { $v = 'hello'; - $obj = deepclone_hydrate('stdClass', ['stdClass' => ['x' => &$v, 'y' => &$v]]); + $obj = deepclone_hydrate('stdClass', ['x' => &$v, 'y' => &$v]); $v = 'world'; $this->assertSame('hello', $obj->x); @@ -1308,28 +1356,37 @@ public function testHydrateGrandparentPrivate() "\0".HydrateGP::class."\0secret" => 'gp_val', "\0".HydrateP::class."\0mid" => 42, 'pub' => 'hi', - ], \DEEPCLONE_HYDRATE_MANGLED_VARS); + ]); $this->assertSame('gp_val', $o->getSecret()); $this->assertSame(42, $o->getMid()); $this->assertSame('hi', $o->pub); } - public function testHydrateIntegerKeyInProperties() + public function testHydrateBareNameReachesParentPrivate() { - $this->expectException(\ValueError::class); - $this->expectExceptionMessage('string keys'); - deepclone_hydrate('stdClass', [0 => 'val'], \DEEPCLONE_HYDRATE_MANGLED_VARS); - } + // Bare "secret" on HydrateC (which extends HydrateP extends HydrateGP, where + // GP declares private $secret) resolves to GP's private slot — no mangled key + // needed as long as there's only one private of that name in the chain. + $o = deepclone_hydrate(HydrateC::class, [ + 'secret' => 'bare_gp_val', + 'mid' => 7, // bare, resolves to HydrateP's private $mid + 'pub' => 'hi', // bare public on HydrateC + ]); - public function testHydrateNonArrayInScopedProperties() - { - $this->expectException(\ValueError::class); - $this->expectExceptionMessage('array values'); - deepclone_hydrate('stdClass', ['stdClass' => 'not-array']); + $this->assertSame('bare_gp_val', $o->getSecret()); + $this->assertSame(7, $o->getMid()); + $this->assertSame('hi', $o->pub); + + // No stray dynamic properties should have been created. + $props = (array) $o; + $this->assertArrayNotHasKey('secret', $props); + $this->assertArrayNotHasKey('mid', $props); + $this->assertArrayHasKey("\0".HydrateGP::class."\0secret", $props); + $this->assertArrayHasKey("\0".HydrateP::class."\0mid", $props); } - public function testHydrateReflectorSubclass() +public function testHydrateReflectorSubclass() { $this->expectException(\DeepClone\NotInstantiableException::class); deepclone_hydrate('ReflectionClass'); @@ -1404,7 +1461,7 @@ public function testHydrateNulInMiddleOfPropertyNameMatchesUnserialize() { // Matches unserialize(): NUL in the middle of a dynamic property name // is silently accepted — the engine stores the raw name. - $o = deepclone_hydrate('stdClass', ['stdClass' => ["foo\0bar" => 'val']]); + $o = deepclone_hydrate('stdClass', ["foo\0bar" => 'val']); $this->assertSame('val', ((array) $o)["foo\0bar"]); } @@ -1412,62 +1469,67 @@ public function testHydrateNulInMangledKeyProperty() { $this->expectException(\ValueError::class); $this->expectExceptionMessage('invalid mangled key'); - deepclone_hydrate('stdClass', ["\0*\0foo\0bar" => 'val'], \DEEPCLONE_HYDRATE_MANGLED_VARS); + deepclone_hydrate('stdClass', ["\0*\0foo\0bar" => 'val']); } public function testHydrateIntegerKeyInsideScopeMatchesUnserialize() { // Matches unserialize(): integer keys coerce to string on dynamic // property access; no pre-validation rejects them. - $o = deepclone_hydrate('stdClass', ['stdClass' => [0 => 'val']]); + $o = deepclone_hydrate('stdClass', [0 => 'val']); $this->assertSame('val', $o->{'0'}); } - public function testHydrateInterfaceAsScope() +public function testFromArrayRejectsUnloadedScope() { $this->expectException(\ValueError::class); - $this->expectExceptionMessage('not a parent'); - deepclone_hydrate(\ArrayObject::class, ['IteratorAggregate' => ['x' => 1]]); + $this->expectExceptionMessage('scope "NoSuchScope"'); + deepclone_from_array([ + 'classes' => ScopeChild::class, + 'objectMeta' => 1, + 'prepared' => 0, + 'properties' => ['NoSuchScope' => ['pub' => [0 => 1]]], + ]); } - public function testHydrateUnrelatedScope() + public function testHydrateRejectsMangledKeyWithEmptyClass() { $this->expectException(\ValueError::class); - $this->expectExceptionMessage('not a parent'); - deepclone_hydrate('stdClass', ['SplHeap' => ['x' => 1]]); + $this->expectExceptionMessage('invalid mangled key'); + deepclone_hydrate('stdClass', ["\0\0x" => 1]); } - public function testHydrateNonExistingScope() + public function testHydrateRejectsMangledKeyNoSecondNul() { $this->expectException(\ValueError::class); - $this->expectExceptionMessage('not a parent'); - deepclone_hydrate('stdClass', ['NonExistent' => ['x' => 1]]); + $this->expectExceptionMessage('invalid mangled key'); + deepclone_hydrate('stdClass', ["\0broken" => 1]); } - public function testFromArrayRejectsUnloadedScope() + public function testHydrateRejectsMangledKeyWithNonParentClass() { $this->expectException(\ValueError::class); - $this->expectExceptionMessage('scope "NoSuchScope"'); - deepclone_from_array([ - 'classes' => ScopeChild::class, - 'objectMeta' => 1, - 'prepared' => 0, - 'properties' => ['NoSuchScope' => ['pub' => [0 => 1]]], - ]); + $this->expectExceptionMessage('not a parent'); + deepclone_hydrate('stdClass', ["\0SomeUnrelatedClass\0x" => 1]); } - public function testHydrateRejectsMangledKeyWithEmptyClass() + public function testHydrateRejectsMangledProtectedKeyForUndeclaredProp() { + // "\0*\0undeclared" specifically targets a protected slot; if the + // slot doesn't exist, reject instead of silently creating a dynamic + // property. $this->expectException(\ValueError::class); - $this->expectExceptionMessage('invalid mangled key'); - deepclone_hydrate('stdClass', ["\0\0x" => 1], \DEEPCLONE_HYDRATE_MANGLED_VARS); + $this->expectExceptionMessage('does not declare'); + deepclone_hydrate(HydrateReadonly::class, ["\0*\0undeclared" => 1]); } - public function testHydrateRejectsMangledKeyNoSecondNul() + public function testHydrateRejectsMangledPrivateKeyForUndeclaredProp() { + // "\0ParentClass\0undeclared" with a valid parent class but a prop + // not declared on it — same reasoning as above. $this->expectException(\ValueError::class); - $this->expectExceptionMessage('invalid mangled key'); - deepclone_hydrate('stdClass', ["\0broken" => 1], \DEEPCLONE_HYDRATE_MANGLED_VARS); + $this->expectExceptionMessage('does not declare'); + deepclone_hydrate(HydrateC::class, ["\0".HydrateGP::class."\0undeclared" => 1]); } /** @@ -1475,7 +1537,7 @@ public function testHydrateRejectsMangledKeyNoSecondNul() */ public function testHydrateInvokesSetHookForVirtualProperty() { - $h = deepclone_hydrate(HookedProps::class, [HookedProps::class => ['x' => 5]]); + $h = deepclone_hydrate(HookedProps::class, ['x' => 5]); $this->assertSame(6, $h->x); } @@ -1495,7 +1557,7 @@ public function testRoundtripOnlyWritesBackingStorage() */ public function testHydrateBypassesSetHookForNonVirtualProperty() { - $h = deepclone_hydrate(HookedBackingProps::class, [HookedBackingProps::class => ['x' => 7]]); + $h = deepclone_hydrate(HookedBackingProps::class, ['x' => 7]); $this->assertSame(7, $h->x); } @@ -1503,8 +1565,8 @@ public function testPrivateShadowingDistinctSlotsPerScope() { $b = new PrivShadowB(); deepclone_hydrate($b, [ - PrivShadowA::class => ['x' => 'A_written'], - PrivShadowB::class => ['x' => 'B_written'], + "\0".PrivShadowA::class."\0x" => 'A_written', + 'x' => 'B_written', ]); $this->assertSame('A_written', $b->get()); $this->assertSame('B_written', $b->getChild()); @@ -1524,7 +1586,7 @@ public function testPrivateShadowingRoundtripPreservesBothSlots() public function testPrivateShadowingBareMangledNameTargetsMostDerived() { $b = new PrivShadowB(); - deepclone_hydrate($b, ['x' => 'bare_val'], \DEEPCLONE_HYDRATE_MANGLED_VARS); + deepclone_hydrate($b, ['x' => 'bare_val']); $this->assertSame('a_init', $b->get()); $this->assertSame('bare_val', $b->getChild()); } @@ -1532,7 +1594,7 @@ public function testPrivateShadowingBareMangledNameTargetsMostDerived() public function testPrivateShadowingMangledKeyTargetsParentSlot() { $b = new PrivShadowB(); - deepclone_hydrate($b, ["\0".PrivShadowA::class."\0x" => 'parent_targeted'], \DEEPCLONE_HYDRATE_MANGLED_VARS); + deepclone_hydrate($b, ["\0".PrivShadowA::class."\0x" => 'parent_targeted']); $this->assertSame('parent_targeted', $b->get()); $this->assertSame('b_init', $b->getChild()); } @@ -1541,12 +1603,12 @@ public function testHydrateTypedPropTypeMismatchThrows() { $this->expectException(\TypeError::class); $this->expectExceptionMessage('type int'); - deepclone_hydrate(TypedInt::class, [TypedInt::class => ['x' => 'hello']]); + deepclone_hydrate(TypedInt::class, ['x' => 'hello']); } public function testHydrateTypedPropCoercesNonStrict() { - $o = deepclone_hydrate(TypedInt::class, [TypedInt::class => ['x' => '42']]); + $o = deepclone_hydrate(TypedInt::class, ['x' => '42']); $this->assertSame(42, $o->x); } @@ -1554,7 +1616,7 @@ public function testHydrateReadonlyTypedMismatchThrows() { $this->expectException(\TypeError::class); $this->expectExceptionMessage('type int'); - deepclone_hydrate(TypedReadonly::class, [TypedReadonly::class => ['v' => 'nope']]); + deepclone_hydrate(TypedReadonly::class, ['v' => 'nope']); } public function testFromArrayTypedPropMismatchThrows() @@ -1574,7 +1636,7 @@ public function testFromArrayTypedPropMismatchThrows() */ public function testHydrateFlagCallHooksInvokesSetHook() { - $o = deepclone_hydrate(HookedBackingProps::class, [HookedBackingProps::class => ['x' => 7]], \DEEPCLONE_HYDRATE_CALL_HOOKS); + $o = deepclone_hydrate(HookedBackingProps::class, ['x' => 7], \DEEPCLONE_HYDRATE_CALL_HOOKS); $this->assertSame(70, $o->x); } @@ -1583,7 +1645,7 @@ public function testHydrateFlagCallHooksInvokesSetHook() */ public function testHydrateDefaultFlagBypassesSetHook() { - $o = deepclone_hydrate(HookedBackingProps::class, [HookedBackingProps::class => ['x' => 7]]); + $o = deepclone_hydrate(HookedBackingProps::class, ['x' => 7]); $this->assertSame(7, $o->x); } @@ -1612,7 +1674,7 @@ public function testHydrateNoLazyInitSkipsInitializer() ++$initRan; $o->x = 1; }); - deepclone_hydrate($ghost, [TypedInt::class => ['x' => 99]], \DEEPCLONE_HYDRATE_NO_LAZY_INIT); + deepclone_hydrate($ghost, ['x' => 99], \DEEPCLONE_HYDRATE_NO_LAZY_INIT); $this->assertSame(0, $initRan); $this->assertSame(99, $ghost->x); } @@ -1628,7 +1690,7 @@ public function testHydrateDefaultFlagRunsLazyInitializer() ++$initRan; $o->x = 1; }); - deepclone_hydrate($ghost, [TypedInt::class => ['x' => 99]]); + deepclone_hydrate($ghost, ['x' => 99]); $this->assertSame(1, $initRan); $this->assertSame(99, $ghost->x); } @@ -1636,7 +1698,7 @@ public function testHydrateDefaultFlagRunsLazyInitializer() public function testHydrateReadonlyIdempotentSkipsSameValue() { $obj = new HydrateReadonly(123); - $obj = deepclone_hydrate($obj, [HydrateReadonly::class => ['value' => 123]]); + $obj = deepclone_hydrate($obj, ['value' => 123]); $this->assertSame(123, $obj->getValue()); } @@ -1644,7 +1706,7 @@ public function testHydrateReadonlyDifferentValueThrows() { $obj = new HydrateReadonly(123); try { - deepclone_hydrate($obj, [HydrateReadonly::class => ['value' => 456]]); + deepclone_hydrate($obj, ['value' => 456]); $this->fail('Expected Error on readonly overwrite'); } catch (\Error $e) { $this->assertStringContainsString('readonly', $e->getMessage()); @@ -1656,21 +1718,21 @@ public function testHydrateReadonlyObjectIdentityIsSkipped() { $inner = new \stdClass(); $host = new HydrateReadonlyObject($inner); - $host = deepclone_hydrate($host, [HydrateReadonlyObject::class => ['o' => $inner]]); + $host = deepclone_hydrate($host, ['o' => $inner]); $this->assertSame($inner, $host->o); } public function testHydrateNullIntoNonNullableTypedUnsetsSlot() { $o = new TypedInt(); - $o = deepclone_hydrate($o, [TypedInt::class => ['x' => null]]); + $o = deepclone_hydrate($o, ['x' => null]); $this->assertFalse((new \ReflectionProperty(TypedInt::class, 'x'))->isInitialized($o)); } public function testHydrateNullIntoNullableTypedKeepsNull() { $o = new HydrateNullableInt(); - deepclone_hydrate($o, [HydrateNullableInt::class => ['y' => null]]); + deepclone_hydrate($o, ['y' => null]); $this->assertNull($o->y); } @@ -1679,31 +1741,31 @@ public function testHydrateCallHooksStillUnsetsOnNullForNonHookedProps() // Per-prop gate: TypedInt::$x is not hooked, so A2 still applies // even under CALL_HOOKS. $o = new TypedInt(); - $o = deepclone_hydrate($o, [TypedInt::class => ['x' => null]], \DEEPCLONE_HYDRATE_CALL_HOOKS); + $o = deepclone_hydrate($o, ['x' => null], \DEEPCLONE_HYDRATE_CALL_HOOKS); $this->assertFalse((new \ReflectionProperty(TypedInt::class, 'x'))->isInitialized($o)); } public function testHydrateStringCastToBackedEnum() { - $o = deepclone_hydrate(WithBackedEnums::class, [WithBackedEnums::class => ['s' => 'S']]); + $o = deepclone_hydrate(WithBackedEnums::class, ['s' => 'S']); $this->assertSame(DeepCloneHydrateSuit::Spades, $o->s); } public function testHydrateIntCastToBackedEnum() { - $o = deepclone_hydrate(WithBackedEnums::class, [WithBackedEnums::class => ['n' => 2]]); + $o = deepclone_hydrate(WithBackedEnums::class, ['n' => 2]); $this->assertSame(DeepCloneHydrateSize::Large, $o->n); } public function testHydrateNullableBackedEnumKeepsNull() { - $o = deepclone_hydrate(WithBackedEnums::class, [WithBackedEnums::class => ['ns' => null]]); + $o = deepclone_hydrate(WithBackedEnums::class, ['ns' => null]); $this->assertNull($o->ns); } public function testHydrateNullableBackedEnumCastsScalar() { - $o = deepclone_hydrate(WithBackedEnums::class, [WithBackedEnums::class => ['ns' => 'S']]); + $o = deepclone_hydrate(WithBackedEnums::class, ['ns' => 'S']); $this->assertSame(DeepCloneHydrateSuit::Spades, $o->ns); } @@ -1711,14 +1773,14 @@ public function testHydrateUnknownEnumValueThrows() { $this->expectException(\ValueError::class); $this->expectExceptionMessage('not a valid backing value for enum'); - deepclone_hydrate(WithBackedEnums::class, [WithBackedEnums::class => ['s' => 'X']]); + deepclone_hydrate(WithBackedEnums::class, ['s' => 'X']); } public function testHydrateEnumCastStillAppliesToNonHookedPropsUnderCallHooks() { // Per-prop gate: WithBackedEnums::$s has no set hook, so A3 still // applies even under CALL_HOOKS. - $o = deepclone_hydrate(WithBackedEnums::class, [WithBackedEnums::class => ['s' => 'S']], \DEEPCLONE_HYDRATE_CALL_HOOKS); + $o = deepclone_hydrate(WithBackedEnums::class, ['s' => 'S'], \DEEPCLONE_HYDRATE_CALL_HOOKS); $this->assertSame(DeepCloneHydrateSuit::Spades, $o->s); } @@ -1729,7 +1791,7 @@ public function testHydrateEnumCastAppliesToHookedPropUnderCallHooks() { // Property-type-only rule: the cast is decided from the prop type, // not the hook signature. The hook receives the enum case. - $o = deepclone_hydrate(HookedEnumMatchingParam::class, [HookedEnumMatchingParam::class => ['s' => 'S']], \DEEPCLONE_HYDRATE_CALL_HOOKS); + $o = deepclone_hydrate(HookedEnumMatchingParam::class, ['s' => 'S'], \DEEPCLONE_HYDRATE_CALL_HOOKS); $this->assertSame(DeepCloneHydrateSuit::Spades, $o->s); } @@ -1741,7 +1803,7 @@ public function testHydrateEnumCastAppliesEvenWhenHookParamIsWider() // Wider hook signature `set(Suit|string $v)` doesn't change the // hydrate decision: cast first, hook receives the enum case via // its non-string union arm. - $o = deepclone_hydrate(HookedEnumWiderParam::class, [HookedEnumWiderParam::class => ['s' => 'S']], \DEEPCLONE_HYDRATE_CALL_HOOKS); + $o = deepclone_hydrate(HookedEnumWiderParam::class, ['s' => 'S'], \DEEPCLONE_HYDRATE_CALL_HOOKS); $this->assertSame(DeepCloneHydrateSuit::Spades, $o->s); $this->assertNull(HookedEnumWiderParam::$lastRaw); } diff --git a/tests/DeepClone/fixtures.php b/tests/DeepClone/fixtures.php index 58cf0422..2f4b7d90 100644 --- a/tests/DeepClone/fixtures.php +++ b/tests/DeepClone/fixtures.php @@ -236,6 +236,17 @@ public function __construct(string $src) } } +class AllowedParent +{ + private string $secret = ''; + public function getSecret(): string { return $this->secret; } +} + +class AllowedChild extends AllowedParent +{ + public string $pub = ''; +} + abstract class AbstractWithPrivate { private string $secret = 'default';