diff --git a/system/Validation/Validation.php b/system/Validation/Validation.php index 57e52d3cd15e..6ca3233f746f 100644 --- a/system/Validation/Validation.php +++ b/system/Validation/Validation.php @@ -178,6 +178,15 @@ public function run(?array $data = null, ?string $group = null, $dbGroup = null) ARRAY_FILTER_USE_KEY, ); + // 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 $values = $values !== [] ? $values : [$field => null]; } else { @@ -987,6 +996,86 @@ protected function splitRules(string $rules): array return array_unique($rules); } + /** + * 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..830296b75b97 100644 --- a/tests/system/Validation/ValidationTest.php +++ b/tests/system/Validation/ValidationTest.php @@ -1850,13 +1850,217 @@ 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(), ); } + 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 testWildcardNonRequiredRuleFiresForMissingElements(): void + { + // 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 - null injected, in_list fails + ], + ], + ]; + + $this->validation->setRules(['contacts.friends.*.name' => 'in_list[Fred,Wilma]']); + $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 + { + // `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` 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 - null injected, permit_empty lets it through + ], + ], + ]; + + $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 + { + // 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' => [ + '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 + { + // 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, 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(), + ); + } + + 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/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