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