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