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

codeigniter4 / CodeIgniter4 / 25902734269

15 May 2026 05:51AM UTC coverage: 88.459% (+0.2%) from 88.299%
25902734269

Pull #10159

github

web-flow
Merge f0573f3e0 into 170b89a6e
Pull Request #10159: feat: Add support for callable TTLs in cache handlers

6 of 10 new or added lines in 3 files covered. (60.0%)

446 existing lines in 24 files now uncovered.

24114 of 27260 relevant lines covered (88.46%)

219.07 hits per line

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

95.39
/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

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

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

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

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

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

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

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

95
    /**
96
     * Our configuration.
97
     *
98
     * @var object{ruleSets: list<class-string>}
99
     */
100
    protected $config;
101

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

109
    /**
110
     * Validation constructor.
111
     *
112
     * @param object{ruleSets: list<class-string>} $config
113
     */
114
    public function __construct($config, RendererInterface $view)
115
    {
116
        $this->ruleSetFiles = $config->ruleSets;
1,827✔
117

118
        $this->config = $config;
1,827✔
119

120
        $this->view = $view;
1,827✔
121

122
        $this->loadRuleSets();
1,827✔
123
    }
124

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

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

145
        $this->loadRuleGroup($group);
1,691✔
146

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

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

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

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

166
            $rules = $setup['rules'];
1,680✔
167

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

172
            if (str_contains($field, '*')) {
1,680✔
173
                $flattenedArray = array_flatten_with_dots($data);
79✔
174

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

181
                // Emit null for every leaf path that is structurally reachable
182
                // but whose key is absent from the data. This mirrors the
183
                // non-wildcard behaviour where a missing key is treated as null,
184
                // so that all rules behave consistently regardless of whether
185
                // the field uses a wildcard or not.
186
                foreach ($this->walkForAllPossiblePaths(explode('.', $field), $data, '') as $path) {
79✔
187
                    $values[$path] = null;
25✔
188
                }
189

190
                // if keys not found
191
                $values = $values !== [] ? $values : [$field => null];
79✔
192
            } else {
193
                $values = dot_array_search($field, $data);
1,613✔
194
            }
195

196
            if ($values === []) {
1,680✔
197
                // We'll process the values right away if an empty array
198
                $this->processRules($field, $setup['label'] ?? $field, $values, $rules, $data, $field);
14✔
199

200
                continue;
14✔
201
            }
202

203
            if (str_contains($field, '*')) {
1,666✔
204
                // Process multiple fields
205
                foreach ($values as $dotField => $value) {
79✔
206
                    $this->processRules($dotField, $setup['label'] ?? $field, $value, $rules, $data, $field);
79✔
207
                }
208
            } else {
209
                // Process single field
210
                $this->processRules($field, $setup['label'] ?? $field, $values, $rules, $data, $field);
1,599✔
211
            }
212
        }
213

214
        if ($this->getErrors() === []) {
1,669✔
215
            // Store data that was actually validated.
216
            $this->validated = DotArrayFilter::run(
936✔
217
                array_keys($this->rules),
936✔
218
                $this->data,
936✔
219
            );
936✔
220

221
            return true;
936✔
222
        }
223

224
        return false;
744✔
225
    }
226

227
    /**
228
     * Returns regex pattern for key with dot array syntax.
229
     */
230
    private static function getRegex(string $field): string
231
    {
232
        return '/\A'
138✔
233
            . str_replace(
138✔
234
                ['\.\*', '\*\.'],
138✔
235
                ['\.[^.]+', '[^.]+\.'],
138✔
236
                preg_quote($field, '/'),
138✔
237
            )
138✔
238
            . '\z/';
138✔
239
    }
240

241
    /**
242
     * Runs the validation process, returning true or false determining whether
243
     * validation was successful or not.
244
     *
245
     * @param array|bool|float|int|object|string|null $value   The data to validate.
246
     * @param array|string                            $rules   The validation rules.
247
     * @param list<string>                            $errors  The custom error message.
248
     * @param string|null                             $dbGroup The database group to use.
249
     */
250
    public function check($value, $rules, array $errors = [], $dbGroup = null): bool
251
    {
252
        $this->reset();
35✔
253

254
        return $this->setRule(
35✔
255
            'check',
35✔
256
            null,
35✔
257
            $rules,
35✔
258
            $errors,
35✔
259
        )->run(
35✔
260
            ['check' => $value],
35✔
261
            null,
35✔
262
            $dbGroup,
35✔
263
        );
35✔
264
    }
265

266
    /**
267
     * Returns the actual validated data.
268
     */
269
    public function getValidated(): array
270
    {
271
        return $this->validated;
44✔
272
    }
273

274
    /**
275
     * Returns the actual validated data as a typed input object.
276
     */
277
    public function getValidatedInput(): ValidatedInput
278
    {
279
        return service('inputdatafactory')->createValidated($this->validated);
2✔
280
    }
281

282
    /**
283
     * Runs all of $rules against $field, until one fails, or
284
     * all of them have been processed. If one fails, it adds
285
     * the error to $this->errors and moves on to the next,
286
     * so that we can collect all of the first errors.
287
     *
288
     * @param array|string $value
289
     * @param array        $rules
290
     * @param array        $data          The array of data to validate, with `DBGroup`.
291
     * @param string|null  $originalField The original asterisk field name like "foo.*.bar".
292
     */
293
    protected function processRules(
294
        string $field,
295
        ?string $label,
296
        $value,
297
        $rules = null,       // @TODO remove `= null`
298
        ?array $data = null, // @TODO remove `= null`
299
        ?string $originalField = null,
300
    ): bool {
301
        if ($data === null) {
1,680✔
UNCOV
302
            throw new InvalidArgumentException('You must supply the parameter: data.');
×
303
        }
304

305
        $rules = $this->processIfExist($field, $rules, $data);
1,680✔
306
        if ($rules === true) {
1,680✔
307
            return true;
6✔
308
        }
309

310
        $rules = $this->processPermitEmpty($value, $rules, $data);
1,676✔
311
        if ($rules === true) {
1,676✔
312
            return true;
60✔
313
        }
314

315
        foreach ($rules as $i => $rule) {
1,642✔
316
            $isCallable     = is_callable($rule);
1,639✔
317
            $stringCallable = $isCallable && is_string($rule);
1,639✔
318
            $arrayCallable  = $isCallable && is_array($rule);
1,639✔
319

320
            $passed = false;
1,639✔
321
            /** @var string|null $param */
322
            $param = null;
1,639✔
323

324
            if (! $isCallable && preg_match('/(.*?)\[(.*)\]/', $rule, $match)) {
1,639✔
325
                $rule  = $match[1];
1,089✔
326
                $param = $match[2];
1,089✔
327
            }
328

329
            // Placeholder for custom errors from the rules.
330
            $error = null;
1,639✔
331

332
            // If it's a callable, call and get out of here.
333
            if ($this->isClosure($rule)) {
1,639✔
334
                $passed = $rule($value, $data, $error, $field);
20✔
335
            } elseif ($isCallable) {
1,631✔
336
                $passed = $stringCallable ? $rule($value) : $rule($value, $data, $error, $field);
44✔
337
            } else {
338
                $found = false;
1,589✔
339

340
                // Check in our rulesets
341
                foreach ($this->ruleSetInstances as $set) {
1,589✔
342
                    if (! method_exists($set, $rule)) {
1,589✔
343
                        continue;
1,011✔
344
                    }
345

346
                    $found = true;
1,587✔
347

348
                    if ($rule === 'field_exists') {
1,587✔
349
                        $passed = $set->{$rule}($value, $param, $data, $error, $originalField);
24✔
350
                    } else {
351
                        $passed = ($param === null)
1,563✔
352
                            ? $set->{$rule}($value, $error)
568✔
353
                            : $set->{$rule}($value, $param, $data, $error, $field);
1,089✔
354
                    }
355

356
                    break;
1,578✔
357
                }
358

359
                // If the rule wasn't found anywhere, we
360
                // should throw an exception so the developer can find it.
361
                if (! $found) {
1,580✔
362
                    throw ValidationException::forRuleNotFound($rule);
2✔
363
                }
364
            }
365

366
            // Set the error message if we didn't survive.
367
            if ($passed === false) {
1,628✔
368
                // if the $value is an array, convert it to as string representation
369
                if (is_array($value)) {
744✔
370
                    $value = $this->isStringList($value)
17✔
371
                        ? '[' . implode(', ', $value) . ']'
10✔
372
                        : json_encode($value);
7✔
373
                } elseif (is_object($value)) {
727✔
374
                    $value = json_encode($value);
2✔
375
                }
376

377
                $fieldForErrors = ($rule === 'field_exists') ? $originalField : $field;
744✔
378

379
                // @phpstan-ignore-next-line $error may be set by rule methods.
380
                $this->errors[$fieldForErrors] = $error !== null
744✔
381
                    ? $this->parseErrorMessage($error, $field, $label, $param, (string) $value)
14✔
382
                    : $this->getErrorMessage(
730✔
383
                        ($this->isClosure($rule) || $arrayCallable) ? (string) $i : $rule,
730✔
384
                        $field,
730✔
385
                        $label,
730✔
386
                        $param,
730✔
387
                        (string) $value,
730✔
388
                        $originalField,
730✔
389
                    );
730✔
390

391
                return false;
744✔
392
            }
393
        }
394

395
        return true;
947✔
396
    }
397

398
    /**
399
     * @param array $data The array of data to validate, with `DBGroup`.
400
     *
401
     * @return array|true The modified rules or true if we return early
402
     */
403
    private function processIfExist(string $field, array $rules, array $data)
404
    {
405
        if (in_array('if_exist', $rules, true)) {
1,680✔
406
            $flattenedData = array_flatten_with_dots($data);
26✔
407
            $ifExistField  = $field;
26✔
408

409
            if (str_contains($field, '.*')) {
26✔
410
                // We'll change the dot notation into a PCRE pattern that can be used later
UNCOV
411
                $ifExistField   = str_replace('\.\*', '\.(?:[^\.]+)', preg_quote($field, '/'));
×
UNCOV
412
                $dataIsExisting = false;
×
UNCOV
413
                $pattern        = sprintf('/%s/u', $ifExistField);
×
414

UNCOV
415
                foreach (array_keys($flattenedData) as $item) {
×
UNCOV
416
                    if (preg_match($pattern, $item) === 1) {
×
UNCOV
417
                        $dataIsExisting = true;
×
UNCOV
418
                        break;
×
419
                    }
420
                }
421
            } elseif (str_contains($field, '.')) {
26✔
422
                $dataIsExisting = array_key_exists($ifExistField, $flattenedData);
18✔
423
            } else {
424
                $dataIsExisting = array_key_exists($ifExistField, $data);
8✔
425
            }
426

427
            if (! $dataIsExisting) {
26✔
428
                // we return early if `if_exist` is not satisfied. we have nothing to do here.
429
                return true;
6✔
430
            }
431

432
            // Otherwise remove the if_exist rule and continue the process
433
            $rules = array_filter($rules, static fn ($rule): bool => $rule instanceof Closure || $rule !== 'if_exist');
22✔
434
        }
435

436
        return $rules;
1,676✔
437
    }
438

439
    /**
440
     * @param array|string $value
441
     * @param array        $data  The array of data to validate, with `DBGroup`.
442
     *
443
     * @return array|true The modified rules or true if we return early
444
     */
445
    private function processPermitEmpty($value, array $rules, array $data)
446
    {
447
        if (in_array('permit_empty', $rules, true)) {
1,676✔
448
            if (
449
                ! in_array('required', $rules, true)
146✔
450
                && (is_array($value) ? $value === [] : trim((string) $value) === '')
146✔
451
            ) {
452
                $passed = true;
74✔
453

454
                foreach ($rules as $rule) {
74✔
455
                    if (! $this->isClosure($rule) && preg_match('/(.*?)\[(.*)\]/', $rule, $match)) {
74✔
456
                        $rule  = $match[1];
50✔
457
                        $param = $match[2];
50✔
458

459
                        if (! in_array($rule, ['required_with', 'required_without'], true)) {
50✔
460
                            continue;
20✔
461
                        }
462

463
                        // Check in our rulesets
464
                        foreach ($this->ruleSetInstances as $set) {
30✔
465
                            if (! method_exists($set, $rule)) {
30✔
UNCOV
466
                                continue;
×
467
                            }
468

469
                            $passed = $passed && $set->{$rule}($value, $param, $data);
30✔
470
                            break;
30✔
471
                        }
472
                    }
473
                }
474

475
                if ($passed) {
74✔
476
                    return true;
60✔
477
                }
478
            }
479

480
            $rules = array_filter($rules, static fn ($rule): bool => $rule instanceof Closure || $rule !== 'permit_empty');
90✔
481
        }
482

483
        return $rules;
1,642✔
484
    }
485

486
    /**
487
     * @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
488
     */
489
    private function isClosure($rule): bool
490
    {
491
        return $rule instanceof Closure;
1,673✔
492
    }
493

494
    /**
495
     * Is the array a string list `list<string>`?
496
     */
497
    private function isStringList(array $array): bool
498
    {
499
        $expectedKey = 0;
17✔
500

501
        foreach ($array as $key => $val) {
17✔
502
            // Note: also covers PHP array key conversion, e.g. '5' and 5.1 both become 5
503
            if (! is_int($key)) {
7✔
504
                return false;
1✔
505
            }
506

507
            if ($key !== $expectedKey) {
6✔
UNCOV
508
                return false;
×
509
            }
510
            $expectedKey++;
6✔
511

512
            if (! is_string($val)) {
6✔
513
                return false;
6✔
514
            }
515
        }
516

517
        return true;
10✔
518
    }
519

520
    /**
521
     * Takes a Request object and grabs the input data to use from its
522
     * array values.
523
     */
524
    public function withRequest(RequestInterface $request): ValidationInterface
525
    {
526
        /** @var IncomingRequest $request */
527
        if (str_contains($request->getHeaderLine('Content-Type'), 'application/json')) {
25✔
528
            $this->data = $request->getJSON(true);
8✔
529

530
            if (! is_array($this->data)) {
6✔
531
                throw HTTPException::forUnsupportedJSONFormat();
2✔
532
            }
533

534
            return $this;
4✔
535
        }
536

537
        if (in_array($request->getMethod(), [Method::PUT, Method::PATCH, Method::DELETE], true)
17✔
538
            && ! str_contains($request->getHeaderLine('Content-Type'), 'multipart/form-data')
17✔
539
        ) {
540
            $this->data = $request->getRawInput();
2✔
541
        } else {
542
            $this->data = $request->getVar() ?? [];
15✔
543
        }
544

545
        return $this;
17✔
546
    }
547

548
    /**
549
     * Sets (or adds) an individual rule and custom error messages for a single
550
     * field.
551
     *
552
     * The custom error message should be just the messages that apply to
553
     * this field, like so:
554
     *    [
555
     *        'rule1' => 'message1',
556
     *        'rule2' => 'message2',
557
     *    ]
558
     *
559
     * @param array|string $rules  The validation rules.
560
     * @param array        $errors The custom error message.
561
     *
562
     * @return $this
563
     *
564
     * @throws InvalidArgumentException
565
     */
566
    public function setRule(string $field, ?string $label, $rules, array $errors = [])
567
    {
568
        if (! is_array($rules) && ! is_string($rules)) {
66✔
569
            throw new InvalidArgumentException('$rules must be of type string|array');
4✔
570
        }
571

572
        $ruleSet = [
62✔
573
            $field => [
62✔
574
                'label' => $label,
62✔
575
                'rules' => $rules,
62✔
576
            ],
62✔
577
        ];
62✔
578

579
        if ($errors !== []) {
62✔
580
            $ruleSet[$field]['errors'] = $errors;
4✔
581
        }
582

583
        $this->setRules(array_merge($this->getRules(), $ruleSet), $this->customErrors);
62✔
584

585
        return $this;
62✔
586
    }
587

588
    /**
589
     * Stores the rules that should be used to validate the items.
590
     *
591
     * Rules should be an array formatted like:
592
     *    [
593
     *        'field' => 'rule1|rule2'
594
     *    ]
595
     *
596
     * The $errors array should be formatted like:
597
     *    [
598
     *        'field' => [
599
     *            'rule1' => 'message1',
600
     *            'rule2' => 'message2',
601
     *        ],
602
     *    ]
603
     *
604
     * @param array $errors An array of custom error messages
605
     */
606
    public function setRules(array $rules, array $errors = []): ValidationInterface
607
    {
608
        $this->customErrors = $errors;
1,720✔
609

610
        foreach ($rules as $field => &$rule) {
1,720✔
611
            if (is_array($rule)) {
1,715✔
612
                if (array_key_exists('errors', $rule)) {
164✔
613
                    $this->customErrors[$field] = $rule['errors'];
27✔
614
                    unset($rule['errors']);
27✔
615
                }
616

617
                // if $rule is already a rule collection, just move it to "rules"
618
                // transforming [foo => [required, foobar]] to [foo => [rules => [required, foobar]]]
619
                if (! array_key_exists('rules', $rule)) {
164✔
620
                    $rule = ['rules' => $rule];
51✔
621
                }
622
            }
623

624
            if (isset($rule['rules']) && is_string($rule['rules'])) {
1,715✔
625
                $rule['rules'] = $this->splitRules($rule['rules']);
66✔
626
            }
627

628
            if (is_string($rule)) {
1,715✔
629
                $rule = ['rules' => $this->splitRules($rule)];
1,590✔
630
            }
631
        }
632

633
        $this->rules = $rules;
1,720✔
634

635
        return $this;
1,720✔
636
    }
637

638
    /**
639
     * Returns all of the rules currently defined.
640
     */
641
    public function getRules(): array
642
    {
643
        return $this->rules;
66✔
644
    }
645

646
    /**
647
     * Checks to see if the rule for key $field has been set or not.
648
     */
649
    public function hasRule(string $field): bool
650
    {
651
        return array_key_exists($field, $this->rules);
2✔
652
    }
653

654
    /**
655
     * Get rule group.
656
     *
657
     * @param string $group Group.
658
     *
659
     * @return list<string> Rule group.
660
     *
661
     * @throws ValidationException If group not found.
662
     */
663
    public function getRuleGroup(string $group): array
664
    {
665
        if (! isset($this->config->{$group})) {
14✔
666
            throw ValidationException::forGroupNotFound($group);
4✔
667
        }
668

669
        if (! is_array($this->config->{$group})) {
10✔
670
            throw ValidationException::forGroupNotArray($group);
2✔
671
        }
672

673
        return $this->config->{$group};
8✔
674
    }
675

676
    /**
677
     * Set rule group.
678
     *
679
     * @param string $group Group.
680
     *
681
     * @return void
682
     *
683
     * @throws ValidationException If group not found.
684
     */
685
    public function setRuleGroup(string $group)
686
    {
687
        $rules = $this->getRuleGroup($group);
10✔
688
        $this->setRules($rules);
6✔
689

690
        $errorName = $group . '_errors';
6✔
691
        if (isset($this->config->{$errorName})) {
6✔
692
            $this->customErrors = $this->config->{$errorName};
4✔
693
        }
694
    }
695

696
    /**
697
     * Returns the rendered HTML of the errors as defined in $template.
698
     *
699
     * You can also use validation_list_errors() in Form helper.
700
     */
701
    public function listErrors(string $template = 'list'): string
702
    {
703
        if (! array_key_exists($template, $this->config->templates)) {
3✔
704
            throw ValidationException::forInvalidTemplate($template);
2✔
705
        }
706

707
        return $this->view
1✔
708
            ->setVar('errors', $this->getErrors())
1✔
709
            ->render($this->config->templates[$template]);
1✔
710
    }
711

712
    /**
713
     * Displays a single error in formatted HTML as defined in the $template view.
714
     *
715
     * You can also use validation_show_error() in Form helper.
716
     */
717
    public function showError(string $field, string $template = 'single'): string
718
    {
719
        if (! array_key_exists($field, $this->getErrors())) {
5✔
720
            return '';
2✔
721
        }
722

723
        if (! array_key_exists($template, $this->config->templates)) {
3✔
724
            throw ValidationException::forInvalidTemplate($template);
2✔
725
        }
726

727
        return $this->view
1✔
728
            ->setVar('error', $this->getError($field))
1✔
729
            ->render($this->config->templates[$template]);
1✔
730
    }
731

732
    /**
733
     * Loads all of the rulesets classes that have been defined in the
734
     * Config\Validation and stores them locally so we can use them.
735
     *
736
     * @return void
737
     */
738
    protected function loadRuleSets()
739
    {
740
        if ($this->ruleSetFiles === [] || $this->ruleSetFiles === null) {
1,827✔
741
            throw ValidationException::forNoRuleSets();
2✔
742
        }
743

744
        foreach ($this->ruleSetFiles as $file) {
1,827✔
745
            $this->ruleSetInstances[] = new $file();
1,827✔
746
        }
747
    }
748

749
    /**
750
     * Loads custom rule groups (if set) into the current rules.
751
     *
752
     * Rules can be pre-defined in Config\Validation and can
753
     * be any name, but must all still be an array of the
754
     * same format used with setRules(). Additionally, check
755
     * for {group}_errors for an array of custom error messages.
756
     *
757
     * @param non-empty-string|null $group
758
     *
759
     * @return array<int, array> [rules, customErrors]
760
     *
761
     * @throws ValidationException
762
     */
763
    public function loadRuleGroup(?string $group = null)
764
    {
765
        if ($group === null || $group === '') {
1,692✔
766
            return [];
1,681✔
767
        }
768

769
        if (! isset($this->config->{$group})) {
24✔
770
            throw ValidationException::forGroupNotFound($group);
2✔
771
        }
772

773
        if (! is_array($this->config->{$group})) {
22✔
774
            throw ValidationException::forGroupNotArray($group);
2✔
775
        }
776

777
        $this->setRules($this->config->{$group});
20✔
778

779
        // If {group}_errors exists in the config file,
780
        // then override our custom errors with them.
781
        $errorName = $group . '_errors';
20✔
782

783
        if (isset($this->config->{$errorName})) {
20✔
784
            $this->customErrors = $this->config->{$errorName};
17✔
785
        }
786

787
        return [$this->rules, $this->customErrors];
20✔
788
    }
789

790
    /**
791
     * Replace any placeholders within the rules with the values that
792
     * match the 'key' of any properties being set. For example, if
793
     * we had the following $data array:
794
     *
795
     * [ 'id' => 13 ]
796
     *
797
     * and the following rule:
798
     *
799
     *  'is_unique[users,email,id,{id}]'
800
     *
801
     * The value of {id} would be replaced with the actual id in the form data:
802
     *
803
     *  'is_unique[users,email,id,13]'
804
     */
805
    protected function fillPlaceholders(array $rules, array $data): array
806
    {
807
        foreach ($rules as &$rule) {
1,684✔
808
            $ruleSet = $rule['rules'];
1,684✔
809

810
            foreach ($ruleSet as &$row) {
1,684✔
811
                if (is_string($row)) {
1,684✔
812
                    $placeholderFields = $this->retrievePlaceholders($row, $data);
1,678✔
813

814
                    foreach ($placeholderFields as $field) {
1,678✔
815
                        $validator ??= service('validation', null, false);
31✔
816
                        assert($validator instanceof Validation);
817

818
                        $placeholderRules = $rules[$field]['rules'] ?? null;
31✔
819

820
                        // Check if the validation rule for the placeholder exists
821
                        if ($placeholderRules === null) {
31✔
822
                            throw new LogicException(
2✔
823
                                'No validation rules for the placeholder: "' . $field
2✔
824
                                . '". You must set the validation rules for the field.'
2✔
825
                                . ' See <https://codeigniter4.github.io/userguide/libraries/validation.html#validation-placeholders>.',
2✔
826
                            );
2✔
827
                        }
828

829
                        // Check if the rule does not have placeholders
830
                        foreach ($placeholderRules as $placeholderRule) {
29✔
831
                            if ($this->retrievePlaceholders($placeholderRule, $data) !== []) {
29✔
UNCOV
832
                                throw new LogicException(
×
UNCOV
833
                                    'The placeholder field cannot use placeholder: ' . $field,
×
834
                                );
×
835
                            }
836
                        }
837

838
                        // Validate the placeholder field
839
                        $dbGroup = $data['DBGroup'] ?? null;
29✔
840
                        if (! $validator->check($data[$field], $placeholderRules, [], $dbGroup)) {
29✔
841
                            // if fails, do nothing
UNCOV
842
                            continue;
×
843
                        }
844

845
                        // Replace the placeholder in the current rule string
846
                        if (str_starts_with($row, 'regex_match[')) {
29✔
847
                            $row = str_replace('{{' . $field . '}}', (string) $data[$field], $row);
2✔
848
                        } else {
849
                            $row = str_replace('{' . $field . '}', (string) $data[$field], $row);
27✔
850
                        }
851
                    }
852
                }
853
            }
854

855
            $rule['rules'] = $ruleSet;
1,682✔
856
        }
857

858
        return $rules;
1,682✔
859
    }
860

861
    /**
862
     * Retrieves valid placeholder fields.
863
     */
864
    private function retrievePlaceholders(string $rule, array $data): array
865
    {
866
        if (str_starts_with($rule, 'regex_match[')) {
1,678✔
867
            // For regex_match rules, only look for double-bracket placeholders
868
            preg_match_all('/\{\{((?:(?![{}]).)+?)\}\}/', $rule, $matches);
12✔
869
        } else {
870
            // For all other rules, use single-bracket placeholders
871
            preg_match_all('/{(.+?)}/', $rule, $matches);
1,672✔
872
        }
873

874
        return array_intersect($matches[1], array_keys($data));
1,678✔
875
    }
876

877
    /**
878
     * Checks to see if an error exists for the given field.
879
     */
880
    public function hasError(string $field): bool
881
    {
882
        return (bool) preg_grep(self::getRegex($field), array_keys($this->getErrors()));
13✔
883
    }
884

885
    /**
886
     * Returns the error(s) for a specified $field (or empty string if not
887
     * set).
888
     */
889
    public function getError(?string $field = null): string
890
    {
891
        if ($field === null && count($this->rules) === 1) {
56✔
892
            $field = array_key_first($this->rules);
4✔
893
        }
894

895
        $errors = array_filter(
56✔
896
            $this->getErrors(),
56✔
897
            static fn ($key): bool => preg_match(self::getRegex($field), $key) === 1,
56✔
898
            ARRAY_FILTER_USE_KEY,
56✔
899
        );
56✔
900

901
        return implode("\n", $errors);
56✔
902
    }
903

904
    /**
905
     * Returns the array of errors that were encountered during
906
     * a run() call. The array should be in the following format:
907
     *
908
     *    [
909
     *        'field1' => 'error message',
910
     *        'field2' => 'error message',
911
     *    ]
912
     *
913
     * @return array<string, string>
914
     *
915
     * @codeCoverageIgnore
916
     */
917
    public function getErrors(): array
918
    {
919
        return $this->errors;
920
    }
921

922
    /**
923
     * Sets the error for a specific field. Used by custom validation methods.
924
     */
925
    public function setError(string $field, string $error): ValidationInterface
926
    {
927
        $this->errors[$field] = $error;
8✔
928

929
        return $this;
8✔
930
    }
931

932
    /**
933
     * Attempts to find the appropriate error message
934
     *
935
     * @param non-empty-string|null $label
936
     * @param string|null           $value The value that caused the validation to fail.
937
     */
938
    protected function getErrorMessage(
939
        string $rule,
940
        string $field,
941
        ?string $label = null,
942
        ?string $param = null,
943
        ?string $value = null,
944
        ?string $originalField = null,
945
    ): string {
946
        $args = $this->buildErrorArgs($field, $label, $param, $value);
730✔
947

948
        // Check if custom message has been defined by user
949
        if (isset($this->customErrors[$field][$rule])) {
730✔
950
            return lang($this->customErrors[$field][$rule], $args);
61✔
951
        }
952
        if (null !== $originalField && isset($this->customErrors[$originalField][$rule])) {
671✔
953
            return lang($this->customErrors[$originalField][$rule], $args);
2✔
954
        }
955

956
        // Try to grab a localized version of the message...
957
        // lang() will return the rule name back if not found,
958
        // so there will always be a string being returned.
959
        return lang('Validation.' . $rule, $args);
669✔
960
    }
961

962
    /**
963
     * Substitutes {field}, {param}, and {value} placeholders in an error message
964
     * set directly by a rule method via the $error reference parameter.
965
     *
966
     * Uses simple string replacement rather than lang() to avoid ICU MessageFormatter
967
     * warnings on unrecognised patterns and to leave any other {xyz} content untouched.
968
     */
969
    private function parseErrorMessage(
970
        string $message,
971
        string $field,
972
        ?string $label = null,
973
        ?string $param = null,
974
        ?string $value = null,
975
    ): string {
976
        $args = $this->buildErrorArgs($field, $label, $param, $value);
14✔
977

978
        return str_replace(
14✔
979
            ['{field}', '{param}', '{value}'],
14✔
980
            [$args['field'], $args['param'], $args['value']],
14✔
981
            $message,
14✔
982
        );
14✔
983
    }
984

985
    /**
986
     * Builds the placeholder arguments array used for error message substitution.
987
     *
988
     * @return array{field: string, param: string, value: string}
989
     */
990
    private function buildErrorArgs(
991
        string $field,
992
        ?string $label = null,
993
        ?string $param = null,
994
        ?string $value = null,
995
    ): array {
996
        $param ??= '';
744✔
997

998
        return [
744✔
999
            'field' => ($label === null || $label === '') ? $field : lang($label),
744✔
1000
            'param' => isset($this->rules[$param]['label']) ? lang($this->rules[$param]['label']) : $param,
744✔
1001
            'value' => $value ?? '',
744✔
1002
        ];
744✔
1003
    }
1004

1005
    /**
1006
     * Split rules string by pipe operator.
1007
     */
1008
    protected function splitRules(string $rules): array
1009
    {
1010
        if (! str_contains($rules, '|')) {
1,676✔
1011
            return [$rules];
1,456✔
1012
        }
1013

1014
        $string = $rules;
260✔
1015
        $rules  = [];
260✔
1016
        $length = strlen($string);
260✔
1017
        $cursor = 0;
260✔
1018

1019
        while ($cursor < $length) {
260✔
1020
            $pos = strpos($string, '|', $cursor);
260✔
1021

1022
            if ($pos === false) {
260✔
1023
                // we're in the last rule
1024
                $pos = $length;
252✔
1025
            }
1026

1027
            $rule = substr($string, $cursor, $pos - $cursor);
260✔
1028

1029
            while (
1030
                (substr_count($rule, '[') - substr_count($rule, '\['))
260✔
1031
                !== (substr_count($rule, ']') - substr_count($rule, '\]'))
260✔
1032
            ) {
1033
                // the pipe is inside the brackets causing the closing bracket to
1034
                // not be included. so, we adjust the rule to include that portion.
1035
                $pos  = strpos($string, '|', $cursor + strlen($rule) + 1) ?: $length;
16✔
1036
                $rule = substr($string, $cursor, $pos - $cursor);
16✔
1037
            }
1038

1039
            $rules[] = $rule;
260✔
1040
            $cursor += strlen($rule) + 1; // +1 to exclude the pipe
260✔
1041
        }
1042

1043
        return array_unique($rules);
260✔
1044
    }
1045

1046
    /**
1047
     * Entry point: allocates a single accumulator and delegates to the
1048
     * recursive collector, so no intermediate arrays are built or unpacked.
1049
     *
1050
     * @param list<string>                  $segments
1051
     * @param array<array-key, mixed>|mixed $current
1052
     *
1053
     * @return list<string>
1054
     */
1055
    private function walkForAllPossiblePaths(array $segments, mixed $current, string $prefix): array
1056
    {
1057
        $result = [];
79✔
1058
        $this->collectMissingPaths($segments, 0, count($segments), $current, $prefix, $result);
79✔
1059

1060
        return $result;
79✔
1061
    }
1062

1063
    /**
1064
     * Recursively walks the data structure, expanding wildcard segments over
1065
     * all array keys, and appends to $result by reference. Only concrete leaf
1066
     * paths where the key is genuinely absent are recorded - intermediate
1067
     * missing segments are silently skipped so `*` never appears in a result.
1068
     *
1069
     * @param list<string>                  $segments
1070
     * @param int<0, max>                   $segmentCount
1071
     * @param array<array-key, mixed>|mixed $current
1072
     * @param list<string>                  $result
1073
     */
1074
    private function collectMissingPaths(
1075
        array $segments,
1076
        int $index,
1077
        int $segmentCount,
1078
        mixed $current,
1079
        string $prefix,
1080
        array &$result,
1081
    ): void {
1082
        if ($index >= $segmentCount) {
79✔
1083
            // Successfully navigated every segment - the path exists in the data.
1084
            return;
78✔
1085
        }
1086

1087
        $segment   = $segments[$index];
79✔
1088
        $nextIndex = $index + 1;
79✔
1089

1090
        if ($segment === '*') {
79✔
1091
            if (! is_array($current)) {
79✔
UNCOV
1092
                return;
×
1093
            }
1094

1095
            foreach ($current as $key => $value) {
79✔
1096
                $keyPrefix = $prefix !== '' ? $prefix . '.' . $key : (string) $key;
79✔
1097

1098
                // Non-array elements with remaining segments are a structural
1099
                // mismatch (e.g. the DBGroup sentinel, scalar siblings) - skip.
1100
                if (! is_array($value) && $nextIndex < $segmentCount) {
79✔
1101
                    continue;
8✔
1102
                }
1103

1104
                $this->collectMissingPaths($segments, $nextIndex, $segmentCount, $value, $keyPrefix, $result);
79✔
1105
            }
1106

1107
            return;
79✔
1108
        }
1109

1110
        $newPrefix = $prefix !== '' ? $prefix . '.' . $segment : $segment;
79✔
1111

1112
        if (! is_array($current) || ! array_key_exists($segment, $current)) {
79✔
1113
            // Only record a missing path for the leaf key. When an intermediate
1114
            // segment is absent there is nothing to validate in that branch,
1115
            // so skip it to avoid false-positive errors.
1116
            if ($nextIndex === $segmentCount) {
25✔
1117
                $result[] = $newPrefix;
25✔
1118
            }
1119

1120
            return;
25✔
1121
        }
1122

1123
        $this->collectMissingPaths($segments, $nextIndex, $segmentCount, $current[$segment], $newPrefix, $result);
79✔
1124
    }
1125

1126
    /**
1127
     * Resets the class to a blank slate. Should be called whenever
1128
     * you need to process more than one array.
1129
     */
1130
    public function reset(): ValidationInterface
1131
    {
1132
        $this->data         = [];
1,722✔
1133
        $this->validated    = [];
1,722✔
1134
        $this->rules        = [];
1,722✔
1135
        $this->errors       = [];
1,722✔
1136
        $this->customErrors = [];
1,722✔
1137

1138
        return $this;
1,722✔
1139
    }
1140
}
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

© 2026 Coveralls, Inc