• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / CodeIgniter4 / 12673986434

08 Jan 2025 03:42PM UTC coverage: 84.455% (+0.001%) from 84.454%
12673986434

Pull #9385

github

web-flow
Merge 06e47f0ee into e475fd8fa
Pull Request #9385: refactor: Fix phpstan expr.resultUnused

20699 of 24509 relevant lines covered (84.45%)

190.57 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

95.16
/system/Validation/Validation.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\Validation;
15

16
use Closure;
17
use CodeIgniter\Database\BaseConnection;
18
use CodeIgniter\Exceptions\InvalidArgumentException;
19
use CodeIgniter\Exceptions\LogicException;
20
use CodeIgniter\HTTP\Exceptions\HTTPException;
21
use CodeIgniter\HTTP\IncomingRequest;
22
use CodeIgniter\HTTP\Method;
23
use CodeIgniter\HTTP\RequestInterface;
24
use CodeIgniter\Validation\Exceptions\ValidationException;
25
use CodeIgniter\View\RendererInterface;
26
use Config\Validation as ValidationConfig;
27

28
/**
29
 * Validator
30
 *
31
 * @see \CodeIgniter\Validation\ValidationTest
32
 */
33
class Validation implements ValidationInterface
34
{
35
    /**
36
     * Files to load with validation functions.
37
     *
38
     * @var array
39
     */
40
    protected $ruleSetFiles;
41

42
    /**
43
     * The loaded instances of our validation files.
44
     *
45
     * @var array
46
     */
47
    protected $ruleSetInstances = [];
48

49
    /**
50
     * Stores the actual rules that should be run against $data.
51
     *
52
     * @var array<array-key, array{label?: string, rules: list<string>}>
53
     *
54
     * [
55
     *     field1 => [
56
     *         'label' => label,
57
     *         'rules' => [
58
     *              rule1, rule2, ...
59
     *          ],
60
     *     ],
61
     * ]
62
     */
63
    protected $rules = [];
64

65
    /**
66
     * The data that should be validated,
67
     * where 'key' is the alias, with value.
68
     *
69
     * @var array
70
     */
71
    protected $data = [];
72

73
    /**
74
     * The data that was actually validated.
75
     *
76
     * @var array
77
     */
78
    protected $validated = [];
79

80
    /**
81
     * Any generated errors during validation.
82
     * 'key' is the alias, 'value' is the message.
83
     *
84
     * @var array
85
     */
86
    protected $errors = [];
87

88
    /**
89
     * Stores custom error message to use
90
     * during validation. Where 'key' is the alias.
91
     *
92
     * @var array
93
     */
94
    protected $customErrors = [];
95

96
    /**
97
     * Our configuration.
98
     *
99
     * @var ValidationConfig
100
     */
101
    protected $config;
102

103
    /**
104
     * The view renderer used to render validation messages.
105
     *
106
     * @var RendererInterface
107
     */
108
    protected $view;
109

110
    /**
111
     * Validation constructor.
112
     *
113
     * @param ValidationConfig $config
114
     */
115
    public function __construct($config, RendererInterface $view)
116
    {
117
        $this->ruleSetFiles = $config->ruleSets;
1,750✔
118

119
        $this->config = $config;
1,750✔
120

121
        $this->view = $view;
1,750✔
122

123
        $this->loadRuleSets();
1,750✔
124
    }
125

126
    /**
127
     * Runs the validation process, returning true/false determining whether
128
     * validation was successful or not.
129
     *
130
     * @param array|null                                 $data    The array of data to validate.
131
     * @param string|null                                $group   The predefined group of rules to apply.
132
     * @param array|BaseConnection|non-empty-string|null $dbGroup The database group to use.
133
     */
134
    public function run(?array $data = null, ?string $group = null, $dbGroup = null): bool
135
    {
136
        if ($data === null) {
1,614✔
137
            $data = $this->data;
15✔
138
        } else {
139
            // Store data to validate.
140
            $this->data = $data;
1,599✔
141
        }
142

143
        // `DBGroup` is a reserved name. For is_unique and is_not_unique
144
        $data['DBGroup'] = $dbGroup;
1,614✔
145

146
        $this->loadRuleGroup($group);
1,614✔
147

148
        // If no rules exist, we return false to ensure
149
        // the developer didn't forget to set the rules.
150
        if ($this->rules === []) {
1,610✔
151
            return false;
5✔
152
        }
153

154
        // Replace any placeholders (e.g. {id}) in the rules with
155
        // the value found in $data, if any.
156
        $this->rules = $this->fillPlaceholders($this->rules, $data);
1,605✔
157

158
        // Need this for searching arrays in validation.
159
        helper('array');
1,603✔
160

161
        // Run through each rule. If we have any field set for
162
        // this rule, then we need to run them through!
163
        foreach ($this->rules as $field => $setup) {
1,603✔
164
            //  An array key might be int.
165
            $field = (string) $field;
1,603✔
166

167
            $rules = $setup['rules'];
1,603✔
168

169
            if (is_string($rules)) {
1,603✔
170
                $rules = $this->splitRules($rules);
×
171
            }
172

173
            if (str_contains($field, '*')) {
1,603✔
174
                $flattenedArray = array_flatten_with_dots($data);
57✔
175

176
                $values = array_filter(
57✔
177
                    $flattenedArray,
57✔
178
                    static fn ($key): bool => preg_match(self::getRegex($field), $key) === 1,
57✔
179
                    ARRAY_FILTER_USE_KEY
57✔
180
                );
57✔
181

182
                // if keys not found
183
                $values = $values !== [] ? $values : [$field => null];
57✔
184
            } else {
185
                $values = dot_array_search($field, $data);
1,558✔
186
            }
187

188
            if ($values === []) {
1,603✔
189
                // We'll process the values right away if an empty array
190
                $this->processRules($field, $setup['label'] ?? $field, $values, $rules, $data, $field);
14✔
191

192
                continue;
14✔
193
            }
194

195
            if (str_contains($field, '*')) {
1,589✔
196
                // Process multiple fields
197
                foreach ($values as $dotField => $value) {
57✔
198
                    $this->processRules($dotField, $setup['label'] ?? $field, $value, $rules, $data, $field);
57✔
199
                }
200
            } else {
201
                // Process single field
202
                $this->processRules($field, $setup['label'] ?? $field, $values, $rules, $data, $field);
1,544✔
203
            }
204
        }
205

206
        if ($this->getErrors() === []) {
1,592✔
207
            // Store data that was actually validated.
208
            $this->validated = DotArrayFilter::run(
895✔
209
                array_keys($this->rules),
895✔
210
                $this->data
895✔
211
            );
895✔
212

213
            return true;
895✔
214
        }
215

216
        return false;
707✔
217
    }
218

219
    /**
220
     * Returns regex pattern for key with dot array syntax.
221
     */
222
    private static function getRegex(string $field): string
223
    {
224
        return '/\A'
109✔
225
            . str_replace(
109✔
226
                ['\.\*', '\*\.'],
109✔
227
                ['\.[^.]+', '[^.]+\.'],
109✔
228
                preg_quote($field, '/')
109✔
229
            )
109✔
230
            . '\z/';
109✔
231
    }
232

233
    /**
234
     * Runs the validation process, returning true or false determining whether
235
     * validation was successful or not.
236
     *
237
     * @param array|bool|float|int|object|string|null $value   The data to validate.
238
     * @param array|string                            $rules   The validation rules.
239
     * @param list<string>                            $errors  The custom error message.
240
     * @param string|null                             $dbGroup The database group to use.
241
     */
242
    public function check($value, $rules, array $errors = [], $dbGroup = null): bool
243
    {
244
        $this->reset();
33✔
245

246
        return $this->setRule(
33✔
247
            'check',
33✔
248
            null,
33✔
249
            $rules,
33✔
250
            $errors
33✔
251
        )->run(
33✔
252
            ['check' => $value],
33✔
253
            null,
33✔
254
            $dbGroup
33✔
255
        );
33✔
256
    }
257

258
    /**
259
     * Returns the actual validated data.
260
     */
261
    public function getValidated(): array
262
    {
263
        return $this->validated;
22✔
264
    }
265

266
    /**
267
     * Runs all of $rules against $field, until one fails, or
268
     * all of them have been processed. If one fails, it adds
269
     * the error to $this->errors and moves on to the next,
270
     * so that we can collect all of the first errors.
271
     *
272
     * @param array|string $value
273
     * @param array        $rules
274
     * @param array        $data          The array of data to validate, with `DBGroup`.
275
     * @param string|null  $originalField The original asterisk field name like "foo.*.bar".
276
     */
277
    protected function processRules(
278
        string $field,
279
        ?string $label,
280
        $value,
281
        $rules = null,       // @TODO remove `= null`
282
        ?array $data = null, // @TODO remove `= null`
283
        ?string $originalField = null
284
    ): bool {
285
        if ($data === null) {
1,603✔
286
            throw new InvalidArgumentException('You must supply the parameter: data.');
×
287
        }
288

289
        $rules = $this->processIfExist($field, $rules, $data);
1,603✔
290
        if ($rules === true) {
1,603✔
291
            return true;
4✔
292
        }
293

294
        $rules = $this->processPermitEmpty($value, $rules, $data);
1,599✔
295
        if ($rules === true) {
1,599✔
296
            return true;
55✔
297
        }
298

299
        foreach ($rules as $i => $rule) {
1,570✔
300
            $isCallable     = is_callable($rule);
1,567✔
301
            $stringCallable = $isCallable && is_string($rule);
1,567✔
302
            $arrayCallable  = $isCallable && is_array($rule);
1,567✔
303

304
            $passed = false;
1,567✔
305
            /** @var string|null $param */
306
            $param = null;
1,567✔
307

308
            if (! $isCallable && preg_match('/(.*?)\[(.*)\]/', $rule, $match)) {
1,567✔
309
                $rule  = $match[1];
1,052✔
310
                $param = $match[2];
1,052✔
311
            }
312

313
            // Placeholder for custom errors from the rules.
314
            $error = null;
1,567✔
315

316
            // If it's a callable, call and get out of here.
317
            if ($this->isClosure($rule)) {
1,567✔
318
                $passed = $rule($value, $data, $error, $field);
14✔
319
            } elseif ($isCallable) {
1,565✔
320
                $passed = $stringCallable ? $rule($value) : $rule($value, $data, $error, $field);
44✔
321
            } else {
322
                $found = false;
1,523✔
323

324
                // Check in our rulesets
325
                foreach ($this->ruleSetInstances as $set) {
1,523✔
326
                    if (! method_exists($set, $rule)) {
1,523✔
327
                        continue;
998✔
328
                    }
329

330
                    $found = true;
1,521✔
331

332
                    if ($rule === 'field_exists') {
1,521✔
333
                        $passed = $set->{$rule}($value, $param, $data, $error, $originalField);
20✔
334
                    } else {
335
                        $passed = ($param === null)
1,501✔
336
                            ? $set->{$rule}($value, $error)
523✔
337
                            : $set->{$rule}($value, $param, $data, $error, $field);
1,052✔
338
                    }
339

340
                    break;
1,512✔
341
                }
342

343
                // If the rule wasn't found anywhere, we
344
                // should throw an exception so the developer can find it.
345
                if (! $found) {
1,514✔
346
                    throw ValidationException::forRuleNotFound($rule);
2✔
347
                }
348
            }
349

350
            // Set the error message if we didn't survive.
351
            if ($passed === false) {
1,556✔
352
                // if the $value is an array, convert it to as string representation
353
                if (is_array($value)) {
707✔
354
                    $value = $this->isStringList($value)
17✔
355
                        ? '[' . implode(', ', $value) . ']'
10✔
356
                        : json_encode($value);
7✔
357
                } elseif (is_object($value)) {
690✔
358
                    $value = json_encode($value);
2✔
359
                }
360

361
                $fieldForErrors = ($rule === 'field_exists') ? $originalField : $field;
707✔
362

363
                // @phpstan-ignore-next-line $error may be set by rule methods.
364
                $this->errors[$fieldForErrors] = $error ?? $this->getErrorMessage(
707✔
365
                    ($this->isClosure($rule) || $arrayCallable) ? (string) $i : $rule,
707✔
366
                    $field,
707✔
367
                    $label,
707✔
368
                    $param,
707✔
369
                    (string) $value,
707✔
370
                    $originalField
707✔
371
                );
707✔
372

373
                return false;
707✔
374
            }
375
        }
376

377
        return true;
901✔
378
    }
379

380
    /**
381
     * @param array $data The array of data to validate, with `DBGroup`.
382
     *
383
     * @return array|true The modified rules or true if we return early
384
     */
385
    private function processIfExist(string $field, array $rules, array $data)
386
    {
387
        if (in_array('if_exist', $rules, true)) {
1,603✔
388
            $flattenedData = array_flatten_with_dots($data);
24✔
389
            $ifExistField  = $field;
24✔
390

391
            if (str_contains($field, '.*')) {
24✔
392
                // We'll change the dot notation into a PCRE pattern that can be used later
393
                $ifExistField   = str_replace('\.\*', '\.(?:[^\.]+)', preg_quote($field, '/'));
×
394
                $dataIsExisting = false;
×
395
                $pattern        = sprintf('/%s/u', $ifExistField);
×
396

397
                foreach (array_keys($flattenedData) as $item) {
×
398
                    if (preg_match($pattern, $item) === 1) {
×
399
                        $dataIsExisting = true;
×
400
                        break;
×
401
                    }
402
                }
403
            } elseif (str_contains($field, '.')) {
24✔
404
                $dataIsExisting = array_key_exists($ifExistField, $flattenedData);
16✔
405
            } else {
406
                $dataIsExisting = array_key_exists($ifExistField, $data);
8✔
407
            }
408

409
            if (! $dataIsExisting) {
24✔
410
                // we return early if `if_exist` is not satisfied. we have nothing to do here.
411
                return true;
4✔
412
            }
413

414
            // Otherwise remove the if_exist rule and continue the process
415
            $rules = array_filter($rules, static fn ($rule): bool => $rule instanceof Closure || $rule !== 'if_exist');
20✔
416
        }
417

418
        return $rules;
1,599✔
419
    }
420

421
    /**
422
     * @param array|string $value
423
     * @param array        $data  The array of data to validate, with `DBGroup`.
424
     *
425
     * @return array|true The modified rules or true if we return early
426
     */
427
    private function processPermitEmpty($value, array $rules, array $data)
428
    {
429
        if (in_array('permit_empty', $rules, true)) {
1,599✔
430
            if (
431
                ! in_array('required', $rules, true)
141✔
432
                && (is_array($value) ? $value === [] : trim((string) $value) === '')
141✔
433
            ) {
434
                $passed = true;
69✔
435

436
                foreach ($rules as $rule) {
69✔
437
                    if (! $this->isClosure($rule) && preg_match('/(.*?)\[(.*)\]/', $rule, $match)) {
69✔
438
                        $rule  = $match[1];
48✔
439
                        $param = $match[2];
48✔
440

441
                        if (! in_array($rule, ['required_with', 'required_without'], true)) {
48✔
442
                            continue;
18✔
443
                        }
444

445
                        // Check in our rulesets
446
                        foreach ($this->ruleSetInstances as $set) {
30✔
447
                            if (! method_exists($set, $rule)) {
30✔
448
                                continue;
×
449
                            }
450

451
                            $passed = $passed && $set->{$rule}($value, $param, $data);
30✔
452
                            break;
30✔
453
                        }
454
                    }
455
                }
456

457
                if ($passed) {
69✔
458
                    return true;
55✔
459
                }
460
            }
461

462
            $rules = array_filter($rules, static fn ($rule): bool => $rule instanceof Closure || $rule !== 'permit_empty');
90✔
463
        }
464

465
        return $rules;
1,570✔
466
    }
467

468
    /**
469
     * @param Closure(bool|float|int|list<mixed>|object|string|null, bool|float|int|list<mixed>|object|string|null, string|null, string|null): (bool|string) $rule
470
     */
471
    private function isClosure($rule): bool
472
    {
473
        return $rule instanceof Closure;
1,596✔
474
    }
475

476
    /**
477
     * Is the array a string list `list<string>`?
478
     */
479
    private function isStringList(array $array): bool
480
    {
481
        $expectedKey = 0;
17✔
482

483
        foreach ($array as $key => $val) {
17✔
484
            // Note: also covers PHP array key conversion, e.g. '5' and 5.1 both become 5
485
            if (! is_int($key)) {
7✔
486
                return false;
1✔
487
            }
488

489
            if ($key !== $expectedKey) {
6✔
490
                return false;
×
491
            }
492
            $expectedKey++;
6✔
493

494
            if (! is_string($val)) {
6✔
495
                return false;
6✔
496
            }
497
        }
498

499
        return true;
10✔
500
    }
501

502
    /**
503
     * Takes a Request object and grabs the input data to use from its
504
     * array values.
505
     */
506
    public function withRequest(RequestInterface $request): ValidationInterface
507
    {
508
        /** @var IncomingRequest $request */
509
        if (str_contains($request->getHeaderLine('Content-Type'), 'application/json')) {
25✔
510
            $this->data = $request->getJSON(true);
8✔
511

512
            if (! is_array($this->data)) {
6✔
513
                throw HTTPException::forUnsupportedJSONFormat();
2✔
514
            }
515

516
            return $this;
4✔
517
        }
518

519
        if (in_array($request->getMethod(), [Method::PUT, Method::PATCH, Method::DELETE], true)
17✔
520
            && ! str_contains($request->getHeaderLine('Content-Type'), 'multipart/form-data')
17✔
521
        ) {
522
            $this->data = $request->getRawInput();
2✔
523
        } else {
524
            $this->data = $request->getVar() ?? [];
15✔
525
        }
526

527
        return $this;
17✔
528
    }
529

530
    /**
531
     * Sets (or adds) an individual rule and custom error messages for a single
532
     * field.
533
     *
534
     * The custom error message should be just the messages that apply to
535
     * this field, like so:
536
     *    [
537
     *        'rule1' => 'message1',
538
     *        'rule2' => 'message2',
539
     *    ]
540
     *
541
     * @param array|string $rules  The validation rules.
542
     * @param array        $errors The custom error message.
543
     *
544
     * @return $this
545
     *
546
     * @throws InvalidArgumentException
547
     */
548
    public function setRule(string $field, ?string $label, $rules, array $errors = [])
549
    {
550
        if (! is_array($rules) && ! is_string($rules)) {
64✔
551
            throw new InvalidArgumentException('$rules must be of type string|array');
4✔
552
        }
553

554
        $ruleSet = [
60✔
555
            $field => [
60✔
556
                'label' => $label,
60✔
557
                'rules' => $rules,
60✔
558
            ],
60✔
559
        ];
60✔
560

561
        if ($errors !== []) {
60✔
562
            $ruleSet[$field]['errors'] = $errors;
4✔
563
        }
564

565
        $this->setRules(array_merge($this->getRules(), $ruleSet), $this->customErrors);
60✔
566

567
        return $this;
60✔
568
    }
569

570
    /**
571
     * Stores the rules that should be used to validate the items.
572
     *
573
     * Rules should be an array formatted like:
574
     *    [
575
     *        'field' => 'rule1|rule2'
576
     *    ]
577
     *
578
     * The $errors array should be formatted like:
579
     *    [
580
     *        'field' => [
581
     *            'rule1' => 'message1',
582
     *            'rule2' => 'message2',
583
     *        ],
584
     *    ]
585
     *
586
     * @param array $errors An array of custom error messages
587
     */
588
    public function setRules(array $rules, array $errors = []): ValidationInterface
589
    {
590
        $this->customErrors = $errors;
1,643✔
591

592
        foreach ($rules as $field => &$rule) {
1,643✔
593
            if (is_array($rule)) {
1,638✔
594
                if (array_key_exists('errors', $rule)) {
147✔
595
                    $this->customErrors[$field] = $rule['errors'];
27✔
596
                    unset($rule['errors']);
27✔
597
                }
598

599
                // if $rule is already a rule collection, just move it to "rules"
600
                // transforming [foo => [required, foobar]] to [foo => [rules => [required, foobar]]]
601
                if (! array_key_exists('rules', $rule)) {
147✔
602
                    $rule = ['rules' => $rule];
51✔
603
                }
604
            }
605

606
            if (isset($rule['rules']) && is_string($rule['rules'])) {
1,638✔
607
                $rule['rules'] = $this->splitRules($rule['rules']);
64✔
608
            }
609

610
            if (is_string($rule)) {
1,638✔
611
                $rule = ['rules' => $this->splitRules($rule)];
1,528✔
612
            }
613
        }
614

615
        $this->rules = $rules;
1,643✔
616

617
        return $this;
1,643✔
618
    }
619

620
    /**
621
     * Returns all of the rules currently defined.
622
     */
623
    public function getRules(): array
624
    {
625
        return $this->rules;
64✔
626
    }
627

628
    /**
629
     * Checks to see if the rule for key $field has been set or not.
630
     */
631
    public function hasRule(string $field): bool
632
    {
633
        return array_key_exists($field, $this->rules);
2✔
634
    }
635

636
    /**
637
     * Get rule group.
638
     *
639
     * @param string $group Group.
640
     *
641
     * @return list<string> Rule group.
642
     *
643
     * @throws ValidationException If group not found.
644
     */
645
    public function getRuleGroup(string $group): array
646
    {
647
        if (! isset($this->config->{$group})) {
14✔
648
            throw ValidationException::forGroupNotFound($group);
4✔
649
        }
650

651
        if (! is_array($this->config->{$group})) {
10✔
652
            throw ValidationException::forGroupNotArray($group);
2✔
653
        }
654

655
        return $this->config->{$group};
8✔
656
    }
657

658
    /**
659
     * Set rule group.
660
     *
661
     * @param string $group Group.
662
     *
663
     * @return void
664
     *
665
     * @throws ValidationException If group not found.
666
     */
667
    public function setRuleGroup(string $group)
668
    {
669
        $rules = $this->getRuleGroup($group);
10✔
670
        $this->setRules($rules);
6✔
671

672
        $errorName = $group . '_errors';
6✔
673
        if (isset($this->config->{$errorName})) {
6✔
674
            $this->customErrors = $this->config->{$errorName};
4✔
675
        }
676
    }
677

678
    /**
679
     * Returns the rendered HTML of the errors as defined in $template.
680
     *
681
     * You can also use validation_list_errors() in Form helper.
682
     */
683
    public function listErrors(string $template = 'list'): string
684
    {
685
        if (! array_key_exists($template, $this->config->templates)) {
3✔
686
            throw ValidationException::forInvalidTemplate($template);
2✔
687
        }
688

689
        return $this->view
1✔
690
            ->setVar('errors', $this->getErrors())
1✔
691
            ->render($this->config->templates[$template]);
1✔
692
    }
693

694
    /**
695
     * Displays a single error in formatted HTML as defined in the $template view.
696
     *
697
     * You can also use validation_show_error() in Form helper.
698
     */
699
    public function showError(string $field, string $template = 'single'): string
700
    {
701
        if (! array_key_exists($field, $this->getErrors())) {
5✔
702
            return '';
2✔
703
        }
704

705
        if (! array_key_exists($template, $this->config->templates)) {
3✔
706
            throw ValidationException::forInvalidTemplate($template);
2✔
707
        }
708

709
        return $this->view
1✔
710
            ->setVar('error', $this->getError($field))
1✔
711
            ->render($this->config->templates[$template]);
1✔
712
    }
713

714
    /**
715
     * Loads all of the rulesets classes that have been defined in the
716
     * Config\Validation and stores them locally so we can use them.
717
     *
718
     * @return void
719
     */
720
    protected function loadRuleSets()
721
    {
722
        if ($this->ruleSetFiles === [] || $this->ruleSetFiles === null) {
1,750✔
723
            throw ValidationException::forNoRuleSets();
2✔
724
        }
725

726
        foreach ($this->ruleSetFiles as $file) {
1,750✔
727
            $this->ruleSetInstances[] = new $file();
1,750✔
728
        }
729
    }
730

731
    /**
732
     * Loads custom rule groups (if set) into the current rules.
733
     *
734
     * Rules can be pre-defined in Config\Validation and can
735
     * be any name, but must all still be an array of the
736
     * same format used with setRules(). Additionally, check
737
     * for {group}_errors for an array of custom error messages.
738
     *
739
     * @param non-empty-string|null $group
740
     *
741
     * @return array<int, array> [rules, customErrors]
742
     *
743
     * @throws ValidationException
744
     */
745
    public function loadRuleGroup(?string $group = null)
746
    {
747
        if ($group === null || $group === '') {
1,615✔
748
            return [];
1,604✔
749
        }
750

751
        if (! isset($this->config->{$group})) {
24✔
752
            throw ValidationException::forGroupNotFound($group);
2✔
753
        }
754

755
        if (! is_array($this->config->{$group})) {
22✔
756
            throw ValidationException::forGroupNotArray($group);
2✔
757
        }
758

759
        $this->setRules($this->config->{$group});
20✔
760

761
        // If {group}_errors exists in the config file,
762
        // then override our custom errors with them.
763
        $errorName = $group . '_errors';
20✔
764

765
        if (isset($this->config->{$errorName})) {
20✔
766
            $this->customErrors = $this->config->{$errorName};
17✔
767
        }
768

769
        return [$this->rules, $this->customErrors];
20✔
770
    }
771

772
    /**
773
     * Replace any placeholders within the rules with the values that
774
     * match the 'key' of any properties being set. For example, if
775
     * we had the following $data array:
776
     *
777
     * [ 'id' => 13 ]
778
     *
779
     * and the following rule:
780
     *
781
     *  'is_unique[users,email,id,{id}]'
782
     *
783
     * The value of {id} would be replaced with the actual id in the form data:
784
     *
785
     *  'is_unique[users,email,id,13]'
786
     */
787
    protected function fillPlaceholders(array $rules, array $data): array
788
    {
789
        foreach ($rules as &$rule) {
1,607✔
790
            $ruleSet = $rule['rules'];
1,607✔
791

792
            foreach ($ruleSet as &$row) {
1,607✔
793
                if (is_string($row)) {
1,607✔
794
                    $placeholderFields = $this->retrievePlaceholders($row, $data);
1,607✔
795

796
                    foreach ($placeholderFields as $field) {
1,607✔
797
                        $validator ??= service('validation', null, false);
29✔
798
                        assert($validator instanceof Validation);
799

800
                        $placeholderRules = $rules[$field]['rules'] ?? null;
29✔
801

802
                        // Check if the validation rule for the placeholder exists
803
                        if ($placeholderRules === null) {
29✔
804
                            throw new LogicException(
2✔
805
                                'No validation rules for the placeholder: "' . $field
2✔
806
                                . '". You must set the validation rules for the field.'
2✔
807
                                . ' See <https://codeigniter4.github.io/userguide/libraries/validation.html#validation-placeholders>.'
2✔
808
                            );
2✔
809
                        }
810

811
                        // Check if the rule does not have placeholders
812
                        foreach ($placeholderRules as $placeholderRule) {
27✔
813
                            if ($this->retrievePlaceholders($placeholderRule, $data) !== []) {
27✔
814
                                throw new LogicException(
×
815
                                    'The placeholder field cannot use placeholder: ' . $field
×
816
                                );
×
817
                            }
818
                        }
819

820
                        // Validate the placeholder field
821
                        $dbGroup = $data['DBGroup'] ?? null;
27✔
822
                        if (! $validator->check($data[$field], $placeholderRules, [], $dbGroup)) {
27✔
823
                            // if fails, do nothing
824
                            continue;
×
825
                        }
826

827
                        // Replace the placeholder in the rule
828
                        $ruleSet = str_replace('{' . $field . '}', (string) $data[$field], $ruleSet);
27✔
829
                    }
830
                }
831
            }
832

833
            $rule['rules'] = $ruleSet;
1,605✔
834
        }
835

836
        return $rules;
1,605✔
837
    }
838

839
    /**
840
     * Retrieves valid placeholder fields.
841
     */
842
    private function retrievePlaceholders(string $rule, array $data): array
843
    {
844
        preg_match_all('/{(.+?)}/', $rule, $matches);
1,607✔
845

846
        return array_intersect($matches[1], array_keys($data));
1,607✔
847
    }
848

849
    /**
850
     * Checks to see if an error exists for the given field.
851
     */
852
    public function hasError(string $field): bool
853
    {
854
        return (bool) preg_grep(self::getRegex($field), array_keys($this->getErrors()));
6✔
855
    }
856

857
    /**
858
     * Returns the error(s) for a specified $field (or empty string if not
859
     * set).
860
     */
861
    public function getError(?string $field = null): string
862
    {
863
        if ($field === null && count($this->rules) === 1) {
55✔
864
            $field = array_key_first($this->rules);
4✔
865
        }
866

867
        $errors = array_filter(
55✔
868
            $this->getErrors(),
55✔
869
            static fn ($key): bool => preg_match(self::getRegex($field), $key) === 1,
55✔
870
            ARRAY_FILTER_USE_KEY
55✔
871
        );
55✔
872

873
        return $errors === [] ? '' : implode("\n", $errors);
55✔
874
    }
875

876
    /**
877
     * Returns the array of errors that were encountered during
878
     * a run() call. The array should be in the following format:
879
     *
880
     *    [
881
     *        'field1' => 'error message',
882
     *        'field2' => 'error message',
883
     *    ]
884
     *
885
     * @return array<string, string>
886
     *
887
     * @codeCoverageIgnore
888
     */
889
    public function getErrors(): array
890
    {
891
        return $this->errors;
1,604✔
892
    }
893

894
    /**
895
     * Sets the error for a specific field. Used by custom validation methods.
896
     */
897
    public function setError(string $field, string $error): ValidationInterface
898
    {
899
        $this->errors[$field] = $error;
8✔
900

901
        return $this;
8✔
902
    }
903

904
    /**
905
     * Attempts to find the appropriate error message
906
     *
907
     * @param non-empty-string|null $label
908
     * @param string|null           $value The value that caused the validation to fail.
909
     */
910
    protected function getErrorMessage(
911
        string $rule,
912
        string $field,
913
        ?string $label = null,
914
        ?string $param = null,
915
        ?string $value = null,
916
        ?string $originalField = null
917
    ): string {
918
        $param ??= '';
701✔
919

920
        $args = [
701✔
921
            'field' => ($label === null || $label === '') ? $field : lang($label),
701✔
922
            'param' => (! isset($this->rules[$param]['label'])) ? $param : lang($this->rules[$param]['label']),
701✔
923
            'value' => $value ?? '',
701✔
924
        ];
701✔
925

926
        // Check if custom message has been defined by user
927
        if (isset($this->customErrors[$field][$rule])) {
701✔
928
            return lang($this->customErrors[$field][$rule], $args);
61✔
929
        }
930
        if (null !== $originalField && isset($this->customErrors[$originalField][$rule])) {
642✔
931
            return lang($this->customErrors[$originalField][$rule], $args);
2✔
932
        }
933

934
        // Try to grab a localized version of the message...
935
        // lang() will return the rule name back if not found,
936
        // so there will always be a string being returned.
937
        return lang('Validation.' . $rule, $args);
640✔
938
    }
939

940
    /**
941
     * Split rules string by pipe operator.
942
     */
943
    protected function splitRules(string $rules): array
944
    {
945
        if (! str_contains($rules, '|')) {
1,612✔
946
            return [$rules];
1,405✔
947
        }
948

949
        $string = $rules;
226✔
950
        $rules  = [];
226✔
951
        $length = strlen($string);
226✔
952
        $cursor = 0;
226✔
953

954
        while ($cursor < $length) {
226✔
955
            $pos = strpos($string, '|', $cursor);
226✔
956

957
            if ($pos === false) {
226✔
958
                // we're in the last rule
959
                $pos = $length;
220✔
960
            }
961

962
            $rule = substr($string, $cursor, $pos - $cursor);
226✔
963

964
            while (
965
                (substr_count($rule, '[') - substr_count($rule, '\['))
226✔
966
                !== (substr_count($rule, ']') - substr_count($rule, '\]'))
226✔
967
            ) {
968
                // the pipe is inside the brackets causing the closing bracket to
969
                // not be included. so, we adjust the rule to include that portion.
970
                $pos  = strpos($string, '|', $cursor + strlen($rule) + 1) ?: $length;
14✔
971
                $rule = substr($string, $cursor, $pos - $cursor);
14✔
972
            }
973

974
            $rules[] = $rule;
226✔
975
            $cursor += strlen($rule) + 1; // +1 to exclude the pipe
226✔
976
        }
977

978
        return array_unique($rules);
226✔
979
    }
980

981
    /**
982
     * Resets the class to a blank slate. Should be called whenever
983
     * you need to process more than one array.
984
     */
985
    public function reset(): ValidationInterface
986
    {
987
        $this->data         = [];
1,679✔
988
        $this->validated    = [];
1,679✔
989
        $this->rules        = [];
1,679✔
990
        $this->errors       = [];
1,679✔
991
        $this->customErrors = [];
1,679✔
992

993
        return $this;
1,679✔
994
    }
995
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc