Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
80.00% covered (warning)
80.00%
132 / 165
44.44% covered (danger)
44.44%
8 / 18
CRAP
0.00% covered (danger)
0.00%
0 / 1
Entries
80.00% covered (warning)
80.00%
132 / 165
44.44% covered (danger)
44.44%
8 / 18
84.91
0.00% covered (danger)
0.00%
0 / 1
 value
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 structure
82.61% covered (warning)
82.61%
19 / 23
0.00% covered (danger)
0.00%
0 / 1
8.34
 shape
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
2
 allow
64.29% covered (warning)
64.29%
9 / 14
0.00% covered (danger)
0.00%
0 / 1
7.64
 allowedEntryTypes
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 entryFields
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 entryFieldsFor
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 properties
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
2
 allows
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 entryStructure
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
4.01
 finalizeEntryValue
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
5.20
 reviewEntryValues
47.37% covered (danger)
47.37%
9 / 19
0.00% covered (danger)
0.00%
0 / 1
17.33
 entryShape
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 buildEntryFields
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
4.02
 orderedFields
33.33% covered (danger)
33.33%
3 / 9
0.00% covered (danger)
0.00%
0 / 1
12.41
 fieldClass
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
3.21
 requireAllowedEntryTypes
50.00% covered (danger)
50.00%
1 / 2
0.00% covered (danger)
0.00%
0 / 1
2.50
 nodeTypes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Field;
