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