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