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: | |
25: | |
26: | |
27: | |
28: | final class SyncContext extends ProviderContext implements SyncContextInterface |
29: | { |
30: | use ImmutableTrait; |
31: | |
32: | |
33: | protected ?int $Operation = null; |
34: | |
35: | protected array $Filters = []; |
36: | |
37: | protected array $FilterKeys = []; |
38: | protected ?bool $Offline = null; |
39: | |
40: | protected int $DeferralPolicy = DeferralPolicy::RESOLVE_EARLY; |
41: | |
42: | |
43: | |
44: | |
45: | |
46: | |
47: | protected array $EntityHydrationPolicy = []; |
48: | |
49: | |
50: | protected array $FallbackHydrationPolicy = [0 => HydrationPolicy::DEFER]; |
51: | protected bool $RecursionDetected = false; |
52: | |
53: | private static array $ServiceKeyMap; |
54: | |
55: | |
56: | |
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: | |
68: | |
69: | public function recursionDetected(): bool |
70: | { |
71: | return $this->RecursionDetected; |
72: | } |
73: | |
74: | |
75: | |
76: | |
77: | public function hasOperation(): bool |
78: | { |
79: | return $this->Operation !== null; |
80: | } |
81: | |
82: | |
83: | |
84: | |
85: | public function getOperation(): ?int |
86: | { |
87: | return $this->Operation; |
88: | } |
89: | |
90: | |
91: | |
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: | |
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: | |
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: | |
136: | $altKey = Arr::search($this->FilterKeys, $key); |
137: | return $key; |
138: | } |
139: | $altKey = $key; |
140: | return $this->FilterKeys[$key] ?? null; |
141: | } |
142: | |
143: | |
144: | |
145: | |
146: | public function getFilters(): array |
147: | { |
148: | return $this->Filters; |
149: | } |
150: | |
151: | |
152: | |
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: | |
164: | |
165: | |
166: | |
167: | private function withArgs(int $operation, ...$args) |
168: | { |
169: | |
170: | |
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: | |
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: | |
244: | |
245: | |
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: | |
256: | |
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: | |
295: | return $value; |
296: | } |
297: | |
298: | throw new InvalidFilterException(sprintf( |
299: | 'Invalid filter value: %s', |
300: | Get::type($value), |
301: | )); |
302: | } |
303: | |
304: | |
305: | |
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: | |
330: | |
331: | public function getDeferralPolicy(): int |
332: | { |
333: | return $this->DeferralPolicy; |
334: | } |
335: | |
336: | |
337: | |
338: | |
339: | public function withDeferralPolicy(int $policy) |
340: | { |
341: | return $this->with('DeferralPolicy', $policy); |
342: | } |
343: | |
344: | |
345: | |
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: | |
377: | |
378: | public function withHydrationPolicy( |
379: | int $policy, |
380: | ?string $entityType = null, |
381: | $depth = null |
382: | ) { |
383: | |
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: | |
403: | |
404: | |
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: | |
445: | |
446: | |
447: | |
448: | |
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: | |
468: | |
469: | public function getOffline(): ?bool |
470: | { |
471: | return $this->Offline; |
472: | } |
473: | |
474: | |
475: | |
476: | |
477: | public function withOffline(?bool $offline) |
478: | { |
479: | return $this->with('Offline', $offline); |
480: | } |
481: | } |
482: | |