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\AbstractProvider;
15: use Salient\Core\Pipeline;
16: use Salient\Sync\Exception\FilterPolicyViolationException;
17: use Salient\Sync\Exception\SyncEntityRecursionException;
18: use Salient\Sync\Reflection\ReflectionSyncProvider;
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->App, $this);
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: * $this,
149: * $ctx,
150: * )
151: * );
152: * }
153: * }
154: * ```
155: *
156: * @template T of SyncEntityInterface
157: * @template TOutput of iterable<T>|T
158: *
159: * @param Closure(): TOutput $operation
160: * @return TOutput
161: */
162: protected function run(SyncContextInterface $context, Closure $operation)
163: {
164: return $this->filterOperationOutput(
165: $context,
166: $this->runOperation($context, $operation),
167: );
168: }
169:
170: /**
171: * Get a new pipeline for mapping provider data to entities
172: *
173: * @template T of SyncEntityInterface
174: *
175: * @param class-string<T> $entity
176: * @return PipelineInterface<mixed[],T,SyncPipelineArgument>
177: */
178: protected function pipelineFrom(string $entity): PipelineInterface
179: {
180: /** @var PipelineInterface<mixed[],T,SyncPipelineArgument> */
181: return Pipeline::create($this->App);
182: }
183:
184: /**
185: * Get a new pipeline for mapping entities to provider data
186: *
187: * @template T of SyncEntityInterface
188: *
189: * @param class-string<T> $entity
190: * @return PipelineInterface<T,mixed[],SyncPipelineArgument>
191: */
192: protected function pipelineTo(string $entity): PipelineInterface
193: {
194: /** @var PipelineInterface<T,mixed[],SyncPipelineArgument> */
195: return Pipeline::create($this->App);
196: }
197:
198: /**
199: * @inheritDoc
200: */
201: final public static function getServices(): array
202: {
203: $provider = new ReflectionSyncProvider(static::class);
204: return $provider->getSyncProviderInterfaces();
205: }
206:
207: /**
208: * @template TEntity of SyncEntityInterface
209: *
210: * @param class-string<TEntity> $entity
211: * @return SyncEntityProvider<TEntity,static>
212: */
213: final public function with(string $entity, ?SyncContextInterface $context = null): SyncEntityProvider
214: {
215: if ($context) {
216: if ($context->recursionDetected()) {
217: throw new SyncEntityRecursionException(sprintf(
218: 'Circular reference detected: %s',
219: $context->getLastEntity()->getUri($this->Store),
220: ));
221: }
222: $container = $context->getContainer();
223: } else {
224: $container = $this->App;
225: }
226:
227: $container = $container->inContextOf(static::class);
228: $context = ($context ?? $this->getContext())->withContainer($container);
229:
230: return $container->get(
231: SyncEntityProvider::class,
232: ['entity' => $entity, 'provider' => $this, 'context' => $context],
233: );
234: }
235:
236: /**
237: * @template T
238: * @template TOutput of iterable<T>|T
239: *
240: * @param Closure(): TOutput $operation
241: * @return TOutput
242: */
243: final public function runOperation(SyncContextInterface $context, Closure $operation)
244: {
245: if (!$context->hasOperation()) {
246: throw new LogicException('Context has no operation');
247: }
248:
249: if ($context->hasFilter()) {
250: $policy = $context->getProvider()->getFilterPolicy()
251: ?? FilterPolicy::THROW_EXCEPTION;
252:
253: switch ($policy) {
254: case FilterPolicy::IGNORE:
255: break;
256:
257: case FilterPolicy::THROW_EXCEPTION:
258: throw new FilterPolicyViolationException(
259: $this,
260: $context->getEntityType(),
261: $context->getFilters(),
262: );
263:
264: case FilterPolicy::RETURN_EMPTY:
265: /** @var TOutput */
266: return SyncUtil::isListOperation($context->getOperation())
267: ? []
268: : null;
269:
270: case FilterPolicy::FILTER:
271: break;
272:
273: default:
274: throw new LogicException(sprintf(
275: 'Invalid unclaimed filter policy: %d',
276: $policy,
277: ));
278: }
279: }
280:
281: return $operation();
282: }
283:
284: /**
285: * @inheritDoc
286: */
287: final public function filterOperationOutput(SyncContextInterface $context, $output)
288: {
289: if (!$context->hasOperation()) {
290: throw new LogicException('Context has no operation');
291: }
292:
293: if (
294: $context->hasFilter()
295: && $context->getProvider()->getFilterPolicy() === FilterPolicy::FILTER
296: ) {
297: throw new LogicException('Unclaimed filter policy not implemented');
298: }
299:
300: return $output;
301: }
302:
303: /**
304: * @param mixed[] $arguments
305: * @return mixed
306: */
307: final public function __call(string $name, array $arguments)
308: {
309: $name = Str::lower($name);
310: if (array_key_exists($name, $this->MagicMethodClosures)) {
311: $closure = $this->MagicMethodClosures[$name];
312: } else {
313: $closure = SyncIntrospector::get(static::class)->getMagicSyncOperationClosure($name, $this);
314: $this->MagicMethodClosures[$name] = $closure;
315: }
316:
317: if ($closure) {
318: return $closure(...$arguments);
319: }
320:
321: throw new LogicException('Call to undefined method: ' . static::class . "::$name()");
322: }
323: }
324: