Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
271 / 271
99.35% covered (success)
99.35%
152 / 153
65.52% covered (warning)
65.52%
76 / 116
96.88% covered (success)
96.88%
31 / 32
CRAP
0.00% covered (danger)
0.00%
0 / 1
ValidationRun
100.00% covered (success)
100.00%
271 / 271
99.35% covered (success)
99.35%
152 / 153
65.52% covered (warning)
65.52%
76 / 116
100.00% covered (success)
100.00%
32 / 32
373.31
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validate
100.00% covered (success)
100.00%
16 / 16
100.00% covered (success)
100.00%
11 / 11
25.00% covered (danger)
25.00%
4 / 16
100.00% covered (success)
100.00%
1 / 1
15.55
 prepareData
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
4 / 4
66.67% covered (warning)
66.67%
2 / 3
100.00% covered (success)
100.00%
1 / 1
2.15
 validateField
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
11 / 11
87.50% covered (warning)
87.50%
7 / 8
100.00% covered (success)
100.00%
1 / 1
6.07
 finalizeValues
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
6 / 6
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
4.12
 finalizeItem
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
6 / 6
75.00% covered (warning)
75.00%
3 / 4
100.00% covered (success)
100.00%
1 / 1
3.14
 extractValues
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
6 / 6
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
4.12
 readFromData
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
9 / 9
50.00% covered (danger)
50.00%
3 / 6
100.00% covered (success)
100.00%
1 / 1
6.00
 readFieldValue
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 readEmptyValue
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 readDefaultValue
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 addReadErrors
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
5 / 5
75.00% covered (warning)
75.00%
3 / 4
100.00% covered (success)
100.00%
1 / 1
3.14
 readExtraValue
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 readKnownValue
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 formatExtraFailure
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formatNullFailure
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formatMissingFailure
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formatCoercionFailure
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 formatShapeFailure
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 formatFailure
100.00% covered (success)
100.00%
16 / 16
85.71% covered (warning)
85.71%
6 / 7
50.00% covered (danger)
50.00%
2 / 4
100.00% covered (success)
100.00%
1 / 1
4.12
 toSubValues
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 review
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
4 / 4
66.67% covered (warning)
66.67%
2 / 3
100.00% covered (success)
100.00%
1 / 1
2.15
 fillMissingFromFields
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
14 / 14
58.33% covered (warning)
58.33%
7 / 12
100.00% covered (success)
100.00%
1 / 1
10.54
 readValues
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
8 / 8
40.00% covered (danger)
40.00%
2 / 5
100.00% covered (success)
100.00%
1 / 1
7.46
 validateItem
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
9 / 9
66.67% covered (warning)
66.67%
4 / 6
100.00% covered (success)
100.00%
1 / 1
4.59
 getValues
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 blockRules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 rulesBlocked
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 path
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 pathKey
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 isRawEmptyValue
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
5 / 5
25.00% covered (danger)
25.00%
1 / 4
100.00% covered (success)
100.00%
1 / 1
6.80
 params
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
4 / 4
66.67% covered (warning)
66.67%
2 / 3
100.00% covered (success)
100.00%
1 / 1
2.15
1<?php
2
3declare(strict_types=1);
4
5namespace Celemas\Sire;
6
7use Celemas\Sire\Contract\Value;
8use ValueError;
9
10/** @internal */
11final class ValidationRun
12{
13    private ErrorBag $errors;
14    /** @var array<string, true> */
15    private array $ruleBlockedPaths = [];
16
17    public function __construct(
18        private readonly ShapeDefinition $shape,
19        private readonly array $data,
20    ) {
21        $this->errors = new ErrorBag();
22    }
23
24    public function validate(): Result
25    {
26        $data = $this->prepareData($this->data);
27        $values = $this->readValues($data);
28        $validatedValues = [];
29
30        if ($this->shape->list) {
31            foreach ($values as $listIndex => $subValues) {
32                /** @var array<string, Value> $subValues */
33                $validatedValues[] = $this->validateItem($subValues, $listIndex);
34            }
35        } else {
36            /** @var array<string, Value> $values */
37            $validatedValues = $this->validateItem($values);
38        }
39
40        if (!$this->errors->hasErrors()) {
41            $validatedValues = $this->finalizeValues($validatedValues);
42        }
43
44        $extractedValues = $this->extractValues($validatedValues);
45
46        if (!$this->errors->hasErrors()) {
47            $this->review($extractedValues);
48        }
49
50        return new Result(
51            $this->errors->issues(),
52            $extractedValues,
53        );
54    }
55
56    /** @return array<array-key, mixed> */
57    private function prepareData(array $data): array
58    {
59        foreach ($this->shape->prepareCallbacks as $prepare) {
60            $data = $prepare($data);
61        }
62
63        return $data;
64    }
65
66    private function validateField(
67        Field $definition,
68        Value $value,
69        string $ruleDefinition,
70        string|int|null $listIndex,
71    ): void {
72        $parsedRule = $this->shape->ruleParser->parse($ruleDefinition);
73        $ruleName = $parsedRule['name'];
74        $ruleArgs = $parsedRule['args'];
75
76        $rule = $this->shape->rules->get($ruleName);
77
78        if ($rule === null) {
79            throw new ValueError(
80                sprintf('Unknown rule "%s" in field "%s"', $ruleName, $definition->field),
81            );
82        }
83
84        if ($this->rulesBlocked($definition->field, $listIndex)) {
85            return;
86        }
87
88        if (!$rule instanceof Contract\ValidatesEmpty && $value->empty) {
89            return;
90        }
91
92        $validation = $rule->validate($value, ...$ruleArgs);
93
94        if ($validation->failure !== null) {
95            $this->errors->add(
96                self::path($definition->field, $listIndex),
97                $this->formatFailure(
98                    $validation->failure,
99                    $definition->name(),
100                    $definition->field,
101                    $value->pristine,
102                    'rule.' . $ruleName,
103                    $rule->message,
104                    $ruleArgs,
105                    $definition->messageOverrides(),
106                ),
107            );
108        }
109    }
110
111    /** @param array<string, Value>|list<array<string, Value>> $validatedValues */
112    private function finalizeValues(array $validatedValues): array
113    {
114        if ($this->shape->list) {
115            $values = [];
116
117            foreach ($validatedValues as $item) {
118                /** @var array<string, Value> $item */
119                $values[] = $this->finalizeItem($item);
120            }
121
122            return $values;
123        }
124
125        /** @var array<string, Value> $validatedValues */
126        return $this->finalizeItem($validatedValues);
127    }
128
129    /**
130     * @param array<string, Value> $values
131     * @return array<string, Value>
132     */
133    private function finalizeItem(array $values): array
134    {
135        $itemValues = $this->getValues($values);
136
137        foreach ($this->shape->fields as $field => $definition) {
138            if (!array_key_exists($field, $values)) {
139                continue;
140            }
141
142            $value = $values[$field];
143            $finalValue = $definition->applyFinalization($value->value, $itemValues);
144            $values[$field] = new \Celemas\Sire\Value($finalValue, $value->pristine, $value->empty);
145        }
146
147        return $values;
148    }
149
150    /** @param array<string, Value>|list<array<string, Value>> $validatedValues */
151    private function extractValues(array $validatedValues): array
152    {
153        if ($this->shape->list) {
154            $values = [];
155
156            foreach ($validatedValues as $item) {
157                /** @var array<string, Value> $item */
158                $values[] = $this->getValues($item);
159            }
160
161            return $values;
162        }
163
164        /** @var array<string, Value> $validatedValues */
165        return $this->getValues($validatedValues);
166    }
167
168    /** @return array<string, Value> */
169    private function readFromData(array $data, string|int|null $listIndex = null): array
170    {
171        $values = [];
172
173        foreach ($data as $field => $value) {
174            $field = (string) $field;
175            $definition = $this->shape->fields[$field] ?? null;
176            $value = $definition instanceof Field
177                ? $this->readFieldValue($field, $definition, $value, $data, $listIndex)
178                : $this->readExtraValue($field, $value, $listIndex);
179
180            if ($value !== null) {
181                $values[$field] = $value;
182            }
183        }
184
185        return $values;
186    }
187
188    /** @param array<string, mixed> $data */
189    private function readFieldValue(
190        string $field,
191        Field $definition,
192        mixed $value,
193        array $data,
194        string|int|null $listIndex,
195    ): ?Value {
196        $value = $definition->applyPreparation($value, $data);
197
198        if ($definition->isBlank($value)) {
199            return $this->readEmptyValue($field, $definition, $data, $listIndex);
200        }
201
202        $read = $this->readKnownValue($definition, $value);
203        $this->addReadErrors($field, $read, $listIndex);
204
205        return $read->value;
206    }
207
208    /** @param array<string, mixed> $data */
209    private function readEmptyValue(
210        string $field,
211        Field $definition,
212        array $data,
213        string|int|null $listIndex,
214    ): ?Value {
215        if ($definition->hasDefault()) {
216            return $this->readDefaultValue($field, $definition, $data, $listIndex);
217        }
218
219        if ($definition->isOptional()) {
220            return null;
221        }
222
223        $this->errors->add(
224            self::path($field, $listIndex),
225            $this->formatMissingFailure($definition),
226        );
227
228        return null;
229    }
230
231    /** @param array<string, mixed> $data */
232    private function readDefaultValue(
233        string $field,
234        Field $definition,
235        array $data,
236        string|int|null $listIndex,
237    ): Value {
238        $value = $definition->applyPreparation($definition->defaultValue(), $data);
239        $read = $this->readKnownValue($definition, $value);
240        $this->addReadErrors($field, $read, $listIndex);
241
242        return $read->value;
243    }
244
245    private function addReadErrors(string $field, ReadValue $read, string|int|null $listIndex): void
246    {
247        if ($read->nestedResult !== null) {
248            $this->errors->addNested(self::path($field, $listIndex), $read->nestedResult);
249            $this->blockRules($field, $listIndex);
250        }
251
252        if ($read->issue !== null) {
253            $this->errors->add(self::path($field, $listIndex), $read->issue);
254            $this->blockRules($field, $listIndex);
255        }
256    }
257
258    private function readExtraValue(string $field, mixed $value, string|int|null $listIndex): ?Value
259    {
260        if ($this->shape->extra === Extra::Allow) {
261            return new \Celemas\Sire\Value($value, $value, self::isRawEmptyValue($value));
262        }
263
264        if ($this->shape->extra === Extra::Forbid) {
265            $this->errors->add(
266                self::path($field, $listIndex),
267                $this->formatExtraFailure($field, $value),
268            );
269        }
270
271        return null;
272    }
273
274    private function readKnownValue(Field $definition, mixed $value): ReadValue
275    {
276        if ($value === null) {
277            return new ReadValue(
278                new \Celemas\Sire\Value(null, null, true),
279                $definition->isNullable() ? null : $this->formatNullFailure($definition),
280            );
281        }
282
283        $type = $definition->type();
284
285        if ($type === 'shape') {
286            $shape = $definition->type;
287            assert($shape instanceof Contract\Validator, 'Expected shape field type to be a shape instance');
288
289            return $this->toSubValues($value, $definition, $shape);
290        }
291
292        $coercer = $this->shape->coercers->get($type);
293
294        if ($coercer === null) {
295            throw new ValueError('Wrong shape type');
296        }
297
298        $coercion = $coercer->coerce(
299            $value,
300            $definition->coercionMode($this->shape->coercionMode),
301        );
302
303        return new ReadValue(
304            new \Celemas\Sire\Value($coercion->value, $coercion->pristine, $coercion->empty),
305            $this->formatCoercionFailure($coercion, $definition, $coercer),
306        );
307    }
308
309    private function formatExtraFailure(string $field, mixed $value): Issue
310    {
311        return $this->formatFailure(
312            Failure::invalid(),
313            $field,
314            $field,
315            $value,
316            'extra',
317            'Field "{field}" is not allowed',
318        );
319    }
320
321    private function formatNullFailure(Field $definition): Issue
322    {
323        return $this->formatFailure(
324            Failure::key('null'),
325            $definition->name(),
326            $definition->field,
327            null,
328            'null',
329            '{label} must not be null',
330            messages: $definition->messageOverrides(),
331        );
332    }
333
334    private function formatMissingFailure(Field $definition): Issue
335    {
336        return $this->formatFailure(
337            Failure::key('missing'),
338            $definition->name(),
339            $definition->field,
340            null,
341            'missing',
342            '{label} is required',
343            messages: $definition->messageOverrides(),
344        );
345    }
346
347    private function formatCoercionFailure(
348        Contract\Coercion $coercion,
349        Field $definition,
350        Contract\Coercer $coercer,
351    ): ?Issue {
352        if ($coercion->failure === null) {
353            return null;
354        }
355
356        return $this->formatFailure(
357            $coercion->failure,
358            $definition->name(),
359            $definition->field,
360            $coercion->pristine,
361            'type.' . $definition->type(),
362            $coercer->message,
363            messages: $definition->messageOverrides(),
364        );
365    }
366
367    private function formatShapeFailure(Field $definition, mixed $value): Issue
368    {
369        return $this->formatFailure(
370            Failure::invalid(),
371            $definition->name(),
372            $definition->field,
373            $value,
374            'type.shape',
375            '{label} must be an array',
376            messages: $definition->messageOverrides(),
377        );
378    }
379
380    /**
381     * @param list<mixed> $args
382     * @param array<string, string> $messages
383     */
384    private function formatFailure(
385        Failure $failure,
386        string $label,
387        string $field,
388        mixed $pristine,
389        ?string $defaultKey,
390        string $fallback,
391        array $args = [],
392        array $messages = [],
393    ): Issue {
394        $args = $failure->args === [] ? $args : $failure->args;
395
396        return new Issue(
397            [],
398            $failure->key !== '' ? $failure->key : $defaultKey ?? 'invalid',
399            $this->shape->messageFormatter->format(
400                $failure,
401                $label,
402                $field,
403                $pristine,
404                $defaultKey,
405                $fallback,
406                $args,
407                $messages,
408            ),
409            self::params($args),
410        );
411    }
412
413    private function toSubValues(
414        mixed $pristine,
415        Field $definition,
416        Contract\Validator $shape,
417    ): ReadValue {
418        if (!is_array($pristine)) {
419            return new ReadValue(
420                new \Celemas\Sire\Value($pristine, $pristine, self::isRawEmptyValue($pristine)),
421                $this->formatShapeFailure($definition, $pristine),
422            );
423        }
424
425        $result = $shape->validate($pristine);
426
427        if ($result->valid()) {
428            $values = $result->values();
429
430            return new ReadValue(new \Celemas\Sire\Value($values, $pristine, $values === []));
431        }
432
433        return new ReadValue(
434            new \Celemas\Sire\Value($pristine, $pristine, self::isRawEmptyValue($pristine)),
435            nestedResult: $result,
436        );
437    }
438
439    /** @param array<string, mixed>|list<array<string, mixed>> $values */
440    private function review(array $values): void
441    {
442        $context = new Review(
443            $this->errors,
444            $values,
445            $this->shape->list,
446        );
447
448        foreach ($this->shape->reviewCallbacks as $review) {
449            $review($context);
450        }
451    }
452
453    /**
454     * @param array<string, Value> $values
455     * @param array<string, mixed> $data
456     * @return array<string, Value>
457     */
458    private function fillMissingFromFields(
459        array $values,
460        array $data,
461        string|int|null $listIndex = null,
462    ): array {
463        foreach ($this->shape->fields as $field => $definition) {
464            if (array_key_exists($field, $values) || array_key_exists($field, $data)) {
465                continue;
466            }
467
468            if ($definition->treatsMissingAsEmpty()) {
469                $value = $this->readEmptyValue($field, $definition, $data, $listIndex);
470
471                if ($value !== null) {
472                    $values[$field] = $value;
473                }
474
475                continue;
476            }
477
478            if ($definition->isOptional()) {
479                continue;
480            }
481
482            $this->errors->add(
483                self::path($field, $listIndex),
484                $this->formatMissingFailure($definition),
485            );
486        }
487
488        return $values;
489    }
490
491    /** @return array<string, Value>|list<array<string, Value>> */
492    private function readValues(array $data): array
493    {
494        if ($this->shape->list) {
495            $values = [];
496
497            foreach ($data as $listIndex => $item) {
498                if (!is_array($item)) {
499                    $this->errors->add(
500                        [$listIndex],
501                        new Issue([], 'type.shape', 'Item must be an array'),
502                    );
503                    $values[] = [];
504
505                    continue;
506                }
507
508                /** @var array<string, mixed> $item */
509                $subValues = $this->readFromData($item, $listIndex);
510                $values[] = $this->fillMissingFromFields($subValues, $item, $listIndex);
511            }
512
513            return $values;
514        }
515
516        $values = $this->readFromData($data);
517
518        return $this->fillMissingFromFields($values, $data);
519    }
520
521    /**
522     * @param array<string, Value> $values
523     * @return array<string, Value>
524     */
525    private function validateItem(array $values, string|int|null $listIndex = null): array
526    {
527        foreach ($this->shape->fields as $field => $definition) {
528            if (!array_key_exists($field, $values)) {
529                continue;
530            }
531
532            foreach ($definition->rules as $rule) {
533                $this->validateField(
534                    $definition,
535                    $values[$field],
536                    $rule,
537                    $listIndex,
538                );
539            }
540        }
541
542        return $values;
543    }
544
545    /**
546     * @param array<string, Value> $values
547     * @return array<string, mixed>
548     */
549    private function getValues(array $values): array
550    {
551        return array_map(
552            static fn(Value $item): mixed => $item->value,
553            $values,
554        );
555    }
556
557    private function blockRules(string $field, string|int|null $listIndex): void
558    {
559        $this->ruleBlockedPaths[self::pathKey(self::path($field, $listIndex))] = true;
560    }
561
562    private function rulesBlocked(string $field, string|int|null $listIndex): bool
563    {
564        return array_key_exists(
565            self::pathKey(self::path($field, $listIndex)),
566            $this->ruleBlockedPaths,
567        );
568    }
569
570    /** @return list<string|int> */
571    private static function path(string $field, string|int|null $listIndex): array
572    {
573        if ($listIndex === null) {
574            return [$field];
575        }
576
577        return [$listIndex, $field];
578    }
579
580    /** @param list<string|int> $path */
581    private static function pathKey(array $path): string
582    {
583        return serialize($path);
584    }
585
586    private static function isRawEmptyValue(mixed $value): bool
587    {
588        return $value === null || $value === [] || $value === '';
589    }
590
591    /**
592     * @param list<mixed> $args
593     * @return array<string, mixed>
594     */
595    private static function params(array $args): array
596    {
597        $params = [];
598
599        foreach ($args as $index => $arg) {
600            $params['arg' . ($index + 1)] = $arg;
601        }
602
603        return $params;
604    }
605}