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<TEntity>)|(Closure(SyncContextInterface, TEntity, mixed...): TEntity)|(Closure(SyncContextInterface, iterable<TEntity>, mixed...): iterable<TEntity>)
34: * @phpstan-type OverrideClosure (Closure(static, OP::*, SyncContextInterface, int|string|null, mixed...): TEntity)|(Closure(static, OP::*, SyncContextInterface, mixed...): iterable<TEntity>)|(Closure(static, OP::*, SyncContextInterface, TEntity, mixed...): TEntity)|(Closure(static, OP::*, SyncContextInterface, iterable<TEntity>, mixed...): iterable<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<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<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<TEntity>)
78: * : (
79: * $operation is OP::CREATE|OP::UPDATE|OP::DELETE
80: * ? (Closure(SyncContextInterface, TEntity, mixed...): TEntity)
81: * : (Closure(SyncContextInterface, iterable<TEntity>, mixed...): iterable<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<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<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: $closure = fn(SyncContextInterface $ctx, ...$args) =>
398: $this->Overrides[$operation](
399: $this,
400: $operation,
401: $ctx,
402: ...$args
403: );
404: return $this->Closures[$operation] = $closure;
405: }
406:
407: // If a method has been declared for this operation, use it, even if
408: // it's not in $this->Operations
409: $closure = $this->ProviderReflector->getSyncOperationClosure(
410: $operation,
411: $this->EntityReflector,
412: $this->Provider
413: );
414:
415: if ($closure) {
416: /** @var SyncOperationClosure */
417: $closure = fn(SyncContextInterface $ctx, ...$args) =>
418: $closure(
419: $ctx,
420: ...$args
421: );
422: return $this->Closures[$operation] = $closure;
423: }
424:
425: if (
426: $operation === OP::READ
427: && $this->ReadFromList
428: && ($closure = $this->getOperationClosure(OP::READ_LIST))
429: ) {
430: return $this->Closures[$operation] =
431: function (SyncContextInterface $ctx, $id, ...$args) use ($closure) {
432: $entity = $this
433: ->getFluentIterator($closure($ctx, ...$args))
434: ->nextWithValue('Id', $id);
435: if ($entity === null) {
436: throw new SyncEntityNotFoundException($this->Provider, $this->Entity, $id);
437: }
438: return $entity;
439: };
440: }
441:
442: // Return null if the operation doesn't appear in $this->Operations
443: if (!in_array($operation, $this->Operations, true)) {
444: return $this->Closures[$operation] = null;
445: }
446:
447: // Otherwise, get a closure from the subclass
448: return $this->Closures[$operation] = $this->getClosure($operation);
449: }
450:
451: /**
452: * Ignoring overrides, get a closure to perform a sync operation on the
453: * entity, throwing an exception if the operation is not supported
454: *
455: * @param OP::* $operation
456: * @return (
457: * $operation is OP::READ
458: * ? (Closure(SyncContextInterface, int|string|null, mixed...): TEntity)
459: * : (
460: * $operation is OP::READ_LIST
461: * ? (Closure(SyncContextInterface, mixed...): iterable<TEntity>)
462: * : (
463: * $operation is OP::CREATE|OP::UPDATE|OP::DELETE
464: * ? (Closure(SyncContextInterface, TEntity, mixed...): TEntity)
465: * : (Closure(SyncContextInterface, iterable<TEntity>, mixed...): iterable<TEntity>)
466: * )
467: * )
468: * )
469: * @throws LogicException If the operation is not supported.
470: */
471: final public function getFallbackClosure(int $operation): Closure
472: {
473: $closure = ($this->WithoutOverrides ??= $this->with('Overrides', []))
474: ->getOperationClosure($operation);
475:
476: if ($closure === null) {
477: throw new LogicException(sprintf(
478: 'SyncOperation::%s not supported on %s',
479: Reflect::getConstantName(OP::class, $operation),
480: $this->Entity,
481: ));
482: }
483:
484: return $closure;
485: }
486:
487: /**
488: * Get an entity-to-data pipeline for the entity
489: *
490: * Before returning the pipeline:
491: *
492: * - a pipe that serializes any unserialized {@see SyncEntityInterface}
493: * instances is added via {@see PipelineInterface::through()}
494: *
495: * @return PipelineInterface<TEntity,mixed[],SyncPipelineArgument>
496: */
497: final protected function getPipelineToBackend(): PipelineInterface
498: {
499: /** @var PipelineInterface<TEntity,mixed[],SyncPipelineArgument> */
500: $pipeline = $this->PipelineToBackend ?? Pipeline::create();
501:
502: /** @var PipelineInterface<TEntity,mixed[],SyncPipelineArgument> */
503: $pipeline = $pipeline->through(
504: fn($payload, Closure $next) =>
505: $payload instanceof SyncEntityInterface
506: ? $next($payload->toArray())
507: : $next($payload)
508: );
509:
510: return $pipeline;
511: }
512:
513: /**
514: * Get a data-to-entity pipeline for the entity
515: *
516: * Before returning the pipeline:
517: *
518: * - if the definition has a key map, it is applied via
519: * {@see PipelineInterface::throughKeyMap()}
520: * - a closure to create instances of the entity from arrays returned by the
521: * pipeline is applied via {@see PipelineInterface::then()}
522: *
523: * @return PipelineInterface<mixed[],TEntity,SyncPipelineArgument>
524: */
525: final protected function getPipelineFromBackend(): PipelineInterface
526: {
527: /** @var PipelineInterface<mixed[],TEntity,SyncPipelineArgument> */
528: $pipeline = $this->PipelineFromBackend ?? Pipeline::create();
529:
530: if ($this->KeyMap !== null) {
531: $pipeline = $pipeline->throughKeyMap($this->KeyMap, $this->KeyMapFlags);
532: }
533:
534: /** @var SyncPipelineArgument|null */
535: $currentArg = null;
536: /** @var SyncContextInterface|null */
537: $ctx = null;
538:
539: return $pipeline
540: ->then(function (
541: array $data,
542: PipelineInterface $pipeline,
543: SyncPipelineArgument $arg
544: ) use (&$ctx, &$closure, &$currentArg) {
545: if (!$ctx || !$closure || $currentArg !== $arg) {
546: $ctx = $arg->Context->withConformity($this->Conformity);
547: $closure = in_array(
548: $this->Conformity,
549: [ListConformity::PARTIAL, ListConformity::COMPLETE]
550: )
551: ? SyncIntrospector::getService($ctx->getContainer(), $this->Entity)
552: ->getCreateSyncEntityFromSignatureClosure(array_keys($data))
553: : SyncIntrospector::getService($ctx->getContainer(), $this->Entity)
554: ->getCreateSyncEntityFromClosure();
555: $currentArg = $arg;
556: }
557: /** @var TEntity */
558: $entity = $closure($data, $this->Provider, $ctx);
559:
560: return $entity;
561: });
562: }
563:
564: /**
565: * @param iterable<TEntity> $result
566: * @return FluentIteratorInterface<array-key,TEntity>
567: */
568: private function getFluentIterator(iterable $result): FluentIteratorInterface
569: {
570: if (!$result instanceof FluentIteratorInterface) {
571: return IterableIterator::fromValues($result);
572: }
573:
574: return $result;
575: }
576:
577: /**
578: * @inheritDoc
579: */
580: public static function getReadableProperties(): array
581: {
582: return [
583: 'Entity',
584: 'Provider',
585: 'Operations',
586: 'Conformity',
587: 'FilterPolicy',
588: 'Overrides',
589: 'KeyMap',
590: 'KeyMapFlags',
591: 'PipelineFromBackend',
592: 'PipelineToBackend',
593: 'ReadFromList',
594: 'ReturnEntitiesFrom',
595: ];
596: }
597: }
598: