diff --git a/CHANGELOG.md b/CHANGELOG.md index a92dcf6..d8eb8e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,14 @@ CHANGELOG --------- +### v4.3.0, 2025.01.08 + +- Proper serialization of private properties +- Improved serialization/deserialization of properties having hooks (PHP 8.4) +- Skip virtual properties (PHP 8.4) +- Added `Opis\Closure\clear_cache()` +- Added `Opis\Closure\get_raw_properties()` + ### v4.2.1, 2025.01.07 - Improved generic object serialization diff --git a/README.md b/README.md index 4b80f11..7ccfd29 100644 --- a/README.md +++ b/README.md @@ -75,7 +75,7 @@ Or you could directly reference it into your `composer.json` file as a dependenc ```json { "require": { - "opis/closure": "^4.2" + "opis/closure": "^4.3" } } ``` diff --git a/src/DeserializationHandler.php b/src/DeserializationHandler.php index 7e9b103..d2c1c38 100644 --- a/src/DeserializationHandler.php +++ b/src/DeserializationHandler.php @@ -51,7 +51,7 @@ public function unserialize(string $serialized): mixed } } - public function handle(mixed &$data): void + private function handle(mixed &$data): void { if (is_object($data)) { $this->handleObject($data); @@ -174,9 +174,13 @@ private function unboxObject(Box $box, bool $isAnonymous): object $this->unboxed[$box] = $object; $this->unboxed[$object] = $object; } + // handle value if ($value) { - // handle - $this->handle($value); + if (is_array($value)) { + $this->handleIterable($value); + } elseif (is_object($value)) { + $this->handleObject($value); + } } }, $info); } diff --git a/src/GenericObjectSerialization.php b/src/GenericObjectSerialization.php index 9ea00a3..284efc7 100644 --- a/src/GenericObjectSerialization.php +++ b/src/GenericObjectSerialization.php @@ -2,45 +2,68 @@ namespace Opis\Closure; +use ReflectionProperty; + /** * @internal */ class GenericObjectSerialization { - public const SERIALIZE_CALLBACK = [self::class, "serialize"]; + // public const SERIALIZE_CALLBACK = [self::class, "serialize"]; public const UNSERIALIZE_CALLBACK = [self::class, "unserialize"]; + private const PRIVATE_KEY = "\0?\0"; + public static function serialize(object $object, ReflectionClass $reflection): array { + // public and protected properties $data = []; - $skip = []; - do { - if (!$reflection->isUserDefined()) { - foreach ($reflection->getProperties() as $property) { - $skip[$property->getName()] = true; - } + // private properties contains on key the class name + /** @var array[] $private */ + $private = []; + + // according to docs get_mangled_object_vars() uses raw values, bypassing hooks + // we don't use reflection because hooks were added in 8.4, this should work just fine for all versions + foreach (get_mangled_object_vars($object) as $name => $value) { + if ($name[0] !== "\0") { + // public property + $data[$name] = $value; continue; } - foreach ($reflection->getProperties() as $property) { - $name = $property->getName(); - $skip[$name] = true; - if ($property->isStatic() || !$property->getDeclaringClass()->isUserDefined()) { - continue; - } - $property->setAccessible(true); - if ($property->isInitialized($object)) { - $data[$name] = $property->getValue($object); - } - } - } while ($reflection = $reflection->getParentClass()); + // remove NUL + $name = substr($name, 1); - // dynamic - foreach (get_object_vars($object) as $name => $value) { - if (!isset($skip[$name])) { + if ($name[0] === "*") { + // protected property + // remove * and NUL + $name = substr($name, 2); $data[$name] = $value; + continue; } + + // private property + // we have to extract the class + // and replace the anonymous class name + [$class, $name] = explode("\0", $name, 2); + if (str_ends_with($class, "@anonymous")) { + // handle anonymous class + $class = $reflection->info()->fullClassName(); + $pos = strrpos($name, "\0"); + if ($pos !== false) { + $name = substr($name, $pos + 1); + } + } + + $class = strtolower($class); + $private[$class] ??= []; + $private[$class][$name] = $value; + } + + // we save the private values to a special key empty key + if ($data || $private) { + $data[self::PRIVATE_KEY] = $private ?: null; } return $data; @@ -50,32 +73,50 @@ public static function unserialize(array &$data, callable $solve, ReflectionClas { $object = $reflection->newInstanceWithoutConstructor(); - $solve($object, $data); + $private = null; + if (array_key_exists(self::PRIVATE_KEY, $data)) { + $private = &$data[self::PRIVATE_KEY]; + unset($data[self::PRIVATE_KEY]); + $visibility = ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED; + } else { + // old format + $visibility = ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED | ReflectionProperty::IS_PRIVATE; + } + + if ($data) { + $solve($object, $data); + } do { - if (!$data || !$reflection->isUserDefined()) { + if ((!$data && !$private) || !$reflection->isUserDefined()) { break; } - foreach ($data as $name => &$value) { - if (!$reflection->hasProperty($name)) { - continue; - } - $property = $reflection->getProperty($name); - if ($property->isStatic()) { - continue; + $class = strtolower($reflection->name); + + // handle private properties + if (isset($private[$class])) { + foreach ($private[$class] as $name => $value) { + if ($value && !is_scalar($value)) { + // we solve only when needed + $solve($object, $value); + } + self::setProperty($reflection, $object, $name, $value, ReflectionProperty::IS_PRIVATE); } + // done with this class + unset($private[$class]); + } - if (!$property->hasDefaultValue() || $value !== $property->getDefaultValue()) { - $property->setAccessible(true); - $property->setValue($object, $value); + foreach ($data as $name => $value) { + if (self::setProperty($reflection, $object, $name, $value, $visibility)) { + // done with this property + unset($data[$name]); } - unset($data[$name]); } } while ($reflection = $reflection->getParentClass()); if ($data) { - // dynamic + // dynamic properties foreach ($data as $name => $value) { $object->{$name} = $value; } @@ -83,4 +124,39 @@ public static function unserialize(array &$data, callable $solve, ReflectionClas return $object; } + + private static function setProperty( + \ReflectionClass $reflection, + object $object, + string $name, + mixed $value, + int $visibility + ): bool { + if (!$reflection->hasProperty($name)) { + return false; + } + + $property = $reflection->getProperty($name); + if ($property->isStatic()) { + return false; + } + + if (!($property->getModifiers() & $visibility)) { + return false; + } + + if (\PHP_MINOR_VERSION < 4) { + $property->setAccessible(true); + $property->setValue($object, $value); + return true; + } + + if ($property->isVirtual() || $property->isDynamic()) { + return false; + } + + $property->setRawValue($object, $value); + + return true; + } } \ No newline at end of file diff --git a/src/ReflectionClass.php b/src/ReflectionClass.php index 8f9682f..52f8d45 100644 --- a/src/ReflectionClass.php +++ b/src/ReflectionClass.php @@ -81,6 +81,9 @@ public function info(): ?AnonymousClassInfo return $this->_info ??= AnonymousClassParser::parse($this); } + /** + * @var self[] + */ private static array $cache = []; public static function get(string|object $class): self @@ -117,4 +120,24 @@ public static function isAnonymousClassName(string $class): bool } return str_starts_with($class, self::ANONYMOUS_CLASS_PREFIX); } + + public static function getRawProperties(object $object, array $properties, ?string $class = null): array + { + $vars = get_mangled_object_vars($object); + $class ??= get_class($object); + $prefixes = ["\0$class\0", "\0*\0", ""]; + + $data = []; + foreach ($properties as $name) { + foreach ($prefixes as $prefix) { + $prop_name = $prefix . $name; + if (array_key_exists($prop_name, $vars)) { + $data[$name] = $vars[$prop_name]; + break; + } + } + } + + return $data; + } } \ No newline at end of file diff --git a/src/functions.php b/src/functions.php index ee83661..e9c68b9 100644 --- a/src/functions.php +++ b/src/functions.php @@ -142,3 +142,27 @@ function create_closure(string $args, string $body): \Closure return $info->getClosure(); } + +/** + * Get the raw values of properties - it won't invoke property hooks + * @param object $object The object from where to extract raw properties + * @param string[] $properties Array of property names + * @param string|null $class If you need a private property from a parent use the class + * @return array Raw property values keyed by property name + */ +function get_raw_properties(object $object, array $properties, ?string $class = null): array +{ + return ReflectionClass::getRawProperties($object, $properties, $class); +} + +/** + * If you have a long-running process that deserializes closures or anonymous classes, you may want to clear cache + * to prevent high memory usage. + * @return void + */ +function clear_cache(): void +{ + AbstractInfo::clear(); + AbstractParser::clear(); + ReflectionClass::clear(); +} \ No newline at end of file diff --git a/tests/PHP80/Objects/ParentClass.php b/tests/PHP80/Objects/ParentClass.php index 085d23d..5eb347e 100644 --- a/tests/PHP80/Objects/ParentClass.php +++ b/tests/PHP80/Objects/ParentClass.php @@ -10,4 +10,9 @@ public function getFoobar(): array { return $this->foobar; } + + public function setFoobar(array $value) + { + $this->foobar = $value; + } } \ No newline at end of file diff --git a/tests/PHP84/SerializeTest.php b/tests/PHP84/SerializeTest.php new file mode 100644 index 0000000..0e75c38 --- /dev/null +++ b/tests/PHP84/SerializeTest.php @@ -0,0 +1,136 @@ + microtime(true); + } + + public string $value = "def" { + get => $this->value . "-from-getter"; + set(string $value) { + $this->value = $value; + } + } + + public function getSecret(): string + { + return $this->privateValue; + } + }; + + $v->value = "my-value"; + $this->assertEquals("my-value-from-getter", $v->value); + + // serialization + + $u = $this->process($v); + $this->assertEquals("my-value-from-getter", $u->value); + $this->assertEquals("secret", $u->getSecret()); + + // test virtual prop + $now = microtime(true); + usleep(1); + // the computed time should be in realtime (so > $now) + $this->assertGreaterThan($now, $u->computedTime); + } + + public function testHooksWithMagicSerialize() + { + $v = new class() { + public string $value = "def" { + get => $this->value . "-from-getter"; + set(string $value) { + $this->value = $value; + } + } + + public function __serialize(): array + { + // this calls hook + return [$this->value]; + } + + public function __unserialize(array $data): void + { + // this calls hook + [$this->value] = $data; + } + }; + + $v->value = "my-value"; + $this->assertEquals("my-value-from-getter", $v->value); + + // serialization + $u = $this->process($v); + $this->assertEquals("my-value-from-getter-from-getter", $u->value); + } + + public function testHooksWithMagicSerializeAndCustomRawPropertiesResolver() + { + $v = new class() { + public function __construct() + { + $this->pub = "1.pub"; + $this->prot = "2.prot"; + $this->priv = "3.priv"; + } + + public string $pub = "pub" { + get => $this->pub . "-from-getter"; + set(string $value) { + $this->pub = $value; + } + } + + protected string $prot = "prot" { + get => $this->prot . "-from-getter"; + set(string $value) { + $this->prot = $value; + } + } + + protected string $priv = "priv" { + get => $this->priv . "-from-getter"; + set(string $value) { + $this->priv = $value; + } + } + + public string $computed { + get => implode(", ", [$this->pub, $this->prot, $this->priv]); + } + + public function __serialize(): array + { + // this does NOT call hooks + return ReflectionClass::getRawProperties($this, ["pub", "prot", "priv"]); + } + + public function __unserialize(array $data): void + { + // this calls hook + $this->pub = $data["pub"]; + $this->prot = $data["prot"]; + $this->priv = $data["priv"]; + } + }; + + $this->assertEquals("1.pub-from-getter, 2.prot-from-getter, 3.priv-from-getter", $v->computed); + + // serialization + $u = $this->process($v); + $this->assertEquals("1.pub-from-getter, 2.prot-from-getter, 3.priv-from-getter", $u->computed); + } +} \ No newline at end of file