6
7use Celemas\Sire\Review;
8use Celemas\Sire\Shape;
9use Cosray\Exception\RuntimeException;
10use Cosray\Node\Types;
11use Cosray\Validation\Prepare;
12use Cosray\Validation\Shapes;
13use Cosray\Value\Entries as EntriesValue;
14use Cosray\Value\ValueContext;
15use ReflectionClass;
16use ReflectionNamedType;
17use ReflectionProperty;
18
19class Entries extends Field implements Capability\Limitable
20{
21    use Capability\IsLimitable;
22
23    /** @var list<class-string> */
24    protected array $allowedEntryTypes = [];
25    protected ?Types $nodeTypes = null;
26
27    public function value(): EntriesValue
28    {
29        $this->requireAllowedEntryTypes();
30
31        return new EntriesValue($this->owner, $this, $this->valueContext);
32    }
33
34    public function structure(mixed $value = null): array
35    {
36        $this->requireAllowedEntryTypes();
37        $value ??= $this->valueContext->data['value'][self::NEUTRAL_LOCALE] ?? $this->default ?? [];
38
39        if (!is_array($value)) {
40            $value = [];
41        }
42
43        $structures = [];
44
45        foreach ($value as $entryData) {
46            if (!is_array($entryData)) {
47                continue;
48            }
49
50            $type = $entryData['type'] ?? null;
51
52            if (!is_string($type) || !$this->allows($type)) {
53                continue;
54            }
55
56            $entryValue = $entryData['fields'] ?? [];
57
58            if (!is_array($entryValue)) {
59                $entryValue = [];
60            }
61
62            $structures[] = [
63                'uid' => is_string($entryData['uid'] ?? null) ? $entryData['uid'] : null,
64                'type' => $type,
65                'fields' => $this->entryStructure($type, $entryValue),
66            ];
67        }
68
69        return [
70            'type' => $this::class,
71            'value' => [self::NEUTRAL_LOCALE => $structures],
72        ];
73    }
74
75    public function shape(): Shape
76    {
77        $this->requireAllowedEntryTypes();
78
79        $shape = Shapes::create();
80        $this->addType($shape);
81
82        $itemShape = Shapes::list();
83        $itemShape
84            ->add('uid', 'string')
85            ->rules('required');
86        $itemShape
87            ->add('type', 'string')
88            ->rules('required', 'in:' . implode(',', $this->allowedEntryTypes));
89        $itemShape
90            ->add('fields', Shapes::create())
91            ->rules('required')
92            ->finalize($this->finalizeEntryValue(...));
93        $itemShape->review($this->reviewEntryValues(...));
94
95        $value = $shape
96            ->add('value', $this->zxxShape($itemShape, $this->limitValidators()))
97            ->rules(...$this->validators)
98            ->prepare(Prepare::nullAsEmpty(...));
99
100        if (!$this->isRequired()) {
101            $value->optional()->nullable();
102        }
103
104        $this->addMeta($shape);
105
106        return $shape;
107    }
108
109    /** @param class-string ...$types */
110    public function allow(string ...$types): static
111    {
112        if ($types === []) {
113            throw new RuntimeException('Entries fields require at least one allowed entry type');
114        }
115
116        foreach ($types as $type) {
117            if (!class_exists($type)) {
118                throw new RuntimeException("Entries field '{$this->name}' allows unknown entry type '{$type}'");
119            }
120
121            if ($type === self::class || is_subclass_of($type, self::class)) {
122                throw new RuntimeException(
123                    "Entries field '{$this->name}' entry type '{$type}' must not extend Entries",
124                );
125            }
126        }
127
128        $this->allowedEntryTypes = array_values(array_unique([
129            ...$this->allowedEntryTypes,
130            ...$types,
131        ]));
132
133        return $this;
134    }
135
136    /** @return list<class-string> */
137    public function allowedEntryTypes(): array
138    {
139        $this->requireAllowedEntryTypes();
140
141        return $this->allowedEntryTypes;
142    }
143
144    /** @return array<string, Field> */
145    public function entryFields(?string $type = null): array
146    {
147        $this->requireAllowedEntryTypes();
148        $type ??= $this->allowedEntryTypes[0];
149
150        return $this->entryFieldsFor($type);
151    }
152
153    /**
154     * @param class-string $type
155     * @param array<string, mixed> $data
156     * @return array<string, Field>
157     */
158    public function entryFieldsFor(string $type, array $data = []): array
159    {
160        if (!$this->allows($type)) {
161            throw new RuntimeException("Entries field '{$this->name}' does not allow entry type '{$type}'");
162        }
163
164        return $this->orderedFields($type, $this->buildEntryFields($type, $data));
165    }
166
167    public function properties(): array
168    {
169        $this->requireAllowedEntryTypes();
170
171        $result = parent::properties();
172        $result['type'] = Entries::class;
173        $result['entryTypes'] = [];
174
175        foreach ($this->allowedEntryTypes as $type) {
176            $result['entryTypes'][] = [
177                'type' => $type,
178                'label' => $this->nodeTypes()->get($type, 'label'),
179                'fields' => array_values(array_map(
180                    static fn(Field $field): array => $field->properties(),
181                    $this->entryFieldsFor($type),
182                )),
183            ];
184        }
185
186        return $result;
187    }
188
189    public function allows(string $type): bool
190    {
191        return in_array($type, $this->allowedEntryTypes, true);
192    }
193
194    /**
195     * @param class-string $type
196     * @param array<string, mixed> $entryValue
197     * @return array<string, array>
198     */
199    protected function entryStructure(string $type, array $entryValue): array
200    {
201        $structure = [];
202
203        foreach ($this->entryFieldsFor($type) as $name => $entryField) {
204            $entryFieldData = $entryValue[$name] ?? null;
205            $entryFieldValue = is_array($entryFieldData) ? $entryFieldData['value'] ?? null : null;
206            $entryFieldStructure = $entryField->structure($entryFieldValue);
207
208            if (is_array($entryFieldData)) {
209                $structure[$name] = array_replace_recursive($entryFieldStructure, $entryFieldData);
210                $structure[$name]['type'] = $entryFieldStructure['type'];
211
212                continue;
213            }
214
215            $structure[$name] = $entryFieldStructure;
216        }
217
218        return $structure;
219    }
220
221    /** @param array<string, mixed> $values */
222    protected function finalizeEntryValue(mixed $value, array $values): mixed
223    {
224        $type = $values['type'] ?? null;
225
226        if (!is_string($type) || !$this->allows($type) || !is_array($value)) {
227            return $value;
228        }
229
230        $result = $this->entryShape($type)->validate($value);
231
232        return $result->valid() ? $result->values() : $value;
233    }
234
235    protected function reviewEntryValues(Review $review): void
236    {
237        foreach ($review->values() as $index => $entryData) {
238            if (!is_array($entryData)) {
239                continue;
240            }
241
242            $type = $entryData['type'] ?? null;
243
244            if (!is_string($type) || !$this->allows($type)) {
245                continue;
246            }
247
248            $value = $entryData['fields'] ?? null;
249
250            if (!is_array($value)) {
251                continue;
252            }
253
254            $result = $this->entryShape($type)->validate($value);
255
256            if ($result->valid()) {
257                continue;
258            }
259
260            foreach ($result->issues() as $issue) {
261                $review->addError(
262                    [$index, 'fields', ...$issue->path],
263                    $issue->message,
264                    $issue->code,
265                    $issue->params,
266                );
267            }
268        }
269    }
270
271    /** @param class-string $type */
272    protected function entryShape(string $type): Shape
273    {
274        $shape = Shapes::create();
275
276        foreach ($this->entryFieldsFor($type) as $name => $entryField) {
277            $shape
278                ->add($name, $entryField->shape())
279                ->optional()
280                ->nullable()
281                ->prepare(Prepare::nullAsEmpty(...));
282        }
283
284        return $shape;
285    }
286
287    /**
288     * @param class-string $type
289     * @param array<string, mixed> $data
290     * @return array<string, Field>
291     */
292    protected function buildEntryFields(string $type, array $data = []): array
293    {
294        $fields = [];
295        $reflection = new ReflectionClass($type);
296
297        foreach ($reflection->getProperties() as $property) {
298            $fieldClass = $this->fieldClass($property);
299
300            if ($fieldClass === null) {
301                continue;
302            }
303
304            $name = $property->getName();
305            $fieldData = $data[$name] ?? [];
306
307            if (!is_array($fieldData)) {
308                $fieldData = [];
309            }
310
311            $field = new $fieldClass(
312                $name,
313                $this->owner,
314                new ValueContext($name, $fieldData),
315            );
316
317            $field->initSchema($property, $this->schemaRegistry());
318            $fields[$name] = $field;
319        }
320
321        return $fields;
322    }
323
324    /**
325     * @param class-string $type
326     * @param array<string, Field> $fields
327     * @return array<string, Field>
328     */
329    protected function orderedFields(string $type, array $fields): array
330    {
331        $order = $this->nodeTypes()->get($type, 'fieldOrder');
332
333        if (!is_array($order)) {
334            return $fields;
335        }
336
337        $ordered = [];
338
339        foreach ($order as $name) {
340            if (!is_string($name) || !isset($fields[$name])) {
341                continue;
342            }
343
344            $ordered[$name] = $fields[$name];
345        }
346
347        return [...$ordered, ...array_diff_key($fields, $ordered)];
348    }
349
350    /** @return class-string<Field>|null */
351    protected function fieldClass(ReflectionProperty $property): ?string
352    {
353        $type = $property->getType();
354
355        if (!$type instanceof ReflectionNamedType) {
356            return null;
357        }
358
359        $fieldClass = $type->getName();
360
361        if (!is_subclass_of($fieldClass, Field::class)) {
362            return null;
363        }
364
365        return $fieldClass;
366    }
367
368    protected function requireAllowedEntryTypes(): void
369    {
370        if ($this->allowedEntryTypes === []) {
371            throw new RuntimeException("Entries field '{$this->name}' requires #[Allows(...)]");
372        }
373    }
374
375    protected function nodeTypes(): Types
376    {
377        return $this->nodeTypes ??= new Types();
378    }
379}