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\HasMutator;
12: use Salient\Core\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 HasMutator;
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: $altKey = Arr::search($this->FilterKeys, $key);
136: return $key;
137: }
138: $altKey = $key;
139: return $this->FilterKeys[$key] ?? null;
140: }
141:
142: /**
143: * @inheritDoc
144: */
145: public function getFilters(): array
146: {
147: return $this->Filters;
148: }
149:
150: /**
151: * @inheritDoc
152: */
153: public function withOperation(int $operation, string $entityType, ...$args)
154: {
155: return $this
156: ->with('Operation', $operation)
157: ->withEntityType($entityType)
158: ->withArgs($operation, ...$args);
159: }
160:
161: /**
162: * @param SyncOperation::* $operation
163: * @param mixed ...$args
164: * @return static
165: */
166: private function withArgs(int $operation, ...$args)
167: {
168: // READ_LIST is the only operation with no mandatory argument after the
169: // `SyncContextInterface` argument
170: if ($operation !== SyncOperation::READ_LIST) {
171: array_shift($args);
172: }
173:
174: if (!$args) {
175: return $this->applyFilters([]);
176: }
177:
178: if (is_array($args[0]) && count($args) === 1) {
179: $filters = [];
180: $filterKeys = [];
181: foreach ($args[0] as $key => $value) {
182: if (
183: is_int($key)
184: || Test::isNumericKey($key = trim($key))
185: ) {
186: throw new InvalidFilterSignatureException($operation, ...$args);
187: }
188:
189: $normalised = false;
190: if (Regex::match('/^(?:[[:alnum:]]++(?:$|[\s_-]++(?!$)))++$/D', $key)) {
191: $key = Str::snake($key);
192: $normalised = true;
193: }
194:
195: $filters[$key] = $value = $this->normaliseFilterValue($value);
196: unset($filterKeys[$key]);
197:
198: if (!$normalised || !(
199: $value === null
200: || is_int($value)
201: || is_string($value)
202: || Arr::ofArrayKey($value, true)
203: )) {
204: continue;
205: }
206:
207: $altKey = substr($key, -3) === '_id'
208: ? substr($key, 0, -3)
209: : $key . '_id';
210:
211: if (isset($filters[$altKey])) {
212: continue;
213: }
214:
215: $filterKeys[$altKey] = $key;
216: }
217:
218: return $this->applyFilters($filters, $filterKeys);
219: }
220:
221: if (Arr::ofArrayKey($args)) {
222: return $this->applyFilters(['id' => $args]);
223: }
224:
225: if (Arr::of($args, SyncEntityInterface::class)) {
226: foreach ($args as $entity) {
227: /** @var SyncEntityInterface $entity */
228: $id = $this->normaliseFilterEntity($entity);
229: $service = $entity->getService();
230: $key = self::$ServiceKeyMap[$service]
231: ??= Str::snake(Get::basename($service));
232: $filters[$key][] = $id;
233: }
234:
235: return $this->withArgs(SyncOperation::READ_LIST, $filters);
236: }
237:
238: throw new InvalidFilterSignatureException($operation, ...$args);
239: }
240:
241: /**
242: * @param array<string,(int|string|float|bool|null)[]|int|string|float|bool|null> $filters
243: * @param array<string,string> $filterKeys
244: * @return $this
245: */
246: private function applyFilters(array $filters, array $filterKeys = [])
247: {
248: return $this
249: ->with('Filters', $filters)
250: ->with('FilterKeys', $filterKeys);
251: }
252:
253: /**
254: * @param mixed $value
255: * @return (int|string|float|bool|null)[]|int|string|float|bool|null
256: */
257: private function normaliseFilterValue($value)
258: {
259: if ($value === null || $value === [] || is_scalar($value)) {
260: return $value;
261: }
262:
263: if ($value instanceof DateTimeInterface) {
264: return $this->getProvider()->getDateFormatter()->format($value);
265: }
266:
267: if ($value instanceof SyncEntityInterface) {
268: return $this->normaliseFilterEntity($value);
269: }
270:
271: if (is_array($value)) {
272: foreach ($value as &$entry) {
273: if ($entry === null || is_scalar($entry)) {
274: continue;
275: }
276:
277: if ($entry instanceof DateTimeInterface) {
278: $entry = $this->getProvider()->getDateFormatter()->format($entry);
279: continue;
280: }
281:
282: if ($entry instanceof SyncEntityInterface) {
283: $entry = $this->normaliseFilterEntity($entry);
284: continue;
285: }
286:
287: throw new InvalidFilterException(sprintf(
288: 'Invalid in filter value: %s',
289: Get::type($entry),
290: ));
291: }
292:
293: return $value;
294: }
295:
296: throw new InvalidFilterException(sprintf(
297: 'Invalid filter value: %s',
298: Get::type($value),
299: ));
300: }
301:
302: /**
303: * @return array-key
304: */
305: private function normaliseFilterEntity(SyncEntityInterface $entity)
306: {
307: $id = $entity->getId();
308:
309: if ($id === null) {
310: throw new InvalidFilterException(sprintf(
311: '%s has no identifier',
312: get_class($entity),
313: ));
314: }
315:
316: if ($entity->getProvider() === $this->Provider) {
317: return $id;
318: }
319:
320: throw new InvalidFilterException(sprintf(
321: '%s has a different provider',
322: get_class($entity),
323: ));
324: }
325:
326: /**
327: * @inheritDoc
328: */
329: public function getDeferralPolicy(): int
330: {
331: return $this->DeferralPolicy;
332: }
333:
334: /**
335: * @inheritDoc
336: */
337: public function withDeferralPolicy(int $policy)
338: {
339: return $this->with('DeferralPolicy', $policy);
340: }
341:
342: /**
343: * @inheritDoc
344: */
345: public function getHydrationPolicy(?string $entityType): int
346: {
347: $depth = count($this->Entities) + 1;
348:
349: if ($entityType !== null && $this->EntityHydrationPolicy) {
350: $applied = false;
351: $flags = 0;
352: foreach ($this->EntityHydrationPolicy as $entity => $values) {
353: if (!is_a($entity, $entityType, true)) {
354: continue;
355: }
356: $value = $values[$depth] ?? $values[0] ?? null;
357: if ($value === null) {
358: continue;
359: }
360: $flags |= $value;
361: $applied = true;
362: }
363: if ($applied) {
364: return $flags;
365: }
366: }
367:
368: return $this->FallbackHydrationPolicy[$depth]
369: ?? $this->FallbackHydrationPolicy[0]
370: ?? HydrationPolicy::DEFER;
371: }
372:
373: /**
374: * @param int&HydrationPolicy::* $policy
375: */
376: public function withHydrationPolicy(
377: int $policy,
378: ?string $entityType = null,
379: $depth = null
380: ) {
381: // @phpstan-ignore-next-line
382: if ($depth !== null && array_filter((array) $depth, fn($depth) => $depth < 1)) {
383: throw new LogicException('$depth must be greater than 0');
384: }
385:
386: $clone = clone $this;
387: $clone->applyHydrationPolicy($policy, $entityType, $depth);
388:
389: if ($this->EntityHydrationPolicy === $clone->EntityHydrationPolicy
390: && $this->FallbackHydrationPolicy === $clone->FallbackHydrationPolicy) {
391: return $this;
392: }
393:
394: return $clone;
395: }
396:
397: /**
398: * @param int&HydrationPolicy::* $policy
399: * @param class-string<SyncEntityInterface>|null $entityType
400: * @param array<int<1,max>>|int<1,max>|null $depth
401: */
402: private function applyHydrationPolicy(
403: int $policy,
404: ?string $entityType,
405: $depth
406: ): void {
407: $currentDepth = count($this->Entities);
408:
409: if ($entityType === null && $depth === null) {
410: $this->EntityHydrationPolicy = [];
411: $this->FallbackHydrationPolicy = [0 => $policy];
412: return;
413: }
414:
415: if ($entityType === null) {
416: $this->FallbackHydrationPolicy = $this->doApplyHydrationPolicy(
417: $policy,
418: $depth,
419: $currentDepth,
420: $this->FallbackHydrationPolicy,
421: );
422: } else {
423: $this->EntityHydrationPolicy
424: += [$entityType => $this->FallbackHydrationPolicy];
425: }
426:
427: foreach ($this->EntityHydrationPolicy as $entity => &$value) {
428: if ($entityType === null || is_a($entity, $entityType, true)) {
429: $value = $this->doApplyHydrationPolicy(
430: $policy,
431: $depth,
432: $currentDepth,
433: $value,
434: );
435: }
436: }
437: }
438:
439: /**
440: * @param HydrationPolicy::* $policy
441: * @param array<int<1,max>>|int<1,max>|null $depth
442: * @param int<0,max> $currentDepth
443: * @param array<int<0,max>,HydrationPolicy::*> $currentPolicy
444: * @return array<int<0,max>,HydrationPolicy::*>
445: */
446: private function doApplyHydrationPolicy(
447: int $policy,
448: $depth,
449: int $currentDepth,
450: array $currentPolicy
451: ): array {
452: if ($depth === null) {
453: return [0 => $policy];
454: }
455:
456: foreach ((array) $depth as $depth) {
457: $currentPolicy[$currentDepth + $depth] = $policy;
458: }
459: return $currentPolicy;
460: }
461:
462: /**
463: * @inheritDoc
464: */
465: public function getOffline(): ?bool
466: {
467: return $this->Offline;
468: }
469:
470: /**
471: * @inheritDoc
472: */
473: public function withOffline(?bool $offline)
474: {
475: return $this->with('Offline', $offline);
476: }
477: }
478: