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: | |
25: | |
26: | |
27: | |
28: | final class SyncContext extends ProviderContext implements SyncContextInterface |
29: | { |
30: | use HasMutator; |
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: | $altKey = Arr::search($this->FilterKeys, $key); |
136: | return $key; |
137: | } |
138: | $altKey = $key; |
139: | return $this->FilterKeys[$key] ?? null; |
140: | } |
141: | |
142: | |
143: | |
144: | |
145: | public function getFilters(): array |
146: | { |
147: | return $this->Filters; |
148: | } |
149: | |
150: | |
151: | |
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: | |
163: | |
164: | |
165: | |
166: | private function withArgs(int $operation, ...$args) |
167: | { |
168: | |
169: | |
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: | |
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: | |
243: | |
244: | |
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: | |
255: | |
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: | |
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: | |
328: | |
329: | public function getDeferralPolicy(): int |
330: | { |
331: | return $this->DeferralPolicy; |
332: | } |
333: | |
334: | |
335: | |
336: | |
337: | public function withDeferralPolicy(int $policy) |
338: | { |
339: | return $this->with('DeferralPolicy', $policy); |
340: | } |
341: | |
342: | |
343: | |
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: | |
375: | |
376: | public function withHydrationPolicy( |
377: | int $policy, |
378: | ?string $entityType = null, |
379: | $depth = null |
380: | ) { |
381: | |
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: | |
399: | |
400: | |
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: | |
441: | |
442: | |
443: | |
444: | |
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: | |
464: | |
465: | public function getOffline(): ?bool |
466: | { |
467: | return $this->Offline; |
468: | } |
469: | |
470: | |
471: | |
472: | |
473: | public function withOffline(?bool $offline) |
474: | { |
475: | return $this->with('Offline', $offline); |
476: | } |
477: | } |
478: | |