Skip to content

Commit

Permalink
Serialization support for property hooks (#153)
Browse files Browse the repository at this point in the history
* Added generic solution to better handle property hooks

* Added get_raw_properties() and clear_cache()

* Improved unserialization in PHP 8.4

* Added readme and changelog
  • Loading branch information
sorinsarca authored Jan 8, 2025
1 parent 7a32a41 commit 1a22899
Show file tree
Hide file tree
Showing 8 changed files with 316 additions and 40 deletions.
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
```
Expand Down
10 changes: 7 additions & 3 deletions src/DeserializationHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
Expand Down
148 changes: 112 additions & 36 deletions src/GenericObjectSerialization.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -50,37 +73,90 @@ 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;
}
}

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;
}
}
23 changes: 23 additions & 0 deletions src/ReflectionClass.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
24 changes: 24 additions & 0 deletions src/functions.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
5 changes: 5 additions & 0 deletions tests/PHP80/Objects/ParentClass.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,9 @@ public function getFoobar(): array
{
return $this->foobar;
}

public function setFoobar(array $value)
{
$this->foobar = $value;
}
}
Loading

0 comments on commit 1a22899

Please sign in to comment.