1: <?php declare(strict_types=1);
2:
3: namespace Salient\Sync\Support;
4:
5: use Salient\Contract\Container\ContainerInterface;
6: use Salient\Contract\Core\Entity\Relatable;
7: use Salient\Contract\Core\Entity\Treeable;
8: use Salient\Contract\Core\Provider\Providable;
9: use Salient\Contract\Core\DateFormatterInterface;
10: use Salient\Contract\Sync\HydrationPolicy;
11: use Salient\Contract\Sync\SyncContextInterface;
12: use Salient\Contract\Sync\SyncEntityInterface;
13: use Salient\Contract\Sync\SyncProviderInterface;
14: use Salient\Core\Facade\Sync;
15: use Salient\Core\Introspector;
16: use Salient\Core\IntrospectorKeyTargets;
17: use Salient\Sync\Reflection\ReflectionSyncProvider;
18: use Salient\Sync\SyncUtil;
19: use Salient\Utility\Arr;
20: use Salient\Utility\Get;
21: use Salient\Utility\Regex;
22: use Salient\Utility\Str;
23: use Closure;
24: use LogicException;
25:
26: /**
27: * Generates closures that perform sync-related operations on a class
28: *
29: * @template TClass of object
30: *
31: * @extends Introspector<TClass,SyncProviderInterface,SyncEntityInterface,SyncContextInterface>
32: */
33: final class SyncIntrospector extends Introspector
34: {
35: private const ID_KEY = 0;
36: private const PARENT_KEY = 1;
37: private const CHILDREN_KEY = 2;
38: private const ID_PROPERTY = 'Id';
39:
40: /** @var SyncIntrospectionClass<TClass> */
41: protected $_Class;
42:
43: /**
44: * @template T of object
45: *
46: * @param class-string<T> $service
47: * @return static<T>
48: */
49: public static function getService(ContainerInterface $container, string $service)
50: {
51: return new static(
52: $service,
53: $container->getName($service),
54: SyncProviderInterface::class,
55: SyncEntityInterface::class,
56: SyncContextInterface::class,
57: );
58: }
59:
60: /**
61: * @template T of object
62: *
63: * @param class-string<T> $class
64: * @return static<T>
65: */
66: public static function get(string $class)
67: {
68: return new static(
69: $class,
70: $class,
71: SyncProviderInterface::class,
72: SyncEntityInterface::class,
73: SyncContextInterface::class,
74: );
75: }
76:
77: /**
78: * @param class-string<TClass> $class
79: * @return SyncIntrospectionClass<TClass>
80: */
81: protected function getIntrospectionClass(string $class): SyncIntrospectionClass
82: {
83: return new SyncIntrospectionClass($class);
84: }
85:
86: /**
87: * Get a closure that creates SyncProviderInterface-serviced instances of the class
88: * from arrays
89: *
90: * Wraps {@see SyncIntrospector::getCreateSyncEntityFromSignatureClosure()}
91: * in a closure that resolves array signatures to closures on-demand.
92: *
93: * @param bool $strict If `true`, the closure will throw an exception if it
94: * receives any data that would be discarded.
95: * @return Closure(mixed[], SyncProviderInterface, SyncContextInterface): TClass
96: */
97: public function getCreateSyncEntityFromClosure(bool $strict = false): Closure
98: {
99: $closure =
100: $this->_Class->CreateSyncEntityFromClosures[(int) $strict]
101: ?? null;
102:
103: if ($closure) {
104: return $closure;
105: }
106:
107: $closure =
108: function (
109: array $array,
110: SyncProviderInterface $provider,
111: SyncContextInterface $context
112: ) use ($strict) {
113: $keys = array_keys($array);
114: $closure = $this->getCreateSyncEntityFromSignatureClosure($keys, $strict);
115: return $closure($array, $provider, $context);
116: };
117:
118: $this->_Class->CreateSyncEntityFromClosures[(int) $strict] = $closure;
119:
120: return $closure;
121: }
122:
123: /**
124: * Get a closure that creates SyncProviderInterface-serviced instances of the class
125: * from arrays with a given signature
126: *
127: * @param string[] $keys
128: * @param bool $strict If `true`, throw an exception if any data would be
129: * discarded.
130: * @return Closure(mixed[], SyncProviderInterface, SyncContextInterface): TClass
131: */
132: public function getCreateSyncEntityFromSignatureClosure(array $keys, bool $strict = false): Closure
133: {
134: $sig = implode("\0", $keys);
135:
136: $closure =
137: $this->_Class->CreateSyncEntityFromSignatureClosures[$sig][(int) $strict]
138: ?? null;
139:
140: if (!$closure) {
141: $closure = $this->_getCreateFromSignatureSyncClosure($keys, $strict);
142: $this->_Class->CreateSyncEntityFromSignatureClosures[$sig][(int) $strict] = $closure;
143:
144: // If the closure was created successfully in strict mode, use it
145: // for non-strict purposes too
146: if ($strict) {
147: $this->_Class->CreateSyncEntityFromSignatureClosures[$sig][(int) false] = $closure;
148: }
149: }
150:
151: // Return a closure that injects this introspector's service
152: $service = $this->_Service;
153:
154: return
155: static function (
156: array $array,
157: SyncProviderInterface $provider,
158: SyncContextInterface $context
159: ) use ($closure, $service) {
160: return $closure(
161: $array,
162: $service,
163: $context->getContainer(),
164: $provider,
165: $context,
166: $provider->getDateFormatter(),
167: $context->getParent(),
168: );
169: };
170: }
171:
172: /**
173: * Get a closure to perform sync operations on behalf of a provider's
174: * "magic" method
175: *
176: * Returns `null` if:
177: * - the {@see SyncIntrospector} was not created for a
178: * {@see SyncProviderInterface},
179: * - the {@see SyncProviderInterface} class already has `$method`, or
180: * - `$method` doesn't resolve to an unambiguous sync operation on a
181: * {@see SyncEntityInterface} class serviced by the
182: * {@see SyncProviderInterface} class
183: *
184: * @return Closure(SyncContextInterface, mixed...)|null
185: */
186: public function getMagicSyncOperationClosure(string $method, SyncProviderInterface $provider): ?Closure
187: {
188: if (!$this->_Class->IsSyncProvider) {
189: return null;
190: }
191:
192: $method = Str::lower($method);
193: $closure = $this->_Class->MagicSyncOperationClosures[$method] ?? false;
194: // Use strict comparison with `false` because null closures are cached
195: if ($closure === false) {
196: /** @var class-string<SyncProviderInterface> */
197: $class = $this->_Class->Class;
198: $operation = (new ReflectionSyncProvider($class))
199: ->getSyncOperationMagicMethods()[$method] ?? null;
200: if ($operation) {
201: $entity = $operation[1];
202: $operation = $operation[0];
203: $closure =
204: function (SyncContextInterface $ctx, ...$args) use ($entity, $operation) {
205: /** @var SyncProviderInterface $this */
206: return $this->with($entity, $ctx)->run($operation, ...$args);
207: };
208: }
209: $this->_Class->MagicSyncOperationClosures[$method] = $closure ?: null;
210: }
211:
212: return $closure ? $closure->bindTo($provider) : null;
213: }
214:
215: /**
216: * @param string[] $keys
217: * @return Closure(mixed[], string|null, ContainerInterface, SyncProviderInterface|null, SyncContextInterface|null, DateFormatterInterface|null, Treeable|null): TClass
218: */
219: private function _getCreateFromSignatureSyncClosure(array $keys, bool $strict = false): Closure
220: {
221: $sig = implode("\0", $keys);
222:
223: $closure =
224: $this->_Class->CreateFromSignatureSyncClosures[$sig]
225: ?? null;
226:
227: if ($closure) {
228: return $closure;
229: }
230:
231: $targets = $this->getKeyTargets($keys, true, $strict);
232: $constructor = $this->_getConstructor($targets);
233: $updater = $this->_getUpdater($targets);
234: $resolver = $this->_getResolver($targets);
235: $idKey = $targets->CustomKeys[self::ID_KEY] ?? null;
236:
237: $updateTargets = $this->getKeyTargets($keys, false, $strict);
238: $existingUpdater = $this->_getUpdater($updateTargets);
239: $existingResolver = $this->_getResolver($updateTargets);
240:
241: if ($idKey === null) {
242: $closure = static function (
243: array $array,
244: ?string $service,
245: ContainerInterface $container,
246: ?SyncProviderInterface $provider,
247: ?SyncContextInterface $context,
248: ?DateFormatterInterface $dateFormatter,
249: ?Treeable $parent
250: ) use ($constructor, $updater, $resolver) {
251: /** @var class-string<SyncEntityInterface>|null $service */
252: $obj = $constructor($array, $service, $container);
253: $obj = $updater($array, $obj, $container, $provider, $context, $dateFormatter, $parent);
254: $obj = $resolver($array, $service, $obj, $provider, $context);
255: if ($obj instanceof Providable) {
256: $obj->postLoad();
257: }
258: return $obj;
259: };
260: } else {
261: /** @var class-string<TClass&SyncEntityInterface> */
262: $entityType = $this->_Class->Class;
263: $closure = static function (
264: array $array,
265: ?string $service,
266: ContainerInterface $container,
267: ?SyncProviderInterface $provider,
268: ?SyncContextInterface $context,
269: ?DateFormatterInterface $dateFormatter,
270: ?Treeable $parent
271: ) use (
272: $constructor,
273: $updater,
274: $resolver,
275: $existingUpdater,
276: $existingResolver,
277: $idKey,
278: $entityType
279: ) {
280: $id = $array[$idKey];
281:
282: /** @var class-string<SyncEntityInterface>|null $service */
283: if ($id === null || !$provider) {
284: $obj = $constructor($array, $service, $container);
285: $obj = $updater($array, $obj, $container, $provider, $context, $dateFormatter, $parent);
286: $obj = $resolver($array, $service, $obj, $provider, $context);
287: if ($obj instanceof Providable) {
288: $obj->postLoad();
289: }
290: return $obj;
291: }
292:
293: $store = $provider->getStore()->registerEntityType($service ?? $entityType);
294: $providerId = $provider->getProviderId();
295: $obj = $store->getEntity($providerId, $service ?? $entityType, $id, $context->getOffline());
296:
297: if ($obj) {
298: $obj = $existingUpdater($array, $obj, $container, $provider, $context, $dateFormatter, $parent);
299: $obj = $existingResolver($array, $service, $obj, $provider, $context);
300: if ($obj instanceof Providable) {
301: $obj->postLoad();
302: }
303: return $obj;
304: }
305:
306: $obj = $constructor($array, $service, $container);
307: /** @var TClass&SyncEntityInterface */
308: $obj = $updater($array, $obj, $container, $provider, $context, $dateFormatter, $parent);
309: $store->setEntity($providerId, $service ?? $entityType, $id, $obj);
310: $obj = $resolver($array, $service, $obj, $provider, $context);
311: if ($obj instanceof Providable) {
312: $obj->postLoad();
313: }
314: return $obj;
315: };
316: }
317:
318: $this->_Class->CreateFromSignatureSyncClosures[$sig] = $closure;
319: return $closure;
320: }
321:
322: protected function getKeyTargets(
323: array $keys,
324: bool $forNewInstance,
325: bool $strict,
326: bool $normalised = false,
327: array $customKeys = [],
328: array $keyClosures = []
329: ): IntrospectorKeyTargets {
330: /** @var array<string,string> Normalised key => original key */
331: $keys = $this->_Class->Normaliser
332: ? Arr::combine(array_map($this->_Class->CarefulNormaliser, $keys), $keys)
333: : Arr::combine($keys, $keys);
334:
335: foreach ([
336: self::ID_KEY => self::ID_PROPERTY,
337: self::PARENT_KEY => $this->_Class->ParentProperty,
338: self::CHILDREN_KEY => $this->_Class->ChildrenProperty,
339: ] as $key => $property) {
340: if ($property === null) {
341: continue;
342: }
343:
344: if ($key === self::ID_KEY) {
345: $property = $this->_Class->Normaliser
346: ? ($this->_Class->CarefulNormaliser)($property)
347: : $property;
348: }
349:
350: // If receiving values for this property, add the relevant key to
351: // $customKeys
352: $customKey = $keys[$property] ?? null;
353: if ($customKey !== null) {
354: $customKeys[$key] = $customKey;
355: }
356: }
357:
358: $idKey = $customKeys[self::ID_KEY] ?? null;
359:
360: // Check for relationships to honour by applying deferred entities
361: // instead of raw data
362: if ($this->_Class->IsSyncEntity
363: && ($this->_Class->OneToOneRelationships
364: || $this->_Class->OneToManyRelationships)) {
365: $missing = null;
366: foreach ([
367: $this->_Class->OneToOneRelationships,
368: $this->_Class->OneToManyRelationships,
369: ] as $list => $relationships) {
370: if ($list) {
371: $missing = array_diff_key($relationships, $keys);
372: }
373: $relationships = array_intersect_key($relationships, $keys);
374:
375: if (!$relationships) {
376: continue;
377: }
378:
379: foreach ($relationships as $match => $relationship) {
380: if (!is_a($relationship, SyncEntityInterface::class, true)) {
381: throw new LogicException(sprintf(
382: '%s does not implement %s',
383: $relationship,
384: SyncEntityInterface::class,
385: ));
386: }
387:
388: $key = $keys[$match];
389: $list = (bool) $list;
390: $isParent = $match === $this->_Class->ParentProperty;
391: $isChildren = $match === $this->_Class->ChildrenProperty;
392: // If $match doesn't resolve to a declared property, it will
393: // resolve to a magic method
394: $property = $this->_Class->Properties[$match] ?? $match;
395: $keyClosures[$match] = $this->getRelationshipClosure(
396: $key,
397: $list,
398: $relationship,
399: $property,
400: $isParent,
401: $isChildren,
402: );
403: }
404: }
405:
406: // Check for absent one-to-many relationships to hydrate
407: if ($missing && $idKey !== null && $forNewInstance) {
408: foreach ($missing as $key => $relationship) {
409: if (!is_a($relationship, SyncEntityInterface::class, true)) {
410: throw new LogicException(sprintf(
411: '%s does not implement %s',
412: $relationship,
413: SyncEntityInterface::class,
414: ));
415: }
416:
417: $isChildren = $key === $this->_Class->ChildrenProperty;
418: $filter =
419: $isChildren
420: ? $this->_Class->ParentProperty
421: : null;
422: $property = $this->_Class->Properties[$key] ?? $key;
423: $keyClosures[$key] = $this->getHydrator(
424: $idKey,
425: $relationship,
426: $property,
427: $filter,
428: $isChildren,
429: );
430: }
431: }
432: }
433:
434: // Get keys left behind by constructor parameters, declared properties
435: // and magic methods
436: $unclaimed = array_diff_key(
437: $keys,
438: $this->_Class->Parameters,
439: array_flip($this->_Class->NormalisedKeys),
440: );
441:
442: if (!$unclaimed) {
443: return parent::getKeyTargets(
444: $keys,
445: $forNewInstance,
446: $strict,
447: true,
448: $customKeys,
449: $keyClosures,
450: );
451: }
452:
453: // Check for any that end with `_id`, `_ids` or similar that would match
454: // a property or magic method otherwise
455: foreach ($unclaimed as $normalisedKey => $key) {
456: if (!Regex::match('/^(.+)(?:_|\b|(?<=[[:lower:]])(?=[[:upper:]]))id(s?)$/i', $key, $matches)) {
457: continue;
458: }
459:
460: $match = $this->_Class->Normaliser
461: ? ($this->_Class->CarefulNormaliser)($matches[1])
462: : $matches[1];
463:
464: // Don't use the same key twice
465: if (isset($keys[$match]) || isset($keyClosures[$match])) {
466: continue;
467: }
468:
469: if (!in_array($match, $this->_Class->NormalisedKeys, true)) {
470: continue;
471: }
472:
473: // Require a list of values if the key is plural (`_ids` as opposed
474: // to `_id`)
475: $list = $matches[2] !== '';
476:
477: // Check the property or magic method for a relationship to honour
478: // by applying deferred entities instead of raw data
479: $relationship =
480: $this->_Class->IsSyncEntity && $this->_Class->IsRelatable
481: ? ($list
482: ? ($this->_Class->OneToManyRelationships[$match] ?? null)
483: : ($this->_Class->OneToOneRelationships[$match] ?? null))
484: : null;
485:
486: if ($relationship !== null
487: && !is_a($relationship, SyncEntityInterface::class, true)) {
488: throw new LogicException(sprintf(
489: '%s does not implement %s',
490: $relationship,
491: SyncEntityInterface::class,
492: ));
493: }
494:
495: // As above, if $match doesn't resolve to a declared property, it
496: // will resolve to a magic method
497: $property = $this->_Class->Properties[$match] ?? $match;
498: $isParent = $match === $this->_Class->ParentProperty;
499: $isChildren = $match === $this->_Class->ChildrenProperty;
500: $keyClosures[$match] = $this->getRelationshipClosure(
501: $key,
502: $list,
503: $relationship,
504: $property,
505: $isParent,
506: $isChildren,
507: );
508:
509: // Prevent duplication of the key as a meta value
510: unset($keys[$normalisedKey]);
511: }
512:
513: return parent::getKeyTargets(
514: $keys,
515: $forNewInstance,
516: $strict,
517: true,
518: $customKeys,
519: $keyClosures,
520: );
521: }
522:
523: /**
524: * @param class-string<SyncEntityInterface&Relatable>|null $relationship
525: * @return Closure(mixed[], ?string, TClass, ?SyncProviderInterface, ?SyncContextInterface): void
526: */
527: private function getRelationshipClosure(
528: string $key,
529: bool $isList,
530: ?string $relationship,
531: string $property,
532: bool $isParent,
533: bool $isChildren
534: ): Closure {
535: if ($relationship === null) {
536: return
537: static function (
538: array $data,
539: ?string $service,
540: $entity
541: ) use ($key, $property): void {
542: $entity->{$property} = $data[$key];
543: };
544: }
545:
546: return
547: static function (
548: array $data,
549: ?string $service,
550: $entity,
551: ?SyncProviderInterface $provider,
552: ?SyncContextInterface $context
553: ) use (
554: $key,
555: $isList,
556: $relationship,
557: $property,
558: $isParent,
559: $isChildren
560: ): void {
561: if (
562: $data[$key] === null
563: || (Arr::isList($data[$key]) xor $isList)
564: || !$entity instanceof SyncEntityInterface
565: || !$provider instanceof SyncProviderInterface
566: || !$context instanceof SyncContextInterface
567: ) {
568: $entity->{$property} = $data[$key];
569: return;
570: }
571:
572: if ($isList) {
573: if (is_scalar($data[$key][0])) {
574: if (!$isChildren) {
575: DeferredEntity::deferList(
576: $provider,
577: $context->pushEntity($entity, true),
578: $relationship,
579: $data[$key],
580: $entity->{$property},
581: );
582: return;
583: }
584:
585: /** @var SyncEntityInterface&Treeable $entity */
586: /** @disregard P1008 */
587: DeferredEntity::deferList(
588: $provider,
589: $context->pushEntity($entity, true),
590: $relationship,
591: $data[$key],
592: $replace,
593: static function ($child) use ($entity): void {
594: /** @var SyncEntityInterface&Treeable $child */
595: $entity->addChild($child);
596: },
597: );
598: return;
599: }
600:
601: $entities =
602: $relationship::provideMultiple(
603: $data[$key],
604: $provider,
605: $context->getConformity(),
606: $context->pushEntity($entity),
607: )->toArray();
608:
609: if (!$isChildren) {
610: $entity->{$property} = $entities;
611: return;
612: }
613:
614: /** @var array<SyncEntityInterface&Treeable> $entities */
615: foreach ($entities as $child) {
616: /** @var SyncEntityInterface&Treeable $entity */
617: $entity->addChild($child);
618: }
619: return;
620: }
621:
622: if (is_scalar($data[$key])) {
623: if (!$isParent) {
624: DeferredEntity::defer(
625: $provider,
626: $context->pushEntity($entity, true),
627: $relationship,
628: $data[$key],
629: $entity->{$property},
630: );
631: return;
632: }
633:
634: /** @var SyncEntityInterface&Treeable $entity */
635: /** @disregard P1008 */
636: DeferredEntity::defer(
637: $provider,
638: $context->pushEntity($entity, true),
639: $relationship,
640: $data[$key],
641: $replace,
642: static function ($parent) use ($entity): void {
643: /** @var SyncEntityInterface&Treeable $parent */
644: $entity->setParent($parent);
645: },
646: );
647: return;
648: }
649:
650: $related =
651: $relationship::provide(
652: $data[$key],
653: $provider,
654: $context->pushEntity($entity),
655: );
656:
657: if (!$isParent) {
658: $entity->{$property} = $related;
659: return;
660: }
661:
662: /**
663: * @var SyncEntityInterface&Treeable $entity
664: * @var SyncEntityInterface&Treeable $related
665: */
666: $entity->setParent($related);
667: };
668: }
669:
670: /**
671: * @param class-string<SyncEntityInterface&Relatable> $relationship
672: * @return Closure(mixed[], ?string, TClass, ?SyncProviderInterface, ?SyncContextInterface): void
673: */
674: private function getHydrator(
675: string $idKey,
676: string $relationship,
677: string $property,
678: ?string $filter,
679: bool $isChildren
680: ): Closure {
681: $entityType = $this->_Class->Class;
682: $entityProvider = null;
683:
684: return
685: static function (
686: array $data,
687: ?string $service,
688: $entity,
689: ?SyncProviderInterface $provider,
690: ?SyncContextInterface $context
691: ) use (
692: $idKey,
693: $relationship,
694: $property,
695: $filter,
696: $isChildren,
697: $entityType,
698: &$entityProvider
699: ): void {
700: if (
701: !$context instanceof SyncContextInterface
702: || !$provider instanceof SyncProviderInterface
703: || !is_a($provider, $entityProvider ??= SyncUtil::getEntityTypeProvider($relationship, SyncUtil::getStore($context->getContainer())))
704: || $data[$idKey] === null
705: ) {
706: return;
707: }
708:
709: $policy = $context->getHydrationPolicy($relationship);
710: if ($policy === HydrationPolicy::SUPPRESS) {
711: return;
712: }
713:
714: if ($filter !== null) {
715: $filter = [$filter => $data[$idKey]];
716: }
717:
718: if (!$isChildren) {
719: DeferredRelationship::defer(
720: $provider,
721: $context->pushEntity($entity, true),
722: $relationship,
723: $service ?? $entityType,
724: $property,
725: $data[$idKey],
726: $filter,
727: $entity->{$property},
728: );
729: return;
730: }
731:
732: /** @var SyncEntityInterface&Treeable $entity */
733: /** @disregard P1008 */
734: DeferredRelationship::defer(
735: $provider,
736: $context->pushEntity($entity, true),
737: $relationship,
738: $service ?? $entityType,
739: $property,
740: $data[$idKey],
741: $filter,
742: $replace,
743: static function ($entities) use ($entity, $property): void {
744: if (!$entities) {
745: $entity->{$property} = [];
746: return;
747: }
748: foreach ($entities as $child) {
749: /** @var SyncEntityInterface&Treeable $child */
750: $entity->addChild($child);
751: }
752: },
753: );
754: };
755: }
756: }
757: