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