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 (`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,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{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)|string|null>)|string>|(array{string,...}&array<(Closure(mixed, SyncStoreInterface|null, SyncSerializeRules<TEntity>): mixed)|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)|string|null>)|string>|(array{string,...}&array<(Closure(mixed, SyncStoreInterface|null, SyncSerializeRules<TEntity>): mixed)|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: * @inheritDoc
182: */
183: public function merge(SerializeRulesInterface $rules): SerializeRulesInterface
184: {
185: $clone = clone $this;
186: $clone->applyRules($rules);
187: return $clone;
188: }
189:
190: /**
191: * @param static $rules
192: */
193: private function applyRules(SerializeRulesInterface $rules, bool $inherit = false): void
194: {
195: if ($inherit) {
196: $base = $rules;
197: $merge = $this;
198: } else {
199: $base = $this;
200: $merge = $rules;
201: }
202:
203: if (!is_a($merge->Entity, $base->Entity, true)) {
204: throw new LogicException(sprintf(
205: '%s does not inherit %s',
206: $merge->Entity,
207: $base->Entity,
208: ));
209: }
210:
211: $this->Entity = $merge->Entity;
212: $this->DateFormatter = $merge->DateFormatter ?? $base->DateFormatter;
213: $this->IncludeMeta = $merge->IncludeMeta ?? $base->IncludeMeta;
214: $this->SortByKey = $merge->SortByKey ?? $base->SortByKey;
215: $this->MaxDepth = $merge->MaxDepth ?? $base->MaxDepth;
216: $this->DetectRecursion = $merge->DetectRecursion ?? $base->DetectRecursion;
217: $this->RecurseRules = $merge->RecurseRules ?? $base->RecurseRules;
218: $this->ForSyncStore = $merge->ForSyncStore ?? $base->ForSyncStore;
219: $this->IncludeCanonicalId = $merge->IncludeCanonicalId ?? $base->IncludeCanonicalId;
220: $this->EntityIndex = [...$base->EntityIndex, ...$merge->EntityIndex];
221: $this->Remove = [...$base->Remove, ...$merge->Remove];
222: $this->Replace = [...$base->Replace, ...$merge->Replace];
223:
224: $this->updateRuleEntities();
225: }
226:
227: private function updateRuleEntities(): void
228: {
229: $this->RuleEntities = array_reverse(Arr::unique($this->EntityIndex));
230: }
231:
232: /**
233: * @inheritDoc
234: */
235: public function getRemovableKeys(?string $class, ?string $baseClass, array $path): array
236: {
237: return $this->compileRules($class, $baseClass, $path, $this->getRemove(), self::TYPE_REMOVE);
238: }
239:
240: /**
241: * @inheritDoc
242: */
243: public function getReplaceableKeys(?string $class, ?string $baseClass, array $path): array
244: {
245: return $this->compileRules($class, $baseClass, $path, $this->getReplace(), self::TYPE_REPLACE);
246: }
247:
248: /**
249: * @return array<0|string,array<array{class-string,string,string|null,(Closure(mixed, SyncStoreInterface|null, SyncSerializeRules<TEntity>): mixed)|null}>>
250: */
251: private function getRemove(): array
252: {
253: return $this->FlattenCache[self::TYPE_REMOVE] ??= $this->flattenRules(...$this->Remove);
254: }
255:
256: /**
257: * @return array<0|string,array<array{class-string,string,string|null,(Closure(mixed, SyncStoreInterface|null, SyncSerializeRules<TEntity>): mixed)|null}>>
258: */
259: private function getReplace(): array
260: {
261: return $this->FlattenCache[self::TYPE_REPLACE] ??= $this->flattenRules(...$this->Replace);
262: }
263:
264: /**
265: * Merge and normalise rules and property names
266: *
267: * @param array<array<array{string,...}|string>|array{string,...}|string> ...$merge
268: * @return array<0|string,array<array{class-string,string,string|null,(Closure(mixed, SyncStoreInterface|null, SyncSerializeRules<TEntity>): mixed)|null}>>
269: */
270: private function flattenRules(array ...$merge): array
271: {
272: $this->Introspector ??= SyncIntrospector::get($this->Entity);
273:
274: foreach ($merge as $offset => $array) {
275: $entity = $this->EntityIndex[$offset];
276: foreach ($array as $key => $rule) {
277: if (is_int($key)) {
278: /** @var array{string,...}|string $rule */
279: $target = $this->normaliseTarget($this->getTarget($rule));
280: $rule = $this->normaliseRule($rule, $target, $entity);
281: $paths[$target] = $rule;
282: continue;
283: }
284:
285: /** @var array<array{string,...}|string> $rule */
286: foreach ($rule as $_rule) {
287: $target = $this->normaliseTarget($this->getTarget($_rule));
288: $_rule = $this->normaliseRule($_rule, $target, $entity);
289: $classes[$key][$target] = $_rule;
290: }
291: }
292: }
293:
294: // Return path-based rules followed by class-based rules
295: return array_map(
296: fn(array $rules) => array_values($rules),
297: (isset($paths) ? [0 => $paths] : []) + ($classes ?? []),
298: );
299: }
300:
301: /**
302: * @param class-string|null $class
303: * @param class-string|null $untilClass
304: * @param string[] $path
305: * @param array<0|string,array<array{class-string,string,string|null,(Closure(mixed, SyncStoreInterface|null, SyncSerializeRules<TEntity>): mixed)|null}>> $rules
306: * @param self::TYPE_* $ruleType
307: * @return ($ruleType is 0 ? array<string,string> : array<string,array{string|null,(Closure(mixed $value, SyncStoreInterface|null $store=): mixed)|null}>)
308: */
309: private function compileRules(
310: ?string $class,
311: ?string $untilClass,
312: array $path,
313: array $rules,
314: int $ruleType
315: ): array {
316: $key = '.' . implode('.', $path);
317: $cacheKey = Arr::implode("\0", [$class, $untilClass, $key], '');
318:
319: if (isset($this->CompileCache[$ruleType][$cacheKey])) {
320: return $this->CompileCache[$ruleType][$cacheKey];
321: }
322:
323: $keys = [$key => [true]];
324:
325: if ($path && $this->getRecurseRules()) {
326: // If an instance of the entity being serialized is found at
327: // '.path.to.key', add it to `$this->EntityPathIndex`
328: foreach ($this->RuleEntities as $entity) {
329: if ($class !== null && is_a($class, $entity, true)) {
330: $this->EntityPathIndex[$key][$entity] = true;
331: $keys['.'][$entity] = true;
332: }
333:
334: // Then, if `$key` is '.path.to.key.of.child', add '.of.child'
335: // to `$keys`
336: $parent = $path;
337: $child = [];
338: $parts = count($parent);
339: while ($parts-- > 1) {
340: array_unshift($child, array_pop($parent));
341: $parentKey = '.' . implode('.', $parent);
342: if (isset($this->EntityPathIndex[$parentKey])) {
343: $childKey = '.' . implode('.', $child);
344: $keys[$childKey] = $this->EntityPathIndex[$parentKey];
345: }
346: }
347: }
348: }
349:
350: // If '.path.to' is in `$keys`, convert path-based rules like:
351: // - `['.path.to.key', 'key_id', fn($value) => $value['id']]`
352: //
353: // to `$pathRules` entries like:
354: // - `['key', 'key_id', fn($value) => $value['id']]`
355: $pathRules = [];
356: if (isset($rules[0])) {
357: foreach ($rules[0] as $rule) {
358: $target = $rule[1];
359: $allowed = $keys[$this->getTargetParent($target)] ?? null;
360: if ($allowed === [true] || isset($allowed[$rule[0]])) {
361: $rule[1] = $this->getTargetProperty($target);
362: $pathRules[] = $rule;
363: }
364: }
365: }
366:
367: // Copy class-based rules applied to `$class` and its parents to
368: // `$classRules`:
369: $classRules = [];
370: if ($class !== null) {
371: while ($untilClass === null || $class !== $untilClass) {
372: if (isset($rules[$class])) {
373: // Give precedence to rules applied to subclasses
374: array_unshift($classRules, ...$rules[$class]);
375: }
376: $class = get_parent_class($class);
377: if ($class === false) {
378: break;
379: }
380: }
381: }
382:
383: // Return the highest-precedence rule for each key
384: $rules = [];
385: foreach ([...$classRules, ...$pathRules] as $rule) {
386: unset($rule[0]);
387: $target = array_shift($rule);
388: $rules[$target] = $rule;
389: }
390:
391: if ($ruleType === self::TYPE_REMOVE) {
392: $keys = array_keys($rules);
393: $rules = Arr::combine($keys, $keys);
394: return $this->CompileCache[$ruleType][$cacheKey] = $rules;
395: }
396:
397: foreach ($rules as $key => $rule) {
398: if ($rule[1] !== null) {
399: $closure = $rule[1];
400: $rule[1] = fn($value, ?SyncStoreInterface $store = null) =>
401: $closure($value, $store, $this);
402: }
403: $compiled[$key] = $rule;
404: }
405:
406: return $this->CompileCache[$ruleType][$cacheKey] = $compiled ?? [];
407: }
408:
409: /**
410: * @param array{string,...}|string $rule
411: */
412: private function getTarget($rule): string
413: {
414: return is_array($rule) ? reset($rule) : $rule;
415: }
416:
417: private function normaliseTarget(string $target): string
418: {
419: return Regex::replaceCallback(
420: '/[^].[]++/',
421: fn(array $matches): string =>
422: $this->Introspector->maybeNormalise($matches[0], NormaliserFlag::LAZY),
423: $target,
424: );
425: }
426:
427: /**
428: * @param array{string,...}|string $rule
429: * @param class-string $entity
430: * @return array{class-string,string,string|null,(Closure(mixed, SyncStoreInterface|null, SyncSerializeRules<TEntity>): mixed)|null}
431: */
432: private function normaliseRule($rule, string $target, string $entity): array
433: {
434: $normalised = [$entity, $target, null, null];
435: if (!is_array($rule)) {
436: return $normalised;
437: }
438: /** @var array<Closure|string|null> */
439: $rule = array_slice($rule, 1);
440: foreach ($rule as $value) {
441: if ($value === null) {
442: continue;
443: }
444: if ($value instanceof Closure) {
445: $normalised[3] = $value;
446: continue;
447: }
448: $normalised[2] = $this->Introspector->maybeNormalise($value, NormaliserFlag::LAZY);
449: }
450: return $normalised;
451: }
452:
453: private function getTargetParent(string $target): string
454: {
455: return ((substr($target, -2) === '[]')
456: ? substr($target, 0, -2)
457: : substr($target, 0, max(0, strrpos('.' . $target, '.') - 1))) ?: '.';
458: }
459:
460: private function getTargetProperty(string $target): string
461: {
462: return (substr($target, -2) === '[]')
463: ? '[]'
464: : substr((string) strrchr('.' . $target, '.'), 1);
465: }
466:
467: /**
468: * @inheritDoc
469: */
470: public function getEntity(): string
471: {
472: return $this->Entity;
473: }
474:
475: /**
476: * @inheritDoc
477: */
478: public function getDateFormatter(): ?DateFormatterInterface
479: {
480: return $this->DateFormatter;
481: }
482:
483: /**
484: * @inheritDoc
485: */
486: public function getIncludeMeta(): bool
487: {
488: return $this->IncludeMeta ?? true;
489: }
490:
491: /**
492: * @inheritDoc
493: */
494: public function getSortByKey(): bool
495: {
496: return $this->SortByKey ?? false;
497: }
498:
499: /**
500: * @inheritDoc
501: */
502: public function getMaxDepth(): int
503: {
504: return $this->MaxDepth ?? 99;
505: }
506:
507: /**
508: * @inheritDoc
509: */
510: public function getDetectRecursion(): bool
511: {
512: return $this->DetectRecursion ?? true;
513: }
514:
515: /**
516: * @inheritDoc
517: */
518: public function getRecurseRules(): bool
519: {
520: return $this->RecurseRules ?? true;
521: }
522:
523: /**
524: * @inheritDoc
525: */
526: public function getForSyncStore(): bool
527: {
528: return $this->ForSyncStore ?? false;
529: }
530:
531: /**
532: * @inheritDoc
533: */
534: public function getIncludeCanonicalId(): bool
535: {
536: return $this->IncludeCanonicalId ?? false;
537: }
538:
539: /**
540: * @inheritDoc
541: */
542: public function withDateFormatter(?DateFormatterInterface $formatter)
543: {
544: return $this->with('DateFormatter', $formatter);
545: }
546:
547: /**
548: * @inheritDoc
549: */
550: public function withIncludeMeta(?bool $include = true)
551: {
552: return $this->with('IncludeMeta', $include);
553: }
554:
555: /**
556: * @inheritDoc
557: */
558: public function withSortByKey(?bool $sort = true)
559: {
560: return $this->with('SortByKey', $sort);
561: }
562:
563: /**
564: * @inheritDoc
565: */
566: public function withMaxDepth(?int $depth)
567: {
568: return $this->with('MaxDepth', $depth);
569: }
570:
571: /**
572: * @inheritDoc
573: */
574: public function withDetectRecursion(?bool $detect = true)
575: {
576: return $this->with('DetectRecursion', $detect);
577: }
578:
579: /**
580: * @inheritDoc
581: */
582: public function withRecurseRules(?bool $recurse = true)
583: {
584: return $this->with('RecurseRules', $recurse);
585: }
586:
587: /**
588: * @inheritDoc
589: */
590: public function withForSyncStore(?bool $forSyncStore = true)
591: {
592: return $this->with('ForSyncStore', $forSyncStore);
593: }
594:
595: /**
596: * @inheritDoc
597: */
598: public function withIncludeCanonicalId(?bool $include = true)
599: {
600: return $this->with('IncludeCanonicalId', $include);
601: }
602: }
603: