Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
214 / 214
100.00% covered (success)
100.00%
35 / 35
CRAP
100.00% covered (success)
100.00%
1 / 1
Validation
100.00% covered (success)
100.00%
214 / 214
100.00% covered (success)
100.00%
35 / 35
85
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 setLanguage
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 getLanguage
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getValidators
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reset
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 setLabel
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getLabel
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getLabels
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setLabels
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getRuleset
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
4
 escapeArgs
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 parseRule
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 extractRules
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 getFilledMessage
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getRules
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setRule
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 setRules
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 getError
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 getErrors
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 setError
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 setMessage
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getMessage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setMessages
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 getMessages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 validateRule
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 validateField
100.00% covered (success)
100.00%
30 / 30
100.00% covered (success)
100.00%
1 / 1
16
 replaceArgs
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
4
 setEqualsField
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 run
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 validate
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 validateOnly
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
2
 validateOnlySet
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
1
 isRuleAvailable
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
4
 setDebugCollector
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getDebugCollector
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php declare(strict_types=1);
2/*
3 * This file is part of Aplus Framework Validation Library.
4 *
5 * (c) Natan Felles <natanfelles@gmail.com>
6 *
7 * For the full copyright and license information, please view the LICENSE
8 * file that was distributed with this source code.
9 */
10namespace Framework\Validation;
11
12use Framework\Helpers\ArraySimple;
13use Framework\Language\Language;
14use Framework\Validation\Debug\ValidationCollector;
15use InvalidArgumentException;
16use JetBrains\PhpStorm\Pure;
17
18/**
19 * Class Validation.
20 *
21 * @package validation
22 */
23class Validation
24{
25    /**
26     * The labels used to replace field names.
27     *
28     * @var array<string,string> The field names as keys and the labels as values
29     */
30    protected array $labels = [];
31    /**
32     * The Validator rules.
33     *
34     * @var array<string,array<int,array<string,array<int,string>|string>>> The
35     * field names as keys and the rules and arguments as values
36     */
37    protected array $rules = [];
38    /**
39     * The last errors.
40     *
41     * @var array<string,array<string,array<string,array<int,string>|string>>> The
42     * field names as keys and the rule and arguments as values
43     */
44    protected array $errors = [];
45    /**
46     * Custom error messages.
47     *
48     * @var array<string,array<string,string>> The field name as keys and an
49     * associative array of rule names as keys and messages as values
50     */
51    protected array $messages = [];
52    /**
53     * The current Validators.
54     *
55     * @var array<int,string|Validator> Values are the Validators FQCN or
56     * instances
57     */
58    protected array $validators = [];
59    /**
60     * The Language instance.
61     *
62     * @var Language
63     */
64    protected Language $language;
65    protected ValidationCollector $debugCollector;
66
67    /**
68     * Validation constructor.
69     *
70     * @param array<int,string|Validator>|null $validators
71     * @param Language|null $language
72     */
73    public function __construct(array $validators = null, Language $language = null)
74    {
75        $defaultValidators = [
76            Validator::class,
77            FilesValidator::class,
78        ];
79        $this->validators = empty($validators)
80            ? $defaultValidators
81            : \array_reverse($validators);
82        if ($language) {
83            $this->setLanguage($language);
84        }
85    }
86
87    public function setLanguage(Language $language = null) : static
88    {
89        if ($language === null) {
90            $language = new Language();
91        }
92        $language->addDirectory(__DIR__ . '/Languages');
93        $this->language = $language;
94        return $this;
95    }
96
97    public function getLanguage() : Language
98    {
99        if ( ! isset($this->language)) {
100            $this->setLanguage();
101        }
102        return $this->language;
103    }
104
105    /**
106     * @return array<int,string|Validator>
107     */
108    public function getValidators() : array
109    {
110        return $this->validators;
111    }
112
113    /**
114     * Reset the validation.
115     *
116     * @return static
117     */
118    public function reset() : static
119    {
120        $this->labels = [];
121        $this->rules = [];
122        $this->errors = [];
123        $this->messages = [];
124        return $this;
125    }
126
127    /**
128     * Set label for a field.
129     *
130     * @param string $field
131     * @param string $label
132     *
133     * @return static
134     */
135    public function setLabel(string $field, string $label) : static
136    {
137        $this->labels[$field] = $label;
138        return $this;
139    }
140
141    /**
142     * Get the label for a given field.
143     *
144     * @param string $field
145     *
146     * @return string|null
147     */
148    #[Pure]
149    public function getLabel(string $field) : ?string
150    {
151        return $this->labels[$field] ?? null;
152    }
153
154    /**
155     * Get a list of all labels.
156     *
157     * @return array<string,string>
158     */
159    #[Pure]
160    public function getLabels() : array
161    {
162        return $this->labels;
163    }
164
165    /**
166     * Set fields labels.
167     *
168     * @param array<string,string> $labels An associative array with fields as
169     * keys and label as values
170     *
171     * @return static
172     */
173    public function setLabels(array $labels) : static
174    {
175        $this->labels = [];
176        foreach ($labels as $field => $label) {
177            $this->setLabel($field, $label);
178        }
179        return $this;
180    }
181
182    /**
183     * @return array<mixed>
184     */
185    public function getRuleset() : array
186    {
187        $result = [];
188        foreach ($this->getRules() as $field => $rules) {
189            $label = $this->getLabel($field);
190            $tmp = [
191                'field' => $field,
192                'label' => $label,
193                'rules' => [],
194            ];
195            foreach ($rules as $rule) {
196                $rule['args'] = $this->escapeArgs($rule['args']); // @phpstan-ignore-line
197                $args = \implode(',', $rule['args']);
198                $ruleString = $rule['rule'] . ($args === '' ? '' : ':' . $args); // @phpstan-ignore-line
199                $tmp['rules'][] = [
200                    'rule' => $ruleString,
201                    'message' => $this->getFilledMessage(
202                        $field,
203                        $rule['rule'], // @phpstan-ignore-line
204                        \array_merge(
205                            ['field' => $label ?? $field],
206                            $rule['args']
207                        )
208                    ),
209                ];
210            }
211            $result[] = $tmp;
212        }
213        return $result;
214    }
215
216    /**
217     * @param array<string> $args
218     *
219     * @return array<string>
220     */
221    protected function escapeArgs(array $args) : array
222    {
223        foreach ($args as &$arg) {
224            $arg = \strtr($arg, [',' => '\,']);
225        }
226        return $args;
227    }
228
229    /**
230     * @param string $rule
231     *
232     * @return array<string,array<int,string>|string>
233     */
234    protected function parseRule(string $rule) : array
235    {
236        $args = [];
237        if (\str_contains($rule, ':')) {
238            [$rule, $args] = \explode(':', $rule, 2);
239            $args = (array) \preg_split('#(?<!\\\)\,#', $args);
240            foreach ($args as &$arg) {
241                $arg = \strtr((string) $arg, ['\,' => ',']);
242            }
243        }
244        return ['rule' => $rule, 'args' => $args]; // @phpstan-ignore-line
245    }
246
247    /**
248     * @param string $rules
249     *
250     * @return array<int,array<string,array<int,string>|string>>
251     */
252    #[Pure]
253    protected function extractRules(string $rules) : array
254    {
255        $result = [];
256        $rules = (array) \preg_split('#(?<!\\\)\|#', $rules);
257        foreach ($rules as $rule) {
258            $rule = \strtr((string) $rule, ['\|' => '|']);
259            $result[] = $this->parseRule($rule);
260        }
261        return $result;
262    }
263
264    /**
265     * @param string $field
266     * @param string $rule
267     * @param array<mixed> $args
268     *
269     * @return string
270     */
271    public function getFilledMessage(string $field, string $rule, array $args = []) : string
272    {
273        $message = $this->getMessage($field, $rule);
274        if ($message === null) {
275            return $this->getLanguage()->render('validation', $rule, $args);
276        }
277        return $this->getLanguage()->formatMessage($message, $args);
278    }
279
280    /**
281     * Get a list of current rules.
282     *
283     * @return array<string,array<int,array<string,array<int,string>|string>>>
284     */
285    #[Pure]
286    public function getRules() : array
287    {
288        return $this->rules;
289    }
290
291    /**
292     * Set rules for a given field.
293     *
294     * @param string $field
295     * @param array<string>|string $rules
296     *
297     * @return static
298     */
299    public function setRule(string $field, array | string $rules) : static
300    {
301        if (\is_array($rules)) {
302            foreach ($rules as &$rule) {
303                $rule = $this->parseRule($rule);
304            }
305            unset($rule);
306            $this->rules[$field] = $rules; // @phpstan-ignore-line
307            return $this;
308        }
309        $this->rules[$field] = $this->extractRules($rules);
310        return $this;
311    }
312
313    /**
314     * Set field rules.
315     *
316     * @param array<string,array<string>|string> $rules An associative array
317     * with field as keys and values as rules
318     *
319     * @return static
320     */
321    public function setRules(array $rules) : static
322    {
323        $this->rules = [];
324        foreach ($rules as $field => $rule) {
325            $this->setRule($field, $rule);
326        }
327        return $this;
328    }
329
330    /**
331     * Get latest error for a given field.
332     *
333     * @param string $field
334     *
335     * @return string|null
336     */
337    public function getError(string $field) : ?string
338    {
339        $error = $this->errors[$field] ?? null;
340        if ($error === null) {
341            return null;
342        }
343        $error['args']['args'] = $error['args'] ? \implode(', ', $error['args']) : ''; // @phpstan-ignore-line
344        $error['args']['field'] = $this->getLabel($field) ?? $field;
345        return $this->getFilledMessage($field, $error['rule'], $error['args']); // @phpstan-ignore-line
346    }
347
348    /**
349     * Get latest errors.
350     *
351     * @return array<string,string>
352     */
353    public function getErrors() : array
354    {
355        $messages = [];
356        foreach (\array_keys($this->errors) as $field) {
357            $messages[$field] = $this->getError($field);
358        }
359        return $messages;
360    }
361
362    /**
363     * @param string $field
364     * @param string $rule
365     * @param array<int,string> $args
366     *
367     * @return static
368     */
369    public function setError(string $field, string $rule, array $args = []) : static
370    {
371        // @phpstan-ignore-next-line
372        $this->errors[$field] = [
373            'rule' => $rule,
374            'args' => $args,
375        ];
376        return $this;
377    }
378
379    /**
380     * Set a custom error message for a field rule.
381     *
382     * @param string $field The field name
383     * @param string $rule The field rule name
384     * @param string $message The custom error message for the field rule
385     *
386     * @return static
387     */
388    public function setMessage(string $field, string $rule, string $message) : static
389    {
390        $this->messages[$field][$rule] = $message;
391        return $this;
392    }
393
394    /**
395     * Get the custom error message from a field rule.
396     *
397     * @param string $field The field name
398     * @param string $rule The rule name
399     *
400     * @return string|null The message string or null if the message is not set
401     */
402    public function getMessage(string $field, string $rule) : ?string
403    {
404        return $this->messages[$field][$rule] ?? null;
405    }
406
407    /**
408     * Set many custom error messages.
409     *
410     * @param array<string,array<string,string>> $messages A multi-dimensional
411     * array with field names as keys and values as arrays where the keys are
412     * rule names and values are the custom error message strings
413     *
414     * @return static
415     */
416    public function setMessages(array $messages) : static
417    {
418        $this->messages = [];
419        foreach ($messages as $field => $rules) {
420            foreach ($rules as $rule => $message) {
421                $this->setMessage($field, $rule, $message);
422            }
423        }
424        return $this;
425    }
426
427    /**
428     * Get all custom error messages set.
429     *
430     * @return array<string,array<string,string>>
431     */
432    public function getMessages() : array
433    {
434        return $this->messages;
435    }
436
437    /**
438     * @param string $rule
439     * @param string $field
440     * @param array<int|string,mixed> $args
441     * @param array<string,mixed> $data
442     *
443     * @return bool
444     */
445    protected function validateRule(string $rule, string $field, array $args, array $data) : bool
446    {
447        foreach ($this->validators as $validator) {
448            if (\is_callable([$validator, $rule])) {
449                return $validator::$rule($field, $data, ...$args);
450            }
451        }
452        throw new InvalidArgumentException(
453            "Validation rule '{$rule}' not found on field '{$field}'"
454        );
455    }
456
457    /**
458     * @param string $field
459     * @param array<string,array<mixed>> $rules
460     * @param array<string,mixed> $data
461     *
462     * @since 2.2 Added "blank", "null" and "empty" rules
463     *
464     * @return bool
465     */
466    protected function validateField(string $field, array $rules, array $data) : bool
467    {
468        $removeKeys = [];
469        foreach ($rules as $key => $rule) {
470            $fieldExists = \array_key_exists($field, $data);
471            // Field is optional. If the field is undefined, validation passes.
472            if ($rule['rule'] === 'optional') {
473                $removeKeys[] = $key;
474                if ( ! $fieldExists) {
475                    return true;
476                }
477            }
478            // Field must be defined and can have a blank string.
479            if ($rule['rule'] === 'blank') {
480                $removeKeys[] = $key;
481                if ($fieldExists && $data[$field] === '') {
482                    return true;
483                }
484            }
485            // Field must be defined and can have a null value.
486            if ($rule['rule'] === 'null') {
487                $removeKeys[] = $key;
488                if ($fieldExists && $data[$field] === null) {
489                    return true;
490                }
491            }
492            // Field must be defined and can have an empty value
493            if ($rule['rule'] === 'empty') {
494                $removeKeys[] = $key;
495                if ($fieldExists && empty($data[$field])) {
496                    return true;
497                }
498            }
499        }
500        foreach ($removeKeys as $removeKey) {
501            unset($rules[$removeKey]);
502        }
503        $status = true;
504        foreach ($rules as $rule) {
505            $rule['args'] = $this->replaceArgs($rule['args'], $data);
506            $status = $this->validateRule($rule['rule'], $field, $rule['args'], $data);
507            if ($status !== true) {
508                $rule = $this->setEqualsField($rule);
509                $this->setError($field, $rule['rule'], $rule['args']);
510                break;
511            }
512        }
513        return $status;
514    }
515
516    /**
517     * Replace argument placeholders with data values.
518     *
519     * @param array<mixed> $args
520     * @param array<string,mixed> $data
521     *
522     * @return array<mixed>
523     */
524    protected function replaceArgs(array $args, array $data) : array
525    {
526        $result = [];
527        foreach ($args as $arg) {
528            if (\preg_match('#^{(\w+)}$#', $arg)) {
529                $key = \substr($arg, 1, -1);
530                if (isset($data[$key])) {
531                    $result[] = $data[$key];
532                    continue;
533                }
534            }
535            $result[] = $arg;
536        }
537        return $result;
538    }
539
540    /**
541     * @param array<string,mixed> $rule
542     *
543     * @return array<string,mixed>
544     */
545    #[Pure]
546    protected function setEqualsField(array $rule) : array
547    {
548        if ($rule['rule'] === 'equals' || $rule['rule'] === 'notEquals') {
549            $rule['args'][0] = $this->getLabel($rule['args'][0]) ?? $rule['args'][0];
550        }
551        return $rule;
552    }
553
554    /**
555     * @param array<string,array<mixed>> $fieldRules
556     * @param array<string,mixed> $data
557     *
558     * @return bool
559     */
560    protected function run(array $fieldRules, array $data) : bool
561    {
562        $this->errors = [];
563        $result = true;
564        foreach ($fieldRules as $field => $rules) {
565            $status = $this->validateField($field, $rules, $data);
566            if ( ! $status) {
567                $result = false;
568            }
569        }
570        return $result;
571    }
572
573    /**
574     * Validate data with all rules.
575     *
576     * @param array<string,mixed> $data
577     *
578     * @return bool
579     */
580    public function validate(array $data) : bool
581    {
582        if (isset($this->debugCollector)) {
583            $start = \microtime(true);
584            $validated = $this->run($this->getRules(), $data);
585            $end = \microtime(true);
586            $this->debugCollector->addData([
587                'start' => $start,
588                'end' => $end,
589                'validated' => $validated,
590                'errors' => $this->getErrors(),
591                'type' => 'all',
592            ]);
593            return $validated;
594        }
595        return $this->run($this->getRules(), $data);
596    }
597
598    /**
599     * Validate only fields set on data.
600     *
601     * @param array<string,mixed> $data
602     *
603     * @return bool
604     */
605    public function validateOnly(array $data) : bool
606    {
607        if (isset($this->debugCollector)) {
608            $start = \microtime(true);
609            $validated = $this->validateOnlySet($data);
610            $end = \microtime(true);
611            $this->debugCollector->addData([
612                'start' => $start,
613                'end' => $end,
614                'validated' => $validated,
615                'errors' => $this->getErrors(),
616                'type' => 'only',
617            ]);
618            return $validated;
619        }
620        return $this->validateOnlySet($data);
621    }
622
623    /**
624     * @param array<string,mixed> $data
625     *
626     * @return bool
627     */
628    protected function validateOnlySet(array $data) : bool
629    {
630        $fieldRules = \array_intersect_key(
631            $this->getRules(),
632            ArraySimple::convert($data)
633        );
634        return $this->run($fieldRules, $data);
635    }
636
637    /**
638     * Tells if a rule is available in the current validators.
639     *
640     * @param string $rule
641     *
642     * @return bool
643     */
644    public function isRuleAvailable(string $rule) : bool
645    {
646        if (\in_array($rule, [
647            'blank',
648            'empty',
649            'null',
650            'optional',
651        ])) {
652            return true;
653        }
654        foreach ($this->validators as $validator) {
655            if (\is_callable([$validator, $rule])) {
656                return true;
657            }
658        }
659        return false;
660    }
661
662    public function setDebugCollector(ValidationCollector $debugCollector) : static
663    {
664        $this->debugCollector = $debugCollector;
665        $this->debugCollector->setValidation($this);
666        return $this;
667    }
668
669    public function getDebugCollector() : ValidationCollector | null
670    {
671        return $this->debugCollector ?? null;
672    }
673}