src/Stasis.php source

1 <?php
2
3 declare(strict_types=1);
4
5 namespace Serializor;
6
7 use Closure;
8 use ReflectionClass;
9 use ReflectionFunction;
10 use SplHeap;
11 use SplPriorityQueue;
12 use WeakMap;
13 use WeakReference;
14 use SplFixedArray;
15 use SplObjectStorage;
16
17 /**
18 * Abstract base class for serializing values that can't be natively serialized.
19 * Each subclass handles a specific type with typed properties for efficient serialization.
20 */
21 abstract class Stasis
22 {
23 /**
24 * WeakMap for caching resolved instances to preserve object identity.
25 */
26 protected static ?WeakMap $results = null;
27
28 /**
29 * @var Closure[]
30 */
31 public array $whenResolvedListeners = [];
32
33 /**
34 * Custom factories for extending Stasis with user-defined types.
35 * @var array<class-string, callable(object): ?Stasis>
36 */
37 private static array $customFactories = [];
38
39 /**
40 * Create the appropriate Stasis subclass for the given value.
41 */
42 public static function from(mixed $value): Stasis
43 {
44 // Check custom factories first
45 if (\is_object($value)) {
46 foreach (self::$customFactories as $class => $factory) {
47 if ($value instanceof $class) {
48 $result = $factory($value);
49 if ($result !== null) {
50 return $result;
51 }
52 }
53 }
54 }
55
56 // Closures
57 if ($value instanceof Closure) {
58 $rf = new ReflectionFunction($value);
59 $isAnonymous = \str_starts_with($rf->getShortName(), '{closure');
60
61 if (!$isAnonymous) {
62 // Named callable (function, static method, or instance method)
63 if ($rf->getClosureThis() !== null) {
64 return BoundMethodStasis::fromClosure($value, $rf);
65 }
66 return CallableStasis::fromClosure($value, $rf);
67 }
68 return ClosureStasis::fromClosure($value, $rf);
69 }
70
71 // WeakReference
72 if ($value instanceof WeakReference) {
73 return WeakReferenceStasis::fromWeakReference($value);
74 }
75
76 // WeakMap
77 if ($value instanceof WeakMap) {
78 return WeakMapStasis::fromWeakMap($value);
79 }
80
81 // SplObjectStorage
82 if ($value instanceof SplObjectStorage) {
83 return SplObjectStorageStasis::fromStorage($value);
84 }
85
86 // SplHeap subclasses (SplMaxHeap, SplMinHeap)
87 // Note: PHP 8.5 adds __serialize() but older versions don't have it
88 if ($value instanceof SplHeap) {
89 return SplHeapStasis::fromHeap($value);
90 }
91
92 // SplPriorityQueue
93 // Note: PHP 8.5 adds __serialize() but older versions don't have it
94 if ($value instanceof SplPriorityQueue) {
95 return SplPriorityQueueStasis::fromQueue($value);
96 }
97
98 // SplFixedArray on PHP < 8.2 (8.2+ has __serialize()/__unserialize())
99 if ($value instanceof SplFixedArray && !\method_exists($value, '__serialize')) {
100 return SplFixedArrayStasis::fromArray($value);
101 }
102
103 // Anonymous classes
104 if (\is_object($value)) {
105 $rc = new ReflectionClass($value);
106 if ($rc->isAnonymous()) {
107 return AnonymousClassStasis::fromObject($value, $rc);
108 }
109 }
110
111 // Regular objects
112 return ObjectStasis::fromObject($value);
113 }
114
115 /**
116 * Register a custom factory for handling user-defined types.
117 *
118 * @param class-string $class The class name to handle
119 * @param callable(object): ?Stasis $factory Factory that returns a Stasis or null to skip
120 */
121 public static function registerFactory(string $class, callable $factory): void
122 {
123 self::$customFactories[$class] = $factory;
124 }
125
126 /**
127 * Restore the original value from this Stasis.
128 */
129 abstract public function &getInstance(): mixed;
130
131 /**
132 * Get the class name this Stasis represents.
133 */
134 abstract public function getClassName(): string;
135
136 /**
137 * Check if this Stasis can be serialized without Box wrapper.
138 * Override in subclasses that support standalone serialization.
139 */
140 public function isSimple(): bool
141 {
142 return false;
143 }
144
145 /**
146 * Add a callback to be invoked when this Stasis is resolved.
147 */
148 public function whenResolved(Closure $listener): void
149 {
150 $this->whenResolvedListeners[] = $listener;
151 }
152
153 /**
154 * Store the resolved instance and notify listeners.
155 */
156 public function setInstance(mixed $value): void
157 {
158 self::init();
159 self::$results[$this] = [&$value];
160 foreach ($this->whenResolvedListeners as $listener) {
161 $listener($value, $this);
162 }
163 $this->whenResolvedListeners = [];
164 }
165
166 /**
167 * Check if this Stasis has already been resolved.
168 */
169 public function hasInstance(): bool
170 {
171 self::init();
172 return isset(self::$results[$this]);
173 }
174
175 /**
176 * Get the cached instance if it exists.
177 */
178 protected function &getCachedInstance(): mixed
179 {
180 self::init();
181 $a = self::$results[$this];
182 return $a[0];
183 }
184
185 protected static function init(): void
186 {
187 if (self::$results === null) {
188 self::$results = new WeakMap();
189 }
190 }
191
192 /**
193 * Get object properties including private/protected from parent classes.
194 */
195 public static function getObjectProperties(object $value): array
196 {
197 $ro = new \ReflectionObject($value);
198 $cro = $ro;
199 $result = [];
200 do {
201 $prefix = '';
202 foreach ($cro->getProperties() as $rp) {
203 if ($rp->isStatic()) {
204 continue;
205 }
206 // PHP 8.4+: Skip virtual properties (computed properties with only get hook)
207 if (\method_exists($rp, 'isVirtual') && $rp->isVirtual()) {
208 continue;
209 }
210 if ($rp->isInitialized($value)) {
211 $result[$prefix . $rp->getName()] = $rp->getValue($value);
212 }
213 }
214 $cro = $cro->getParentClass();
215 if ($cro !== false) {
216 $prefix = $cro->getName() . "\0";
217 }
218 } while ($cro !== false);
219
220 return $result;
221 }
222
223 /**
224 * Set object properties including private/protected from parent classes.
225 */
226 public static function setObjectProperties(object $value, array $properties): void
227 {
228 $ro = new \ReflectionObject($value);
229 $cro = $ro;
230 $prefix = '';
231 do {
232 \Closure::bind(function () use ($value, $cro, $properties, $prefix) {
233 foreach ($cro->getProperties() as $rp) {
234 if ($rp->isStatic()) {
235 continue;
236 }
237 // PHP 8.4+: Skip virtual properties (computed properties with only get hook)
238 if (\method_exists($rp, 'isVirtual') && $rp->isVirtual()) {
239 continue;
240 }
241 $name = $prefix . $rp->getName();
242 if (isset($properties[$name]) || array_key_exists($name, $properties)) {
243 $rp->setValue($value, $properties[$name]);
244 }
245 }
246 }, $value, $cro->getName())();
247
248 $cro = $cro->getParentClass();
249 if ($cro !== false) {
250 $prefix = $cro->getName() . "\0";
251 }
252 } while ($cro !== false);
253 }
254 }
255