1: <?php declare(strict_types=1);
2:
3: namespace Salient\Sync;
4:
5: use Salient\Container\RequiresContainer;
6: use Salient\Contract\Container\ContainerInterface;
7: use Salient\Contract\Core\Entity\Providable;
8: use Salient\Contract\Core\Entity\Temporal;
9: use Salient\Contract\Core\Provider\ProviderContextInterface;
10: use Salient\Contract\Core\Provider\ProviderInterface;
11: use Salient\Contract\Core\DateFormatterInterface;
12: use Salient\Contract\Core\Flushable;
13: use Salient\Contract\Core\HasDescription;
14: use Salient\Contract\Sync\DeferredEntityInterface;
15: use Salient\Contract\Sync\DeferredRelationshipInterface;
16: use Salient\Contract\Sync\EntityState;
17: use Salient\Contract\Sync\LinkType;
18: use Salient\Contract\Sync\SyncContextInterface;
19: use Salient\Contract\Sync\SyncEntityInterface;
20: use Salient\Contract\Sync\SyncEntityProviderInterface;
21: use Salient\Contract\Sync\SyncProviderInterface;
22: use Salient\Contract\Sync\SyncSerializeRulesInterface;
23: use Salient\Contract\Sync\SyncStoreInterface;
24: use Salient\Core\Concern\ConstructibleTrait;
25: use Salient\Core\Concern\ExtensibleTrait;
26: use Salient\Core\Concern\ProvidableTrait;
27: use Salient\Core\Concern\ReadableTrait;
28: use Salient\Core\Concern\WritableTrait;
29: use Salient\Core\Date\DateFormatter;
30: use Salient\Core\Facade\Sync;
31: use Salient\Sync\Exception\SyncEntityNotFoundException;
32: use Salient\Sync\Reflection\SyncEntityReflection;
33: use Salient\Sync\Support\SyncIntrospector;
34: use Salient\Utility\Arr;
35: use Salient\Utility\Get;
36: use Salient\Utility\Inflect;
37: use Salient\Utility\Regex;
38: use Salient\Utility\Str;
39: use DateTimeInterface;
40: use LogicException;
41: use ReflectionClass;
42: use UnexpectedValueException;
43:
44: abstract class AbstractSyncEntity implements
45: SyncEntityInterface,
46: Temporal,
47: Flushable
48: {
49: use ConstructibleTrait;
50: use ExtensibleTrait;
51: use ReadableTrait;
52: use WritableTrait;
53: /** @use ProvidableTrait<SyncProviderInterface,SyncContextInterface> */
54: use ProvidableTrait;
55: use RequiresContainer;
56:
57: /**
58: * The unique identifier assigned to the entity by its provider
59: *
60: * @var int|string|null
61: */
62: public $Id;
63:
64: /**
65: * The unique identifier assigned to the entity by its canonical backend
66: *
67: * @var int|string|null
68: */
69: public $CanonicalId;
70:
71: // --
72:
73: /** @var SyncProviderInterface|null */
74: private ?ProviderInterface $Provider = null;
75: /** @var SyncContextInterface|null */
76: private ?ProviderContextInterface $Context = null;
77: /** @var int-mask-of<EntityState::*> */
78: private int $State = 0;
79:
80: /**
81: * Entity => "/^<pattern>_/" | false
82: *
83: * @var array<class-string<self>,string|false>
84: */
85: private static array $RemovablePrefixRegex = [];
86:
87: /**
88: * Entity => [ property name => normalised property name, ... ]
89: *
90: * @var array<class-string<self>,array<string,string>>
91: */
92: private static array $NormalisedNames = [];
93:
94: /**
95: * Entity => [ normalised property name | property name, ... ]
96: *
97: * @var array<class-string<self>,string[]>
98: */
99: private static array $SerializableNames;
100:
101: // --
102:
103: /**
104: * @inheritDoc
105: */
106: public static function getPlural(): ?string
107: {
108: return Inflect::plural(Get::basename(static::class));
109: }
110:
111: /**
112: * @inheritDoc
113: */
114: public static function getRelationships(): array
115: {
116: return [];
117: }
118:
119: /**
120: * @inheritDoc
121: */
122: public static function getDateProperties(): array
123: {
124: return [];
125: }
126:
127: /**
128: * @inheritDoc
129: */
130: public static function flushStatic(): void
131: {
132: unset(
133: self::$RemovablePrefixRegex[static::class],
134: self::$NormalisedNames[static::class],
135: self::$SerializableNames[static::class],
136: );
137: }
138:
139: /**
140: * @inheritDoc
141: */
142: public function getName(): string
143: {
144: return SyncIntrospector::get(static::class)
145: ->getGetNameClosure()($this);
146: }
147:
148: /**
149: * Override to specify how object graphs below entities of this type should
150: * be serialized
151: *
152: * To prevent infinite recursion when entities of this type are serialized,
153: * return a {@see SyncSerializeRulesBuilder} object configured to remove or
154: * replace circular references.
155: *
156: * @param SyncSerializeRulesBuilder<static> $rulesB
157: * @return SyncSerializeRulesBuilder<static>
158: */
159: protected static function buildSerializeRules(SyncSerializeRulesBuilder $rulesB): SyncSerializeRulesBuilder
160: {
161: return $rulesB;
162: }
163:
164: /**
165: * Override to specify prefixes to remove when normalising property names
166: *
167: * Entity names are removed by default, e.g. for an
168: * {@see AbstractSyncEntity} subclass called `User`, "User" is removed to
169: * ensure fields like "USER_ID" and "USER_NAME" match properties like "Id"
170: * and "Name". For a subclass of `User` called `AdminUser`, both "User" and
171: * "AdminUser" are removed.
172: *
173: * Return `null` to suppress prefix removal, otherwise use
174: * {@see AbstractSyncEntity::normalisePrefixes()} or
175: * {@see AbstractSyncEntity::expandPrefixes()} to normalise the return
176: * value.
177: *
178: * @return string[]|null
179: */
180: protected static function getRemovablePrefixes(): ?array
181: {
182: $current = new ReflectionClass(static::class);
183: do {
184: $prefixes[] = Get::basename($current->getName());
185: $current = $current->getParentClass();
186: } while ($current && $current->isSubclassOf(self::class));
187:
188: return self::expandPrefixes($prefixes);
189: }
190:
191: // --
192:
193: /**
194: * @inheritDoc
195: */
196: final public function getId()
197: {
198: return $this->Id;
199: }
200:
201: /**
202: * @inheritDoc
203: */
204: final public function getCanonicalId()
205: {
206: return $this->CanonicalId;
207: }
208:
209: /**
210: * @inheritDoc
211: */
212: final public static function getDefaultProvider(ContainerInterface $container): SyncProviderInterface
213: {
214: return $container->get(SyncUtil::getEntityTypeProvider(static::class, SyncUtil::getStore($container)));
215: }
216:
217: /**
218: * @inheritDoc
219: */
220: final public static function withDefaultProvider(ContainerInterface $container, ?SyncContextInterface $context = null): SyncEntityProviderInterface
221: {
222: return self::getDefaultProvider($container)->with(static::class, $context);
223: }
224:
225: /**
226: * @inheritDoc
227: */
228: final public static function getSerializeRules(): SyncSerializeRulesInterface
229: {
230: return static::buildSerializeRules(
231: SyncSerializeRules::build()
232: ->entity(static::class)
233: )->build();
234: }
235:
236: /**
237: * Get the serialization rules of the entity's parent class
238: *
239: * @return SyncSerializeRules<static>|null
240: */
241: final protected static function getParentSerializeRules(): ?SyncSerializeRules
242: {
243: $class = get_parent_class(get_called_class());
244:
245: if (
246: $class === false
247: || !is_a($class, self::class, true)
248: || $class === self::class
249: ) {
250: return null;
251: }
252:
253: /** @var SyncSerializeRules<static> */
254: return $class::buildSerializeRules(
255: SyncSerializeRules::build()
256: ->entity($class)
257: )->build();
258: }
259:
260: /**
261: * Normalise a property name
262: *
263: * 1. `$name` is converted to snake_case
264: * 2. If `$fromData` is `false` or the result matches a `$declaredName`, it
265: * is returned
266: * 3. Otherwise, prefixes returned by {@see getRemovablePrefixes()} are
267: * removed
268: */
269: final public static function normaliseProperty(
270: string $name,
271: bool $fromData = true,
272: string ...$declaredName
273: ): string {
274: if (isset(self::$NormalisedNames[static::class][$name])) {
275: return self::$NormalisedNames[static::class][$name];
276: }
277:
278: $_name = Str::snake($name);
279:
280: if (
281: $fromData
282: && (!$declaredName || !in_array($_name, $declaredName))
283: && ($regex = self::$RemovablePrefixRegex[static::class] ??=
284: self::getRemovablePrefixRegex()) !== false
285: ) {
286: $_name = Regex::replace($regex, '', $_name);
287: }
288:
289: return self::$NormalisedNames[static::class][$name] = $_name;
290: }
291:
292: /**
293: * @return string|false
294: */
295: private static function getRemovablePrefixRegex()
296: {
297: $prefixes = static::getRemovablePrefixes();
298:
299: return $prefixes
300: ? sprintf(
301: count($prefixes) > 1 ? '/^(?:%s)_/' : '/^%s_/',
302: implode('|', $prefixes),
303: )
304: : false;
305: }
306:
307: /**
308: * @inheritDoc
309: */
310: final public function toArray(?SyncStoreInterface $store = null): array
311: {
312: /** @var SyncSerializeRulesInterface<self> */
313: $rules = self::getSerializeRules();
314: return $this->_toArray($rules, $store);
315: }
316:
317: /**
318: * @inheritDoc
319: */
320: final public function toArrayWith(SyncSerializeRulesInterface $rules, ?SyncStoreInterface $store = null): array
321: {
322: /** @var SyncSerializeRulesInterface<self> $rules */
323: return $this->_toArray($rules, $store);
324: }
325:
326: /**
327: * @inheritDoc
328: */
329: final public function toLink(?SyncStoreInterface $store = null, int $type = LinkType::DEFAULT, bool $compact = true): array
330: {
331: switch ($type) {
332: case LinkType::DEFAULT:
333: return [
334: '@type' => $this->getTypeUri($store, $compact),
335: '@id' => $this->Id === null
336: ? spl_object_id($this)
337: : $this->Id,
338: ];
339:
340: case LinkType::COMPACT:
341: return [
342: '@id' => $this->getUri($store, $compact),
343: ];
344:
345: case LinkType::FRIENDLY:
346: return Arr::whereNotEmpty([
347: '@type' => $this->getTypeUri($store, $compact),
348: '@id' => $this->Id === null
349: ? spl_object_id($this)
350: : $this->Id,
351: '@name' => $this->getName(),
352: '@description' =>
353: $this instanceof HasDescription
354: ? $this->getDescription()
355: : null,
356: ]);
357:
358: default:
359: throw new LogicException("Invalid link type: $type");
360: }
361: }
362:
363: /**
364: * @inheritDoc
365: */
366: final public function getUri(?SyncStoreInterface $store = null, bool $compact = true): string
367: {
368: return sprintf(
369: '%s/%s',
370: $this->getTypeUri($store, $compact),
371: $this->Id === null
372: ? spl_object_id($this)
373: : $this->Id
374: );
375: }
376:
377: /**
378: * Get the current state of the entity
379: *
380: * @return int-mask-of<EntityState::*>
381: */
382: final public function state(): int
383: {
384: return $this->State;
385: }
386:
387: /**
388: * Convert prefixes to snake_case for removal from property names
389: *
390: * e.g. `['AdminUserGroup']` becomes `['admin_user_group']`.
391: *
392: * @param string[] $prefixes
393: * @return string[]
394: */
395: final protected static function normalisePrefixes(array $prefixes): array
396: {
397: if (!$prefixes) {
398: return [];
399: }
400:
401: return Arr::snakeCase($prefixes);
402: }
403:
404: /**
405: * Convert prefixes to snake_case and expand them for removal from property
406: * names
407: *
408: * e.g. `['AdminUserGroup']` becomes `['admin_user_group', 'user_group',
409: * 'group']`.
410: *
411: * @param string[] $prefixes
412: * @return string[]
413: */
414: final protected static function expandPrefixes(array $prefixes): array
415: {
416: if (!$prefixes) {
417: return [];
418: }
419:
420: foreach ($prefixes as $prefix) {
421: $prefix = Str::snake($prefix);
422: $expanded[$prefix] = true;
423: $prefix = explode('_', $prefix);
424: while (array_shift($prefix)) {
425: $expanded[implode('_', $prefix)] = true;
426: }
427: }
428:
429: return array_keys($expanded);
430: }
431:
432: private function getTypeUri(?SyncStoreInterface $store, bool $compact): string
433: {
434: /** @var class-string<self> */
435: $service = $this->getService();
436: $store ??= $this->Provider ? $this->Provider->getStore() : null;
437: return SyncUtil::getEntityTypeUri($service, $compact, $store);
438: }
439:
440: /**
441: * @param SyncSerializeRulesInterface<self> $rules
442: * @return array<string,mixed>
443: */
444: private function _toArray(SyncSerializeRulesInterface $rules, ?SyncStoreInterface $store): array
445: {
446: /** @var SyncSerializeRulesInterface<self> $rules */
447: if ($rules->getDateFormatter() === null) {
448: $rules = $rules->withDateFormatter(
449: $this->Provider
450: ? $this->Provider->getDateFormatter()
451: : new DateFormatter()
452: );
453: }
454:
455: $array = $this;
456: $this->_serialize($array, [], $rules, $store);
457:
458: return (array) $array;
459: }
460:
461: /**
462: * @param self|DeferredEntityInterface<self>|DeferredRelationshipInterface<self>|DateTimeInterface|mixed[] $node
463: * @param string[] $path
464: * @param SyncSerializeRulesInterface<self> $rules
465: * @param array<int,true> $parents
466: * @param-out ($node is self ? array<string,mixed> : mixed[]|string|null) $node
467: */
468: private function _serialize(
469: &$node,
470: array $path,
471: SyncSerializeRulesInterface $rules,
472: ?SyncStoreInterface $store,
473: bool $nodeIsList = false,
474: array $parents = []
475: ): void {
476: $maxDepth = $rules->getMaxDepth();
477: if (count($path) > $maxDepth) {
478: throw new UnexpectedValueException('In too deep: ' . implode('.', $path));
479: }
480:
481: if ($node instanceof DateTimeInterface) {
482: /** @var DateFormatterInterface */
483: $formatter = $rules->getDateFormatter();
484: $node = $formatter->format($node);
485: return;
486: }
487:
488: // Now is not the time to resolve deferred entities
489: if ($node instanceof DeferredEntityInterface) {
490: $node = $node->toLink(LinkType::DEFAULT);
491: return;
492: }
493:
494: /** @todo Serialize deferred relationships */
495: if ($node instanceof DeferredRelationshipInterface) {
496: $node = null;
497: return;
498: }
499:
500: if ($node instanceof AbstractSyncEntity) {
501: if ($path && $rules->getForSyncStore()) {
502: $node = $node->toLink($store, LinkType::DEFAULT);
503: return;
504: }
505:
506: if ($rules->getDetectRecursion()) {
507: // Prevent infinite recursion by replacing each sync entity
508: // descended from itself with a link
509: if ($parents[spl_object_id($node)] ?? false) {
510: $node = $node->toLink($store, LinkType::DEFAULT);
511: $node['@why'] = 'Circular reference detected';
512: return;
513: }
514: $parents[spl_object_id($node)] = true;
515: }
516:
517: $class = get_class($node);
518: $node = $node->serialize($rules);
519: }
520:
521: $delete = $rules->getRemovableKeys($class ?? null, null, $path);
522: $replace = $rules->getReplaceableKeys($class ?? null, null, $path);
523:
524: // Don't delete values returned in both lists
525: $delete = array_diff_key($delete, $replace);
526:
527: if ($delete) {
528: $node = array_diff_key($node, $delete);
529: }
530:
531: $replace = array_intersect_key($replace, $node + ['[]' => null]);
532:
533: foreach ($replace as $key => [$newKey, $closure]) {
534: if ($key !== '[]') {
535: if ($newKey !== null && $key !== $newKey) {
536: $node = Arr::rename($node, $key, $newKey);
537: $key = $newKey;
538: }
539:
540: if ($closure) {
541: if ($node[$key] !== null) {
542: $node[$key] = $closure($node[$key], $store);
543: }
544: continue;
545: }
546:
547: /** @var self[]|self|null */
548: $child = &$node[$key];
549: $this->_serializeId($child);
550: unset($child);
551: continue;
552: }
553:
554: if (!$nodeIsList) {
555: continue;
556: }
557:
558: if ($closure) {
559: foreach ($node as &$child) {
560: if ($child !== null) {
561: $child = $closure($child, $store);
562: }
563: }
564: unset($child);
565: continue;
566: }
567:
568: $_path = $path;
569: $last = array_pop($_path) . '[]';
570: $_path[] = $last;
571: /** @var self|null $child */
572: foreach ($node as &$child) {
573: $this->_serializeId($child);
574: }
575: unset($child);
576: }
577:
578: $isList = false;
579: if (Arr::hasNumericKeys($node)) {
580: $isList = true;
581: $last = array_pop($path) . '[]';
582: $path[] = $last;
583: }
584:
585: unset($_path);
586: foreach ($node as $key => &$child) {
587: if ($child === null || is_scalar($child)) {
588: continue;
589: }
590: if (!$isList) {
591: $_path = $path;
592: $_path[] = (string) $key;
593: }
594: /** @var self|DeferredEntityInterface<self>|DeferredRelationshipInterface<self>|DateTimeInterface|mixed[] $child */
595: $this->_serialize(
596: $child,
597: $_path ?? $path,
598: $rules,
599: $store,
600: $isList,
601: $parents,
602: );
603: }
604: }
605:
606: /**
607: * @param self[]|self|null $node
608: * @param-out ($node is self[] ? array<int|string|null> : ($node is self ? int|string|null : null)) $node
609: */
610: private function _serializeId(&$node): void
611: {
612: if ($node === null) {
613: return;
614: }
615:
616: if (is_array($node)) {
617: foreach ($node as $key => $child) {
618: $children[$key] = $child->Id;
619: }
620: $node = $children ?? [];
621: return;
622: }
623:
624: $node = $node->Id;
625: }
626:
627: /**
628: * Convert the entity to an associative array
629: *
630: * Nested objects and lists are returned as-is. Only the top-level entity is
631: * replaced.
632: *
633: * @param SyncSerializeRulesInterface<static> $rules
634: * @return array<string,mixed>
635: */
636: private function serialize(SyncSerializeRulesInterface $rules): array
637: {
638: $clone = clone $this;
639: $clone->State |= EntityState::SERIALIZING;
640: $array = SyncIntrospector::get(static::class)
641: ->getSerializeClosure($rules)($clone);
642:
643: if (!$rules->getCanonicalId()) {
644: unset($array[self::normaliseProperty('CanonicalId', false)]);
645: }
646:
647: return $array;
648: }
649:
650: /**
651: * @internal
652: *
653: * @return array<string,mixed>
654: */
655: final public function jsonSerialize(): array
656: {
657: return $this->toArray();
658: }
659:
660: /**
661: * @inheritDoc
662: */
663: final public static function provide(
664: array $data,
665: ProviderContextInterface $context
666: ) {
667: $provider = $context->getProvider();
668: $container = $context
669: ->getContainer()
670: ->inContextOf(get_class($provider));
671: $context = $context->withContainer($container);
672:
673: $closure = SyncIntrospector::getService($container, static::class)
674: ->getCreateSyncEntityFromClosure();
675:
676: return $closure($data, $provider, $context);
677: }
678:
679: /**
680: * @inheritDoc
681: */
682: final public static function provideMultiple(
683: iterable $data,
684: ProviderContextInterface $context,
685: int $conformity = Providable::CONFORMITY_NONE
686: ): iterable {
687: $provider = $context->getProvider();
688: $container = $context
689: ->getContainer()
690: ->inContextOf(get_class($provider));
691: $context = $context->withContainer($container);
692: $conformity = max($context->getConformity(), $conformity);
693: $introspector = SyncIntrospector::getService($container, static::class);
694:
695: foreach ($data as $key => $data) {
696: if (!isset($closure)) {
697: $closure = $conformity === self::CONFORMITY_PARTIAL || $conformity === self::CONFORMITY_COMPLETE
698: ? $introspector->getCreateSyncEntityFromSignatureClosure(array_keys($data))
699: : $introspector->getCreateSyncEntityFromClosure();
700: }
701:
702: yield $key => $closure($data, $provider, $context);
703: }
704: }
705:
706: /**
707: * @inheritDoc
708: */
709: final public static function idFromNameOrId(
710: $nameOrId,
711: $providerOrContext,
712: ?float $uncertaintyThreshold = null,
713: ?string $nameProperty = null,
714: ?float &$uncertainty = null
715: ) {
716: if ($nameOrId === null) {
717: $uncertainty = null;
718: return null;
719: }
720:
721: if ($providerOrContext instanceof SyncProviderInterface) {
722: $provider = $providerOrContext;
723: $context = $provider->getContext();
724: } else {
725: $context = $providerOrContext;
726: $provider = $context->getProvider();
727: }
728:
729: if ($provider->isValidIdentifier($nameOrId, static::class)) {
730: $uncertainty = 0.0;
731: return $nameOrId;
732: }
733:
734: $entity = $provider
735: ->with(static::class, $context)
736: ->getResolver(
737: $nameProperty,
738: SyncEntityProviderInterface::ALGORITHM_SAME
739: | SyncEntityProviderInterface::ALGORITHM_CONTAINS
740: | SyncEntityProviderInterface::ALGORITHM_NGRAM_SIMILARITY
741: | SyncEntityProviderInterface::NORMALISE,
742: $uncertaintyThreshold,
743: null,
744: true,
745: )
746: ->getByName((string) $nameOrId, $uncertainty);
747:
748: if ($entity) {
749: return $entity->Id;
750: }
751:
752: throw new SyncEntityNotFoundException(
753: $provider,
754: static::class,
755: $nameProperty === null
756: ? ['name' => $nameOrId]
757: : [$nameProperty => $nameOrId],
758: );
759: }
760:
761: /**
762: * @return array<string,mixed>
763: */
764: public function __serialize(): array
765: {
766: $properties = self::$SerializableNames[static::class] ??= [
767: ...(new SyncEntityReflection(static::class))->getSerializableNames(),
768: static::getDynamicPropertiesProperty(),
769: static::getDynamicPropertyNamesProperty(),
770: ];
771:
772: foreach ($properties as $property) {
773: $data[$property] = $this->{$property};
774: }
775:
776: $data['Provider'] = $this->Provider === null
777: ? null
778: : $this->Provider->getStore()->getProviderSignature($this->Provider);
779:
780: return $data;
781: }
782:
783: /**
784: * @param array<string,mixed> $data
785: */
786: public function __unserialize(array $data): void
787: {
788: foreach ($data as $property => $value) {
789: if ($property === 'Provider' && $value !== null) {
790: $value = is_string($value) && Sync::isLoaded() && Sync::hasProvider($value)
791: ? Sync::getProvider($value)
792: : null;
793: if ($value === null) {
794: throw new UnexpectedValueException('Cannot unserialize missing provider');
795: }
796: }
797: $this->{$property} = $value;
798: }
799: }
800: }
801: