1: <?php declare(strict_types=1);
2:
3: namespace Salient\Core\Reflection;
4:
5: use Salient\Contract\Core\Entity\Extensible;
6: use Salient\Contract\Core\Entity\Normalisable;
7: use Salient\Contract\Core\Entity\Providable;
8: use Salient\Contract\Core\Entity\Readable;
9: use Salient\Contract\Core\Entity\Relatable;
10: use Salient\Contract\Core\Entity\Temporal;
11: use Salient\Contract\Core\Entity\Treeable;
12: use Salient\Contract\Core\Entity\Writable;
13: use Salient\Contract\Core\Hierarchical;
14: use Salient\Utility\Reflect;
15: use Salient\Utility\Regex;
16: use Salient\Utility\Str;
17: use Closure;
18: use DateTimeImmutable;
19: use DateTimeInterface;
20: use ReflectionClass;
21: use ReflectionException;
22: use ReflectionNamedType;
23: use ReflectionProperty;
24:
25: /**
26: * @api
27: *
28: * @template T of object
29: *
30: * @extends ReflectionClass<T>
31: */
32: class ClassReflection extends ReflectionClass
33: {
34: private ?bool $HasNormaliser = null;
35: /** @var Closure(string $name, bool $fromData=): string */
36: private Closure $Normaliser;
37: /** @var list<string> */
38: private array $DeclaredNames;
39: /** @var array<string,true> */
40: private array $ReservedNames;
41:
42: /**
43: * @inheritDoc
44: */
45: public function getConstructor(): ?MethodReflection
46: {
47: $method = parent::getConstructor();
48: return $method
49: ? new MethodReflection($this->name, $method->name)
50: : null;
51: }
52:
53: /**
54: * @inheritDoc
55: */
56: public function getMethod($name): MethodReflection
57: {
58: return new MethodReflection($this->name, $name);
59: }
60:
61: /**
62: * @return MethodReflection[]
63: */
64: public function getMethods($filter = null): array
65: {
66: foreach (parent::getMethods($filter) as $method) {
67: $methods[] = new MethodReflection($this->name, $method->name);
68: }
69: return $methods ?? [];
70: }
71:
72: /**
73: * Check if the class is hierarchical
74: */
75: public function isHierarchical(): bool
76: {
77: return $this->implementsInterface(Hierarchical::class);
78: }
79:
80: /**
81: * Check if the class has readable properties
82: */
83: public function isReadable(): bool
84: {
85: return $this->implementsInterface(Readable::class);
86: }
87:
88: /**
89: * Check if the class has writable properties
90: */
91: public function isWritable(): bool
92: {
93: return $this->implementsInterface(Writable::class);
94: }
95:
96: /**
97: * Check if the class has dynamic properties
98: *
99: * @phpstan-assert-if-true !null $this->getDynamicPropertiesProperty()
100: * @phpstan-assert-if-true !null $this->getDynamicPropertyNamesProperty()
101: */
102: public function isExtensible(): bool
103: {
104: return $this->implementsInterface(Extensible::class);
105: }
106:
107: /**
108: * Check if the class can normalise property names
109: *
110: * @phpstan-assert-if-true !null $this->getNormaliser()
111: */
112: public function isNormalisable(): bool
113: {
114: return $this->implementsInterface(Normalisable::class);
115: }
116:
117: /**
118: * Check if the class can be serviced by a provider
119: */
120: public function isProvidable(): bool
121: {
122: return $this->implementsInterface(Providable::class);
123: }
124:
125: /**
126: * Check if the class has relationships
127: */
128: public function isRelatable(): bool
129: {
130: return $this->implementsInterface(Relatable::class);
131: }
132:
133: /**
134: * Check if the class has parent/children properties
135: *
136: * @phpstan-assert-if-true true $this->isHierarchical()
137: * @phpstan-assert-if-true true $this->isRelatable()
138: * @phpstan-assert-if-true !null $this->getParentProperty()
139: * @phpstan-assert-if-true !null $this->getChildrenProperty()
140: */
141: public function isTreeable(): bool
142: {
143: return $this->implementsInterface(Treeable::class);
144: }
145:
146: /**
147: * Check if the class has date properties
148: */
149: public function isTemporal(): bool
150: {
151: return $this->implementsInterface(Temporal::class);
152: }
153:
154: /**
155: * Get the property that stores dynamic properties, or null if the class
156: * does not have dynamic properties
157: */
158: public function getDynamicPropertiesProperty(): ?string
159: {
160: /** @var string|null */
161: return $this->isExtensible()
162: ? $this->getMethod('getDynamicPropertiesProperty')->invoke(null)
163: : null;
164: }
165:
166: /**
167: * Get the property that stores dynamic property names, or null if the class
168: * does not have dynamic properties
169: */
170: public function getDynamicPropertyNamesProperty(): ?string
171: {
172: /** @var string|null */
173: return $this->isExtensible()
174: ? $this->getMethod('getDynamicPropertyNamesProperty')->invoke(null)
175: : null;
176: }
177:
178: /**
179: * Get the property that links children to a parent of the same type, or
180: * null if the class does not have parent/children properties
181: */
182: public function getParentProperty(): ?string
183: {
184: /** @var string|null */
185: return $this->isTreeable()
186: ? $this->getMethod('getParentProperty')->invoke(null)
187: : null;
188: }
189:
190: /**
191: * Get the property that links a parent to children of the same type, or
192: * null if the class does not have parent/children properties
193: */
194: public function getChildrenProperty(): ?string
195: {
196: /** @var string|null */
197: return $this->isTreeable()
198: ? $this->getMethod('getChildrenProperty')->invoke(null)
199: : null;
200: }
201:
202: /**
203: * Normalise the given property name or names
204: *
205: * If the class doesn't implement {@see Normalisable}, `$name` is returned
206: * unchanged.
207: *
208: * @param string[]|string $name
209: * @return ($name is string[] ? string[] : string)
210: */
211: public function normalise($name, bool $fromData = true)
212: {
213: $normaliser = $this->HasNormaliser
214: ? $this->Normaliser
215: : ($this->HasNormaliser === false
216: ? null
217: : $this->getNormaliser());
218:
219: if (!$normaliser) {
220: return $name;
221: }
222: if (is_string($name)) {
223: return $normaliser($name, $fromData);
224: }
225: foreach ($name as $key => $name) {
226: $result[$key] = $normaliser($name, $fromData);
227: }
228: return $result ?? [];
229: }
230:
231: /**
232: * Get a closure that normalises property names, or null if the class does
233: * not normalise property names
234: *
235: * @return (Closure(string $name, bool $fromData=): string)|null
236: */
237: public function getNormaliser(): ?Closure
238: {
239: if ($this->HasNormaliser) {
240: return $this->Normaliser;
241: }
242: if ($this->HasNormaliser === false) {
243: return null;
244: }
245: if (!$this->isNormalisable()) {
246: $this->HasNormaliser = false;
247: return null;
248: }
249: $closure = $this->getMethod('normaliseProperty')->getClosure(null);
250: $this->Normaliser = fn(string $name, bool $fromData = true) =>
251: $fromData
252: ? $closure($name, true, ...($this->DeclaredNames ?? $this->getDeclaredNames()))
253: : $closure($name, false);
254: $this->HasNormaliser = true;
255: return $this->Normaliser;
256: }
257:
258: /**
259: * Get normalised names for the declared and "magic" properties of the class
260: * that are readable or writable
261: *
262: * @return list<string>
263: */
264: public function getDeclaredNames(): array
265: {
266: return $this->DeclaredNames ??= array_keys(
267: $this->getReadableProperties()
268: + $this->getWritableProperties()
269: + $this->getActionProperties()
270: );
271: }
272:
273: /**
274: * Get normalised names for the declared and "magic" properties of the class
275: * that are both readable and writable
276: *
277: * @return list<string>
278: */
279: public function getSerializableNames(): array
280: {
281: return array_keys(
282: array_intersect_key(
283: $this->getReadableProperties() + $this->getActionProperties('get'),
284: $this->getWritableProperties() + $this->getActionProperties('set'),
285: )
286: );
287: }
288:
289: /**
290: * Get normalised names for the declared and "magic" properties of the class
291: * that are writable
292: *
293: * @return list<string>
294: */
295: public function getWritableNames(): array
296: {
297: return array_keys(
298: $this->getWritableProperties() + $this->getActionProperties('set')
299: );
300: }
301:
302: /**
303: * Get an array that maps normalised names to declared names for accessible
304: * properties
305: *
306: * Returns names of public properties and any protected properties covered
307: * by {@see Readable::getReadableProperties()} or
308: * {@see Writable::getWritableProperties()}, if applicable.
309: *
310: * @return array<string,string>
311: */
312: public function getAccessiblePropertyNames(): array
313: {
314: return Reflect::getNames($this->getAccessibleProperties());
315: }
316:
317: /**
318: * Get an array that maps normalised names to declared names for readable
319: * properties
320: *
321: * Returns names of public properties and any protected properties covered
322: * by {@see Readable::getReadableProperties()}, if applicable.
323: *
324: * @return array<string,string>
325: */
326: public function getReadablePropertyNames(): array
327: {
328: return Reflect::getNames($this->getReadableProperties());
329: }
330:
331: /**
332: * Get an array that maps normalised names to declared names for writable
333: * properties
334: *
335: * Returns names of public properties and any protected properties covered
336: * by {@see Writable::getWritableProperties()}, if applicable.
337: *
338: * @return array<string,string>
339: */
340: public function getWritablePropertyNames(): array
341: {
342: return Reflect::getNames($this->getWritableProperties());
343: }
344:
345: /**
346: * Get accessible properties, indexed by normalised name
347: *
348: * Returns public properties and any protected properties covered by
349: * {@see Readable::getReadableProperties()} or
350: * {@see Writable::getWritableProperties()}, if applicable.
351: *
352: * @return array<string,ReflectionProperty>
353: */
354: public function getAccessibleProperties(): array
355: {
356: return $this->filterProperties($this->isReadable(), 'getReadableProperties')
357: + $this->filterProperties($this->isWritable(), 'getWritableProperties');
358: }
359:
360: /**
361: * Get readable properties, indexed by normalised name
362: *
363: * Returns public properties and any protected properties covered by
364: * {@see Readable::getReadableProperties()}, if applicable.
365: *
366: * @return array<string,ReflectionProperty>
367: */
368: public function getReadableProperties(): array
369: {
370: return $this->filterProperties($this->isReadable(), 'getReadableProperties');
371: }
372:
373: /**
374: * Get writable properties, indexed by normalised name
375: *
376: * Returns public properties and any protected properties covered by
377: * {@see Writable::getWritableProperties()}, if applicable.
378: *
379: * @return array<string,ReflectionProperty>
380: */
381: public function getWritableProperties(): array
382: {
383: return $this->filterProperties($this->isWritable(), 'getWritableProperties');
384: }
385:
386: /**
387: * @return array<string,ReflectionProperty>
388: */
389: private function filterProperties(bool $protected, string $listMethod): array
390: {
391: $filter = ReflectionProperty::IS_PUBLIC;
392: if ($protected) {
393: $filter |= ReflectionProperty::IS_PROTECTED;
394: /** @var string[] */
395: $list = $this->getMethod($listMethod)->invoke(null);
396: }
397: $reserved = $this->ReservedNames ??= $this->getReservedNames();
398: $normaliser = $this->getNormaliser();
399: foreach ($this->getProperties($filter) as $property) {
400: if (!$property->isStatic() && (
401: !$protected
402: || $list === ['*']
403: || $property->isPublic()
404: || in_array($property->name, $list, true)
405: )) {
406: $name = $normaliser
407: ? $normaliser($property->name, false)
408: : $property->name;
409: if (isset($properties[$name])) {
410: throw new ReflectionException(sprintf(
411: "Too many '%s' properties: %s",
412: $name,
413: $this->name,
414: ));
415: }
416: $properties[$name] = ($reserved[$name] ?? null)
417: ? false
418: : $property;
419: }
420: }
421: return array_filter($properties ?? []);
422: }
423:
424: /**
425: * Get "magic" properties, indexed by action and normalised property name
426: *
427: * @return array<"get"|"isset"|"set"|"unset",array<string,MethodReflection>>
428: */
429: public function getPropertyActions(): array
430: {
431: $regex = [];
432: if ($this->isReadable()) {
433: $regex[] = 'get';
434: $regex[] = 'isset';
435: }
436: if ($this->isWritable()) {
437: $regex[] = 'set';
438: $regex[] = 'unset';
439: }
440: if (!$regex) {
441: return [];
442: }
443: $regex = '/^_(?<action>' . implode('|', $regex) . ')(?<property>.+)$/i';
444: $filter = MethodReflection::IS_PUBLIC | MethodReflection::IS_PROTECTED;
445: $reserved = $this->ReservedNames ??= $this->getReservedNames();
446: $normaliser = $this->getNormaliser();
447: foreach ($this->getMethods($filter) as $method) {
448: if (
449: !$method->isStatic()
450: && Regex::match($regex, $method->name, $matches)
451: ) {
452: $property = $normaliser
453: ? $normaliser($matches['property'], false)
454: : $matches['property'];
455: if ($reserved[$property] ?? null) {
456: throw new ReflectionException(sprintf(
457: "Reserved property '%s' cannot be serviced by %s::%s()",
458: $property,
459: $this->name,
460: $method->name,
461: ));
462: }
463: /** @var "get"|"isset"|"set"|"unset" */
464: $action = Str::lower($matches['action']);
465: if (isset($actions[$action][$property])) {
466: throw new ReflectionException(sprintf(
467: "Too many methods for '%s' action on %s property '%s'",
468: $action,
469: $this->name,
470: $property,
471: ));
472: }
473: $actions[$action][$property] = $method;
474: }
475: }
476: return $actions ?? [];
477: }
478:
479: /**
480: * Get "magic" properties, indexed by normalised property name and action
481: *
482: * If no actions are given, properties are returned for all actions.
483: *
484: * @param "get"|"isset"|"set"|"unset" ...$action
485: * @return array<string,array<"get"|"isset"|"set"|"unset",MethodReflection>>
486: */
487: public function getActionProperties(string ...$action): array
488: {
489: $actions = $this->getPropertyActions();
490: if ($action) {
491: $actions = array_intersect_key($actions, array_fill_keys($action, true));
492: }
493: foreach ($actions as $action => $methods) {
494: foreach ($methods as $property => $method) {
495: $properties[$property][$action] = $method;
496: }
497: }
498: return $properties ?? [];
499: }
500:
501: /**
502: * Get normalised names for the declared and "magic" properties of the class
503: * that accept date values
504: *
505: * @return list<string>
506: */
507: public function getDateNames(): array
508: {
509: /** @var string[] */
510: $properties = $this->isTemporal()
511: ? $this->getMethod('getDateProperties')->invoke(null)
512: : [];
513: $readable = $this->getReadableProperties();
514: $writable = $this->getWritableProperties();
515: $readOnly = array_diff_key($readable, $writable);
516: $names = [];
517: foreach ($readable + $writable as $name => $property) {
518: if (
519: ($type = $property->getType())
520: && $type instanceof ReflectionNamedType
521: // Allow `DateTimeInterface` out, require `DateTimeImmutable` in
522: && ((
523: ($isReadOnly = isset($readOnly[$name]))
524: && is_a($type->getName(), DateTimeInterface::class, true)
525: ) || (
526: !$isReadOnly && (
527: !strcmp($typeName = $type->getName(), DateTimeImmutable::class)
528: || is_a(DateTimeImmutable::class, $typeName, true)
529: )
530: ))
531: ) {
532: $names[$name] = $property->name;
533: }
534: }
535: // Remove native date properties from `$properties` and normalise the
536: // rest so they can be matched with "magic" properties
537: if (
538: $properties
539: && $properties !== ['*']
540: && ($properties = array_diff($properties, $names))
541: ) {
542: $properties = $this->normalise($properties, false);
543: }
544: foreach ($this->getActionProperties('get', 'set') as $name => $actions) {
545: // Allow `DateTimeInterface` out, require `DateTimeImmutable` in
546: if ((
547: !isset($actions['get'])
548: || $actions['get']->returns(DateTimeInterface::class)
549: ) && (
550: !isset($actions['set'])
551: || $actions['set']->accepts(DateTimeImmutable::class)
552: )) {
553: $names[$name] = $name;
554: }
555: }
556: if ($names) {
557: $names = array_keys($names);
558: }
559: if ($properties === ['*']) {
560: return $names
561: ? $names
562: : $this->getDeclaredNames();
563: }
564: if (
565: $properties
566: && ($properties = array_diff($properties, $names))
567: && ($properties = array_intersect($properties, $this->getDeclaredNames()))
568: ) {
569: return array_merge($names, array_values($properties));
570: }
571: return $names;
572: }
573:
574: /**
575: * Get property relationships, indexed by normalised property name
576: *
577: * @return array<string,PropertyRelationship>
578: */
579: public function getPropertyRelationships(): array
580: {
581: if (!$this->isRelatable()) {
582: return [];
583: }
584: $normaliser = $this->getNormaliser();
585: $declared = array_fill_keys($this->getDeclaredNames(), true);
586: // Create self-referencing parent/child relationships for `Treeable`
587: // classes by targeting the least-generic parent that declared
588: // `getParentProperty()` or `getChildrenProperty()`
589: if ($this->isTreeable()) {
590: $class = $this->getMethod('getParentProperty')->getDeclaringClass();
591: $class2 = $this->getMethod('getChildrenProperty')->getDeclaringClass();
592: if ($class2->isSubclassOf($class)) {
593: $class = $class2;
594: }
595: if (!$class->implementsInterface(Treeable::class)) {
596: $class = $this;
597: while (
598: ($parent = $class->getParentClass())
599: && $parent->implementsInterface(Treeable::class)
600: ) {
601: $class = $parent;
602: }
603: }
604: /** @var class-string<Treeable> */
605: $target = $class->name;
606: foreach ([
607: [$this->getParentProperty(), Relatable::ONE_TO_ONE],
608: [$this->getChildrenProperty(), Relatable::ONE_TO_MANY],
609: ] as [$property, $type]) {
610: $name = $normaliser
611: ? $normaliser($property, false)
612: : $property;
613: $relationships[$name] = new PropertyRelationship($property, $type, $target);
614: }
615: $relationships = array_intersect_key($relationships, $declared);
616: if (count($relationships) !== 2) {
617: throw new ReflectionException(sprintf(
618: '%s did not return valid parent/children properties',
619: $this->name,
620: ));
621: }
622: }
623: /** @var array<string,non-empty-array<Relatable::*,class-string<Relatable>>> */
624: $references = $this->getMethod('getRelationships')->invoke(null);
625: foreach ($references as $property => $reference) {
626: $name = $normaliser
627: ? $normaliser($property, false)
628: : $property;
629: if ($declared[$name] ?? null) {
630: $type = array_key_first($reference);
631: $target = $reference[$type];
632: $relationships[$name] = new PropertyRelationship($property, $type, $target);
633: }
634: }
635: return $relationships ?? [];
636: }
637:
638: /**
639: * @return array<string,true>
640: */
641: private function getReservedNames(): array
642: {
643: $reserved = [];
644: if ($this->isExtensible()) {
645: $reserved[] = $this->getDynamicPropertiesProperty();
646: $reserved[] = $this->getDynamicPropertyNamesProperty();
647: }
648: /** @disregard P1006 */
649: return $reserved
650: ? array_fill_keys($this->normalise($reserved, false), true)
651: : $reserved;
652: }
653: }
654: