1: <?php declare(strict_types=1);
2:
3: namespace Salient\Sync;
4:
5: use Salient\Contract\Container\ContainerInterface;
6: use Salient\Contract\Container\HasContextualBindings;
7: use Salient\Contract\Container\HasServices;
8: use Salient\Contract\Core\Pipeline\PipelineInterface;
9: use Salient\Contract\Sync\FilterPolicy;
10: use Salient\Contract\Sync\SyncContextInterface;
11: use Salient\Contract\Sync\SyncEntityInterface;
12: use Salient\Contract\Sync\SyncProviderInterface;
13: use Salient\Contract\Sync\SyncStoreInterface;
14: use Salient\Core\Provider\AbstractProvider;
15: use Salient\Core\Pipeline;
16: use Salient\Sync\Exception\FilterPolicyViolationException;
17: use Salient\Sync\Exception\SyncEntityRecursionException;
18: use Salient\Sync\Reflection\SyncProviderReflection;
19: use Salient\Sync\Support\SyncContext;
20: use Salient\Sync\Support\SyncEntityProvider;
21: use Salient\Sync\Support\SyncIntrospector;
22: use Salient\Sync\Support\SyncPipelineArgument;
23: use Salient\Utility\Regex;
24: use Salient\Utility\Str;
25: use Closure;
26: use LogicException;
27:
28: /**
29: * Base class for providers that sync entities to and from third-party backends
30: */
31: abstract class AbstractSyncProvider extends AbstractProvider implements
32: SyncProviderInterface,
33: HasServices,
34: HasContextualBindings
35: {
36: /**
37: * Get a dependency substitution map for the provider
38: *
39: * {@inheritDoc}
40: *
41: * Override this method to bind any {@see SyncEntityInterface} classes
42: * customised for the provider to their generic parent classes, e.g.:
43: *
44: * ```php
45: * <?php
46: * public static function getContextualBindings(): array
47: * {
48: * return [
49: * Post::class => CustomPost::class,
50: * User::class => CustomUser::class,
51: * ];
52: * }
53: * ```
54: */
55: public static function getContextualBindings(): array
56: {
57: return [];
58: }
59:
60: protected SyncStoreInterface $Store;
61: private int $Id;
62: /** @var array<string,Closure|null> */
63: private array $MagicMethodClosures = [];
64:
65: /**
66: * Creates a new sync provider object
67: *
68: * Creating an instance of the provider registers it with the entity store
69: * injected by the container.
70: */
71: public function __construct(ContainerInterface $app, SyncStoreInterface $store)
72: {
73: parent::__construct($app);
74:
75: $this->Store = $store;
76: $this->Store->registerProvider($this);
77: }
78:
79: /**
80: * @inheritDoc
81: */
82: public function getContext(): SyncContextInterface
83: {
84: return new SyncContext($this, $this->App);
85: }
86:
87: /**
88: * @inheritDoc
89: */
90: public function getFilterPolicy(): ?int
91: {
92: return null;
93: }
94:
95: /**
96: * @inheritDoc
97: */
98: public function isValidIdentifier($id, string $entity): bool
99: {
100: return is_int($id)
101: || Regex::match(Regex::delimit('^' . Regex::MONGODB_OBJECTID . '$', '/'), $id)
102: || Regex::match(Regex::delimit('^' . Regex::UUID . '$', '/'), $id);
103: }
104:
105: /**
106: * @inheritDoc
107: */
108: final public function getStore(): SyncStoreInterface
109: {
110: return $this->Store;
111: }
112:
113: /**
114: * @inheritDoc
115: */
116: final public function getProviderId(): int
117: {
118: return $this->Id ??= $this->Store->getProviderId($this);
119: }
120:
121: /**
122: * Perform a sync operation if its context is valid
123: *
124: * Providers where sync operations are performed by declared methods should
125: * use this method to ensure filter policy violations are caught and to take
126: * advantage of other safety checks that may be added in the future.
127: *
128: * Example:
129: *
130: * ```php
131: * <?php
132: * class Provider extends HttpSyncProvider
133: * {
134: * public function getEntities(SyncContextInterface $ctx): iterable
135: * {
136: * // Claim filter values
137: * $start = $ctx->claimFilter('start_date');
138: * $end = $ctx->claimFilter('end_date');
139: *
140: * return $this->run(
141: * $ctx,
142: * fn(): iterable =>
143: * Entity::provide(
144: * $this->getCurler('/entities')->getP([
145: * 'from' => $start,
146: * 'to' => $end,
147: * ]),
148: * $ctx,
149: * )
150: * );
151: * }
152: * }
153: * ```
154: *
155: * @template T of SyncEntityInterface
156: * @template TOutput of iterable<T>|T
157: *
158: * @param Closure(): TOutput $operation
159: * @return TOutput
160: */
161: protected function run(SyncContextInterface $context, Closure $operation)
162: {
163: return $this->filterOperationOutput(
164: $context,
165: $this->runOperation($context, $operation),
166: );
167: }
168:
169: /**
170: * Get a new pipeline for mapping provider data to entities
171: *
172: * @template T of SyncEntityInterface
173: *
174: * @param class-string<T> $entity
175: * @return PipelineInterface<mixed[],T,SyncPipelineArgument>
176: */
177: protected function pipelineFrom(string $entity): PipelineInterface
178: {
179: /** @var PipelineInterface<mixed[],T,SyncPipelineArgument> */
180: return Pipeline::create();
181: }
182:
183: /**
184: * Get a new pipeline for mapping entities to provider data
185: *
186: * @template T of SyncEntityInterface
187: *
188: * @param class-string<T> $entity
189: * @return PipelineInterface<T,mixed[],SyncPipelineArgument>
190: */
191: protected function pipelineTo(string $entity): PipelineInterface
192: {
193: /** @var PipelineInterface<T,mixed[],SyncPipelineArgument> */
194: return Pipeline::create();
195: }
196:
197: /**
198: * @inheritDoc
199: */
200: final public static function getServices(): array
201: {
202: $provider = new SyncProviderReflection(static::class);
203: return $provider->getSyncProviderInterfaces();
204: }
205:
206: /**
207: * @template TEntity of SyncEntityInterface
208: *
209: * @param class-string<TEntity> $entity
210: * @return SyncEntityProvider<TEntity,static>
211: */
212: final public function with(string $entity, ?SyncContextInterface $context = null): SyncEntityProvider
213: {
214: if ($context) {
215: if ($context->recursionDetected()) {
216: throw new SyncEntityRecursionException(sprintf(
217: 'Circular reference detected: %s',
218: $context->getLastEntity()->getUri($this->Store),
219: ));
220: }
221: $container = $context->getContainer();
222: } else {
223: $container = $this->App;
224: }
225:
226: $container = $container->inContextOf(static::class);
227: $context = ($context ?? $this->getContext())->withContainer($container);
228:
229: return $container->get(
230: SyncEntityProvider::class,
231: ['entity' => $entity, 'provider' => $this, 'context' => $context],
232: );
233: }
234:
235: /**
236: * @template T
237: * @template TOutput of iterable<T>|T
238: *
239: * @param Closure(): TOutput $operation
240: * @return TOutput
241: */
242: final public function runOperation(SyncContextInterface $context, Closure $operation)
243: {
244: if (!$context->hasOperation()) {
245: throw new LogicException('Context has no operation');
246: }
247:
248: if ($context->hasFilter()) {
249: $policy = $context->getProvider()->getFilterPolicy()
250: ?? FilterPolicy::THROW_EXCEPTION;
251:
252: switch ($policy) {
253: case FilterPolicy::IGNORE:
254: break;
255:
256: case FilterPolicy::THROW_EXCEPTION:
257: throw new FilterPolicyViolationException(
258: $this,
259: $context->getEntityType(),
260: $context->getFilters(),
261: );
262:
263: case FilterPolicy::RETURN_EMPTY:
264: /** @var TOutput */
265: return SyncUtil::isListOperation($context->getOperation())
266: ? []
267: : null;
268:
269: case FilterPolicy::FILTER:
270: break;
271:
272: default:
273: throw new LogicException(sprintf(
274: 'Invalid unclaimed filter policy: %d',
275: $policy,
276: ));
277: }
278: }
279:
280: return $operation();
281: }
282:
283: /**
284: * @inheritDoc
285: */
286: final public function filterOperationOutput(SyncContextInterface $context, $output)
287: {
288: if (!$context->hasOperation()) {
289: throw new LogicException('Context has no operation');
290: }
291:
292: if (
293: $context->hasFilter()
294: && $context->getProvider()->getFilterPolicy() === FilterPolicy::FILTER
295: ) {
296: throw new LogicException('Unclaimed filter policy not implemented');
297: }
298:
299: return $output;
300: }
301:
302: /**
303: * @param mixed[] $arguments
304: * @return mixed
305: */
306: final public function __call(string $name, array $arguments)
307: {
308: $name = Str::lower($name);
309: if (array_key_exists($name, $this->MagicMethodClosures)) {
310: $closure = $this->MagicMethodClosures[$name];
311: } else {
312: $closure = SyncIntrospector::get(static::class)->getMagicSyncOperationClosure($name, $this);
313: $this->MagicMethodClosures[$name] = $closure;
314: }
315:
316: if ($closure) {
317: return $closure(...$arguments);
318: }
319:
320: throw new LogicException('Call to undefined method: ' . static::class . "::$name()");
321: }
322: }
323: