917cb3726b6c4c3257315a8d4849e6130f6e5b9b
[GitHub/WoltLab/WCF.git] /
1 <?php
2
3 declare(strict_types=1);
4
5 namespace CuyZ\Valinor\Normalizer\Transformer;
6
7 use BackedEnum;
8 use Closure;
9 use CuyZ\Valinor\Definition\Attributes;
10 use CuyZ\Valinor\Definition\Repository\ClassDefinitionRepository;
11 use CuyZ\Valinor\Normalizer\Exception\CircularReferenceFoundDuringNormalization;
12 use CuyZ\Valinor\Normalizer\Exception\TypeUnhandledByNormalizer;
13 use CuyZ\Valinor\Type\Types\NativeClassType;
14 use DateTimeInterface;
15 use Generator;
16 use ReflectionClass;
17 use stdClass;
18 use UnitEnum;
19 use WeakMap;
20
21 use function array_map;
22 use function array_reverse;
23 use function get_object_vars;
24 use function is_array;
25 use function is_iterable;
26
27 /** @internal */
28 final class RecursiveTransformer
29 {
30 public function __construct(
31 private ClassDefinitionRepository $classDefinitionRepository,
32 private ValueTransformersHandler $valueTransformers,
33 private KeyTransformersHandler $keyTransformers,
34 /** @var list<callable> */
35 private array $transformers,
36 /** @var list<class-string> */
37 private array $transformerAttributes,
38 ) {}
39
40 /**
41 * @return array<mixed>|scalar|null
42 */
43 public function transform(mixed $value): mixed
44 {
45 return $this->doTransform($value, new WeakMap()); // @phpstan-ignore-line
46 }
47
48 /**
49 * @param WeakMap<object, true> $references
50 * @param list<object> $attributes
51 * @return iterable<mixed>|scalar|null
52 */
53 private function doTransform(mixed $value, WeakMap $references, array $attributes = []): mixed
54 {
55 if (is_object($value)) {
56 if (isset($references[$value])) {
57 throw new CircularReferenceFoundDuringNormalization($value);
58 }
59
60 // @infection-ignore-all
61 $references[$value] = true;
62 }
63
64 if ($this->transformers === [] && $this->transformerAttributes === []) {
65 return $this->defaultTransformer($value, $references);
66 }
67
68 if ($this->transformerAttributes !== [] && is_object($value)) {
69 $classAttributes = $this->classDefinitionRepository->for(NativeClassType::for($value::class))->attributes();
70
71 $attributes = [...$attributes, ...$classAttributes];
72 }
73
74 return $this->valueTransformers->transform(
75 $value,
76 $attributes,
77 $this->transformers,
78 fn (mixed $value) => $this->defaultTransformer($value, $references),
79 );
80 }
81
82 /**
83 * @param WeakMap<object, true> $references
84 * @return iterable<mixed>|scalar|null
85 */
86 private function defaultTransformer(mixed $value, WeakMap $references): mixed
87 {
88 if ($value === null) {
89 return null;
90 }
91
92 if (is_scalar($value)) {
93 return $value;
94 }
95
96 if (is_object($value) && ! $value instanceof Closure && ! $value instanceof Generator) {
97 if ($value instanceof UnitEnum) {
98 return $value instanceof BackedEnum ? $value->value : $value->name;
99 }
100
101 if ($value instanceof DateTimeInterface) {
102 return $value->format('Y-m-d\\TH:i:s.uP'); // RFC 3339
103 }
104
105 if ($value::class === stdClass::class) {
106 return array_map(
107 fn (mixed $value) => $this->doTransform($value, $references),
108 (array)$value
109 );
110 }
111
112 $values = (fn () => get_object_vars($this))->call($value);
113
114 // @infection-ignore-all
115 if (PHP_VERSION_ID < 8_01_00) {
116 // In PHP 8.1, behavior changed for `get_object_vars` function:
117 // the sorting order was taking children properties first, now
118 // it takes parents properties first. This code is a temporary
119 // workaround to keep the same behavior in PHP 8.0 and later
120 // versions.
121 $sorted = [];
122
123 $parents = array_reverse(class_parents($value));
124 $parents[] = $value::class;
125
126 foreach ($parents as $parent) {
127 foreach ((new ReflectionClass($parent))->getProperties() as $property) {
128 if (! isset($values[$property->name])) {
129 continue;
130 }
131
132 $sorted[$property->name] = $values[$property->name];
133 }
134 }
135
136 $values = $sorted;
137 }
138
139 $transformed = [];
140
141 $class = $this->classDefinitionRepository->for(NativeClassType::for($value::class));
142
143 foreach ($values as $key => $subValue) {
144 $attributes = $this->filterAttributes($class->properties()->get($key)->attributes());
145
146 $key = $this->keyTransformers->transformKey($key, $attributes);
147
148 $transformed[$key] = $this->doTransform($subValue, $references, $attributes);
149 }
150
151 return $transformed;
152 }
153
154 if (is_iterable($value)) {
155 if (is_array($value)) {
156 return array_map(
157 fn (mixed $value) => $this->doTransform($value, $references),
158 $value
159 );
160 }
161
162 return (function () use ($value, $references) {
163 foreach ($value as $key => $item) {
164 yield $key => $this->doTransform($item, $references);
165 }
166 })();
167 }
168
169 throw new TypeUnhandledByNormalizer($value);
170 }
171
172 /**
173 * @return list<object>
174 */
175 private function filterAttributes(Attributes $attributes): array
176 {
177 $filteredAttributes = [];
178
179 foreach ($attributes as $attribute) {
180 foreach ($this->transformerAttributes as $transformerAttribute) {
181 if ($attribute instanceof $transformerAttribute) {
182 $filteredAttributes[] = $attribute;
183 break;
184 }
185 }
186 }
187
188 return $filteredAttributes;
189 }
190 }