src/ObjectStasis.php source

1 <?php
2
3 declare(strict_types=1);
4
5 namespace Serializor;
6
7 use Closure;
8
9 /**
10 * Stasis for regular objects that need custom serialization handling.
11 */
12 final class ObjectStasis extends Stasis
13 {
14 /**
15 * The class name.
16 * @var class-string
17 */
18 private string $c;
19
20 /**
21 * The serialized class members.
22 */
23 public array $p = [];
24
25 /**
26 * @param class-string $className
27 */
28 public function __construct(string $className)
29 {
30 $this->c = $className;
31 }
32
33 public function __serialize(): array
34 {
35 return [$this->c, $this->p];
36 }
37
38 public function __unserialize(array $data): void
39 {
40 [$this->c, $this->p] = $data;
41 }
42
43 public function getClassName(): string
44 {
45 return $this->c;
46 }
47
48 public static function fromObject(object $source): ObjectStasis
49 {
50 $className = \get_class($source);
51 \assert($className !== \Closure::class, "Can't serialize Closure via ObjectStasis::fromObject()");
52
53 $rc = Reflect::getReflectionClass($className);
54 \assert(!$rc->isAnonymous(), "Can't serialize anonymous classes via ObjectStasis::fromObject()");
55
56 $frozen = new ObjectStasis($className);
57
58 if (\method_exists($source, '__serialize')) {
59 $frozen->p = $source->__serialize();
60 } else {
61 $rps = Reflect::getReflectionProperties($className);
62 foreach ($rps as $name => $rp) {
63 if ($rp->isStatic() || !$rp->isInitialized($source)) {
64 continue;
65 }
66 // PHP 8.4+: Skip virtual properties (computed properties with only get hook)
67 if (\method_exists($rp, 'isVirtual') && $rp->isVirtual()) {
68 continue;
69 }
70 $frozen->p[$name] = $rp->getValue($source);
71 }
72 $objectVars = \get_object_vars($source);
73 foreach ($objectVars as $name => $v) {
74 if (!\array_key_exists($name, $frozen->p)) {
75 $frozen->p[$name] = &$objectVars[$name];
76 }
77 }
78 }
79
80 return $frozen;
81 }
82
83 public function &getInstance(): mixed
84 {
85 if ($this->hasInstance()) {
86 return $this->getCachedInstance();
87 }
88 $rc = new \ReflectionClass($this->c);
89 $newInstance = $rc->newInstanceWithoutConstructor();
90
91 if (\method_exists($newInstance, '__unserialize')) {
92 // Some internal classes (like ArrayObject) don't accept references in their data.
93 // Create a dereferenced copy to avoid PHP's internal reference markers.
94 $newInstance->__unserialize($this->deref($this->p));
95 $this->setInstance($newInstance);
96
97 return $newInstance;
98 }
99
100 if ($rc->isInternal()) {
101 foreach ($this->p as $k => $v) {
102 $newInstance->$k = &$this->p[$k];
103 }
104 $this->setInstance($newInstance);
105
106 return $newInstance;
107 }
108
109 $properties = $this->p;
110 $propertiesToSet = [];
111 foreach (Reflect::getReflectionProperties($this->c) as $name => $rp) {
112 $parts = \explode("\0", $name, 2);
113 if (isset($parts[1])) {
114 $propertiesToSet[$parts[0]][$parts[1]] = $rp;
115 } else {
116 $propertiesToSet[$this->c][$name] = $rp;
117 }
118 }
119 $deferred = [];
120 foreach ($propertiesToSet as $className => $props) {
121 if ($className === $this->c) {
122 $prefix = '';
123 } else {
124 $prefix = $className . "\0";
125 }
126 $self = &$this;
127 \Closure::bind(function () use ($props, $properties, $prefix, &$deferred, $self) {
128 foreach ($props as $name => $rp) {
129 if ($rp->isStatic()) {
130 continue;
131 }
132 // PHP 8.4+: Skip virtual properties (computed properties with only get hook)
133 if (\method_exists($rp, 'isVirtual') && $rp->isVirtual()) {
134 continue;
135 }
136 if (!isset($properties[$name]) && !\array_key_exists($name, $properties)) {
137 continue;
138 }
139 $name = $prefix . $rp->getName();
140 if ($properties[$name] instanceof Stasis) {
141 if ($properties[$name]->hasInstance()) {
142 $rp->setValue($this, $properties[$name]->getInstance());
143 } else {
144 $properties[$name]->whenResolved(function ($instance) use ($rp, $properties, $name) {
145 $rp->setValue($this, $instance);
146 });
147 }
148 } else {
149 $rp->setValue($this, $properties[$name]);
150 }
151 }
152 }, $newInstance, $className)();
153 }
154
155 // Restore dynamic properties (not in reflection but in serialized data)
156 $reflectedProps = Reflect::getReflectionProperties($this->c);
157 foreach ($this->p as $name => $value) {
158 // Skip if it's a reflected property (already handled above)
159 if (isset($reflectedProps[$name])) {
160 continue;
161 }
162 // Skip prefixed properties (parent class private properties)
163 if (\str_contains($name, "\0")) {
164 continue;
165 }
166 // Set dynamic property
167 if ($value instanceof Stasis) {
168 if ($value->hasInstance()) {
169 $newInstance->$name = $value->getInstance();
170 } else {
171 $value->whenResolved(function ($instance) use ($newInstance, $name) {
172 $newInstance->$name = $instance;
173 });
174 }
175 } else {
176 $newInstance->$name = $value;
177 }
178 }
179
180 $this->setInstance($newInstance);
181
182 while (!empty($deferred)) {
183 $c = array_shift($deferred);
184 if (!$c()) {
185 $deferred[] = $c;
186 }
187 }
188
189 return $newInstance;
190 }
191
192 /**
193 * Create a copy of an array with all PHP references removed.
194 * This is necessary for internal classes like ArrayObject that don't accept references.
195 */
196 private function deref(array $arr): array
197 {
198 $result = [];
199 foreach ($arr as $k => $v) {
200 $result[$k] = \is_array($v) ? $this->deref($v) : $v;
201 }
202 return $result;
203 }
204 }
205