From d8be2f7e17cce64f953babc5fb14d1f0a84c11b3 Mon Sep 17 00:00:00 2001 From: michalsn Date: Mon, 23 Feb 2026 16:19:12 +0100 Subject: [PATCH 1/3] fix: validation when key does not exists --- system/Validation/Validation.php | 114 ++++++++++++++ tests/system/Validation/ValidationTest.php | 164 +++++++++++++++++++++ 2 files changed, 278 insertions(+) diff --git a/system/Validation/Validation.php b/system/Validation/Validation.php index 57e52d3cd15e..a67a2a0cd682 100644 --- a/system/Validation/Validation.php +++ b/system/Validation/Validation.php @@ -178,6 +178,16 @@ public function run(?array $data = null, ?string $group = null, $dbGroup = null) ARRAY_FILTER_USE_KEY, ); + // For required* rules: when at least one sibling path already + // matched (partial-missing scenario), also emit null for array + // elements that are structurally present but lack the leaf key, + // so that the required rule can fire for each of them. + if ($values !== [] && $this->rulesHaveRequired($rules)) { + foreach ($this->walkForAllPossiblePaths(explode('.', $field), $data, '') as $path) { + $values[$path] = null; + } + } + // if keys not found $values = $values !== [] ? $values : [$field => null]; } else { @@ -987,6 +997,110 @@ protected function splitRules(string $rules): array return array_unique($rules); } + /** + * Returns true if any rule in the set is required, required_with, or required_without. + * Used to decide whether to emit null for missing wildcard leaf keys. + * + * @param list $rules + */ + private function rulesHaveRequired(array $rules): bool + { + foreach ($rules as $rule) { + if (! is_string($rule)) { + continue; + } + + $ruleName = strstr($rule, '[', true); + $name = $ruleName !== false ? $ruleName : $rule; + + if (in_array($name, ['required', 'required_with', 'required_without'], true)) { + return true; + } + } + + return false; + } + + /** + * Entry point: allocates a single accumulator and delegates to the + * recursive collector, so no intermediate arrays are built or unpacked. + * + * @param list $segments + * @param array|mixed $current + * + * @return list + */ + private function walkForAllPossiblePaths(array $segments, mixed $current, string $prefix): array + { + $result = []; + $this->collectMissingPaths($segments, 0, count($segments), $current, $prefix, $result); + + return $result; + } + + /** + * Recursively walks the data structure, expanding wildcard segments over + * all array keys, and appends to $result by reference. Only concrete leaf + * paths where the key is genuinely absent are recorded - intermediate + * missing segments are silently skipped so `*` never appears in a result. + * + * @param list $segments + * @param int<0, max> $segmentCount + * @param array|mixed $current + * @param list $result + */ + private function collectMissingPaths( + array $segments, + int $index, + int $segmentCount, + mixed $current, + string $prefix, + array &$result, + ): void { + if ($index >= $segmentCount) { + // Successfully navigated every segment - the path exists in the data. + return; + } + + $segment = $segments[$index]; + $nextIndex = $index + 1; + + if ($segment === '*') { + if (! is_array($current)) { + return; + } + + foreach ($current as $key => $value) { + $keyPrefix = $prefix !== '' ? $prefix . '.' . $key : (string) $key; + + // Non-array elements with remaining segments are a structural + // mismatch (e.g. the DBGroup sentinel, scalar siblings) - skip. + if (! is_array($value) && $nextIndex < $segmentCount) { + continue; + } + + $this->collectMissingPaths($segments, $nextIndex, $segmentCount, $value, $keyPrefix, $result); + } + + return; + } + + $newPrefix = $prefix !== '' ? $prefix . '.' . $segment : $segment; + + if (! is_array($current) || ! array_key_exists($segment, $current)) { + // Only record a missing path for the leaf key. When an intermediate + // segment is absent there is nothing to validate in that branch, + // so skip it to avoid false-positive errors. + if ($nextIndex === $segmentCount) { + $result[] = $newPrefix; + } + + return; + } + + $this->collectMissingPaths($segments, $nextIndex, $segmentCount, $current[$segment], $newPrefix, $result); + } + /** * Resets the class to a blank slate. Should be called whenever * you need to process more than one array. diff --git a/tests/system/Validation/ValidationTest.php b/tests/system/Validation/ValidationTest.php index 386266f44391..69efaba0a1dd 100644 --- a/tests/system/Validation/ValidationTest.php +++ b/tests/system/Validation/ValidationTest.php @@ -1857,6 +1857,170 @@ public function testRuleWithAsteriskToMultiDimensionalArray(): void ); } + public function testRequiredWildcardFailsWhenSomeElementsMissingKey(): void + { + $data = [ + 'contacts' => [ + 'friends' => [ + ['name' => 'Fred', 'age' => 20], + ['age' => 21], + ], + ], + ]; + + $this->validation->setRules(['contacts.friends.*.name' => 'required']); + $this->assertFalse($this->validation->run($data)); + $this->assertSame( + ['contacts.friends.1.name' => 'The contacts.friends.*.name field is required.'], + $this->validation->getErrors(), + ); + } + + public function testRequiredWildcardFailsForEachMissingElement(): void + { + // One element has the key (creating a non-empty initial match set), + // the other two are missing it - each missing element gets its own error. + $data = [ + 'contacts' => [ + 'friends' => [ + ['name' => 'Fred', 'age' => 20], + ['age' => 21], + ['age' => 22], + ], + ], + ]; + + $this->validation->setRules(['contacts.friends.*.name' => 'required']); + $this->assertFalse($this->validation->run($data)); + $this->assertSame( + [ + 'contacts.friends.1.name' => 'The contacts.friends.*.name field is required.', + 'contacts.friends.2.name' => 'The contacts.friends.*.name field is required.', + ], + $this->validation->getErrors(), + ); + } + + public function testWildcardNonRequiredRuleSkipsMissingElements(): void + { + // Without a required* rule, elements whose key does not exist must + // never be queued for validation - no false positives. + $data = [ + 'contacts' => [ + 'friends' => [ + ['name' => 'Fred'], // passes in_list + ['age' => 21], // key absent, must be skipped entirely + ], + ], + ]; + + $this->validation->setRules(['contacts.friends.*.name' => 'in_list[Fred,Wilma]']); + $this->assertTrue($this->validation->run($data)); + $this->assertSame([], $this->validation->getErrors()); + } + + public function testWildcardIfExistRequiredSkipsMissingElements(): void + { + // `if_exist` must short-circuit before `required` fires for elements + // whose key is absent from the data structure. + $data = [ + 'contacts' => [ + 'friends' => [ + ['name' => 'Fred'], // exists and non-empty - passes + ['age' => 21], // key absent - if_exist skips it + ], + ], + ]; + + $this->validation->setRules(['contacts.friends.*.name' => 'if_exist|required']); + $this->assertTrue($this->validation->run($data)); + $this->assertSame([], $this->validation->getErrors()); + } + + public function testWildcardPermitEmptySkipsMissingElements(): void + { + // `permit_empty` without any required* rule: an empty existing value + // passes and a missing element is never queued. + $data = [ + 'contacts' => [ + 'friends' => [ + ['name' => ''], // exists but empty - permit_empty lets it through + ['age' => 21], // key absent - not queued (no required* rule) + ], + ], + ]; + + $this->validation->setRules(['contacts.friends.*.name' => 'permit_empty|min_length[2]']); + $this->assertTrue($this->validation->run($data)); + $this->assertSame([], $this->validation->getErrors()); + } + + public function testWildcardRequiredWithFailsForMissingElementWhenConditionMet(): void + { + // `required_with` is a required* variant, so missing elements ARE queued. + // When the condition field is present the rule fires and the missing + // element generates an error. + $data = [ + 'has_friends' => '1', + 'contacts' => [ + 'friends' => [ + ['name' => 'Fred', 'age' => 20], // passes + ['age' => 21], // missing name, condition met - error + ], + ], + ]; + + $this->validation->setRules(['contacts.friends.*.name' => 'required_with[has_friends]']); + $this->assertFalse($this->validation->run($data)); + $this->assertSame( + ['contacts.friends.1.name' => 'The contacts.friends.*.name field is required when has_friends is present.'], + $this->validation->getErrors(), + ); + } + + public function testWildcardRequiredWithPassesForMissingElementWhenConditionNotMet(): void + { + // Same structure but the condition field is absent, so required_with + // does not apply and the missing element generates no error. + $data = [ + 'contacts' => [ + 'friends' => [ + ['name' => 'Fred', 'age' => 20], // passes + ['age' => 21], // missing name, but condition absent - ok + ], + ], + ]; + + $this->validation->setRules(['contacts.friends.*.name' => 'required_with[has_friends]']); + $this->assertTrue($this->validation->run($data)); + $this->assertSame([], $this->validation->getErrors()); + } + + public function testWildcardRequiredNoFalsePositiveForMissingIntermediateSegment(): void + { + // users.1 has no `contacts` key at all - an intermediate segment is + // absent, not the leaf. Only the leaf-absent branch (users.0.contacts.1) + // should produce an error; the entirely-missing branch must be silent. + $data = [ + 'users' => [ + [ + 'contacts' => [ + ['name' => 'Alice'], // leaf present + ['age' => 20], // leaf absent - error + ], + ], + ['age' => 30], // intermediate segment `contacts` missing - no error + ], + ]; + + $this->validation->setRules(['users.*.contacts.*.name' => 'required']); + $this->assertFalse($this->validation->run($data)); + $this->assertSame( + ['users.0.contacts.1.name' => 'The users.*.contacts.*.name field is required.'], + $this->validation->getErrors(), + ); + } + /** * @param array $data * @param array $rules From 43340831f67e1b7a46871e9f873c1e0fab4f8c78 Mon Sep 17 00:00:00 2001 From: michalsn Date: Wed, 25 Feb 2026 08:53:26 +0100 Subject: [PATCH 2/3] add changelog --- user_guide_src/source/changelogs/v4.7.1.rst | 1 + user_guide_src/source/libraries/validation.rst | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/user_guide_src/source/changelogs/v4.7.1.rst b/user_guide_src/source/changelogs/v4.7.1.rst index 9cc1efde2af9..f5348e284395 100644 --- a/user_guide_src/source/changelogs/v4.7.1.rst +++ b/user_guide_src/source/changelogs/v4.7.1.rst @@ -58,6 +58,7 @@ Bugs Fixed - **Toolbar:** Fixed a bug where the standalone toolbar page loaded from ``?debugbar_time=...`` was not interactive. - **Toolbar:** Fixed a bug in the Routes panel where only the first route parameter was converted to an input field on hover. - **Testing:** Fixed a bug in ``FeatureTestTrait::withRoutes()`` where invalid HTTP methods were not properly validated, thus passing them all to ``RouteCollection``. +- **Validation:** Fixed a bug where the ``required``, ``required_with``, and ``required_without`` rules did not fire for array elements missing a key when using wildcard fields (e.g., ``contacts.friends.*.name``). - **View:** Fixed a bug where ``View`` would throw an error if the ``appOverridesFolder`` config property was not defined. See the repo's diff --git a/user_guide_src/source/libraries/validation.rst b/user_guide_src/source/libraries/validation.rst index 49fad20186b3..ff1020abba1a 100644 --- a/user_guide_src/source/libraries/validation.rst +++ b/user_guide_src/source/libraries/validation.rst @@ -1043,6 +1043,11 @@ valid_url_strict Yes Fails if field does not contain a valid URL. ``FILTER_VALIDATE_URL``. ======================= ========== ============================================= =================================================== +.. note:: When validating nested array data with the wildcard ``*`` syntax, use ``required``, + ``required_with``, or ``required_without`` to ensure that every array element is checked + for the presence of a key. Without one of these rules, elements whose key is absent are + silently skipped. + .. note:: You can also use any native PHP functions that return boolean and permit at least one parameter, the field data to validate. From b75859005e34002d30b0064b076bf67eb4646e93 Mon Sep 17 00:00:00 2001 From: michalsn Date: Wed, 25 Feb 2026 20:45:08 +0100 Subject: [PATCH 3/3] refactor --- system/Validation/Validation.php | 39 ++-------- tests/system/Validation/ValidationTest.php | 76 ++++++++++++++----- .../source/libraries/validation.rst | 5 -- 3 files changed, 65 insertions(+), 55 deletions(-) diff --git a/system/Validation/Validation.php b/system/Validation/Validation.php index a67a2a0cd682..6ca3233f746f 100644 --- a/system/Validation/Validation.php +++ b/system/Validation/Validation.php @@ -178,14 +178,13 @@ public function run(?array $data = null, ?string $group = null, $dbGroup = null) ARRAY_FILTER_USE_KEY, ); - // For required* rules: when at least one sibling path already - // matched (partial-missing scenario), also emit null for array - // elements that are structurally present but lack the leaf key, - // so that the required rule can fire for each of them. - if ($values !== [] && $this->rulesHaveRequired($rules)) { - foreach ($this->walkForAllPossiblePaths(explode('.', $field), $data, '') as $path) { - $values[$path] = null; - } + // Emit null for every leaf path that is structurally reachable + // but whose key is absent from the data. This mirrors the + // non-wildcard behaviour where a missing key is treated as null, + // so that all rules behave consistently regardless of whether + // the field uses a wildcard or not. + foreach ($this->walkForAllPossiblePaths(explode('.', $field), $data, '') as $path) { + $values[$path] = null; } // if keys not found @@ -997,30 +996,6 @@ protected function splitRules(string $rules): array return array_unique($rules); } - /** - * Returns true if any rule in the set is required, required_with, or required_without. - * Used to decide whether to emit null for missing wildcard leaf keys. - * - * @param list $rules - */ - private function rulesHaveRequired(array $rules): bool - { - foreach ($rules as $rule) { - if (! is_string($rule)) { - continue; - } - - $ruleName = strstr($rule, '[', true); - $name = $ruleName !== false ? $ruleName : $rule; - - if (in_array($name, ['required', 'required_with', 'required_without'], true)) { - return true; - } - } - - return false; - } - /** * Entry point: allocates a single accumulator and delegates to the * recursive collector, so no intermediate arrays are built or unpacked. diff --git a/tests/system/Validation/ValidationTest.php b/tests/system/Validation/ValidationTest.php index 69efaba0a1dd..830296b75b97 100644 --- a/tests/system/Validation/ValidationTest.php +++ b/tests/system/Validation/ValidationTest.php @@ -1850,9 +1850,9 @@ public function testRuleWithAsteriskToMultiDimensionalArray(): void ); $this->assertFalse($this->validation->run($data)); $this->assertSame( - // The data for `contacts.*.name` does not exist. So it is interpreted - // as `null`, and this error message returns. - ['contacts.*.name' => 'The contacts.*.name field is required.'], + // `contacts.just` exists but has no `name` key, so null is injected + // and the error is reported on the concrete path. + ['contacts.just.name' => 'The contacts.*.name field is required.'], $this->validation->getErrors(), ); } @@ -1901,22 +1901,25 @@ public function testRequiredWildcardFailsForEachMissingElement(): void ); } - public function testWildcardNonRequiredRuleSkipsMissingElements(): void + public function testWildcardNonRequiredRuleFiresForMissingElements(): void { - // Without a required* rule, elements whose key does not exist must - // never be queued for validation - no false positives. + // A missing key is treated as null, consistent with non-wildcard behaviour. + // Use `if_exist` or `permit_empty` to explicitly skip absent keys. $data = [ 'contacts' => [ 'friends' => [ ['name' => 'Fred'], // passes in_list - ['age' => 21], // key absent, must be skipped entirely + ['age' => 21], // key absent - null injected, in_list fails ], ], ]; $this->validation->setRules(['contacts.friends.*.name' => 'in_list[Fred,Wilma]']); - $this->assertTrue($this->validation->run($data)); - $this->assertSame([], $this->validation->getErrors()); + $this->assertFalse($this->validation->run($data)); + $this->assertSame( + ['contacts.friends.1.name' => 'The contacts.friends.*.name field must be one of: Fred,Wilma.'], + $this->validation->getErrors(), + ); } public function testWildcardIfExistRequiredSkipsMissingElements(): void @@ -1939,13 +1942,13 @@ public function testWildcardIfExistRequiredSkipsMissingElements(): void public function testWildcardPermitEmptySkipsMissingElements(): void { - // `permit_empty` without any required* rule: an empty existing value - // passes and a missing element is never queued. + // `permit_empty` treats null as empty and short-circuits remaining rules, + // so both an explicitly empty value and an absent key (injected as null) pass. $data = [ 'contacts' => [ 'friends' => [ ['name' => ''], // exists but empty - permit_empty lets it through - ['age' => 21], // key absent - not queued (no required* rule) + ['age' => 21], // key absent - null injected, permit_empty lets it through ], ], ]; @@ -1957,9 +1960,8 @@ public function testWildcardPermitEmptySkipsMissingElements(): void public function testWildcardRequiredWithFailsForMissingElementWhenConditionMet(): void { - // `required_with` is a required* variant, so missing elements ARE queued. - // When the condition field is present the rule fires and the missing - // element generates an error. + // The missing key is injected as null. When the condition field is present + // the rule fires and the missing element generates an error. $data = [ 'has_friends' => '1', 'contacts' => [ @@ -1980,13 +1982,13 @@ public function testWildcardRequiredWithFailsForMissingElementWhenConditionMet() public function testWildcardRequiredWithPassesForMissingElementWhenConditionNotMet(): void { - // Same structure but the condition field is absent, so required_with - // does not apply and the missing element generates no error. + // The missing key is injected as null, but required_with passes because + // the condition field is absent, so no error is generated. $data = [ 'contacts' => [ 'friends' => [ ['name' => 'Fred', 'age' => 20], // passes - ['age' => 21], // missing name, but condition absent - ok + ['age' => 21], // missing name, condition absent - ok ], ], ]; @@ -2021,6 +2023,44 @@ public function testWildcardRequiredNoFalsePositiveForMissingIntermediateSegment ); } + public function testWildcardFieldExistsFailsWhenSomeElementsMissingKey(): void + { + // field_exists uses dotKeyExists against the whole wildcard pattern, so + // it reports on the template field rather than individual concrete paths + // (unlike `required`, which reports per concrete path). + $data = [ + 'contacts' => [ + 'friends' => [ + ['name' => 'Fred', 'age' => 20], + ['age' => 21], // 'name' key absent + ], + ], + ]; + + $this->validation->setRules(['contacts.friends.*.name' => 'field_exists']); + $this->assertFalse($this->validation->run($data)); + $this->assertSame( + ['contacts.friends.*.name' => 'The contacts.friends.*.name field must exist.'], + $this->validation->getErrors(), + ); + } + + public function testWildcardFieldExistsPassesWhenAllElementsHaveKey(): void + { + $data = [ + 'contacts' => [ + 'friends' => [ + ['name' => 'Fred', 'age' => 20], + ['name' => 'Wilma', 'age' => 25], + ], + ], + ]; + + $this->validation->setRules(['contacts.friends.*.name' => 'field_exists']); + $this->assertTrue($this->validation->run($data)); + $this->assertSame([], $this->validation->getErrors()); + } + /** * @param array $data * @param array $rules diff --git a/user_guide_src/source/libraries/validation.rst b/user_guide_src/source/libraries/validation.rst index ff1020abba1a..49fad20186b3 100644 --- a/user_guide_src/source/libraries/validation.rst +++ b/user_guide_src/source/libraries/validation.rst @@ -1043,11 +1043,6 @@ valid_url_strict Yes Fails if field does not contain a valid URL. ``FILTER_VALIDATE_URL``. ======================= ========== ============================================= =================================================== -.. note:: When validating nested array data with the wildcard ``*`` syntax, use ``required``, - ``required_with``, or ``required_without`` to ensure that every array element is checked - for the presence of a key. Without one of these rules, elements whose key is absent are - silently skipped. - .. note:: You can also use any native PHP functions that return boolean and permit at least one parameter, the field data to validate.