1: <?php declare(strict_types=1);
2:
3: namespace Salient\Sync;
4:
5: use Salient\Contract\Core\Entity\Readable;
6: use Salient\Contract\Core\Pipeline\PipelineInterface;
7: use Salient\Contract\Core\ArrayMapperInterface;
8: use Salient\Contract\Core\Chainable;
9: use Salient\Contract\Core\ListConformity;
10: use Salient\Contract\Iterator\FluentIteratorInterface;
11: use Salient\Contract\Sync\EntitySource;
12: use Salient\Contract\Sync\FilterPolicy;
13: use Salient\Contract\Sync\SyncContextInterface;
14: use Salient\Contract\Sync\SyncDefinitionInterface;
15: use Salient\Contract\Sync\SyncEntityInterface;
16: use Salient\Contract\Sync\SyncOperation as OP;
17: use Salient\Contract\Sync\SyncProviderInterface;
18: use Salient\Core\Concern\HasChainableMethods;
19: use Salient\Core\Concern\HasMutator;
20: use Salient\Core\Concern\HasReadableProperties;
21: use Salient\Core\Pipeline;
22: use Salient\Iterator\IterableIterator;
23: use Salient\Sync\Exception\SyncEntityNotFoundException;
24: use Salient\Sync\Reflection\ReflectionSyncEntity;
25: use Salient\Sync\Reflection\ReflectionSyncProvider;
26: use Salient\Sync\Support\SyncIntrospector;
27: use Salient\Sync\Support\SyncPipelineArgument;
28: use Salient\Utility\Reflect;
29: use Closure;
30: use LogicException;
31:
32: /**
33: * @phpstan-type SyncOperationClosure (Closure(SyncContextInterface, int|string|null, mixed...): TEntity)|(Closure(SyncContextInterface, mixed...): iterable<array-key,TEntity>)|(Closure(SyncContextInterface, TEntity, mixed...): TEntity)|(Closure(SyncContextInterface, iterable<TEntity>, mixed...): iterable<array-key,TEntity>)
34: * @phpstan-type OverrideClosure (Closure(static, OP::*, SyncContextInterface, int|string|null, mixed...): TEntity)|(Closure(static, OP::*, SyncContextInterface, mixed...): iterable<array-key,TEntity>)|(Closure(static, OP::*, SyncContextInterface, TEntity, mixed...): TEntity)|(Closure(static, OP::*, SyncContextInterface, iterable<TEntity>, mixed...): iterable<array-key,TEntity>)
35: *
36: * @property-read class-string<TEntity> $Entity The entity being serviced
37: * @property-read TProvider $Provider The provider servicing the entity
38: * @property-read array<OP::*> $Operations Supported sync operations
39: * @property-read ListConformity::* $Conformity Conformity level of data returned by the provider for this entity
40: * @property-read FilterPolicy::* $FilterPolicy Action to take when filters are not claimed by the provider
41: * @property-read array<OP::*,Closure(SyncDefinitionInterface<TEntity,TProvider>, OP::*, SyncContextInterface, mixed...): (iterable<array-key,TEntity>|TEntity)> $Overrides Array that maps sync operations to closures that override other implementations
42: * @phpstan-property-read array<OP::*,OverrideClosure> $Overrides
43: * @property-read array<array-key,array-key|array-key[]>|null $KeyMap Array that maps keys to properties for entity data returned by the provider
44: * @property-read int-mask-of<ArrayMapperInterface::*> $KeyMapFlags Array mapper flags used if a key map is provided
45: * @property-read PipelineInterface<mixed[],TEntity,SyncPipelineArgument>|null $PipelineFromBackend Pipeline that maps provider data to a serialized entity, or `null` if mapping is not required
46: * @property-read PipelineInterface<TEntity,mixed[],SyncPipelineArgument>|null $PipelineToBackend Pipeline that maps a serialized entity to provider data, or `null` if mapping is not required
47: * @property-read bool $ReadFromList Perform READ operations by iterating over entities returned by READ_LIST
48: * @property-read EntitySource::*|null $ReturnEntitiesFrom Source of entity data for the return value of a successful CREATE, UPDATE or DELETE operation
49: *
50: * @template TEntity of SyncEntityInterface
51: * @template TProvider of SyncProviderInterface
52: *
53: * @implements SyncDefinitionInterface<TEntity,TProvider>
54: */
55: abstract class AbstractSyncDefinition implements SyncDefinitionInterface, Chainable, Readable
56: {
57: use HasChainableMethods;
58: use HasMutator;
59: use HasReadableProperties;
60:
61: /**
62: * Get a closure to perform a sync operation on the entity
63: *
64: * This method is called if:
65: *
66: * - the operation is in {@see AbstractSyncDefinition::$Operations},
67: * - there is no override for the operation, and
68: * - the provider has not implemented the operation via a declared method
69: *
70: * @param OP::* $operation
71: * @return (Closure(SyncContextInterface, mixed...): (iterable<array-key,TEntity>|TEntity))|null
72: * @phpstan-return (
73: * $operation is OP::READ
74: * ? (Closure(SyncContextInterface, int|string|null, mixed...): TEntity)
75: * : (
76: * $operation is OP::READ_LIST
77: * ? (Closure(SyncContextInterface, mixed...): iterable<array-key,TEntity>)
78: * : (
79: * $operation is OP::CREATE|OP::UPDATE|OP::DELETE
80: * ? (Closure(SyncContextInterface, TEntity, mixed...): TEntity)
81: * : (Closure(SyncContextInterface, iterable<TEntity>, mixed...): iterable<array-key,TEntity>)
82: * )
83: * )
84: * )|null
85: */
86: abstract protected function getClosure(int $operation): ?Closure;
87:
88: /**
89: * The entity being serviced
90: *
91: * @var class-string<TEntity>
92: */
93: protected string $Entity;
94:
95: /**
96: * The provider servicing the entity
97: *
98: * @var TProvider
99: */
100: protected SyncProviderInterface $Provider;
101:
102: /**
103: * Supported sync operations
104: *
105: * @var array<OP::*>
106: */
107: protected array $Operations;
108:
109: /**
110: * Conformity level of data returned by the provider for this entity
111: *
112: * Use {@see ListConformity::COMPLETE} or {@see ListConformity::PARTIAL}
113: * wherever possible to improve performance.
114: *
115: * @var ListConformity::*
116: */
117: protected $Conformity;
118:
119: /**
120: * Action to take when filters are not claimed by the provider
121: *
122: * To prevent a request for entities that meet one or more criteria
123: * inadvertently reaching the backend as a request for a larger set of
124: * entities--if not all of them--the default policy if there are unclaimed
125: * filters is {@see FilterPolicy::THROW_EXCEPTION}.
126: *
127: * @see SyncContextInterface::withOperation()
128: *
129: * @var FilterPolicy::*
130: */
131: protected int $FilterPolicy;
132:
133: /**
134: * Array that maps sync operations to closures that override other
135: * implementations
136: *
137: * Two arguments are inserted before the operation's arguments:
138: *
139: * - The sync definition object
140: * - The sync operation
141: *
142: * Operations implemented here are added to
143: * {@see AbstractSyncDefinition::$Operations} automatically.
144: *
145: * @var array<OP::*,Closure(SyncDefinitionInterface<TEntity,TProvider>, OP::*, SyncContextInterface, mixed...): (iterable<array-key,TEntity>|TEntity)>
146: * @phpstan-var array<OP::*,OverrideClosure>
147: */
148: protected array $Overrides = [];
149:
150: /**
151: * Array that maps keys to properties for entity data returned by the
152: * provider
153: *
154: * Providing a key map has the same effect as passing the following pipeline
155: * to `$pipelineFromBackend`:
156: *
157: * ```php
158: * <?php
159: * Pipeline::create()->throughKeyMap($keyMap);
160: * ```
161: *
162: * @var array<array-key,array-key|array-key[]>|null
163: */
164: protected ?array $KeyMap;
165:
166: /**
167: * Array mapper flags used if a key map is provided
168: *
169: * @var int-mask-of<ArrayMapperInterface::*>
170: */
171: protected int $KeyMapFlags;
172:
173: /**
174: * Pipeline that maps provider data to a serialized entity, or `null` if
175: * mapping is not required
176: *
177: * @var PipelineInterface<mixed[],TEntity,SyncPipelineArgument>|null
178: */
179: protected ?PipelineInterface $PipelineFromBackend;
180:
181: /**
182: * Pipeline that maps a serialized entity to provider data, or `null` if
183: * mapping is not required
184: *
185: * @var PipelineInterface<TEntity,mixed[],SyncPipelineArgument>|null
186: */
187: protected ?PipelineInterface $PipelineToBackend;
188:
189: /**
190: * Perform READ operations by iterating over entities returned by READ_LIST
191: *
192: * Useful with backends that don't provide an endpoint for retrieval of
193: * individual entities.
194: */
195: protected bool $ReadFromList;
196:
197: /**
198: * Source of entity data for the return value of a successful CREATE, UPDATE
199: * or DELETE operation
200: *
201: * @var EntitySource::*|null
202: */
203: protected ?int $ReturnEntitiesFrom;
204:
205: /** @var ReflectionSyncEntity<TEntity> */
206: protected ReflectionSyncEntity $EntityReflector;
207: /** @var ReflectionSyncProvider<TProvider> */
208: protected ReflectionSyncProvider $ProviderReflector;
209: /** @var array<OP::*,SyncOperationClosure|null> */
210: private array $Closures = [];
211: /** @var static|null */
212: private ?self $WithoutOverrides = null;
213:
214: /**
215: * @param class-string<TEntity> $entity
216: * @param TProvider $provider
217: * @param array<OP::*> $operations
218: * @param ListConformity::* $conformity
219: * @param FilterPolicy::*|null $filterPolicy
220: * @param array<int-mask-of<OP::*>,Closure(SyncDefinitionInterface<TEntity,TProvider>, OP::*, SyncContextInterface, mixed...): (iterable<array-key,TEntity>|TEntity)> $overrides
221: * @phpstan-param array<int-mask-of<OP::*>,OverrideClosure> $overrides
222: * @param array<array-key,array-key|array-key[]>|null $keyMap
223: * @param int-mask-of<ArrayMapperInterface::*> $keyMapFlags
224: * @param PipelineInterface<mixed[],TEntity,SyncPipelineArgument>|null $pipelineFromBackend
225: * @param PipelineInterface<TEntity,mixed[],SyncPipelineArgument>|null $pipelineToBackend
226: * @param EntitySource::*|null $returnEntitiesFrom
227: */
228: public function __construct(
229: string $entity,
230: SyncProviderInterface $provider,
231: array $operations = [],
232: int $conformity = ListConformity::NONE,
233: ?int $filterPolicy = null,
234: array $overrides = [],
235: ?array $keyMap = null,
236: int $keyMapFlags = ArrayMapperInterface::ADD_UNMAPPED,
237: ?PipelineInterface $pipelineFromBackend = null,
238: ?PipelineInterface $pipelineToBackend = null,
239: bool $readFromList = false,
240: ?int $returnEntitiesFrom = null
241: ) {
242: $this->Entity = $entity;
243: $this->Provider = $provider;
244: $this->Conformity = $conformity;
245: $this->FilterPolicy = $filterPolicy
246: ?? $provider->getFilterPolicy() ?? FilterPolicy::THROW_EXCEPTION;
247: $this->KeyMap = $keyMap;
248: $this->KeyMapFlags = $keyMapFlags;
249: $this->PipelineFromBackend = $pipelineFromBackend;
250: $this->PipelineToBackend = $pipelineToBackend;
251: $this->ReadFromList = $readFromList;
252: $this->ReturnEntitiesFrom = $returnEntitiesFrom;
253:
254: /** @var list<int&OP::*> */
255: $allOps = array_values(Reflect::getConstants(OP::class));
256:
257: // Expand $overrides into an entry per operation
258: foreach ($overrides as $ops => $override) {
259: foreach ($allOps as $op) {
260: if (!($ops & $op)) {
261: continue;
262: }
263: if (array_key_exists($op, $this->Overrides)) {
264: throw new LogicException(sprintf(
265: 'Too many overrides for SyncOperation::%s on %s: %s',
266: Reflect::getConstantName(OP::class, $op),
267: $entity,
268: get_class($provider),
269: ));
270: }
271: $this->Overrides[$op] = $override;
272: $operations[] = $op;
273: }
274: }
275:
276: $this->Operations = array_intersect($allOps, $operations);
277: $this->EntityReflector = new ReflectionSyncEntity($entity);
278: $this->ProviderReflector = new ReflectionSyncProvider($provider);
279: }
280:
281: /**
282: * @internal
283: */
284: public function __clone()
285: {
286: $this->Closures = [];
287: $this->WithoutOverrides = null;
288: }
289:
290: /**
291: * Get an instance with the given entity data conformity level
292: *
293: * @param ListConformity::* $conformity
294: * @return static
295: */
296: final public function withConformity($conformity)
297: {
298: return $this->with('Conformity', $conformity);
299: }
300:
301: /**
302: * Get an instance with the given unclaimed filter policy
303: *
304: * @param FilterPolicy::* $policy
305: * @return static
306: */
307: final public function withFilterPolicy(int $policy)
308: {
309: return $this->with('FilterPolicy', $policy);
310: }
311:
312: /**
313: * Get an instance that maps keys to the given properties for entity data
314: * returned by the provider
315: *
316: * @param array<array-key,array-key|array-key[]>|null $map
317: * @return static
318: */
319: final public function withKeyMap(?array $map)
320: {
321: return $this->with('KeyMap', $map);
322: }
323:
324: /**
325: * Get an instance where the given array mapper flags are used if a key map
326: * is provided
327: *
328: * @param int-mask-of<ArrayMapperInterface::*> $flags
329: * @return static
330: */
331: final public function withKeyMapFlags(int $flags)
332: {
333: return $this->with('KeyMapFlags', $flags);
334: }
335:
336: /**
337: * Get an instance that uses the given pipeline to map provider data to a
338: * serialized entity
339: *
340: * @param PipelineInterface<mixed[],TEntity,SyncPipelineArgument>|null $pipeline
341: * @return static
342: */
343: final public function withPipelineFromBackend(?PipelineInterface $pipeline)
344: {
345: return $this->with('PipelineFromBackend', $pipeline);
346: }
347:
348: /**
349: * Get an instance that uses the given pipeline to map a serialized entity
350: * to provider data
351: *
352: * @param PipelineInterface<TEntity,mixed[],SyncPipelineArgument>|null $pipeline
353: * @return static
354: */
355: final public function withPipelineToBackend(?PipelineInterface $pipeline)
356: {
357: return $this->with('PipelineToBackend', $pipeline);
358: }
359:
360: /**
361: * Get an instance that performs READ operations by iterating over entities
362: * returned by READ_LIST
363: *
364: * @return static
365: */
366: final public function withReadFromList(bool $readFromList = true)
367: {
368: return $this->with('ReadFromList', $readFromList);
369: }
370:
371: /**
372: * Get an instance that uses the given entity data source for the return
373: * value of a successful CREATE, UPDATE or DELETE operation
374: *
375: * @param EntitySource::*|null $source
376: * @return static
377: */
378: final public function withReturnEntitiesFrom(?int $source)
379: {
380: return $this->with('ReturnEntitiesFrom', $source);
381: }
382:
383: /**
384: * @inheritDoc
385: */
386: final public function getOperationClosure(int $operation): ?Closure
387: {
388: // Return a previous result if possible
389: if (array_key_exists($operation, $this->Closures)) {
390: return $this->Closures[$operation];
391: }
392:
393: // Overrides take precedence over everything else, including declared
394: // methods
395: if (array_key_exists($operation, $this->Overrides)) {
396: /** @var SyncOperationClosure */
397: // @phpstan-ignore varTag.nativeType
398: $closure = fn(SyncContextInterface $ctx, ...$args) =>
399: $this->Overrides[$operation](
400: $this,
401: $operation,
402: $ctx,
403: ...$args
404: );
405: return $this->Closures[$operation] = $closure;
406: }
407:
408: // If a method has been declared for this operation, use it, even if
409: // it's not in $this->Operations
410: $closure = $this->ProviderReflector->getSyncOperationClosure(
411: $operation,
412: $this->EntityReflector,
413: $this->Provider
414: );
415:
416: if ($closure) {
417: /** @var SyncOperationClosure */
418: // @phpstan-ignore varTag.nativeType
419: $closure = fn(SyncContextInterface $ctx, ...$args) =>
420: $closure(
421: $ctx,
422: ...$args
423: );
424: return $this->Closures[$operation] = $closure;
425: }
426:
427: if (
428: $operation === OP::READ
429: && $this->ReadFromList
430: && ($closure = $this->getOperationClosure(OP::READ_LIST))
431: ) {
432: return $this->Closures[$operation] =
433: function (SyncContextInterface $ctx, $id, ...$args) use ($closure) {
434: $entity = $this
435: ->getFluentIterator($closure($ctx, ...$args))
436: ->nextWithValue('Id', $id);
437: if ($entity === null) {
438: throw new SyncEntityNotFoundException($this->Provider, $this->Entity, $id);
439: }
440: return $entity;
441: };
442: }
443:
444: // Return null if the operation doesn't appear in $this->Operations
445: if (!in_array($operation, $this->Operations, true)) {
446: return $this->Closures[$operation] = null;
447: }
448:
449: // Otherwise, get a closure from the subclass
450: return $this->Closures[$operation] = $this->getClosure($operation);
451: }
452:
453: /**
454: * Ignoring overrides, get a closure to perform a sync operation on the
455: * entity, throwing an exception if the operation is not supported
456: *
457: * @param OP::* $operation
458: * @return (
459: * $operation is OP::READ
460: * ? (Closure(SyncContextInterface, int|string|null, mixed...): TEntity)
461: * : (
462: * $operation is OP::READ_LIST
463: * ? (Closure(SyncContextInterface, mixed...): iterable<array-key,TEntity>)
464: * : (
465: * $operation is OP::CREATE|OP::UPDATE|OP::DELETE
466: * ? (Closure(SyncContextInterface, TEntity, mixed...): TEntity)
467: * : (Closure(SyncContextInterface, iterable<TEntity>, mixed...): iterable<array-key,TEntity>)
468: * )
469: * )
470: * )
471: * @throws LogicException If the operation is not supported.
472: */
473: final public function getFallbackClosure(int $operation): Closure
474: {
475: $closure = ($this->WithoutOverrides ??= $this->with('Overrides', []))
476: ->getOperationClosure($operation);
477:
478: if ($closure === null) {
479: throw new LogicException(sprintf(
480: 'SyncOperation::%s not supported on %s',
481: Reflect::getConstantName(OP::class, $operation),
482: $this->Entity,
483: ));
484: }
485:
486: return $closure;
487: }
488:
489: /**
490: * Get an entity-to-data pipeline for the entity
491: *
492: * Before returning the pipeline:
493: *
494: * - a pipe that serializes any unserialized {@see SyncEntityInterface}
495: * instances is added via {@see PipelineInterface::through()}
496: *
497: * @return PipelineInterface<TEntity,mixed[],SyncPipelineArgument>
498: */
499: final protected function getPipelineToBackend(): PipelineInterface
500: {
501: /** @var PipelineInterface<TEntity,mixed[],SyncPipelineArgument> */
502: $pipeline = $this->PipelineToBackend ?? Pipeline::create();
503:
504: /** @var PipelineInterface<TEntity,mixed[],SyncPipelineArgument> */
505: $pipeline = $pipeline->through(
506: fn($payload, Closure $next) =>
507: $payload instanceof SyncEntityInterface
508: ? $next($payload->toArray())
509: : $next($payload)
510: );
511:
512: return $pipeline;
513: }
514:
515: /**
516: * Get a data-to-entity pipeline for the entity
517: *
518: * Before returning the pipeline:
519: *
520: * - if the definition has a key map, it is applied via
521: * {@see PipelineInterface::throughKeyMap()}
522: * - a closure to create instances of the entity from arrays returned by the
523: * pipeline is applied via {@see PipelineInterface::then()}
524: *
525: * @return PipelineInterface<mixed[],TEntity,SyncPipelineArgument>
526: */
527: final protected function getPipelineFromBackend(): PipelineInterface
528: {
529: /** @var PipelineInterface<mixed[],TEntity,SyncPipelineArgument> */
530: $pipeline = $this->PipelineFromBackend ?? Pipeline::create();
531:
532: if ($this->KeyMap !== null) {
533: $pipeline = $pipeline->throughKeyMap($this->KeyMap, $this->KeyMapFlags);
534: }
535:
536: /** @var SyncPipelineArgument|null */
537: $currentArg = null;
538: /** @var SyncContextInterface|null */
539: $ctx = null;
540:
541: return $pipeline
542: ->then(function (
543: array $data,
544: PipelineInterface $pipeline,
545: SyncPipelineArgument $arg
546: ) use (&$ctx, &$closure, &$currentArg) {
547: if (!$ctx || !$closure || $currentArg !== $arg) {
548: $ctx = $arg->Context->withConformity($this->Conformity);
549: $closure = in_array(
550: $this->Conformity,
551: [ListConformity::PARTIAL, ListConformity::COMPLETE]
552: )
553: ? SyncIntrospector::getService($ctx->getContainer(), $this->Entity)
554: ->getCreateSyncEntityFromSignatureClosure(array_keys($data))
555: : SyncIntrospector::getService($ctx->getContainer(), $this->Entity)
556: ->getCreateSyncEntityFromClosure();
557: $currentArg = $arg;
558: }
559: /** @var TEntity */
560: $entity = $closure($data, $this->Provider, $ctx);
561:
562: return $entity;
563: });
564: }
565:
566: /**
567: * @param iterable<TEntity> $result
568: * @return FluentIteratorInterface<array-key,TEntity>
569: */
570: private function getFluentIterator(iterable $result): FluentIteratorInterface
571: {
572: if (!$result instanceof FluentIteratorInterface) {
573: return IterableIterator::fromValues($result);
574: }
575:
576: return $result;
577: }
578:
579: /**
580: * @inheritDoc
581: */
582: public static function getReadableProperties(): array
583: {
584: return [
585: 'Entity',
586: 'Provider',
587: 'Operations',
588: 'Conformity',
589: 'FilterPolicy',
590: 'Overrides',
591: 'KeyMap',
592: 'KeyMapFlags',
593: 'PipelineFromBackend',
594: 'PipelineToBackend',
595: 'ReadFromList',
596: 'ReturnEntitiesFrom',
597: ];
598: }
599: }
600: