Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.93% covered (success)
97.93%
142 / 145
94.44% covered (success)
94.44%
17 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Definitions
97.93% covered (success)
97.93%
142 / 145
94.44% covered (success)
94.44%
17 / 18
64
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 for
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 clear
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fields
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 field
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 names
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 embedded
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 embed
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 fieldsets
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 discover
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
11
 discoverEmbedded
100.00% covered (success)
100.00%
42 / 42
100.00% covered (success)
100.00%
1 / 1
15
 fieldset
100.00% covered (success)
100.00%
33 / 33
100.00% covered (success)
100.00%
1 / 1
10
 addField
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 namedType
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 isField
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isEmbedded
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 containsEmbedded
75.00% covered (warning)
75.00%
9 / 12
0.00% covered (danger)
0.00%
0 / 1
11.56
 rejectFieldset
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Field;
6
7use Cosray\Contract\Embedded;
8use Cosray\Exception\RuntimeException;
9use Cosray\Schema\Description;
10use Cosray\Schema\Fieldset;
11use Cosray\Schema\Label;
12use Cosray\Schema\Width;
13use ReflectionClass;
14use ReflectionIntersectionType;
15use ReflectionNamedType;
16use ReflectionProperty;
17use ReflectionType;
18use ReflectionUnionType;
19
20final class Definitions
21{
22    /** @var array<class-string, self> */
23    private static array $cache = [];
24
25    /**
26     * @param class-string $class
27     * @param array<string, Definition> $fields
28     * @param array<string, EmbeddedDefinition> $embedded
29     * @param list<FieldsetDefinition> $fieldsets
30     */
31    private function __construct(
32        public readonly string $class,
33        private readonly array $fields,
34        private readonly array $embedded,
35        private readonly array $fieldsets,
36    ) {}
37
38    /** @param class-string $class */
39    public static function for(string $class): self
40    {
41        return self::$cache[$class] ??= self::discover($class);
42    }
43
44    public static function clear(): void
45    {
46        self::$cache = [];
47    }
48
49    /** @return array<string, Definition> */
50    public function fields(): array
51    {
52        return $this->fields;
53    }
54
55    public function field(string $name): ?Definition
56    {
57        return $this->fields[$name] ?? null;
58    }
59
60    /** @return list<string> */
61    public function names(): array
62    {
63        return array_keys($this->fields);
64    }
65
66    /** @return array<string, EmbeddedDefinition> */
67    public function embedded(): array
68    {
69        return $this->embedded;
70    }
71
72    public function embed(string $name): ?EmbeddedDefinition
73    {
74        return $this->embedded[$name] ?? null;
75    }
76
77    /** @return list<FieldsetDefinition> */
78    public function fieldsets(): array
79    {
80        return $this->fieldsets;
81    }
82
83    /** @param class-string $class */
84    private static function discover(string $class): self
85    {
86        $reflection = new ReflectionClass($class);
87        $fields = [];
88        $embedded = [];
89        $fieldsets = [];
90
91        foreach ($reflection->getProperties() as $property) {
92            $type = self::namedType($property);
93
94            if ($type !== null && self::isField($type)) {
95                self::rejectFieldset($class, $property);
96                self::addField(
97                    $fields,
98                    new Definition($property->getName(), $type, $property),
99                    $class,
100                );
101                continue;
102            }
103
104            if ($type !== null && self::isEmbedded($type)) {
105                $definition = self::discoverEmbedded($class, $property, $type);
106                $embedded[$definition->name] = $definition;
107
108                foreach ($definition->fields as $field) {
109                    self::addField($fields, $field, $class);
110                }
111
112                if ($definition->fieldset !== null) {
113                    $fieldsets[] = $definition->fieldset;
114                }
115
116                continue;
117            }
118
119            if (self::containsEmbedded($property->getType())) {
120                throw new RuntimeException(
121                    "Embedded property '{$class}::\${$property->getName()}' requires one non-nullable named type.",
122                );
123            }
124
125            self::rejectFieldset($class, $property);
126        }
127
128        foreach ($embedded as $name => $definition) {
129            if (isset($fields[$name])) {
130                throw new RuntimeException(
131                    "Embedded property '{$class}::\${$name}' collides with the flat field '{$name}'.",
132                );
133            }
134        }
135
136        return new self($class, $fields, $embedded, $fieldsets);
137    }
138
139    /**
140     * @param class-string $owner
141     * @param class-string<Embedded> $type
142     */
143    private static function discoverEmbedded(
144        string $owner,
145        ReflectionProperty $property,
146        string $type,
147    ): EmbeddedDefinition {
148        $name = $property->getName();
149        $where = "{$owner}::\${$name}";
150        $reflection = new ReflectionClass($type);
151
152        if ($property->isStatic()) {
153            throw new RuntimeException("Embedded property '{$where}' must not be static.");
154        }
155
156        if ($property->isReadOnly()) {
157            throw new RuntimeException("Embedded property '{$where}' must not be readonly.");
158        }
159
160        if ($property->isPromoted() || $property->hasDefaultValue()) {
161            throw new RuntimeException(
162                "Embedded property '{$where}' must be uninitialized and must not be constructor-promoted.",
163            );
164        }
165
166        $typeReflection = $property->getType();
167
168        if (!$typeReflection instanceof ReflectionNamedType || $typeReflection->allowsNull()) {
169            throw new RuntimeException(
170                "Embedded property '{$where}' requires one non-nullable named type.",
171            );
172        }
173
174        if (!$reflection->isInstantiable()) {
175            throw new RuntimeException("Embedded type '{$type}' on '{$where}' must be instantiable.");
176        }
177
178        $fields = [];
179
180        foreach ($reflection->getProperties() as $child) {
181            $childType = self::namedType($child);
182
183            if ($childType !== null && self::isEmbedded($childType)) {
184                throw new RuntimeException(
185                    "Embedded property '{$where}' contains unsupported nested embed '{$type}::\${$child->getName()}'.",
186                );
187            }
188
189            if (self::containsEmbedded($child->getType())) {
190                throw new RuntimeException(
191                    "Embedded property '{$where}' contains unsupported nested embed '{$type}::\${$child->getName()}'.",
192                );
193            }
194
195            if ($childType === null || !self::isField($childType)) {
196                self::rejectFieldset($type, $child);
197                continue;
198            }
199
200            self::rejectFieldset($type, $child);
201            self::addField(
202                $fields,
203                new Definition($child->getName(), $childType, $child, $name),
204                $type,
205            );
206        }
207
208        if ($fields === []) {
209            throw new RuntimeException("Embedded type '{$type}' on '{$where}' does not declare any fields.");
210        }
211
212        $fieldset = self::fieldset($property, $reflection, array_keys($fields));
213
214        return new EmbeddedDefinition($name, $type, $property, $fields, $fieldset);
215    }
216
217    /**
218     * @param ReflectionClass<Embedded> $class
219     * @param list<string> $fields
220     */
221    private static function fieldset(
222        ReflectionProperty $property,
223        ReflectionClass $class,
224        array $fields,
225    ): ?FieldsetDefinition {
226        $attributes = $property->getAttributes(Fieldset::class);
227
228        if ($attributes === []) {
229            foreach ([Label::class, Description::class, Width::class] as $attribute) {
230                if ($property->getAttributes($attribute) !== []) {
231                    throw new RuntimeException(
232                        "Layout attribute '{$attribute}' on inline embed "
233                            . "'{$property
234                                ->getDeclaringClass()
235                                ->getName()}::\${$property->getName()}' requires #[Fieldset].",
236                    );
237                }
238            }
239
240            return null;
241        }
242
243        $label = null;
244        $labels = $property->getAttributes(Label::class);
245
246        if ($labels !== []) {
247            $label = $labels[0]->newInstance()->label;
248        } else {
249            $labels = $class->getAttributes(Label::class);
250
251            if ($labels !== []) {
252                $label = $labels[0]->newInstance()->label;
253            }
254        }
255
256        $description = null;
257        $descriptions = $property->getAttributes(Description::class);
258
259        if ($descriptions !== []) {
260            $description = $descriptions[0]->newInstance()->description;
261        }
262
263        $width = 100;
264        $widths = $property->getAttributes(Width::class);
265
266        if ($widths !== []) {
267            $width = $widths[0]->newInstance()->width;
268        }
269
270        if ($width < 1 || $width > 100) {
271            throw new RuntimeException(
272                "Fieldset width on '{$property
273                    ->getDeclaringClass()
274                    ->getName()}::\${$property->getName()}' must be between 1 and 100.",
275            );
276        }
277
278        return new FieldsetDefinition($property->getName(), $label, $description, $width, $fields);
279    }
280
281    /**
282     * @param array<string, Definition> $fields
283     * @param class-string $owner
284     */
285    private static function addField(array &$fields, Definition $field, string $owner): void
286    {
287        if (isset($fields[$field->name])) {
288            throw new RuntimeException(
289                "Field '{$field->name}' is declared more than once in the flat schema for '{$owner}'.",
290            );
291        }
292
293        $fields[$field->name] = $field;
294    }
295
296    private static function namedType(ReflectionProperty $property): ?string
297    {
298        $type = $property->getType();
299
300        if (!$type instanceof ReflectionNamedType || $type->isBuiltin()) {
301            return null;
302        }
303
304        return $type->getName();
305    }
306
307    /** @phpstan-assert-if-true class-string<Field> $type */
308    private static function isField(string $type): bool
309    {
310        return is_subclass_of($type, Field::class);
311    }
312
313    /** @phpstan-assert-if-true class-string<Embedded> $type */
314    private static function isEmbedded(string $type): bool
315    {
316        return is_a($type, Embedded::class, true);
317    }
318
319    private static function containsEmbedded(?ReflectionType $type): bool
320    {
321        if ($type === null) {
322            return false;
323        }
324
325        if ($type instanceof ReflectionNamedType) {
326            return !$type->isBuiltin() && self::isEmbedded($type->getName());
327        }
328
329        if (!$type instanceof ReflectionUnionType && !$type instanceof ReflectionIntersectionType) {
330            return false;
331        }
332
333        foreach ($type->getTypes() as $part) {
334            if (
335                $part instanceof ReflectionNamedType
336                && !$part->isBuiltin()
337                && self::isEmbedded($part->getName())
338            ) {
339                return true;
340            }
341        }
342
343        return false;
344    }
345
346    /** @param class-string $owner */
347    private static function rejectFieldset(string $owner, ReflectionProperty $property): void
348    {
349        if ($property->getAttributes(Fieldset::class) === []) {
350            return;
351        }
352
353        throw new RuntimeException(
354            "The #[Fieldset] attribute on '{$owner}::\${$property->getName()}' requires an Embedded-typed property.",
355        );
356    }
357}