1: <?php declare(strict_types=1);
2:
3: namespace Salient\Core;
4:
5: use Salient\Contract\Container\ContainerInterface;
6: use Salient\Contract\Core\Entity\Extensible;
7: use Salient\Contract\Core\Entity\Normalisable;
8: use Salient\Contract\Core\Entity\Relatable;
9: use Salient\Contract\Core\Entity\Treeable;
10: use Salient\Contract\Core\Provider\Providable;
11: use Salient\Contract\Core\Provider\ProviderContextInterface;
12: use Salient\Contract\Core\Provider\ProviderInterface;
13: use Salient\Contract\Core\DateFormatterInterface;
14: use Salient\Contract\Core\HasName;
15: use Salient\Contract\Core\NormaliserFlag;
16: use Salient\Contract\Core\SerializeRulesInterface;
17: use Salient\Utility\Arr;
18: use Salient\Utility\Get;
19: use Closure;
20: use LogicException;
21: use UnexpectedValueException;
22:
23: /**
24: * Generates closures that perform operations on a class
25: *
26: * @property-read class-string<TClass> $Class The name of the class under introspection
27: * @property-read bool $IsReadable True if the class implements Readable
28: * @property-read bool $IsWritable True if the class implements Writable
29: * @property-read bool $IsExtensible True if the class implements Extensible
30: * @property-read bool $IsProvidable True if the class implements Providable
31: * @property-read bool $IsRelatable True if the class implements Relatable
32: * @property-read bool $IsTreeable True if the class implements Treeable
33: * @property-read bool $HasDates True if the class implements Temporal
34: * @property-read array<string,string> $Properties Properties (normalised name => declared name)
35: * @property-read array<string,string> $PublicProperties Public properties (normalised name => declared name)
36: * @property-read array<string,string> $ReadableProperties Readable properties (normalised name => declared name)
37: * @property-read array<string,string> $WritableProperties Writable properties (normalised name => declared name)
38: * @property-read array<string,array<string,string>> $Actions Action => normalised property name => "magic" property method
39: * @property-read array<string,string> $Parameters Constructor parameters (normalised name => declared name)
40: * @property-read array<string,string> $RequiredParameters Parameters that aren't nullable and don't have a default value (normalised name => declared name)
41: * @property-read array<string,string> $NotNullableParameters Parameters that aren't nullable and have a default value (normalised name => declared name)
42: * @property-read array<string,string> $ServiceParameters Required parameters with a declared type that can be resolved by a service container (normalised name => class/interface name)
43: * @property-read array<string,string> $PassByRefParameters Parameters to pass by reference (normalised name => declared name)
44: * @property-read array<string,string> $DateParameters Parameters with a declared type that implements DateTimeInterface (normalised name => declared name)
45: * @property-read mixed[] $DefaultArguments Default values for (all) constructor parameters
46: * @property-read int $RequiredArguments Minimum number of arguments required by the constructor
47: * @property-read array<string,int> $ParameterIndex Constructor parameter name => index
48: * @property-read string[] $SerializableProperties Declared and "magic" properties that are both readable and writable
49: * @property-read string[] $NormalisedKeys Normalised properties (declared and "magic" property names)
50: * @property-read string|null $ParentProperty The normalised parent property
51: * @property-read string|null $ChildrenProperty The normalised children property
52: * @property-read array<string,class-string<Relatable>> $OneToOneRelationships One-to-one relationships between the class and others (normalised property name => target class)
53: * @property-read array<string,class-string<Relatable>> $OneToManyRelationships One-to-many relationships between the class and others (normalised property name => target class)
54: * @property-read string[] $DateKeys Normalised date properties (declared and "magic" property names)
55: *
56: * @method string[] getReadableProperties() Get readable properties, including "magic" properties
57: * @method string[] getWritableProperties() Get writable properties, including "magic" properties
58: * @method bool propertyActionIsAllowed(string $property, IntrospectionClass::ACTION_* $action) True if an action can be performed on a property
59: *
60: * @template TClass of object
61: * @template TProvider of ProviderInterface
62: * @template TEntity of Providable
63: * @template TContext of ProviderContextInterface
64: */
65: class Introspector
66: {
67: /** @var IntrospectionClass<TClass> */
68: protected $_Class;
69: /** @var class-string|null */
70: protected $_Service;
71: /** @var class-string<TProvider> */
72: protected $_Provider;
73: /** @var class-string<TEntity> */
74: protected $_Entity;
75: /** @var class-string<TContext> */
76: protected $_Context;
77: /** @var array<class-string,IntrospectionClass<object>> */
78: private static $_IntrospectionClasses = [];
79:
80: /**
81: * Get an introspector for a service
82: *
83: * Uses a container to resolve a service to a concrete class and returns an
84: * introspector for it.
85: *
86: * @template T of object
87: *
88: * @param class-string<T> $service
89: * @return static<T,AbstractProvider,AbstractEntity,ProviderContext<AbstractProvider,AbstractEntity>>
90: */
91: public static function getService(ContainerInterface $container, string $service)
92: {
93: return new static(
94: $service,
95: $container->getName($service),
96: AbstractProvider::class,
97: AbstractEntity::class,
98: ProviderContext::class,
99: );
100: }
101:
102: /**
103: * Get an introspector for a class
104: *
105: * @template T of object
106: *
107: * @param class-string<T> $class
108: * @return static<T,AbstractProvider,AbstractEntity,ProviderContext<AbstractProvider,AbstractEntity>>
109: */
110: public static function get(string $class)
111: {
112: return new static(
113: $class,
114: $class,
115: AbstractProvider::class,
116: AbstractEntity::class,
117: ProviderContext::class,
118: );
119: }
120:
121: /**
122: * Creates a new Introspector object
123: *
124: * @param class-string $service
125: * @param class-string<TClass> $class
126: * @param class-string<TProvider> $provider
127: * @param class-string<TEntity> $entity
128: * @param class-string<TContext> $context
129: */
130: final protected function __construct(
131: string $service,
132: string $class,
133: string $provider,
134: string $entity,
135: string $context
136: ) {
137: $this->_Class =
138: self::$_IntrospectionClasses[static::class][$class]
139: ?? (self::$_IntrospectionClasses[static::class][$class] = $this->getIntrospectionClass($class));
140: $this->_Service = $service === $class ? null : $service;
141: $this->_Provider = $provider;
142: $this->_Entity = $entity;
143: $this->_Context = $context;
144: }
145:
146: /**
147: * @param class-string<TClass> $class
148: * @return IntrospectionClass<TClass>
149: */
150: protected function getIntrospectionClass(string $class): IntrospectionClass
151: {
152: return new IntrospectionClass($class);
153: }
154:
155: /**
156: * @param mixed[] $arguments
157: * @return mixed
158: */
159: final public function __call(string $name, array $arguments)
160: {
161: return $this->_Class->{$name}(...$arguments);
162: }
163:
164: /**
165: * @return mixed
166: */
167: final public function __get(string $name)
168: {
169: return $this->_Class->{$name};
170: }
171:
172: /**
173: * Normalise strings if the class has a normaliser, otherwise return them
174: * as-is
175: *
176: * @template T of string[]|string
177: *
178: * @param T $value
179: * @param int-mask-of<NormaliserFlag::*> $flags
180: * @return T
181: *
182: * @see Normalisable::normalise()
183: */
184: final public function maybeNormalise($value, int $flags = NormaliserFlag::GREEDY)
185: {
186: return $this->_Class->maybeNormalise($value, $flags);
187: }
188:
189: /**
190: * True if the class has a normaliser
191: */
192: final public function hasNormaliser(): bool
193: {
194: return $this->_Class->Normaliser !== null;
195: }
196:
197: /**
198: * Get a closure that creates instances of the class from arrays
199: *
200: * Wraps {@see Introspector::getCreateFromSignatureClosure()} in a closure
201: * that resolves array signatures to closures on-demand.
202: *
203: * @param bool $strict If `true`, the closure will throw an exception if it
204: * receives any data that would be discarded.
205: * @return Closure(mixed[], ContainerInterface, DateFormatterInterface|null=, Treeable|null=): TClass
206: */
207: final public function getCreateFromClosure(bool $strict = false): Closure
208: {
209: $closure =
210: $this->_Class->CreateProviderlessFromClosures[(int) $strict]
211: ?? null;
212:
213: if ($closure) {
214: return $closure;
215: }
216:
217: $closure =
218: function (
219: array $array,
220: ContainerInterface $container,
221: ?DateFormatterInterface $dateFormatter = null,
222: ?Treeable $parent = null
223: ) use ($strict) {
224: $keys = array_keys($array);
225: $closure = $this->getCreateFromSignatureClosure($keys, $strict);
226: return $closure($array, $container, $dateFormatter, $parent);
227: };
228:
229: $this->_Class->CreateProviderlessFromClosures[(int) $strict] = $closure;
230:
231: return $closure;
232: }
233:
234: /**
235: * Get a closure that creates instances of the class from arrays with a
236: * given signature
237: *
238: * @param string[] $keys
239: * @param bool $strict If `true`, throw an exception if any data would be
240: * discarded.
241: * @return Closure(mixed[], ContainerInterface, DateFormatterInterface|null=, Treeable|null=): TClass
242: */
243: final public function getCreateFromSignatureClosure(array $keys, bool $strict = false): Closure
244: {
245: $sig = implode("\0", $keys);
246:
247: $closure =
248: $this->_Class->CreateProviderlessFromSignatureClosures[$sig][(int) $strict]
249: ?? null;
250:
251: if (!$closure) {
252: $closure = $this->_getCreateFromSignatureClosure($keys, $strict);
253: $this->_Class->CreateProviderlessFromSignatureClosures[$sig][(int) $strict] = $closure;
254:
255: // If the closure was created successfully in strict mode, use it
256: // for non-strict purposes too
257: if ($strict) {
258: $this->_Class->CreateProviderlessFromSignatureClosures[$sig][(int) false] = $closure;
259: }
260: }
261:
262: // Return a closure that injects this introspector's service
263: $service = $this->_Service;
264:
265: return
266: static function (
267: array $array,
268: ContainerInterface $container,
269: ?DateFormatterInterface $dateFormatter = null,
270: ?Treeable $parent = null
271: ) use ($closure, $service) {
272: return $closure(
273: $array,
274: $service,
275: $container,
276: null,
277: null,
278: $dateFormatter,
279: $parent,
280: );
281: };
282: }
283:
284: /**
285: * Get a closure that creates provider-serviced instances of the class from
286: * arrays
287: *
288: * Wraps {@see Introspector::getCreateProvidableFromSignatureClosure()} in a
289: * closure that resolves array signatures to closures on-demand.
290: *
291: * @param bool $strict If `true`, the closure will throw an exception if it
292: * receives any data that would be discarded.
293: * @return Closure(mixed[], TProvider, TContext): TClass
294: */
295: final public function getCreateProvidableFromClosure(bool $strict = false): Closure
296: {
297: $closure =
298: $this->_Class->CreateProvidableFromClosures[(int) $strict]
299: ?? null;
300:
301: if ($closure) {
302: return $closure;
303: }
304:
305: $closure =
306: function (
307: array $array,
308: ProviderInterface $provider,
309: ProviderContextInterface $context
310: ) use ($strict) {
311: $keys = array_keys($array);
312: $closure = $this->getCreateProvidableFromSignatureClosure($keys, $strict);
313: return $closure($array, $provider, $context);
314: };
315:
316: return $this->_Class->CreateProvidableFromClosures[(int) $strict] = $closure;
317: }
318:
319: /**
320: * Get a closure that creates provider-serviced instances of the class from
321: * arrays with a given signature
322: *
323: * @param string[] $keys
324: * @param bool $strict If `true`, throw an exception if any data would be
325: * discarded.
326: * @return Closure(mixed[], TProvider, TContext): TClass
327: */
328: final public function getCreateProvidableFromSignatureClosure(array $keys, bool $strict = false): Closure
329: {
330: $sig = implode("\0", $keys);
331:
332: $closure =
333: $this->_Class->CreateProvidableFromSignatureClosures[$sig][(int) $strict]
334: ?? null;
335:
336: if (!$closure) {
337: $closure = $this->_getCreateFromSignatureClosure($keys, $strict);
338: $this->_Class->CreateProvidableFromSignatureClosures[$sig][(int) $strict] = $closure;
339:
340: // If the closure was created successfully in strict mode, use it
341: // for non-strict purposes too
342: if ($strict) {
343: $this->_Class->CreateProvidableFromSignatureClosures[$sig][(int) false] = $closure;
344: }
345: }
346:
347: // Return a closure that injects this introspector's service
348: $service = $this->_Service;
349:
350: return
351: static function (
352: array $array,
353: ProviderInterface $provider,
354: ProviderContextInterface $context
355: ) use ($closure, $service) {
356: return $closure(
357: $array,
358: $service,
359: $context->getContainer(),
360: $provider,
361: $context,
362: $provider->getDateFormatter(),
363: $context->getParent(),
364: );
365: };
366: }
367:
368: /**
369: * @param string[] $keys
370: * @return Closure(mixed[], class-string|null, ContainerInterface, TProvider|null, TContext|null, DateFormatterInterface|null, Treeable|null): TClass
371: */
372: private function _getCreateFromSignatureClosure(array $keys, bool $strict = false): Closure
373: {
374: $sig = implode("\0", $keys);
375: if ($closure = $this->_Class->CreateFromSignatureClosures[$sig] ?? null) {
376: return $closure;
377: }
378:
379: $targets = $this->getKeyTargets($keys, true, $strict);
380: $constructor = $this->_getConstructor($targets);
381: $updater = $this->_getUpdater($targets);
382: $resolver = $this->_getResolver($targets);
383:
384: $closure = static function (
385: array $array,
386: ?string $service,
387: ContainerInterface $container,
388: ?ProviderInterface $provider,
389: ?ProviderContextInterface $context,
390: ?DateFormatterInterface $dateFormatter,
391: ?Treeable $parent
392: ) use ($constructor, $updater, $resolver) {
393: $obj = $constructor($array, $service, $container);
394: $obj = $updater($array, $obj, $container, $provider, $context, $dateFormatter, $parent);
395: $obj = $resolver($array, $service, $obj, $provider, $context);
396: if ($obj instanceof Providable) {
397: $obj->postLoad();
398: }
399: return $obj;
400: };
401:
402: return $this->_Class->CreateFromSignatureClosures[$sig] = $closure;
403: }
404:
405: /**
406: * Get a list of actions required to apply values from an array to a new or
407: * existing instance of the class
408: *
409: * @param string[] $keys
410: * @param bool $forNewInstance If `true`, keys are matched with constructor
411: * parameters if possible.
412: * @param bool $strict If `true`, an exception is thrown if any keys cannot
413: * be applied to the class.
414: * @param bool $normalised If `true`, the `$keys` array has already been
415: * normalised.
416: * @param array<static::*_KEY,string> $customKeys An array that maps key
417: * types to keys as they appear in `$keys`.
418: * @param array<string,Closure(mixed[] $data, string|null $service, TClass $entity, TProvider|null, TContext|null): void> $keyClosures Normalised key => closure
419: * @return IntrospectorKeyTargets<static,TClass,TProvider,TContext>
420: */
421: protected function getKeyTargets(
422: array $keys,
423: bool $forNewInstance,
424: bool $strict,
425: bool $normalised = false,
426: array $customKeys = [],
427: array $keyClosures = []
428: ): IntrospectorKeyTargets {
429: if (!$normalised) {
430: $keys = $this->_Class->Normaliser
431: ? Arr::combine(array_map($this->_Class->CarefulNormaliser, $keys), $keys)
432: : Arr::combine($keys, $keys);
433: }
434:
435: /** @var array<string,string> $keys Normalised key => original key */
436:
437: // Exclude keys with closures because they can't be passed to the
438: // constructor
439: $keys = array_diff_key($keys, $keyClosures);
440:
441: // Check for missing constructor arguments if preparing an object
442: // factory, otherwise check for readonly properties
443: if ($forNewInstance) {
444: $missing = array_diff_key(
445: $this->_Class->RequiredParameters,
446: $this->_Class->ServiceParameters,
447: $keys,
448: );
449: if ($missing) {
450: throw new LogicException(sprintf(
451: 'Cannot call %s::__construct() without: %s',
452: $this->_Class->Class,
453: implode(', ', $missing),
454: ));
455: }
456: } else {
457: // Get keys that correspond to constructor parameters and isolate
458: // any that don't also match a writable property or "magic" method
459: $parameters = array_intersect_key(
460: $this->_Class->Parameters,
461: $keys,
462: );
463: $readonly = array_diff_key(
464: $parameters,
465: array_flip($this->_Class->getWritableProperties()),
466: );
467: if ($readonly) {
468: throw new LogicException(sprintf(
469: 'Cannot set readonly properties of %s: %s',
470: $this->_Class->Class,
471: implode(', ', $readonly),
472: ));
473: }
474: }
475:
476: // Get keys that correspond to date parameters and properties
477: $dateKeys = array_values(array_intersect_key(
478: $keys,
479: array_flip($this->_Class->DateKeys) + $this->_Class->DateParameters,
480: ));
481:
482: $keys += $keyClosures;
483:
484: // Resolve `$keys` to:
485: //
486: // - constructor parameters (`$parameterKeys`, `$passByRefKeys`,
487: // `$notNullableKeys`)
488: // - callbacks (`$callbackKeys`)
489: // - "magic" property methods (`$methodKeys`)
490: // - properties (`$propertyKeys`)
491: // - arbitrary properties (`$metaKeys`)
492: foreach ($keys as $normalisedKey => $key) {
493: if ($key instanceof Closure) {
494: $callbackKeys[] = $key;
495: continue;
496: }
497:
498: if ($forNewInstance) {
499: $param = $this->_Class->Parameters[$normalisedKey] ?? null;
500: if ($param !== null) {
501: $parameterKeys[$key] = $this->_Class->ParameterIndex[$param];
502: if (isset($this->_Class->PassByRefParameters[$normalisedKey])) {
503: $passByRefKeys[$key] = true;
504: }
505: if (isset($this->_Class->NotNullableParameters[$normalisedKey])) {
506: $notNullableKeys[$key] = true;
507: }
508: continue;
509: }
510: }
511:
512: $method = $this->_Class->Actions[IntrospectionClass::ACTION_SET][$normalisedKey] ?? null;
513: if ($method !== null) {
514: $methodKeys[$key] = $method;
515: continue;
516: }
517:
518: $property = $this->_Class->Properties[$normalisedKey] ?? null;
519: if ($property !== null) {
520: /** @disregard P1006 */
521: if ($this->_Class->propertyActionIsAllowed(
522: $normalisedKey, IntrospectionClass::ACTION_SET
523: )) {
524: $propertyKeys[$key] = $property;
525: continue;
526: }
527: if ($strict) {
528: throw new LogicException(sprintf(
529: 'Cannot set unwritable property: %s::$%s',
530: $this->_Class->Class,
531: $property,
532: ));
533: }
534: continue;
535: }
536:
537: if ($this->_Class->IsExtensible) {
538: $metaKeys[] = $key;
539: continue;
540: }
541:
542: if ($strict) {
543: throw new LogicException(sprintf(
544: 'Cannot apply %s to %s',
545: $key,
546: $this->_Class->Class,
547: ));
548: }
549: }
550:
551: /** @var IntrospectorKeyTargets<static,TClass,TProvider,TContext> */
552: $targets = new IntrospectorKeyTargets(
553: $parameterKeys ?? [],
554: $passByRefKeys ?? [],
555: $notNullableKeys ?? [],
556: $callbackKeys ?? [],
557: $methodKeys ?? [],
558: $propertyKeys ?? [],
559: $metaKeys ?? [],
560: $dateKeys,
561: $customKeys,
562: );
563:
564: return $targets;
565: }
566:
567: /**
568: * @param IntrospectorKeyTargets<covariant static,TClass,TProvider,TContext> $targets
569: * @return Closure(mixed[], class-string|null, ContainerInterface): TClass
570: */
571: final protected function _getConstructor(IntrospectorKeyTargets $targets): Closure
572: {
573: $length = max(
574: $this->_Class->RequiredArguments,
575: $targets->LastParameterIndex + 1,
576: );
577:
578: $args = array_slice($this->_Class->DefaultArguments, 0, $length);
579: $class = $this->_Class->Class;
580:
581: if (!$targets->Parameters) {
582: return static function (
583: array $array,
584: ?string $service,
585: ContainerInterface $container
586: ) use ($args, $class) {
587: if ($service && strcasecmp($service, $class)) {
588: /** @var class-string $service */
589: return $container->getAs($class, $service, $args);
590: }
591: return $container->get($class, $args);
592: };
593: }
594:
595: /** @var array<string,int> Service parameter name => index */
596: $serviceArgs = array_intersect_key(
597: $this->_Class->ParameterIndex,
598: array_flip(array_intersect_key(
599: $this->_Class->Parameters,
600: $this->_Class->ServiceParameters,
601: )),
602: );
603:
604: // Reduce `$serviceArgs` to arguments in `$args`
605: $serviceArgs = array_intersect($serviceArgs, array_keys($args));
606:
607: // `null` is never applied to service parameters, so remove unmatched
608: // `$args` in service parameter positions and reduce `$serviceArgs` to
609: // matched arguments
610: $missingServiceArgs = array_diff($serviceArgs, $targets->Parameters);
611: $args = array_diff_key($args, array_flip($missingServiceArgs));
612: /** @var array<int,string> Service parameter index => `true` */
613: $serviceArgs = array_fill_keys(array_intersect(
614: $serviceArgs,
615: $targets->Parameters,
616: ), true);
617:
618: $parameterKeys = $targets->Parameters;
619: $passByRefKeys = $targets->PassByRefParameters;
620: $notNullableKeys = $targets->NotNullableParameters;
621:
622: return static function (
623: array $array,
624: ?string $service,
625: ContainerInterface $container
626: ) use (
627: $args,
628: $class,
629: $serviceArgs,
630: $parameterKeys,
631: $passByRefKeys,
632: $notNullableKeys
633: ) {
634: foreach ($parameterKeys as $key => $index) {
635: if ($array[$key] === null) {
636: if ($serviceArgs[$index] ?? false) {
637: unset($args[$index]);
638: continue;
639: }
640: if ($notNullableKeys[$key] ?? false) {
641: throw new LogicException(sprintf(
642: "Argument #%d is not nullable, cannot apply value at key '%s': %s::__construct()",
643: $index + 1,
644: $key,
645: $class,
646: ));
647: }
648: }
649: if ($passByRefKeys[$key] ?? false) {
650: $args[$index] = &$array[$key];
651: continue;
652: }
653: $args[$index] = $array[$key];
654: }
655:
656: if ($service && strcasecmp($service, $class)) {
657: /** @var class-string $service */
658: return $container->getAs($class, $service, $args);
659: }
660:
661: return $container->get($class, $args);
662: };
663: }
664:
665: /**
666: * Get a static closure to perform an action on a property of the class
667: *
668: * If `$name` and `$action` correspond to a "magic" property method (e.g.
669: * `_get<Property>()`), a closure to invoke the method is returned.
670: * Otherwise, if `$name` corresponds to an accessible declared property, or
671: * the class implements {@see Extensible}), a closure to perform the
672: * requested `$action` on the property directly is returned.
673: *
674: * Fails with an exception if {@see Extensible} is not implemented and no
675: * declared or "magic" property matches `$name` and `$action`.
676: *
677: * Closure signature:
678: *
679: * ```php
680: * static function ($instance, ...$params)
681: * ```
682: *
683: * @param string $action Either {@see IntrospectionClass::ACTION_SET},
684: * {@see IntrospectionClass::ACTION_GET},
685: * {@see IntrospectionClass::ACTION_ISSET} or
686: * {@see IntrospectionClass::ACTION_UNSET}.
687: */
688: final public function getPropertyActionClosure(string $name, string $action): Closure
689: {
690: $_name = $this->_Class->maybeNormalise($name, NormaliserFlag::CAREFUL);
691:
692: if ($closure = $this->_Class->PropertyActionClosures[$_name][$action] ?? null) {
693: return $closure;
694: }
695:
696: if (!in_array($action, [
697: IntrospectionClass::ACTION_SET,
698: IntrospectionClass::ACTION_GET,
699: IntrospectionClass::ACTION_ISSET,
700: IntrospectionClass::ACTION_UNSET
701: ])) {
702: throw new UnexpectedValueException("Invalid action: $action");
703: }
704:
705: if ($method = $this->_Class->Actions[$action][$_name] ?? null) {
706: $closure = static function ($instance, ...$params) use ($method) {
707: return $instance->$method(...$params);
708: };
709: } elseif ($property = $this->_Class->Properties[$_name] ?? null) {
710: if ($this->_Class->propertyActionIsAllowed($_name, $action)) {
711: switch ($action) {
712: case IntrospectionClass::ACTION_SET:
713: $closure = static function ($instance, $value) use ($property) { $instance->$property = $value; };
714: break;
715:
716: case IntrospectionClass::ACTION_GET:
717: $closure = static function ($instance) use ($property) { return $instance->$property; };
718: break;
719:
720: case IntrospectionClass::ACTION_ISSET:
721: $closure = static function ($instance) use ($property) { return isset($instance->$property); };
722: break;
723:
724: case IntrospectionClass::ACTION_UNSET:
725: // Removal of a declared property is unlikely to be the
726: // intended outcome, so assign null instead of unsetting
727: $closure = static function ($instance) use ($property) { $instance->$property = null; };
728: break;
729: }
730: }
731: } elseif ($this->_Class->IsExtensible) {
732: $properties = $this->_Class->DynamicPropertiesProperty;
733: $propertyNames = $this->_Class->DynamicPropertyNamesProperty;
734: switch ($action) {
735: case IntrospectionClass::ACTION_SET:
736: $closure = static function ($instance, $value) use (
737: $name,
738: $_name,
739: $properties,
740: $propertyNames
741: ) {
742: $instance->$properties[$_name] = $value;
743: $instance->$propertyNames[$_name] ??= $name;
744: };
745: break;
746:
747: case IntrospectionClass::ACTION_GET:
748: $closure = static function ($instance) use (
749: $_name,
750: $properties
751: ) {
752: return $instance->$properties[$_name] ?? null;
753: };
754: break;
755:
756: case IntrospectionClass::ACTION_ISSET:
757: $closure = static function ($instance) use (
758: $_name,
759: $properties
760: ) {
761: return isset($instance->$properties[$_name]);
762: };
763: break;
764:
765: case IntrospectionClass::ACTION_UNSET:
766: $closure = static function ($instance) use (
767: $_name,
768: $properties,
769: $propertyNames
770: ) {
771: unset(
772: $instance->$properties[$_name],
773: $instance->$propertyNames[$_name],
774: );
775: };
776: break;
777: }
778: }
779:
780: if (!$closure) {
781: throw new UnexpectedValueException("Unable to perform '$action' on property '$name'");
782: }
783:
784: $closure = $closure->bindTo(null, $this->_Class->Class);
785:
786: return $this->_Class->PropertyActionClosures[$_name][$action] = $closure;
787: }
788:
789: /**
790: * Check if a property is declared or has a "magic" property method
791: */
792: final public function hasProperty(string $name): bool
793: {
794: $_name = $this->_Class->maybeNormalise($name, NormaliserFlag::CAREFUL);
795:
796: return in_array($_name, $this->_Class->NormalisedKeys, true);
797: }
798:
799: /**
800: * Get a closure that returns the name of an instance on a best-effort basis
801: *
802: * Intended for use in default {@see HasName::getName()} implementations.
803: * Instance names are returned from properties most likely to contain them.
804: *
805: * @return Closure(TClass): string
806: */
807: final public function getGetNameClosure(): Closure
808: {
809: if ($this->_Class->GetNameClosure) {
810: return $this->_Class->GetNameClosure;
811: }
812:
813: $names = [
814: 'display_name',
815: 'displayname',
816: 'name',
817: 'full_name',
818: 'fullname',
819: 'surname',
820: 'last_name',
821: 'first_name',
822: 'title',
823: 'id',
824: ];
825:
826: $names = Arr::combine(
827: $names,
828: $this->_Class->maybeNormalise($names, NormaliserFlag::CAREFUL)
829: );
830:
831: $surname = $names['surname'];
832: $lastName = $names['last_name'];
833: $firstName = $names['first_name'];
834: $id = $names['id'];
835:
836: $names = array_intersect(
837: $names,
838: $this->_Class->getReadableProperties()
839: );
840:
841: // If surname|last_name and first_name exist, use them together,
842: // otherwise don't use either of them
843: $maybeLast = reset($names);
844: if (in_array($maybeLast, [$surname, $lastName], true)) {
845: array_shift($names);
846: $maybeFirst = reset($names);
847: if ($maybeFirst === $firstName) {
848: $last = $this->getPropertyActionClosure(
849: $maybeLast,
850: IntrospectionClass::ACTION_GET
851: );
852: $first = $this->getPropertyActionClosure(
853: $maybeFirst,
854: IntrospectionClass::ACTION_GET
855: );
856:
857: return $this->_Class->GetNameClosure =
858: static function (
859: $instance
860: ) use ($first, $last): string {
861: return Arr::implode(' ', [
862: $first($instance),
863: $last($instance),
864: ], '');
865: };
866: }
867: }
868: unset($names['last_name']);
869: unset($names['first_name']);
870:
871: if (!$names) {
872: $name = Get::basename($this->_Class->Class);
873: $name = "<$name>";
874: return $this->_Class->GetNameClosure =
875: static function () use ($name): string {
876: return $name;
877: };
878: }
879:
880: $name = array_shift($names);
881: $closure = $this->getPropertyActionClosure(
882: $name,
883: IntrospectionClass::ACTION_GET
884: );
885:
886: return $this->_Class->GetNameClosure =
887: $name === $id
888: ? static function ($instance) use ($closure): string {
889: return '#' . $closure($instance);
890: }
891: : static function ($instance) use ($closure): string {
892: return (string) $closure($instance);
893: };
894: }
895:
896: /**
897: * @param SerializeRulesInterface<TClass>|null $rules
898: */
899: final public function getSerializeClosure(?SerializeRulesInterface $rules = null): Closure
900: {
901: $rules = $rules
902: ? [$rules->getSortByKey(), $this->_Class->IsExtensible && $rules->getIncludeMeta()]
903: : [false, $this->_Class->IsExtensible];
904: $key = implode("\0", $rules);
905:
906: if ($closure = $this->_Class->SerializeClosures[$key] ?? null) {
907: return $closure;
908: }
909:
910: [$sort, $includeMeta] = $rules;
911: $methods = $this->_Class->Actions[IntrospectionClass::ACTION_GET] ?? [];
912: $props = array_intersect(
913: $this->_Class->Properties,
914: $this->_Class->ReadableProperties ?: $this->_Class->PublicProperties
915: );
916: $keys = array_keys($props + $methods);
917: if ($sort) {
918: sort($keys);
919: }
920:
921: // Iterators aren't serializable, so they're converted to arrays
922: $resolveIterator = function (&$value): void {
923: if (is_iterable($value) && !is_array($value)) {
924: $value = iterator_to_array($value);
925: }
926: };
927: $closure = (static function ($instance) use ($keys, $methods, $props, $resolveIterator) {
928: $arr = [];
929: foreach ($keys as $key) {
930: if ($method = $methods[$key] ?? null) {
931: $arr[$key] = $instance->{$method}();
932: $resolveIterator($arr[$key]);
933: } else {
934: $resolveIterator($instance->{$props[$key]});
935: $arr[$key] = $instance->{$props[$key]};
936: }
937: }
938:
939: return $arr;
940: })->bindTo(null, $this->_Class->Class);
941:
942: if ($includeMeta) {
943: $closure = static function (Extensible $instance) use ($closure) {
944: $meta = $instance->getDynamicProperties();
945:
946: return ($meta ? ['@meta' => $meta] : []) + $closure($instance);
947: };
948: }
949:
950: return $this->_Class->SerializeClosures[$key] = $closure;
951: }
952:
953: /**
954: * @param IntrospectorKeyTargets<covariant static,TClass,TProvider,TContext> $targets
955: * @return Closure(mixed[], TClass, ContainerInterface, TProvider|null, TContext|null, DateFormatterInterface|null, Treeable|null): TClass
956: */
957: final protected function _getUpdater(IntrospectorKeyTargets $targets): Closure
958: {
959: $isProvidable = $this->_Class->IsProvidable;
960: $isTreeable = $this->_Class->IsTreeable;
961: $methodKeys = $targets->Methods;
962: $propertyKeys = $targets->Properties;
963: $metaKeys = $targets->MetaProperties;
964: $dateKeys = $targets->DateProperties;
965:
966: $closure = static function (
967: array $array,
968: $obj,
969: ContainerInterface $container,
970: ?ProviderInterface $provider,
971: ?ProviderContextInterface $context,
972: ?DateFormatterInterface $dateFormatter,
973: ?Treeable $parent
974: ) use (
975: $isProvidable,
976: $isTreeable,
977: $methodKeys,
978: $propertyKeys,
979: $metaKeys,
980: $dateKeys
981: ) {
982: if ($dateKeys) {
983: if ($dateFormatter === null) {
984: $dateFormatter =
985: $provider
986: ? $provider->getDateFormatter()
987: : $container->get(DateFormatter::class);
988: }
989:
990: foreach ($dateKeys as $key) {
991: if (!is_string($array[$key])) {
992: continue;
993: }
994: if ($date = $dateFormatter->parse($array[$key])) {
995: $array[$key] = $date;
996: }
997: }
998: }
999:
1000: // The closure is bound to the class for access to protected
1001: // properties
1002: if ($propertyKeys) {
1003: foreach ($propertyKeys as $key => $property) {
1004: $obj->$property = $array[$key];
1005: }
1006: }
1007:
1008: // Call `setProvider()` and `setContext()` early in case property
1009: // methods need them
1010: if ($isProvidable && $provider) {
1011: if (!$context) {
1012: throw new UnexpectedValueException('$context cannot be null when $provider is not null');
1013: }
1014: /** @var TClass&TEntity $obj */
1015: $currentProvider = $obj->getProvider();
1016: if ($currentProvider === null) {
1017: $obj = $obj->setProvider($provider);
1018: } elseif ($currentProvider !== $provider) {
1019: throw new LogicException(sprintf(
1020: '%s has wrong provider (%s expected): %s',
1021: get_class($obj),
1022: $provider->getName(),
1023: $currentProvider->getName(),
1024: ));
1025: }
1026: $obj = $obj->setContext($context);
1027: }
1028:
1029: // Ditto for `setParent()`
1030: if ($isTreeable && $parent) {
1031: /** @var TClass&TEntity&Treeable $obj */
1032: $obj = $obj->setParent($parent);
1033: }
1034:
1035: // The closure is bound to the class for access to protected methods
1036: if ($methodKeys) {
1037: foreach ($methodKeys as $key => $method) {
1038: $obj->$method($array[$key]);
1039: }
1040: }
1041:
1042: if ($metaKeys) {
1043: foreach ($metaKeys as $key) {
1044: /** @var TClass&TEntity&Extensible $obj */
1045: $obj->__set((string) $key, $array[$key]);
1046: }
1047: }
1048:
1049: return $obj;
1050: };
1051:
1052: return $closure->bindTo(null, $this->_Class->Class);
1053: }
1054:
1055: /**
1056: * @param IntrospectorKeyTargets<covariant static,TClass,TProvider,TContext> $targets
1057: * @return Closure(mixed[], string|null, TClass, TProvider|null, TContext|null): TClass
1058: */
1059: final protected function _getResolver(IntrospectorKeyTargets $targets): Closure
1060: {
1061: $callbackKeys = $targets->Callbacks;
1062:
1063: $closure = static function (
1064: array $array,
1065: ?string $service,
1066: $obj,
1067: ?ProviderInterface $provider,
1068: ?ProviderContextInterface $context
1069: ) use ($callbackKeys) {
1070: if ($callbackKeys) {
1071: foreach ($callbackKeys as $callback) {
1072: $callback($array, $service, $obj, $provider, $context);
1073: }
1074: }
1075:
1076: return $obj;
1077: };
1078:
1079: return $closure->bindTo(null, $this->_Class->Class);
1080: }
1081: }
1082: