1: <?php declare(strict_types=1);
2:
3: namespace Salient\Sync\Support;
4:
5: use Salient\Contract\Sync\DeferralPolicy;
6: use Salient\Contract\Sync\HydrationPolicy;
7: use Salient\Contract\Sync\SyncContextInterface;
8: use Salient\Contract\Sync\SyncEntityInterface;
9: use Salient\Contract\Sync\SyncOperation;
10: use Salient\Contract\Sync\SyncProviderInterface;
11: use Salient\Core\Concern\ImmutableTrait;
12: use Salient\Core\Provider\ProviderContext;
13: use Salient\Sync\Exception\InvalidFilterException;
14: use Salient\Sync\Exception\InvalidFilterSignatureException;
15: use Salient\Utility\Arr;
16: use Salient\Utility\Get;
17: use Salient\Utility\Regex;
18: use Salient\Utility\Str;
19: use Salient\Utility\Test;
20: use DateTimeInterface;
21: use LogicException;
22:
23: /**
24: * The context within which sync entities are instantiated by a provider
25: *
26: * @extends ProviderContext<SyncProviderInterface,SyncEntityInterface>
27: */
28: final class SyncContext extends ProviderContext implements SyncContextInterface
29: {
30: use ImmutableTrait;
31:
32: /** @var SyncOperation::* */
33: protected ?int $Operation = null;
34: /** @var array<string,(int|string|float|bool|null)[]|int|string|float|bool|null> */
35: protected array $Filters = [];
36: /** @var array<string,string> */
37: protected array $FilterKeys = [];
38: protected ?bool $Offline = null;
39: /** @var DeferralPolicy::* */
40: protected int $DeferralPolicy = DeferralPolicy::RESOLVE_EARLY;
41:
42: /**
43: * Entity => depth => policy
44: *
45: * @var array<class-string<SyncEntityInterface>,array<int<0,max>,int&HydrationPolicy::*>>
46: */
47: protected array $EntityHydrationPolicy = [];
48:
49: /** @var array<int<0,max>,HydrationPolicy::*> */
50: protected array $FallbackHydrationPolicy = [0 => HydrationPolicy::DEFER];
51: protected bool $RecursionDetected = false;
52: /** @var array<class-string,string> */
53: private static array $ServiceKeyMap;
54:
55: /**
56: * @inheritDoc
57: */
58: public function pushEntity($entity, bool $detectRecursion = false)
59: {
60: return parent::pushEntity($entity)->with(
61: 'RecursionDetected',
62: $detectRecursion && in_array($entity, $this->Entities, true)
63: );
64: }
65:
66: /**
67: * @inheritDoc
68: */
69: public function recursionDetected(): bool
70: {
71: return $this->RecursionDetected;
72: }
73:
74: /**
75: * @inheritDoc
76: */
77: public function hasOperation(): bool
78: {
79: return $this->Operation !== null;
80: }
81:
82: /**
83: * @inheritDoc
84: */
85: public function getOperation(): ?int
86: {
87: return $this->Operation;
88: }
89:
90: /**
91: * @inheritDoc
92: */
93: public function hasFilter(?string $key = null): bool
94: {
95: return $key === null
96: ? (bool) $this->Filters
97: : $this->getFilterKey($key) !== null;
98: }
99:
100: /**
101: * @inheritDoc
102: */
103: public function getFilter(string $key, bool $orValue = true)
104: {
105: $_key = $this->getFilterKey($key);
106: if ($_key === null) {
107: return $orValue ? $this->getValue($key) : null;
108: }
109: return $this->Filters[$_key];
110: }
111:
112: /**
113: * @inheritDoc
114: */
115: public function claimFilter(string $key, bool $orValue = true)
116: {
117: $_key = $this->getFilterKey($key, $altKey);
118: if ($_key === null) {
119: return $orValue ? $this->getValue($key) : null;
120: }
121: $value = $this->Filters[$_key];
122: unset($this->Filters[$_key]);
123: if ($altKey !== null) {
124: unset($this->FilterKeys[$altKey]);
125: }
126: return $value;
127: }
128:
129: private function getFilterKey(string $key, ?string &$altKey = null): ?string
130: {
131: if (
132: array_key_exists($key, $this->Filters)
133: || array_key_exists($key = Str::snake($key), $this->Filters)
134: ) {
135: // @phpstan-ignore parameterByRef.type
136: $altKey = Arr::search($this->FilterKeys, $key);
137: return $key;
138: }
139: $altKey = $key;
140: return $this->FilterKeys[$key] ?? null;
141: }
142:
143: /**
144: * @inheritDoc
145: */
146: public function getFilters(): array
147: {
148: return $this->Filters;
149: }
150:
151: /**
152: * @inheritDoc
153: */
154: public function withOperation(int $operation, string $entityType, ...$args)
155: {
156: return $this
157: ->with('Operation', $operation)
158: ->withEntityType($entityType)
159: ->withArgs($operation, ...$args);
160: }
161:
162: /**
163: * @param SyncOperation::* $operation
164: * @param mixed ...$args
165: * @return static
166: */
167: private function withArgs(int $operation, ...$args)
168: {
169: // READ_LIST is the only operation with no mandatory argument after the
170: // `SyncContextInterface` argument
171: if ($operation !== SyncOperation::READ_LIST) {
172: array_shift($args);
173: }
174:
175: if (!$args) {
176: return $this->applyFilters([]);
177: }
178:
179: if (is_array($args[0]) && count($args) === 1) {
180: $filters = [];
181: $filterKeys = [];
182: foreach ($args[0] as $key => $value) {
183: if (
184: is_int($key)
185: || Test::isNumericKey($key = trim($key))
186: ) {
187: throw new InvalidFilterSignatureException($operation, ...$args);
188: }
189:
190: $normalised = false;
191: if (Regex::match('/^(?:[[:alnum:]]++(?:$|[\s_-]++(?!$)))++$/D', $key)) {
192: $key = Str::snake($key);
193: $normalised = true;
194: }
195:
196: $filters[$key] = $value = $this->normaliseFilterValue($value);
197: unset($filterKeys[$key]);
198:
199: if (!$normalised || !(
200: $value === null
201: || is_int($value)
202: || is_string($value)
203: || Arr::ofArrayKey($value, true)
204: )) {
205: continue;
206: }
207:
208: $altKey = substr($key, -3) === '_id'
209: ? substr($key, 0, -3)
210: : $key . '_id';
211:
212: if (isset($filters[$altKey])) {
213: continue;
214: }
215:
216: $filterKeys[$altKey] = $key;
217: }
218:
219: return $this->applyFilters($filters, $filterKeys);
220: }
221:
222: if (Arr::ofArrayKey($args)) {
223: return $this->applyFilters(['id' => $args]);
224: }
225:
226: if (Arr::of($args, SyncEntityInterface::class)) {
227: foreach ($args as $entity) {
228: /** @var SyncEntityInterface $entity */
229: $id = $this->normaliseFilterEntity($entity);
230: $service = $entity->getService();
231: $key = self::$ServiceKeyMap[$service] ??=
232: Str::snake(Get::basename($service));
233: $filters[$key][] = $id;
234: }
235:
236: return $this->withArgs(SyncOperation::READ_LIST, $filters);
237: }
238:
239: throw new InvalidFilterSignatureException($operation, ...$args);
240: }
241:
242: /**
243: * @param array<string,(int|string|float|bool|null)[]|int|string|float|bool|null> $filters
244: * @param array<string,string> $filterKeys
245: * @return static
246: */
247: private function applyFilters(array $filters, array $filterKeys = [])
248: {
249: return $this
250: ->with('Filters', $filters)
251: ->with('FilterKeys', $filterKeys);
252: }
253:
254: /**
255: * @param mixed $value
256: * @return (int|string|float|bool|null)[]|int|string|float|bool|null
257: */
258: private function normaliseFilterValue($value)
259: {
260: if ($value === null || $value === [] || is_scalar($value)) {
261: return $value;
262: }
263:
264: if ($value instanceof DateTimeInterface) {
265: return $this->getProvider()->getDateFormatter()->format($value);
266: }
267:
268: if ($value instanceof SyncEntityInterface) {
269: return $this->normaliseFilterEntity($value);
270: }
271:
272: if (is_array($value)) {
273: foreach ($value as &$entry) {
274: if ($entry === null || is_scalar($entry)) {
275: continue;
276: }
277:
278: if ($entry instanceof DateTimeInterface) {
279: $entry = $this->getProvider()->getDateFormatter()->format($entry);
280: continue;
281: }
282:
283: if ($entry instanceof SyncEntityInterface) {
284: $entry = $this->normaliseFilterEntity($entry);
285: continue;
286: }
287:
288: throw new InvalidFilterException(sprintf(
289: 'Invalid in filter value: %s',
290: Get::type($entry),
291: ));
292: }
293:
294: /** @var (int|string|float|bool|null)[] */
295: return $value;
296: }
297:
298: throw new InvalidFilterException(sprintf(
299: 'Invalid filter value: %s',
300: Get::type($value),
301: ));
302: }
303:
304: /**
305: * @return array-key
306: */
307: private function normaliseFilterEntity(SyncEntityInterface $entity)
308: {
309: $id = $entity->getId();
310:
311: if ($id === null) {
312: throw new InvalidFilterException(sprintf(
313: '%s has no identifier',
314: get_class($entity),
315: ));
316: }
317:
318: if ($entity->getProvider() === $this->Provider) {
319: return $id;
320: }
321:
322: throw new InvalidFilterException(sprintf(
323: '%s has a different provider',
324: get_class($entity),
325: ));
326: }
327:
328: /**
329: * @inheritDoc
330: */
331: public function getDeferralPolicy(): int
332: {
333: return $this->DeferralPolicy;
334: }
335:
336: /**
337: * @inheritDoc
338: */
339: public function withDeferralPolicy(int $policy)
340: {
341: return $this->with('DeferralPolicy', $policy);
342: }
343:
344: /**
345: * @inheritDoc
346: */
347: public function getHydrationPolicy(?string $entityType): int
348: {
349: $depth = count($this->Entities) + 1;
350:
351: if ($entityType !== null && $this->EntityHydrationPolicy) {
352: $applied = false;
353: $flags = 0;
354: foreach ($this->EntityHydrationPolicy as $entity => $values) {
355: if (!is_a($entity, $entityType, true)) {
356: continue;
357: }
358: $value = $values[$depth] ?? $values[0] ?? null;
359: if ($value === null) {
360: continue;
361: }
362: $flags |= $value;
363: $applied = true;
364: }
365: if ($applied) {
366: return $flags;
367: }
368: }
369:
370: return $this->FallbackHydrationPolicy[$depth]
371: ?? $this->FallbackHydrationPolicy[0]
372: ?? HydrationPolicy::DEFER;
373: }
374:
375: /**
376: * @param int&HydrationPolicy::* $policy
377: */
378: public function withHydrationPolicy(
379: int $policy,
380: ?string $entityType = null,
381: $depth = null
382: ) {
383: // @phpstan-ignore smaller.alwaysFalse, booleanAnd.rightAlwaysFalse
384: if ($depth !== null && array_filter((array) $depth, fn($depth) => $depth < 1)) {
385: throw new LogicException('$depth must be greater than 0');
386: }
387:
388: $clone = clone $this;
389: $clone->applyHydrationPolicy($policy, $entityType, $depth);
390:
391: if (
392: $this->EntityHydrationPolicy === $clone->EntityHydrationPolicy
393: && $this->FallbackHydrationPolicy === $clone->FallbackHydrationPolicy
394: ) {
395: return $this;
396: }
397:
398: return $clone;
399: }
400:
401: /**
402: * @param int&HydrationPolicy::* $policy
403: * @param class-string<SyncEntityInterface>|null $entityType
404: * @param array<int<1,max>>|int<1,max>|null $depth
405: */
406: private function applyHydrationPolicy(
407: int $policy,
408: ?string $entityType,
409: $depth
410: ): void {
411: $currentDepth = count($this->Entities);
412:
413: if ($entityType === null && $depth === null) {
414: $this->EntityHydrationPolicy = [];
415: $this->FallbackHydrationPolicy = [0 => $policy];
416: return;
417: }
418:
419: if ($entityType === null) {
420: $this->FallbackHydrationPolicy = $this->doApplyHydrationPolicy(
421: $policy,
422: $depth,
423: $currentDepth,
424: $this->FallbackHydrationPolicy,
425: );
426: } else {
427: $this->EntityHydrationPolicy +=
428: [$entityType => $this->FallbackHydrationPolicy];
429: }
430:
431: foreach ($this->EntityHydrationPolicy as $entity => &$value) {
432: if ($entityType === null || is_a($entity, $entityType, true)) {
433: $value = $this->doApplyHydrationPolicy(
434: $policy,
435: $depth,
436: $currentDepth,
437: $value,
438: );
439: }
440: }
441: }
442:
443: /**
444: * @param HydrationPolicy::* $policy
445: * @param array<int<1,max>>|int<1,max>|null $depth
446: * @param int<0,max> $currentDepth
447: * @param array<int<0,max>,HydrationPolicy::*> $currentPolicy
448: * @return array<int<0,max>,HydrationPolicy::*>
449: */
450: private function doApplyHydrationPolicy(
451: int $policy,
452: $depth,
453: int $currentDepth,
454: array $currentPolicy
455: ): array {
456: if ($depth === null) {
457: return [0 => $policy];
458: }
459:
460: foreach ((array) $depth as $depth) {
461: $currentPolicy[$currentDepth + $depth] = $policy;
462: }
463: return $currentPolicy;
464: }
465:
466: /**
467: * @inheritDoc
468: */
469: public function getOffline(): ?bool
470: {
471: return $this->Offline;
472: }
473:
474: /**
475: * @inheritDoc
476: */
477: public function withOffline(?bool $offline)
478: {
479: return $this->with('Offline', $offline);
480: }
481: }
482: