1: <?php declare(strict_types=1);
2:
3: namespace Salient\Core;
4:
5: use Salient\Contract\Core\Entity\Extensible;
6: use Salient\Contract\Core\Entity\Normalisable;
7: use Salient\Contract\Core\Entity\Readable;
8: use Salient\Contract\Core\Entity\Relatable;
9: use Salient\Contract\Core\Entity\Temporal;
10: use Salient\Contract\Core\Entity\Treeable;
11: use Salient\Contract\Core\Entity\Writable;
12: use Salient\Contract\Core\Provider\Providable;
13: use Salient\Contract\Core\NormaliserFlag;
14: use Salient\Utility\Arr;
15: use Salient\Utility\Reflect;
16: use Salient\Utility\Regex;
17: use Salient\Utility\Str;
18: use Closure;
19: use DateTimeInterface;
20: use ReflectionClass;
21: use ReflectionMethod;
22: use ReflectionNamedType;
23: use ReflectionProperty;
24:
25: /**
26: * Cacheable class data shared between Introspectors
27: *
28: * @template TClass of object
29: */
30: class IntrospectionClass
31: {
32: public const ACTION_GET = 'get';
33: public const ACTION_ISSET = 'isset';
34: public const ACTION_SET = 'set';
35: public const ACTION_UNSET = 'unset';
36:
37: /**
38: * The name of the class under introspection
39: *
40: * @var class-string<TClass>
41: */
42: public $Class;
43:
44: /**
45: * True if the class implements Readable
46: *
47: * @var bool
48: */
49: public $IsReadable;
50:
51: /**
52: * True if the class implements Writable
53: *
54: * @var bool
55: */
56: public $IsWritable;
57:
58: /**
59: * True if the class implements Extensible
60: *
61: * @var bool
62: */
63: public $IsExtensible;
64:
65: /**
66: * True if the class implements Providable
67: *
68: * @var bool
69: */
70: public $IsProvidable;
71:
72: /**
73: * True if the class implements Relatable
74: *
75: * @var bool
76: */
77: public $IsRelatable;
78:
79: /**
80: * True if the class implements Treeable
81: *
82: * @var bool
83: */
84: public $IsTreeable;
85:
86: /**
87: * True if the class implements Temporal
88: *
89: * @var bool
90: */
91: public $HasDates;
92:
93: /**
94: * Properties (normalised name => declared name)
95: *
96: * - `public` properties
97: * - `protected` properties if the class implements {@see Readable} or
98: * {@see Writable}
99: *
100: * @var array<string,string>
101: */
102: public $Properties = [];
103:
104: /**
105: * Public properties (normalised name => declared name)
106: *
107: * @var array<string,string>
108: */
109: public $PublicProperties = [];
110:
111: /**
112: * Readable properties (normalised name => declared name)
113: *
114: * Empty if the class does not implement {@see Readable}, otherwise:
115: * - `public` properties
116: * - `protected` properties returned by
117: * {@see Readable::getReadableProperties()}
118: *
119: * Does not include "magic" properties.
120: *
121: * @var array<string,string>
122: */
123: public $ReadableProperties = [];
124:
125: /**
126: * Writable properties (normalised name => declared name)
127: *
128: * Empty if the class does not implement {@see Writable}, otherwise:
129: * - `public` properties
130: * - `protected` properties returned by
131: * {@see Writable::getWritableProperties()}
132: *
133: * Does not include "magic" properties.
134: *
135: * @var array<string,string>
136: */
137: public $WritableProperties = [];
138:
139: /**
140: * Set if the class implements Extensible
141: */
142: public string $DynamicPropertiesProperty;
143:
144: /**
145: * Set if the class implements Extensible
146: */
147: public string $DynamicPropertyNamesProperty;
148:
149: /**
150: * Action => normalised property name => "magic" property method
151: *
152: * @var array<string,array<string,string>>
153: */
154: public $Actions = [];
155:
156: /**
157: * Constructor parameters (normalised name => declared name)
158: *
159: * @var array<string,string>
160: */
161: public $Parameters = [];
162:
163: /**
164: * Parameters that aren't nullable and don't have a default value
165: * (normalised name => declared name)
166: *
167: * @var array<string,string>
168: */
169: public $RequiredParameters = [];
170:
171: /**
172: * Parameters that aren't nullable and have a default value (normalised name
173: * => declared name)
174: *
175: * @var array<string,string>
176: */
177: public $NotNullableParameters = [];
178:
179: /**
180: * Required parameters with a declared type that can be resolved by a
181: * service container (normalised name => class/interface name)
182: *
183: * @var array<string,string>
184: */
185: public $ServiceParameters = [];
186:
187: /**
188: * Parameters to pass by reference (normalised name => declared name)
189: *
190: * @var array<string,string>
191: */
192: public $PassByRefParameters = [];
193:
194: /**
195: * Parameters with a declared type that implements DateTimeInterface
196: * (normalised name => declared name)
197: *
198: * Empty if the class does not implement {@see Temporal}.
199: *
200: * @var array<string,string>
201: */
202: public $DateParameters = [];
203:
204: /**
205: * Default values for (all) constructor parameters
206: *
207: * @var mixed[]
208: */
209: public $DefaultArguments = [];
210:
211: /**
212: * Minimum number of arguments required by the constructor
213: *
214: * @var int
215: */
216: public $RequiredArguments = 0;
217:
218: /**
219: * Constructor parameter name => index
220: *
221: * @var array<string,int>
222: */
223: public $ParameterIndex = [];
224:
225: /**
226: * Declared and "magic" properties that are both readable and writable
227: *
228: * @var string[]
229: */
230: public $SerializableProperties = [];
231:
232: /**
233: * Normalised properties (declared and "magic" property names)
234: *
235: * @var string[]
236: */
237: public $NormalisedKeys = [];
238:
239: /**
240: * The normalised parent property
241: *
242: * `null` if the class does not implement {@see Treeable} or returns an
243: * invalid pair of parent and children properties.
244: *
245: * @var string|null
246: */
247: public $ParentProperty;
248:
249: /**
250: * The normalised children property
251: *
252: * `null` if the class does not implement {@see Treeable} or returns an
253: * invalid pair of parent and children properties.
254: *
255: * @var string|null
256: */
257: public $ChildrenProperty;
258:
259: /**
260: * One-to-one relationships between the class and others (normalised
261: * property name => target class)
262: *
263: * @var array<string,class-string<Relatable>>
264: */
265: public $OneToOneRelationships = [];
266:
267: /**
268: * One-to-many relationships between the class and others (normalised
269: * property name => target class)
270: *
271: * @var array<string,class-string<Relatable>>
272: */
273: public $OneToManyRelationships = [];
274:
275: /**
276: * Normalised date properties (declared and "magic" property names)
277: *
278: * @var string[]
279: */
280: public $DateKeys = [];
281:
282: /**
283: * Normalises property names
284: *
285: * @var (Closure(string $name, bool $greedy=, string...$hints): string)|null
286: */
287: public ?Closure $Normaliser = null;
288:
289: /**
290: * Normalises property names with $greedy = false
291: *
292: * @var Closure(string): string
293: */
294: public Closure $GentleNormaliser;
295:
296: /**
297: * Normalises property names with $hints = $this->NormalisedProperties
298: *
299: * @var Closure(string): string
300: */
301: public Closure $CarefulNormaliser;
302:
303: /**
304: * Signature => closure
305: *
306: * @var array<string,Closure>
307: */
308: public $CreateFromSignatureClosures = [];
309:
310: /**
311: * Signature => (int) $strict => closure
312: *
313: * @var array<string,array<int,Closure>>
314: */
315: public $CreateProviderlessFromSignatureClosures = [];
316:
317: /**
318: * Signature => (int) $strict => closure
319: *
320: * @var array<string,array<int,Closure>>
321: */
322: public $CreateProvidableFromSignatureClosures = [];
323:
324: /**
325: * (int) $strict => closure
326: *
327: * @var array<int,Closure>
328: */
329: public $CreateProviderlessFromClosures = [];
330:
331: /**
332: * (int) $strict => closure
333: *
334: * @var array<int,Closure>
335: */
336: public $CreateProvidableFromClosures = [];
337:
338: /**
339: * Normalised property name => action => closure
340: *
341: * @var array<string,array<string,Closure>>
342: */
343: public $PropertyActionClosures = [];
344:
345: /** @var Closure|null */
346: public $GetNameClosure;
347:
348: /**
349: * Rules signature => closure
350: *
351: * @var array<string,Closure>
352: */
353: public $SerializeClosures = [];
354:
355: /** @var ReflectionClass<TClass> */
356: protected $Reflector;
357:
358: /**
359: * @param class-string<TClass> $class
360: */
361: public function __construct(string $class)
362: {
363: $class = new ReflectionClass($class);
364: $className = $class->getName();
365: $this->Reflector = $class;
366: $this->Class = $className;
367: $this->IsReadable = $class->implementsInterface(Readable::class);
368: $this->IsWritable = $class->implementsInterface(Writable::class);
369: $this->IsExtensible = $class->implementsInterface(Extensible::class);
370: $this->IsProvidable = $class->implementsInterface(Providable::class);
371: $this->IsTreeable = $class->implementsInterface(Treeable::class);
372: $this->IsRelatable = $this->IsTreeable || $class->implementsInterface(Relatable::class);
373: $this->HasDates = $class->implementsInterface(Temporal::class);
374:
375: if ($class->implementsInterface(Normalisable::class)) {
376: /** @var class-string<Normalisable> $className */
377: $this->Normaliser = static fn(string $name, bool $greedy = true, string ...$hints) =>
378: $className::normaliseProperty($name, $greedy, ...$hints);
379: $this->GentleNormaliser = fn(string $name): string => ($this->Normaliser)($name, false);
380: $this->CarefulNormaliser = fn(string $name): string => ($this->Normaliser)($name, true, ...$this->NormalisedKeys);
381: }
382:
383: $propertyFilter = ReflectionProperty::IS_PUBLIC;
384: $methodFilter = 0;
385: $reserved = [];
386:
387: // Readable and Writable provide access to protected and "magic"
388: // property methods
389: if ($this->IsReadable || $this->IsWritable) {
390: $propertyFilter |= ReflectionProperty::IS_PROTECTED;
391: $methodFilter |= ReflectionMethod::IS_PUBLIC | ReflectionMethod::IS_PROTECTED;
392: }
393:
394: if ($this->IsExtensible) {
395: /** @var class-string<Extensible> $className */
396: $reserved[] = $this->DynamicPropertiesProperty = $className::getDynamicPropertiesProperty();
397: $reserved[] = $this->DynamicPropertyNamesProperty = $className::getDynamicPropertyNamesProperty();
398: }
399:
400: $parent = $class;
401: do {
402: $parents[] = $parent->getName();
403: } while ($parent = $parent->getParentClass());
404: $parents = array_flip($parents);
405:
406: // Get instance properties
407: $properties = array_filter(
408: $class->getProperties($propertyFilter),
409: fn(ReflectionProperty $prop) => !$prop->isStatic()
410: );
411: // Sort by order of declaration, starting with the base class
412: uksort(
413: $properties,
414: function (int $a, int $b) use ($parents, $properties) {
415: $depthA = $parents[$properties[$a]->getDeclaringClass()->getName()];
416: $depthB = $parents[$properties[$b]->getDeclaringClass()->getName()];
417:
418: return $depthB <=> $depthA ?: $a <=> $b;
419: }
420: );
421: $names = Reflect::getNames($properties);
422: $this->Properties = array_diff_key(
423: Arr::combine(
424: $this->maybeNormalise($names, NormaliserFlag::LAZY),
425: $names
426: ),
427: array_flip($this->maybeNormalise($reserved, NormaliserFlag::LAZY)),
428: );
429: $this->PublicProperties =
430: $propertyFilter & ReflectionProperty::IS_PROTECTED
431: ? array_intersect(
432: $this->Properties,
433: Reflect::getNames(array_filter(
434: $properties,
435: fn(ReflectionProperty $prop) => $prop->isPublic()
436: ))
437: )
438: : $this->Properties;
439:
440: if ($this->IsReadable) {
441: /** @var class-string<Readable> $className */
442: $readable = $className::getReadableProperties();
443: $readable = array_merge(
444: ['*'] === $readable
445: ? $this->Properties
446: : $readable,
447: $this->PublicProperties
448: );
449: $this->ReadableProperties = array_intersect($this->Properties, $readable);
450: }
451:
452: if ($this->IsWritable) {
453: /** @var class-string<Writable> $className */
454: $writable = $className::getWritableProperties();
455: $writable = array_merge(
456: ['*'] === $writable
457: ? $this->Properties
458: : $writable,
459: $this->PublicProperties
460: );
461: $this->WritableProperties = array_intersect($this->Properties, $writable);
462: }
463:
464: // Get "magic" property methods, e.g. _get<Property>()
465: if ($methodFilter) {
466: /** @var ReflectionMethod[] $methods */
467: $methods = array_filter(
468: $class->getMethods($methodFilter),
469: fn(ReflectionMethod $method) => !$method->isStatic()
470: );
471: $regex = implode('|', [
472: ...($this->IsReadable ? [self::ACTION_GET, self::ACTION_ISSET] : []),
473: ...($this->IsWritable ? [self::ACTION_SET, self::ACTION_UNSET] : []),
474: ]);
475: $regex = "/^_(?<action>{$regex})(?<property>.+)\$/i";
476: foreach ($methods as $method) {
477: if (!Regex::match($regex, $name = $method->getName(), $matches)) {
478: continue;
479: }
480: $action = Str::lower($matches['action']);
481: $property = $this->maybeNormalise($matches['property'], NormaliserFlag::LAZY);
482: $this->Actions[$action][$property] = $name;
483: }
484: }
485:
486: /**
487: * @todo Create a proxy for `protected function __construct()` if the
488: * class implements a designated interface, e.g. `IInstantiable`
489: */
490:
491: // Get constructor parameters
492: if (($constructor = $class->getConstructor()) && $constructor->isPublic()) {
493: $lastRequired = -1;
494: $index = -1;
495: foreach ($constructor->getParameters() as $param) {
496: $type = $param->getType();
497: $type = $type instanceof ReflectionNamedType && !$type->isBuiltin()
498: ? $type->getName()
499: : null;
500: $normalised = $this->maybeNormalise($name = $param->getName(), NormaliserFlag::LAZY);
501: $defaultValue = null;
502: $isOptional = false;
503: if ($param->isOptional()) {
504: if ($param->isDefaultValueAvailable()) {
505: $defaultValue = $param->getDefaultValue();
506: }
507: $isOptional = true;
508: if (!$param->allowsNull()) {
509: $this->NotNullableParameters[$normalised] = $name;
510: }
511: } elseif (!$param->allowsNull()) {
512: $this->RequiredParameters[$normalised] = $name;
513: if ($type) {
514: $this->ServiceParameters[$normalised] = $type;
515: }
516: }
517: $index++;
518: if (!$isOptional) {
519: $lastRequired = $index;
520: }
521: if ($param->isPassedByReference()) {
522: $this->PassByRefParameters[$normalised] = $name;
523: }
524: if ($this->HasDates && is_a($type, DateTimeInterface::class, true)) {
525: $this->DateParameters[$normalised] = $name;
526: }
527: $this->Parameters[$normalised] = $name;
528: $this->DefaultArguments[] = $defaultValue;
529: }
530: $this->RequiredArguments = $lastRequired + 1;
531: $this->ParameterIndex = array_flip(array_values($this->Parameters));
532: }
533:
534: // Create a combined list of normalised property and method names
535: $this->NormalisedKeys = array_keys(
536: $this->Properties
537: + ($this->Actions[self::ACTION_GET] ?? [])
538: + ($this->Actions[self::ACTION_ISSET] ?? [])
539: + ($this->Actions[self::ACTION_SET] ?? [])
540: + ($this->Actions[self::ACTION_UNSET] ?? [])
541: );
542:
543: // Create a combined list of declared property and normalised method
544: // names that are both readable and writable
545: $this->SerializableProperties =
546: array_merge(
547: array_values(
548: array_intersect(
549: ($this->ReadableProperties ?: $this->PublicProperties),
550: ($this->WritableProperties ?: $this->PublicProperties),
551: )
552: ),
553: array_keys(
554: array_intersect_key(
555: ($this->Actions[self::ACTION_GET] ?? []),
556: ($this->Actions[self::ACTION_SET] ?? []),
557: )
558: )
559: );
560:
561: if ($this->IsRelatable) {
562: /** @var class-string<Relatable> $className */
563: $relationships = $className::getRelationships();
564: $relationships = Arr::combine(
565: $this->maybeNormalise(array_keys($relationships), NormaliserFlag::LAZY),
566: $relationships
567: );
568:
569: // Create self-referencing parent/child relationships between
570: // Treeable classes after identifying the class that declared
571: // getParentProperty() and getChildrenProperty(), which is most
572: // likely to be the base/service class. If not, explicit
573: // relationship declarations take precedence over these.
574: if ($this->IsTreeable) {
575: $parentMethod = $class->getMethod('getParentProperty');
576: $parentClass = $parentMethod->getDeclaringClass();
577: $childrenMethod = $class->getMethod('getChildrenProperty');
578: $childrenClass = $childrenMethod->getDeclaringClass();
579:
580: // If the methods were declared in different classes, choose the
581: // least-generic one
582: $service = $childrenClass->isSubclassOf($parentClass)
583: ? $childrenClass->getName()
584: : $parentClass->getName();
585:
586: /** @var class-string<Treeable> $className */
587: $treeable = [
588: $className::getParentProperty(),
589: $className::getChildrenProperty(),
590: ];
591:
592: $treeable = array_unique($this->maybeNormalise(
593: $treeable, NormaliserFlag::LAZY
594: ));
595:
596: // Do nothing if, after normalisation, both methods return the
597: // same value, or if the values they return don't resolve to
598: // serviceable properties
599: if (count(array_intersect($this->NormalisedKeys, $treeable)) === 2) {
600: $this->ParentProperty = $treeable[0];
601: $this->ChildrenProperty = $treeable[1];
602: $this->OneToOneRelationships[$this->ParentProperty] = $service;
603: $this->OneToManyRelationships[$this->ChildrenProperty] = $service;
604: } else {
605: $this->IsTreeable = false;
606: }
607: }
608:
609: foreach ($relationships as $property => $reference) {
610: $type = array_key_first($reference);
611: $target = $reference[$type];
612: if (!in_array($property, $this->NormalisedKeys, true)) {
613: continue;
614: }
615: if (!is_a($target, Relatable::class, true)) {
616: continue;
617: }
618: switch ($type) {
619: case Relatable::ONE_TO_ONE:
620: $this->OneToOneRelationships[$property] = $target;
621: break;
622:
623: case Relatable::ONE_TO_MANY:
624: $this->OneToManyRelationships[$property] = $target;
625: break;
626: }
627: }
628: }
629:
630: if ($this->HasDates) {
631: /** @var class-string<Temporal> $className */
632: $dates = $className::getDateProperties();
633:
634: $nativeDates = [];
635: foreach ($properties as $property) {
636: if (!$property->hasType()) {
637: continue;
638: }
639: foreach (Reflect::getTypeNames($property->getType()) as $type) {
640: if (is_a($type, DateTimeInterface::class, true)) {
641: $nativeDates[] = $property->getName();
642: continue 2;
643: }
644: }
645: }
646:
647: $this->DateKeys =
648: ['*'] === $dates
649: ? ($nativeDates
650: ? $this->maybeNormalise($nativeDates, NormaliserFlag::LAZY)
651: : $this->NormalisedKeys)
652: : array_intersect(
653: $this->NormalisedKeys,
654: $this->maybeNormalise(array_merge($dates, $nativeDates), NormaliserFlag::LAZY),
655: );
656: }
657: }
658:
659: /**
660: * Normalise strings if the class has a normaliser, otherwise return them
661: * as-is
662: *
663: * @template T of string[]|string
664: *
665: * @param T $value
666: * @param int-mask-of<NormaliserFlag::*> $flags
667: * @return T
668: *
669: * @see Normalisable::normalise()
670: */
671: final public function maybeNormalise($value, int $flags = NormaliserFlag::GREEDY)
672: {
673: if (!$this->Normaliser) {
674: return $value;
675: }
676: switch (true) {
677: case $flags & NormaliserFlag::LAZY:
678: $normaliser = $this->GentleNormaliser;
679: break;
680: case $flags & NormaliserFlag::CAREFUL:
681: $normaliser = $this->CarefulNormaliser;
682: break;
683: default:
684: $normaliser = $this->Normaliser;
685: }
686: if (is_array($value)) {
687: return array_map($normaliser, $value);
688: }
689:
690: return ($normaliser)($value);
691: }
692:
693: /**
694: * Get readable properties, including "magic" properties
695: *
696: * @return string[] Normalised property names
697: */
698: final public function getReadableProperties(): array
699: {
700: return array_keys((
701: $this->ReadableProperties
702: ?: $this->PublicProperties
703: ) + ($this->Actions[self::ACTION_GET] ?? []));
704: }
705:
706: /**
707: * Get writable properties, including "magic" properties
708: *
709: * @return string[] Normalised property names
710: */
711: final public function getWritableProperties(): array
712: {
713: return array_keys((
714: $this->WritableProperties
715: ?: $this->PublicProperties
716: ) + ($this->Actions[self::ACTION_SET] ?? []));
717: }
718:
719: /**
720: * True if an action can be performed on a property
721: *
722: * @param string $property The normalised property name to check
723: * @param IntrospectionClass::ACTION_* $action
724: */
725: final public function propertyActionIsAllowed(string $property, string $action): bool
726: {
727: switch ($action) {
728: case self::ACTION_GET:
729: case self::ACTION_ISSET:
730: return in_array(
731: $property,
732: $this->getReadableProperties()
733: );
734:
735: case self::ACTION_SET:
736: case self::ACTION_UNSET:
737: return in_array(
738: $property,
739: $this->getWritableProperties()
740: );
741: }
742:
743: return false;
744: }
745: }
746: