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

codeigniter4 / CodeIgniter4 / 22564855522

02 Mar 2026 06:53AM UTC coverage: 85.725% (+0.009%) from 85.716%
22564855522

push

github

web-flow
fix: validation when key does not exists (#10006)

* fix: validation when key does not exists

* add changelog

* refactor

23 of 24 new or added lines in 1 file covered. (95.83%)

22304 of 26018 relevant lines covered (85.73%)

207.29 hits per line

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

95.27
/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,777✔
117

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

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

122
        $this->loadRuleSets();
1,777✔
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,641✔
136
            $data = $this->data;
15✔
137
        } else {
138
            // Store data to validate.
139
            $this->data = $data;
1,626✔
140
        }
141

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

145
        $this->loadRuleGroup($group);
1,641✔
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,637✔
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,632✔
156

157
        // Need this for searching arrays in validation.
158
        helper('array');
1,630✔
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,630✔
163
            //  An array key might be int.
164
            $field = (string) $field;
1,630✔
165

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

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

172
            if (str_contains($field, '*')) {
1,630✔
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,563✔
194
            }
195

196
            if ($values === []) {
1,630✔
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,616✔
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,549✔
211
            }
212
        }
213

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

221
            return true;
908✔
222
        }
223

224
        return false;
722✔
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'
132✔
233
            . str_replace(
132✔
234
                ['\.\*', '\*\.'],
132✔
235
                ['\.[^.]+', '[^.]+\.'],
132✔
236
                preg_quote($field, '/'),
132✔
237
            )
132✔
238
            . '\z/';
132✔
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;
22✔
272
    }
273

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

297
        $rules = $this->processIfExist($field, $rules, $data);
1,630✔
298
        if ($rules === true) {
1,630✔
299
            return true;
6✔
300
        }
301

302
        $rules = $this->processPermitEmpty($value, $rules, $data);
1,626✔
303
        if ($rules === true) {
1,626✔
304
            return true;
57✔
305
        }
306

307
        foreach ($rules as $i => $rule) {
1,595✔
308
            $isCallable     = is_callable($rule);
1,592✔
309
            $stringCallable = $isCallable && is_string($rule);
1,592✔
310
            $arrayCallable  = $isCallable && is_array($rule);
1,592✔
311

312
            $passed = false;
1,592✔
313
            /** @var string|null $param */
314
            $param = null;
1,592✔
315

316
            if (! $isCallable && preg_match('/(.*?)\[(.*)\]/', $rule, $match)) {
1,592✔
317
                $rule  = $match[1];
1,062✔
318
                $param = $match[2];
1,062✔
319
            }
320

321
            // Placeholder for custom errors from the rules.
322
            $error = null;
1,592✔
323

324
            // If it's a callable, call and get out of here.
325
            if ($this->isClosure($rule)) {
1,592✔
326
                $passed = $rule($value, $data, $error, $field);
14✔
327
            } elseif ($isCallable) {
1,590✔
328
                $passed = $stringCallable ? $rule($value) : $rule($value, $data, $error, $field);
44✔
329
            } else {
330
                $found = false;
1,548✔
331

332
                // Check in our rulesets
333
                foreach ($this->ruleSetInstances as $set) {
1,548✔
334
                    if (! method_exists($set, $rule)) {
1,548✔
335
                        continue;
1,004✔
336
                    }
337

338
                    $found = true;
1,546✔
339

340
                    if ($rule === 'field_exists') {
1,546✔
341
                        $passed = $set->{$rule}($value, $param, $data, $error, $originalField);
24✔
342
                    } else {
343
                        $passed = ($param === null)
1,522✔
344
                            ? $set->{$rule}($value, $error)
536✔
345
                            : $set->{$rule}($value, $param, $data, $error, $field);
1,062✔
346
                    }
347

348
                    break;
1,537✔
349
                }
350

351
                // If the rule wasn't found anywhere, we
352
                // should throw an exception so the developer can find it.
353
                if (! $found) {
1,539✔
354
                    throw ValidationException::forRuleNotFound($rule);
2✔
355
                }
356
            }
357

358
            // Set the error message if we didn't survive.
359
            if ($passed === false) {
1,581✔
360
                // if the $value is an array, convert it to as string representation
361
                if (is_array($value)) {
722✔
362
                    $value = $this->isStringList($value)
17✔
363
                        ? '[' . implode(', ', $value) . ']'
10✔
364
                        : json_encode($value);
7✔
365
                } elseif (is_object($value)) {
705✔
366
                    $value = json_encode($value);
2✔
367
                }
368

369
                $fieldForErrors = ($rule === 'field_exists') ? $originalField : $field;
722✔
370

371
                // @phpstan-ignore-next-line $error may be set by rule methods.
372
                $this->errors[$fieldForErrors] = $error ?? $this->getErrorMessage(
722✔
373
                    ($this->isClosure($rule) || $arrayCallable) ? (string) $i : $rule,
722✔
374
                    $field,
722✔
375
                    $label,
722✔
376
                    $param,
722✔
377
                    (string) $value,
722✔
378
                    $originalField,
722✔
379
                );
722✔
380

381
                return false;
722✔
382
            }
383
        }
384

385
        return true;
922✔
386
    }
387

388
    /**
389
     * @param array $data The array of data to validate, with `DBGroup`.
390
     *
391
     * @return array|true The modified rules or true if we return early
392
     */
393
    private function processIfExist(string $field, array $rules, array $data)
394
    {
395
        if (in_array('if_exist', $rules, true)) {
1,630✔
396
            $flattenedData = array_flatten_with_dots($data);
26✔
397
            $ifExistField  = $field;
26✔
398

399
            if (str_contains($field, '.*')) {
26✔
400
                // We'll change the dot notation into a PCRE pattern that can be used later
401
                $ifExistField   = str_replace('\.\*', '\.(?:[^\.]+)', preg_quote($field, '/'));
×
402
                $dataIsExisting = false;
×
403
                $pattern        = sprintf('/%s/u', $ifExistField);
×
404

405
                foreach (array_keys($flattenedData) as $item) {
×
406
                    if (preg_match($pattern, $item) === 1) {
×
407
                        $dataIsExisting = true;
×
408
                        break;
×
409
                    }
410
                }
411
            } elseif (str_contains($field, '.')) {
26✔
412
                $dataIsExisting = array_key_exists($ifExistField, $flattenedData);
18✔
413
            } else {
414
                $dataIsExisting = array_key_exists($ifExistField, $data);
8✔
415
            }
416

417
            if (! $dataIsExisting) {
26✔
418
                // we return early if `if_exist` is not satisfied. we have nothing to do here.
419
                return true;
6✔
420
            }
421

422
            // Otherwise remove the if_exist rule and continue the process
423
            $rules = array_filter($rules, static fn ($rule): bool => $rule instanceof Closure || $rule !== 'if_exist');
22✔
424
        }
425

426
        return $rules;
1,626✔
427
    }
428

429
    /**
430
     * @param array|string $value
431
     * @param array        $data  The array of data to validate, with `DBGroup`.
432
     *
433
     * @return array|true The modified rules or true if we return early
434
     */
435
    private function processPermitEmpty($value, array $rules, array $data)
436
    {
437
        if (in_array('permit_empty', $rules, true)) {
1,626✔
438
            if (
439
                ! in_array('required', $rules, true)
143✔
440
                && (is_array($value) ? $value === [] : trim((string) $value) === '')
143✔
441
            ) {
442
                $passed = true;
71✔
443

444
                foreach ($rules as $rule) {
71✔
445
                    if (! $this->isClosure($rule) && preg_match('/(.*?)\[(.*)\]/', $rule, $match)) {
71✔
446
                        $rule  = $match[1];
50✔
447
                        $param = $match[2];
50✔
448

449
                        if (! in_array($rule, ['required_with', 'required_without'], true)) {
50✔
450
                            continue;
20✔
451
                        }
452

453
                        // Check in our rulesets
454
                        foreach ($this->ruleSetInstances as $set) {
30✔
455
                            if (! method_exists($set, $rule)) {
30✔
456
                                continue;
×
457
                            }
458

459
                            $passed = $passed && $set->{$rule}($value, $param, $data);
30✔
460
                            break;
30✔
461
                        }
462
                    }
463
                }
464

465
                if ($passed) {
71✔
466
                    return true;
57✔
467
                }
468
            }
469

470
            $rules = array_filter($rules, static fn ($rule): bool => $rule instanceof Closure || $rule !== 'permit_empty');
90✔
471
        }
472

473
        return $rules;
1,595✔
474
    }
475

476
    /**
477
     * @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
478
     */
479
    private function isClosure($rule): bool
480
    {
481
        return $rule instanceof Closure;
1,623✔
482
    }
483

484
    /**
485
     * Is the array a string list `list<string>`?
486
     */
487
    private function isStringList(array $array): bool
488
    {
489
        $expectedKey = 0;
17✔
490

491
        foreach ($array as $key => $val) {
17✔
492
            // Note: also covers PHP array key conversion, e.g. '5' and 5.1 both become 5
493
            if (! is_int($key)) {
7✔
494
                return false;
1✔
495
            }
496

497
            if ($key !== $expectedKey) {
6✔
498
                return false;
×
499
            }
500
            $expectedKey++;
6✔
501

502
            if (! is_string($val)) {
6✔
503
                return false;
6✔
504
            }
505
        }
506

507
        return true;
10✔
508
    }
509

510
    /**
511
     * Takes a Request object and grabs the input data to use from its
512
     * array values.
513
     */
514
    public function withRequest(RequestInterface $request): ValidationInterface
515
    {
516
        /** @var IncomingRequest $request */
517
        if (str_contains($request->getHeaderLine('Content-Type'), 'application/json')) {
25✔
518
            $this->data = $request->getJSON(true);
8✔
519

520
            if (! is_array($this->data)) {
6✔
521
                throw HTTPException::forUnsupportedJSONFormat();
2✔
522
            }
523

524
            return $this;
4✔
525
        }
526

527
        if (in_array($request->getMethod(), [Method::PUT, Method::PATCH, Method::DELETE], true)
17✔
528
            && ! str_contains($request->getHeaderLine('Content-Type'), 'multipart/form-data')
17✔
529
        ) {
530
            $this->data = $request->getRawInput();
2✔
531
        } else {
532
            $this->data = $request->getVar() ?? [];
15✔
533
        }
534

535
        return $this;
17✔
536
    }
537

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

562
        $ruleSet = [
62✔
563
            $field => [
62✔
564
                'label' => $label,
62✔
565
                'rules' => $rules,
62✔
566
            ],
62✔
567
        ];
62✔
568

569
        if ($errors !== []) {
62✔
570
            $ruleSet[$field]['errors'] = $errors;
4✔
571
        }
572

573
        $this->setRules(array_merge($this->getRules(), $ruleSet), $this->customErrors);
62✔
574

575
        return $this;
62✔
576
    }
577

578
    /**
579
     * Stores the rules that should be used to validate the items.
580
     *
581
     * Rules should be an array formatted like:
582
     *    [
583
     *        'field' => 'rule1|rule2'
584
     *    ]
585
     *
586
     * The $errors array should be formatted like:
587
     *    [
588
     *        'field' => [
589
     *            'rule1' => 'message1',
590
     *            'rule2' => 'message2',
591
     *        ],
592
     *    ]
593
     *
594
     * @param array $errors An array of custom error messages
595
     */
596
    public function setRules(array $rules, array $errors = []): ValidationInterface
597
    {
598
        $this->customErrors = $errors;
1,670✔
599

600
        foreach ($rules as $field => &$rule) {
1,670✔
601
            if (is_array($rule)) {
1,665✔
602
                if (array_key_exists('errors', $rule)) {
150✔
603
                    $this->customErrors[$field] = $rule['errors'];
27✔
604
                    unset($rule['errors']);
27✔
605
                }
606

607
                // if $rule is already a rule collection, just move it to "rules"
608
                // transforming [foo => [required, foobar]] to [foo => [rules => [required, foobar]]]
609
                if (! array_key_exists('rules', $rule)) {
150✔
610
                    $rule = ['rules' => $rule];
51✔
611
                }
612
            }
613

614
            if (isset($rule['rules']) && is_string($rule['rules'])) {
1,665✔
615
                $rule['rules'] = $this->splitRules($rule['rules']);
64✔
616
            }
617

618
            if (is_string($rule)) {
1,665✔
619
                $rule = ['rules' => $this->splitRules($rule)];
1,554✔
620
            }
621
        }
622

623
        $this->rules = $rules;
1,670✔
624

625
        return $this;
1,670✔
626
    }
627

628
    /**
629
     * Returns all of the rules currently defined.
630
     */
631
    public function getRules(): array
632
    {
633
        return $this->rules;
66✔
634
    }
635

636
    /**
637
     * Checks to see if the rule for key $field has been set or not.
638
     */
639
    public function hasRule(string $field): bool
640
    {
641
        return array_key_exists($field, $this->rules);
2✔
642
    }
643

644
    /**
645
     * Get rule group.
646
     *
647
     * @param string $group Group.
648
     *
649
     * @return list<string> Rule group.
650
     *
651
     * @throws ValidationException If group not found.
652
     */
653
    public function getRuleGroup(string $group): array
654
    {
655
        if (! isset($this->config->{$group})) {
14✔
656
            throw ValidationException::forGroupNotFound($group);
4✔
657
        }
658

659
        if (! is_array($this->config->{$group})) {
10✔
660
            throw ValidationException::forGroupNotArray($group);
2✔
661
        }
662

663
        return $this->config->{$group};
8✔
664
    }
665

666
    /**
667
     * Set rule group.
668
     *
669
     * @param string $group Group.
670
     *
671
     * @return void
672
     *
673
     * @throws ValidationException If group not found.
674
     */
675
    public function setRuleGroup(string $group)
676
    {
677
        $rules = $this->getRuleGroup($group);
10✔
678
        $this->setRules($rules);
6✔
679

680
        $errorName = $group . '_errors';
6✔
681
        if (isset($this->config->{$errorName})) {
6✔
682
            $this->customErrors = $this->config->{$errorName};
4✔
683
        }
684
    }
685

686
    /**
687
     * Returns the rendered HTML of the errors as defined in $template.
688
     *
689
     * You can also use validation_list_errors() in Form helper.
690
     */
691
    public function listErrors(string $template = 'list'): string
692
    {
693
        if (! array_key_exists($template, $this->config->templates)) {
3✔
694
            throw ValidationException::forInvalidTemplate($template);
2✔
695
        }
696

697
        return $this->view
1✔
698
            ->setVar('errors', $this->getErrors())
1✔
699
            ->render($this->config->templates[$template]);
1✔
700
    }
701

702
    /**
703
     * Displays a single error in formatted HTML as defined in the $template view.
704
     *
705
     * You can also use validation_show_error() in Form helper.
706
     */
707
    public function showError(string $field, string $template = 'single'): string
708
    {
709
        if (! array_key_exists($field, $this->getErrors())) {
5✔
710
            return '';
2✔
711
        }
712

713
        if (! array_key_exists($template, $this->config->templates)) {
3✔
714
            throw ValidationException::forInvalidTemplate($template);
2✔
715
        }
716

717
        return $this->view
1✔
718
            ->setVar('error', $this->getError($field))
1✔
719
            ->render($this->config->templates[$template]);
1✔
720
    }
721

722
    /**
723
     * Loads all of the rulesets classes that have been defined in the
724
     * Config\Validation and stores them locally so we can use them.
725
     *
726
     * @return void
727
     */
728
    protected function loadRuleSets()
729
    {
730
        if ($this->ruleSetFiles === [] || $this->ruleSetFiles === null) {
1,777✔
731
            throw ValidationException::forNoRuleSets();
2✔
732
        }
733

734
        foreach ($this->ruleSetFiles as $file) {
1,777✔
735
            $this->ruleSetInstances[] = new $file();
1,777✔
736
        }
737
    }
738

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

759
        if (! isset($this->config->{$group})) {
24✔
760
            throw ValidationException::forGroupNotFound($group);
2✔
761
        }
762

763
        if (! is_array($this->config->{$group})) {
22✔
764
            throw ValidationException::forGroupNotArray($group);
2✔
765
        }
766

767
        $this->setRules($this->config->{$group});
20✔
768

769
        // If {group}_errors exists in the config file,
770
        // then override our custom errors with them.
771
        $errorName = $group . '_errors';
20✔
772

773
        if (isset($this->config->{$errorName})) {
20✔
774
            $this->customErrors = $this->config->{$errorName};
17✔
775
        }
776

777
        return [$this->rules, $this->customErrors];
20✔
778
    }
779

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

800
            foreach ($ruleSet as &$row) {
1,634✔
801
                if (is_string($row)) {
1,634✔
802
                    $placeholderFields = $this->retrievePlaceholders($row, $data);
1,634✔
803

804
                    foreach ($placeholderFields as $field) {
1,634✔
805
                        $validator ??= service('validation', null, false);
31✔
806
                        assert($validator instanceof Validation);
807

808
                        $placeholderRules = $rules[$field]['rules'] ?? null;
31✔
809

810
                        // Check if the validation rule for the placeholder exists
811
                        if ($placeholderRules === null) {
31✔
812
                            throw new LogicException(
2✔
813
                                'No validation rules for the placeholder: "' . $field
2✔
814
                                . '". You must set the validation rules for the field.'
2✔
815
                                . ' See <https://codeigniter4.github.io/userguide/libraries/validation.html#validation-placeholders>.',
2✔
816
                            );
2✔
817
                        }
818

819
                        // Check if the rule does not have placeholders
820
                        foreach ($placeholderRules as $placeholderRule) {
29✔
821
                            if ($this->retrievePlaceholders($placeholderRule, $data) !== []) {
29✔
822
                                throw new LogicException(
×
823
                                    'The placeholder field cannot use placeholder: ' . $field,
×
824
                                );
×
825
                            }
826
                        }
827

828
                        // Validate the placeholder field
829
                        $dbGroup = $data['DBGroup'] ?? null;
29✔
830
                        if (! $validator->check($data[$field], $placeholderRules, [], $dbGroup)) {
29✔
831
                            // if fails, do nothing
832
                            continue;
×
833
                        }
834

835
                        // Replace the placeholder in the current rule string
836
                        if (str_starts_with($row, 'regex_match[')) {
29✔
837
                            $row = str_replace('{{' . $field . '}}', (string) $data[$field], $row);
2✔
838
                        } else {
839
                            $row = str_replace('{' . $field . '}', (string) $data[$field], $row);
27✔
840
                        }
841
                    }
842
                }
843
            }
844

845
            $rule['rules'] = $ruleSet;
1,632✔
846
        }
847

848
        return $rules;
1,632✔
849
    }
850

851
    /**
852
     * Retrieves valid placeholder fields.
853
     */
854
    private function retrievePlaceholders(string $rule, array $data): array
855
    {
856
        if (str_starts_with($rule, 'regex_match[')) {
1,634✔
857
            // For regex_match rules, only look for double-bracket placeholders
858
            preg_match_all('/\{\{((?:(?![{}]).)+?)\}\}/', $rule, $matches);
12✔
859
        } else {
860
            // For all other rules, use single-bracket placeholders
861
            preg_match_all('/{(.+?)}/', $rule, $matches);
1,628✔
862
        }
863

864
        return array_intersect($matches[1], array_keys($data));
1,634✔
865
    }
866

867
    /**
868
     * Checks to see if an error exists for the given field.
869
     */
870
    public function hasError(string $field): bool
871
    {
872
        return (bool) preg_grep(self::getRegex($field), array_keys($this->getErrors()));
7✔
873
    }
874

875
    /**
876
     * Returns the error(s) for a specified $field (or empty string if not
877
     * set).
878
     */
879
    public function getError(?string $field = null): string
880
    {
881
        if ($field === null && count($this->rules) === 1) {
56✔
882
            $field = array_key_first($this->rules);
4✔
883
        }
884

885
        $errors = array_filter(
56✔
886
            $this->getErrors(),
56✔
887
            static fn ($key): bool => preg_match(self::getRegex($field), $key) === 1,
56✔
888
            ARRAY_FILTER_USE_KEY,
56✔
889
        );
56✔
890

891
        return implode("\n", $errors);
56✔
892
    }
893

894
    /**
895
     * Returns the array of errors that were encountered during
896
     * a run() call. The array should be in the following format:
897
     *
898
     *    [
899
     *        'field1' => 'error message',
900
     *        'field2' => 'error message',
901
     *    ]
902
     *
903
     * @return array<string, string>
904
     *
905
     * @codeCoverageIgnore
906
     */
907
    public function getErrors(): array
908
    {
909
        return $this->errors;
1,631✔
910
    }
911

912
    /**
913
     * Sets the error for a specific field. Used by custom validation methods.
914
     */
915
    public function setError(string $field, string $error): ValidationInterface
916
    {
917
        $this->errors[$field] = $error;
8✔
918

919
        return $this;
8✔
920
    }
921

922
    /**
923
     * Attempts to find the appropriate error message
924
     *
925
     * @param non-empty-string|null $label
926
     * @param string|null           $value The value that caused the validation to fail.
927
     */
928
    protected function getErrorMessage(
929
        string $rule,
930
        string $field,
931
        ?string $label = null,
932
        ?string $param = null,
933
        ?string $value = null,
934
        ?string $originalField = null,
935
    ): string {
936
        $param ??= '';
716✔
937

938
        $args = [
716✔
939
            'field' => ($label === null || $label === '') ? $field : lang($label),
716✔
940
            'param' => isset($this->rules[$param]['label']) ? lang($this->rules[$param]['label']) : $param,
716✔
941
            'value' => $value ?? '',
716✔
942
        ];
716✔
943

944
        // Check if custom message has been defined by user
945
        if (isset($this->customErrors[$field][$rule])) {
716✔
946
            return lang($this->customErrors[$field][$rule], $args);
61✔
947
        }
948
        if (null !== $originalField && isset($this->customErrors[$originalField][$rule])) {
657✔
949
            return lang($this->customErrors[$originalField][$rule], $args);
2✔
950
        }
951

952
        // Try to grab a localized version of the message...
953
        // lang() will return the rule name back if not found,
954
        // so there will always be a string being returned.
955
        return lang('Validation.' . $rule, $args);
655✔
956
    }
957

958
    /**
959
     * Split rules string by pipe operator.
960
     */
961
    protected function splitRules(string $rules): array
962
    {
963
        if (! str_contains($rules, '|')) {
1,638✔
964
            return [$rules];
1,423✔
965
        }
966

967
        $string = $rules;
234✔
968
        $rules  = [];
234✔
969
        $length = strlen($string);
234✔
970
        $cursor = 0;
234✔
971

972
        while ($cursor < $length) {
234✔
973
            $pos = strpos($string, '|', $cursor);
234✔
974

975
            if ($pos === false) {
234✔
976
                // we're in the last rule
977
                $pos = $length;
226✔
978
            }
979

980
            $rule = substr($string, $cursor, $pos - $cursor);
234✔
981

982
            while (
983
                (substr_count($rule, '[') - substr_count($rule, '\['))
234✔
984
                !== (substr_count($rule, ']') - substr_count($rule, '\]'))
234✔
985
            ) {
986
                // the pipe is inside the brackets causing the closing bracket to
987
                // not be included. so, we adjust the rule to include that portion.
988
                $pos  = strpos($string, '|', $cursor + strlen($rule) + 1) ?: $length;
16✔
989
                $rule = substr($string, $cursor, $pos - $cursor);
16✔
990
            }
991

992
            $rules[] = $rule;
234✔
993
            $cursor += strlen($rule) + 1; // +1 to exclude the pipe
234✔
994
        }
995

996
        return array_unique($rules);
234✔
997
    }
998

999
    /**
1000
     * Entry point: allocates a single accumulator and delegates to the
1001
     * recursive collector, so no intermediate arrays are built or unpacked.
1002
     *
1003
     * @param list<string>                  $segments
1004
     * @param array<array-key, mixed>|mixed $current
1005
     *
1006
     * @return list<string>
1007
     */
1008
    private function walkForAllPossiblePaths(array $segments, mixed $current, string $prefix): array
1009
    {
1010
        $result = [];
79✔
1011
        $this->collectMissingPaths($segments, 0, count($segments), $current, $prefix, $result);
79✔
1012

1013
        return $result;
79✔
1014
    }
1015

1016
    /**
1017
     * Recursively walks the data structure, expanding wildcard segments over
1018
     * all array keys, and appends to $result by reference. Only concrete leaf
1019
     * paths where the key is genuinely absent are recorded - intermediate
1020
     * missing segments are silently skipped so `*` never appears in a result.
1021
     *
1022
     * @param list<string>                  $segments
1023
     * @param int<0, max>                   $segmentCount
1024
     * @param array<array-key, mixed>|mixed $current
1025
     * @param list<string>                  $result
1026
     */
1027
    private function collectMissingPaths(
1028
        array $segments,
1029
        int $index,
1030
        int $segmentCount,
1031
        mixed $current,
1032
        string $prefix,
1033
        array &$result,
1034
    ): void {
1035
        if ($index >= $segmentCount) {
79✔
1036
            // Successfully navigated every segment - the path exists in the data.
1037
            return;
78✔
1038
        }
1039

1040
        $segment   = $segments[$index];
79✔
1041
        $nextIndex = $index + 1;
79✔
1042

1043
        if ($segment === '*') {
79✔
1044
            if (! is_array($current)) {
79✔
NEW
1045
                return;
×
1046
            }
1047

1048
            foreach ($current as $key => $value) {
79✔
1049
                $keyPrefix = $prefix !== '' ? $prefix . '.' . $key : (string) $key;
79✔
1050

1051
                // Non-array elements with remaining segments are a structural
1052
                // mismatch (e.g. the DBGroup sentinel, scalar siblings) - skip.
1053
                if (! is_array($value) && $nextIndex < $segmentCount) {
79✔
1054
                    continue;
8✔
1055
                }
1056

1057
                $this->collectMissingPaths($segments, $nextIndex, $segmentCount, $value, $keyPrefix, $result);
79✔
1058
            }
1059

1060
            return;
79✔
1061
        }
1062

1063
        $newPrefix = $prefix !== '' ? $prefix . '.' . $segment : $segment;
79✔
1064

1065
        if (! is_array($current) || ! array_key_exists($segment, $current)) {
79✔
1066
            // Only record a missing path for the leaf key. When an intermediate
1067
            // segment is absent there is nothing to validate in that branch,
1068
            // so skip it to avoid false-positive errors.
1069
            if ($nextIndex === $segmentCount) {
25✔
1070
                $result[] = $newPrefix;
25✔
1071
            }
1072

1073
            return;
25✔
1074
        }
1075

1076
        $this->collectMissingPaths($segments, $nextIndex, $segmentCount, $current[$segment], $newPrefix, $result);
79✔
1077
    }
1078

1079
    /**
1080
     * Resets the class to a blank slate. Should be called whenever
1081
     * you need to process more than one array.
1082
     */
1083
    public function reset(): ValidationInterface
1084
    {
1085
        $this->data         = [];
1,705✔
1086
        $this->validated    = [];
1,705✔
1087
        $this->rules        = [];
1,705✔
1088
        $this->errors       = [];
1,705✔
1089
        $this->customErrors = [];
1,705✔
1090

1091
        return $this;
1,705✔
1092
    }
1093
}
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