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: | |
27: | |
28: | |
29: | |
30: | |
31: | |
32: | class ClassReflection extends ReflectionClass |
33: | { |
34: | private ?bool $HasNormaliser = null; |
35: | |
36: | private Closure $Normaliser; |
37: | |
38: | private array $DeclaredNames; |
39: | |
40: | private array $ReservedNames; |
41: | |
42: | |
43: | |
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: | |
55: | |
56: | public function getMethod($name): MethodReflection |
57: | { |
58: | return new MethodReflection($this->name, $name); |
59: | } |
60: | |
61: | |
62: | |
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: | |
74: | |
75: | public function isHierarchical(): bool |
76: | { |
77: | return $this->implementsInterface(Hierarchical::class); |
78: | } |
79: | |
80: | |
81: | |
82: | |
83: | public function isReadable(): bool |
84: | { |
85: | return $this->implementsInterface(Readable::class); |
86: | } |
87: | |
88: | |
89: | |
90: | |
91: | public function isWritable(): bool |
92: | { |
93: | return $this->implementsInterface(Writable::class); |
94: | } |
95: | |
96: | |
97: | |
98: | |
99: | |
100: | |
101: | |
102: | public function isExtensible(): bool |
103: | { |
104: | return $this->implementsInterface(Extensible::class); |
105: | } |
106: | |
107: | |
108: | |
109: | |
110: | |
111: | |
112: | public function isNormalisable(): bool |
113: | { |
114: | return $this->implementsInterface(Normalisable::class); |
115: | } |
116: | |
117: | |
118: | |
119: | |
120: | public function isProvidable(): bool |
121: | { |
122: | return $this->implementsInterface(Providable::class); |
123: | } |
124: | |
125: | |
126: | |
127: | |
128: | public function isRelatable(): bool |
129: | { |
130: | return $this->implementsInterface(Relatable::class); |
131: | } |
132: | |
133: | |
134: | |
135: | |
136: | |
137: | |
138: | |
139: | |
140: | |
141: | public function isTreeable(): bool |
142: | { |
143: | return $this->implementsInterface(Treeable::class); |
144: | } |
145: | |
146: | |
147: | |
148: | |
149: | public function isTemporal(): bool |
150: | { |
151: | return $this->implementsInterface(Temporal::class); |
152: | } |
153: | |
154: | |
155: | |
156: | |
157: | |
158: | public function getDynamicPropertiesProperty(): ?string |
159: | { |
160: | |
161: | return $this->isExtensible() |
162: | ? $this->getMethod('getDynamicPropertiesProperty')->invoke(null) |
163: | : null; |
164: | } |
165: | |
166: | |
167: | |
168: | |
169: | |
170: | public function getDynamicPropertyNamesProperty(): ?string |
171: | { |
172: | |
173: | return $this->isExtensible() |
174: | ? $this->getMethod('getDynamicPropertyNamesProperty')->invoke(null) |
175: | : null; |
176: | } |
177: | |
178: | |
179: | |
180: | |
181: | |
182: | public function getParentProperty(): ?string |
183: | { |
184: | |
185: | return $this->isTreeable() |
186: | ? $this->getMethod('getParentProperty')->invoke(null) |
187: | : null; |
188: | } |
189: | |
190: | |
191: | |
192: | |
193: | |
194: | public function getChildrenProperty(): ?string |
195: | { |
196: | |
197: | return $this->isTreeable() |
198: | ? $this->getMethod('getChildrenProperty')->invoke(null) |
199: | : null; |
200: | } |
201: | |
202: | |
203: | |
204: | |
205: | |
206: | |
207: | |
208: | |
209: | |
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: | |
233: | |
234: | |
235: | |
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: | |
260: | |
261: | |
262: | |
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: | |
275: | |
276: | |
277: | |
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: | |
291: | |
292: | |
293: | |
294: | |
295: | public function getWritableNames(): array |
296: | { |
297: | return array_keys( |
298: | $this->getWritableProperties() + $this->getActionProperties('set') |
299: | ); |
300: | } |
301: | |
302: | |
303: | |
304: | |
305: | |
306: | |
307: | |
308: | |
309: | |
310: | |
311: | |
312: | public function getAccessiblePropertyNames(): array |
313: | { |
314: | return Reflect::getNames($this->getAccessibleProperties()); |
315: | } |
316: | |
317: | |
318: | |
319: | |
320: | |
321: | |
322: | |
323: | |
324: | |
325: | |
326: | public function getReadablePropertyNames(): array |
327: | { |
328: | return Reflect::getNames($this->getReadableProperties()); |
329: | } |
330: | |
331: | |
332: | |
333: | |
334: | |
335: | |
336: | |
337: | |
338: | |
339: | |
340: | public function getWritablePropertyNames(): array |
341: | { |
342: | return Reflect::getNames($this->getWritableProperties()); |
343: | } |
344: | |
345: | |
346: | |
347: | |
348: | |
349: | |
350: | |
351: | |
352: | |
353: | |
354: | public function getAccessibleProperties(): array |
355: | { |
356: | return $this->filterProperties($this->isReadable(), 'getReadableProperties') |
357: | + $this->filterProperties($this->isWritable(), 'getWritableProperties'); |
358: | } |
359: | |
360: | |
361: | |
362: | |
363: | |
364: | |
365: | |
366: | |
367: | |
368: | public function getReadableProperties(): array |
369: | { |
370: | return $this->filterProperties($this->isReadable(), 'getReadableProperties'); |
371: | } |
372: | |
373: | |
374: | |
375: | |
376: | |
377: | |
378: | |
379: | |
380: | |
381: | public function getWritableProperties(): array |
382: | { |
383: | return $this->filterProperties($this->isWritable(), 'getWritableProperties'); |
384: | } |
385: | |
386: | |
387: | |
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: | |
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: | |
426: | |
427: | |
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: | |
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: | |
481: | |
482: | |
483: | |
484: | |
485: | |
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: | |
503: | |
504: | |
505: | |
506: | |
507: | public function getDateNames(): array |
508: | { |
509: | |
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: | |
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: | |
536: | |
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: | |
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: | |
576: | |
577: | |
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: | |
587: | |
588: | |
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: | |
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: | |
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: | |
640: | |
641: | private function getReservedNames(): array |
642: | { |
643: | $reserved = []; |
644: | if ($this->isExtensible()) { |
645: | $reserved[] = $this->getDynamicPropertiesProperty(); |
646: | $reserved[] = $this->getDynamicPropertyNamesProperty(); |
647: | } |
648: | |
649: | return $reserved |
650: | ? array_fill_keys($this->normalise($reserved, false), true) |
651: | : $reserved; |
652: | } |
653: | } |
654: | |