1: <?php declare(strict_types=1);
2:
3: namespace Salient\Sync;
4:
5: use Salient\Contract\Core\Buildable;
6: use Salient\Contract\Core\DateFormatterInterface;
7: use Salient\Contract\Core\NormaliserFlag;
8: use Salient\Contract\Core\SerializeRulesInterface;
9: use Salient\Contract\Sync\SyncEntityInterface;
10: use Salient\Contract\Sync\SyncSerializeRulesInterface;
11: use Salient\Contract\Sync\SyncStoreInterface;
12: use Salient\Core\Concern\HasBuilder;
13: use Salient\Core\Concern\HasMutator;
14: use Salient\Sync\Support\SyncIntrospector;
15: use Salient\Utility\Arr;
16: use Salient\Utility\Regex;
17: use Closure;
18: use LogicException;
19:
20: /**
21: * Rules applied when serializing a sync entity
22: *
23: * Rules can be applied to any combination of paths and class properties
24: * reachable from the entity, e.g. for a `User` entity that normalises property
25: * names:
26: *
27: * ```php
28: * <?php
29: * [
30: * // Applied to '.Manager.OrgUnit'
31: * '.manager.org_unit',
32: * User::class => [
33: * // Ignored for '.Manager.OrgUnit' because a path-based rule applies,
34: * // but applied to '.OrgUnit', '.Staff[].OrgUnit', ...
35: * 'org_unit',
36: * ],
37: * ];
38: * ```
39: *
40: * Path-based rules apply to specific nodes in the object graph below the entity
41: * (only the `OrgUnit` of the user's manager in the example above), whereas
42: * class-based rules apply to every appearance of a class in the entity's object
43: * graph (the `OrgUnit` of every `User` object, including the one being
44: * serialized, in this example).
45: *
46: * Each rule must be either a `string` containing the path or key to act upon,
47: * or an `array` with up to 3 values:
48: *
49: * 1. the path or key to act upon (`string`; required; must be the first value)
50: * 2. a new key for the value (`int|string`; optional)
51: * 3. a closure to return a new value for the key (`Closure($value, $store,
52: * $rules): mixed`; optional)
53: *
54: * Optional values may be omitted.
55: *
56: * If multiple rules apply to the same key, path-based rules take precedence
57: * over class-based ones, then later rules take precedence over earlier ones:
58: *
59: * ```php
60: * <?php
61: * [
62: * // Ignored because it applies to the same path as the next rule
63: * '.manager.org_unit',
64: * // Applied to '.Manager.OrgUnit'
65: * [
66: * '.manager.org_unit',
67: * 'org_unit_id',
68: * fn($ou) => $ou->Id,
69: * ],
70: * User::class => [
71: * // Ignored because it applies to the same property as the next rule
72: * 'org_unit',
73: * // Ignored for '.Manager.OrgUnit' because a path-based rule applies,
74: * // but applied to '.OrgUnit', '.Staff[].OrgUnit', ...
75: * ['org_unit', 'org_unit_id'],
76: * ],
77: * ];
78: * ```
79: *
80: * @template TEntity of SyncEntityInterface
81: *
82: * @implements SyncSerializeRulesInterface<TEntity>
83: * @implements Buildable<SyncSerializeRulesBuilder<TEntity>>
84: */
85: final class SyncSerializeRules implements SyncSerializeRulesInterface, Buildable
86: {
87: private const TYPE_REMOVE = 0;
88: private const TYPE_REPLACE = 1;
89:
90: /** @use HasBuilder<SyncSerializeRulesBuilder<TEntity>> */
91: use HasBuilder;
92: use HasMutator;
93:
94: /** @var class-string<TEntity> */
95: private string $Entity;
96: private ?DateFormatterInterface $DateFormatter;
97: private ?bool $IncludeMeta;
98: private ?bool $SortByKey;
99: private ?int $MaxDepth;
100: private ?bool $DetectRecursion;
101: private ?bool $RecurseRules;
102: private ?bool $ForSyncStore;
103: private ?bool $IncludeCanonicalId;
104: /** @var class-string[] */
105: private array $EntityIndex;
106: /** @var class-string[] */
107: private array $RuleEntities;
108: /** @var array<array<array<array{string,...}|string>|array{string,...}|string>> */
109: private array $Remove;
110: /** @var array<array<array<array{string,...}|string>|array{string,...}|string>> */
111: private array $Replace;
112: /** @var array<self::TYPE_*,array<0|string,array<array{class-string,string,int|string|null,(Closure(mixed, SyncStoreInterface|null, SyncSerializeRules<TEntity>): mixed)|null}>>> */
113: private array $FlattenCache;
114: /** @var array{0:array<string,array<string,string>>,1:array<string,array<string,array{int|string|null,(Closure(mixed $value, SyncStoreInterface|null $store=): mixed)|null}>>} */
115: private array $CompileCache;
116: /** @var array<string,array<class-string,true>> */
117: private array $EntityPathIndex;
118: /** @var SyncIntrospector<TEntity> */
119: private SyncIntrospector $Introspector;
120:
121: /**
122: * @internal
123: *
124: * @param class-string<TEntity> $entity Entity to which the rules apply (required)
125: * @param DateFormatterInterface|null $dateFormatter Date formatter used to serialize date and time values
126: * @param bool|null $includeMeta Serialize undeclared property values? (default: true)
127: * @param bool|null $sortByKey Sort serialized entities by key? (default: false)
128: * @param int|null $maxDepth Maximum depth of nested values (default: 99)
129: * @param bool|null $detectRecursion Detect recursion when serializing nested entities? (default: true)
130: * @param bool|null $recurseRules Apply path-based rules to nested instances of the entity? (default: true)
131: * @param bool|null $forSyncStore Are values being serialized for an entity store? (default: false)
132: * @param bool|null $includeCanonicalId Serialize canonical identifiers of sync entities? (default: false)
133: * @param array<array<(array{string,...}&array<(Closure(mixed, SyncStoreInterface|null, SyncSerializeRules<TEntity>): mixed)|int|string|null>)|string>|(array{string,...}&array<(Closure(mixed, SyncStoreInterface|null, SyncSerializeRules<TEntity>): mixed)|int|string|null>)|string> $remove Values to remove, e.g. `[OrgUnit::class => ['users']]` to remove `users` from `OrgUnit` objects
134: * @param array<array<(array{string,...}&array<(Closure(mixed, SyncStoreInterface|null, SyncSerializeRules<TEntity>): mixed)|int|string|null>)|string>|(array{string,...}&array<(Closure(mixed, SyncStoreInterface|null, SyncSerializeRules<TEntity>): mixed)|int|string|null>)|string> $replace Values to replace, e.g. `[User::class => [['org_unit', 'org_unit_id', fn($ou) => $ou->Id]]]` to replace `"org_unit" => $entity` with `"org_unit_id" => $entity->Id` in `User` objects
135: * @param SyncSerializeRules<TEntity>|null $inherit Inherit rules from another instance
136: */
137: public function __construct(
138: string $entity,
139: ?DateFormatterInterface $dateFormatter = null,
140: ?bool $includeMeta = null,
141: ?bool $sortByKey = null,
142: ?int $maxDepth = null,
143: ?bool $detectRecursion = null,
144: ?bool $recurseRules = null,
145: ?bool $forSyncStore = null,
146: ?bool $includeCanonicalId = null,
147: array $remove = [],
148: array $replace = [],
149: ?SyncSerializeRules $inherit = null
150: ) {
151: $this->Entity = $entity;
152: $this->DateFormatter = $dateFormatter;
153: $this->IncludeMeta = $includeMeta;
154: $this->SortByKey = $sortByKey;
155: $this->MaxDepth = $maxDepth;
156: $this->DetectRecursion = $detectRecursion;
157: $this->RecurseRules = $recurseRules;
158: $this->ForSyncStore = $forSyncStore;
159: $this->IncludeCanonicalId = $includeCanonicalId;
160: $this->EntityIndex[] = $entity;
161: $this->Remove[] = $remove;
162: $this->Replace[] = $replace;
163:
164: if ($inherit) {
165: $this->applyRules($inherit, true);
166: return;
167: }
168:
169: $this->updateRuleEntities();
170: }
171:
172: private function __clone()
173: {
174: unset($this->FlattenCache);
175: unset($this->CompileCache);
176: unset($this->EntityPathIndex);
177: unset($this->Introspector);
178: }
179:
180: /**
181: * @template T of TEntity
182: *
183: * @param static<T> $rules
184: * @return static<T>
185: */
186: public function merge(SerializeRulesInterface $rules): SerializeRulesInterface
187: {
188: /** @var static<T> */
189: $clone = clone $this;
190: $clone->applyRules($rules);
191: return $clone;
192: }
193:
194: /**
195: * @param static $rules
196: */
197: private function applyRules(SerializeRulesInterface $rules, bool $inherit = false): void
198: {
199: if ($inherit) {
200: $base = $rules;
201: $merge = $this;
202: } else {
203: $base = $this;
204: $merge = $rules;
205: }
206:
207: if (!is_a($merge->Entity, $base->Entity, true)) {
208: throw new LogicException(sprintf(
209: '%s does not inherit %s',
210: $merge->Entity,
211: $base->Entity,
212: ));
213: }
214:
215: $this->Entity = $merge->Entity;
216: $this->DateFormatter = $merge->DateFormatter ?? $base->DateFormatter;
217: $this->IncludeMeta = $merge->IncludeMeta ?? $base->IncludeMeta;
218: $this->SortByKey = $merge->SortByKey ?? $base->SortByKey;
219: $this->MaxDepth = $merge->MaxDepth ?? $base->MaxDepth;
220: $this->DetectRecursion = $merge->DetectRecursion ?? $base->DetectRecursion;
221: $this->RecurseRules = $merge->RecurseRules ?? $base->RecurseRules;
222: $this->ForSyncStore = $merge->ForSyncStore ?? $base->ForSyncStore;
223: $this->IncludeCanonicalId = $merge->IncludeCanonicalId ?? $base->IncludeCanonicalId;
224: $this->EntityIndex = [...$base->EntityIndex, ...$merge->EntityIndex];
225: $this->Remove = [...$base->Remove, ...$merge->Remove];
226: $this->Replace = [...$base->Replace, ...$merge->Replace];
227:
228: $this->updateRuleEntities();
229: }
230:
231: private function updateRuleEntities(): void
232: {
233: $this->RuleEntities = array_reverse(Arr::unique($this->EntityIndex));
234: }
235:
236: /**
237: * @inheritDoc
238: */
239: public function getRemovableKeys(?string $class, ?string $baseClass, array $path): array
240: {
241: return $this->compileRules($class, $baseClass, $path, $this->getRemove(), self::TYPE_REMOVE);
242: }
243:
244: /**
245: * @inheritDoc
246: */
247: public function getReplaceableKeys(?string $class, ?string $baseClass, array $path): array
248: {
249: return $this->compileRules($class, $baseClass, $path, $this->getReplace(), self::TYPE_REPLACE);
250: }
251:
252: /**
253: * @return array<0|string,array<array{class-string,string,int|string|null,(Closure(mixed, SyncStoreInterface|null, SyncSerializeRules<TEntity>): mixed)|null}>>
254: */
255: private function getRemove(): array
256: {
257: return $this->FlattenCache[self::TYPE_REMOVE] ??= $this->flattenRules(...$this->Remove);
258: }
259:
260: /**
261: * @return array<0|string,array<array{class-string,string,int|string|null,(Closure(mixed, SyncStoreInterface|null, SyncSerializeRules<TEntity>): mixed)|null}>>
262: */
263: private function getReplace(): array
264: {
265: return $this->FlattenCache[self::TYPE_REPLACE] ??= $this->flattenRules(...$this->Replace);
266: }
267:
268: /**
269: * Merge and normalise rules and property names
270: *
271: * @param array<array<array{string,...}|string>|array{string,...}|string> ...$merge
272: * @return array<0|string,array<array{class-string,string,int|string|null,(Closure(mixed, SyncStoreInterface|null, SyncSerializeRules<TEntity>): mixed)|null}>>
273: */
274: private function flattenRules(array ...$merge): array
275: {
276: $this->Introspector ??= SyncIntrospector::get($this->Entity);
277:
278: foreach ($merge as $offset => $array) {
279: $entity = $this->EntityIndex[$offset];
280: foreach ($array as $key => $rule) {
281: if (is_int($key)) {
282: /** @var array{string,...}|string $rule */
283: $target = $this->normaliseTarget($this->getTarget($rule));
284: $rule = $this->normaliseRule($rule, $target, $entity);
285: $paths[$target] = $rule;
286: continue;
287: }
288:
289: /** @var array<array{string,...}|string> $rule */
290: foreach ($rule as $_rule) {
291: $target = $this->normaliseTarget($this->getTarget($_rule));
292: $_rule = $this->normaliseRule($_rule, $target, $entity);
293: $classes[$key][$target] = $_rule;
294: }
295: }
296: }
297:
298: // Return path-based rules followed by class-based rules
299: return array_map(
300: fn(array $rules) => array_values($rules),
301: (isset($paths) ? [0 => $paths] : []) + ($classes ?? []),
302: );
303: }
304:
305: /**
306: * @param class-string|null $class
307: * @param class-string|null $untilClass
308: * @param string[] $path
309: * @param array<0|string,array<array{class-string,string,int|string|null,(Closure(mixed, SyncStoreInterface|null, SyncSerializeRules<TEntity>): mixed)|null}>> $rules
310: * @param self::TYPE_* $ruleType
311: * @return ($ruleType is 0 ? array<string,string> : array<string,array{int|string|null,(Closure(mixed $value, SyncStoreInterface|null $store=): mixed)|null}>)
312: */
313: private function compileRules(
314: ?string $class,
315: ?string $untilClass,
316: array $path,
317: array $rules,
318: int $ruleType
319: ): array {
320: $key = '.' . implode('.', $path);
321: $cacheKey = Arr::implode("\0", [$class, $untilClass, $key], '');
322:
323: if (isset($this->CompileCache[$ruleType][$cacheKey])) {
324: return $this->CompileCache[$ruleType][$cacheKey];
325: }
326:
327: $keys = [$key => [true]];
328:
329: if ($path && $this->getRecurseRules()) {
330: // If an instance of the entity being serialized is found at
331: // '.path.to.key', add it to `$this->EntityPathIndex`
332: foreach ($this->RuleEntities as $entity) {
333: if ($class !== null && is_a($class, $entity, true)) {
334: $this->EntityPathIndex[$key][$entity] = true;
335: $keys['.'][$entity] = true;
336: }
337:
338: // Then, if `$key` is '.path.to.key.of.child', add '.of.child'
339: // to `$keys`
340: $parent = $path;
341: $child = [];
342: $parts = count($parent);
343: while ($parts-- > 1) {
344: array_unshift($child, array_pop($parent));
345: $parentKey = '.' . implode('.', $parent);
346: if (isset($this->EntityPathIndex[$parentKey])) {
347: $childKey = '.' . implode('.', $child);
348: $keys[$childKey] = $this->EntityPathIndex[$parentKey];
349: }
350: }
351: }
352: }
353:
354: // If '.path.to' is in `$keys`, convert path-based rules like:
355: // - `['.path.to.key', 'key_id', fn($value) => $value['id']]`
356: //
357: // to `$pathRules` entries like:
358: // - `['key', 'key_id', fn($value) => $value['id']]`
359: $pathRules = [];
360: if (isset($rules[0])) {
361: foreach ($rules[0] as $rule) {
362: $target = $rule[1];
363: $allowed = $keys[$this->getTargetParent($target)] ?? null;
364: if ($allowed === [true] || isset($allowed[$rule[0]])) {
365: $rule[1] = $this->getTargetProperty($target);
366: $pathRules[] = $rule;
367: }
368: }
369: }
370:
371: // Copy class-based rules applied to `$class` and its parents to
372: // `$classRules`:
373: $classRules = [];
374: if ($class !== null) {
375: while ($untilClass === null || $class !== $untilClass) {
376: if (isset($rules[$class])) {
377: // Give precedence to rules applied to subclasses
378: array_unshift($classRules, ...$rules[$class]);
379: }
380: $class = get_parent_class($class);
381: if ($class === false) {
382: break;
383: }
384: }
385: }
386:
387: // Return the highest-precedence rule for each key
388: $rules = [];
389: foreach ([...$classRules, ...$pathRules] as $rule) {
390: unset($rule[0]);
391: $target = array_shift($rule);
392: $rules[$target] = $rule;
393: }
394:
395: if ($ruleType === self::TYPE_REMOVE) {
396: $keys = array_keys($rules);
397: $rules = Arr::combine($keys, $keys);
398: return $this->CompileCache[$ruleType][$cacheKey] = $rules;
399: }
400:
401: foreach ($rules as $key => $rule) {
402: if ($rule[1] !== null) {
403: $closure = $rule[1];
404: $rule[1] = fn($value, ?SyncStoreInterface $store = null) =>
405: $closure($value, $store, $this);
406: }
407: $compiled[$key] = $rule;
408: }
409:
410: return $this->CompileCache[$ruleType][$cacheKey] = $compiled ?? [];
411: }
412:
413: /**
414: * @param array{string,...}|string $rule
415: */
416: private function getTarget($rule): string
417: {
418: return is_array($rule) ? reset($rule) : $rule;
419: }
420:
421: private function normaliseTarget(string $target): string
422: {
423: return Regex::replaceCallback(
424: '/[^].[]++/',
425: fn(array $matches): string =>
426: $this->Introspector->maybeNormalise($matches[0], NormaliserFlag::LAZY),
427: $target,
428: );
429: }
430:
431: /**
432: * @param array{string,...}|string $rule
433: * @param class-string $entity
434: * @return array{class-string,string,int|string|null,(Closure(mixed, SyncStoreInterface|null, SyncSerializeRules<TEntity>): mixed)|null}
435: */
436: private function normaliseRule($rule, string $target, string $entity): array
437: {
438: $normalised = [$entity, $target, null, null];
439: if (!is_array($rule)) {
440: return $normalised;
441: }
442: /** @var array<Closure|int|string|null> */
443: $rule = array_slice($rule, 1);
444: foreach ($rule as $value) {
445: if ($value === null) {
446: continue;
447: }
448: if ($value instanceof Closure) {
449: $normalised[3] = $value;
450: continue;
451: }
452: $normalised[2] = is_string($value)
453: ? $this->Introspector->maybeNormalise($value, NormaliserFlag::LAZY)
454: : $value;
455: }
456: return $normalised;
457: }
458:
459: private function getTargetParent(string $target): string
460: {
461: return ((substr($target, -2) === '[]')
462: ? substr($target, 0, -2)
463: : substr($target, 0, max(0, strrpos('.' . $target, '.') - 1))) ?: '.';
464: }
465:
466: private function getTargetProperty(string $target): string
467: {
468: return (substr($target, -2) === '[]')
469: ? '[]'
470: : substr((string) strrchr('.' . $target, '.'), 1);
471: }
472:
473: /**
474: * @inheritDoc
475: */
476: public function getEntity(): string
477: {
478: return $this->Entity;
479: }
480:
481: /**
482: * @inheritDoc
483: */
484: public function getDateFormatter(): ?DateFormatterInterface
485: {
486: return $this->DateFormatter;
487: }
488:
489: /**
490: * @inheritDoc
491: */
492: public function getIncludeMeta(): bool
493: {
494: return $this->IncludeMeta ?? true;
495: }
496:
497: /**
498: * @inheritDoc
499: */
500: public function getSortByKey(): bool
501: {
502: return $this->SortByKey ?? false;
503: }
504:
505: /**
506: * @inheritDoc
507: */
508: public function getMaxDepth(): int
509: {
510: return $this->MaxDepth ?? 99;
511: }
512:
513: /**
514: * @inheritDoc
515: */
516: public function getDetectRecursion(): bool
517: {
518: return $this->DetectRecursion ?? true;
519: }
520:
521: /**
522: * @inheritDoc
523: */
524: public function getRecurseRules(): bool
525: {
526: return $this->RecurseRules ?? true;
527: }
528:
529: /**
530: * @inheritDoc
531: */
532: public function getForSyncStore(): bool
533: {
534: return $this->ForSyncStore ?? false;
535: }
536:
537: /**
538: * @inheritDoc
539: */
540: public function getIncludeCanonicalId(): bool
541: {
542: return $this->IncludeCanonicalId ?? false;
543: }
544:
545: /**
546: * @inheritDoc
547: */
548: public function withDateFormatter(?DateFormatterInterface $formatter)
549: {
550: return $this->with('DateFormatter', $formatter);
551: }
552:
553: /**
554: * @inheritDoc
555: */
556: public function withIncludeMeta(?bool $include = true)
557: {
558: return $this->with('IncludeMeta', $include);
559: }
560:
561: /**
562: * @inheritDoc
563: */
564: public function withSortByKey(?bool $sort = true)
565: {
566: return $this->with('SortByKey', $sort);
567: }
568:
569: /**
570: * @inheritDoc
571: */
572: public function withMaxDepth(?int $depth)
573: {
574: return $this->with('MaxDepth', $depth);
575: }
576:
577: /**
578: * @inheritDoc
579: */
580: public function withDetectRecursion(?bool $detect = true)
581: {
582: return $this->with('DetectRecursion', $detect);
583: }
584:
585: /**
586: * @inheritDoc
587: */
588: public function withRecurseRules(?bool $recurse = true)
589: {
590: return $this->with('RecurseRules', $recurse);
591: }
592:
593: /**
594: * @inheritDoc
595: */
596: public function withForSyncStore(?bool $forSyncStore = true)
597: {
598: return $this->with('ForSyncStore', $forSyncStore);
599: }
600:
601: /**
602: * @inheritDoc
603: */
604: public function withIncludeCanonicalId(?bool $include = true)
605: {
606: return $this->with('IncludeCanonicalId', $include);
607: }
608: }
609: