Skip to content

Commit 0a5ce99

Browse files
authored
fix: validation when key does not exists (#10006)
* fix: validation when key does not exists * add changelog * refactor
1 parent d9724c7 commit 0a5ce99

File tree

3 files changed

+297
-3
lines changed

3 files changed

+297
-3
lines changed

system/Validation/Validation.php

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,15 @@ public function run(?array $data = null, ?string $group = null, $dbGroup = null)
178178
ARRAY_FILTER_USE_KEY,
179179
);
180180

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) {
187+
$values[$path] = null;
188+
}
189+
181190
// if keys not found
182191
$values = $values !== [] ? $values : [$field => null];
183192
} else {
@@ -987,6 +996,86 @@ protected function splitRules(string $rules): array
987996
return array_unique($rules);
988997
}
989998

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 = [];
1011+
$this->collectMissingPaths($segments, 0, count($segments), $current, $prefix, $result);
1012+
1013+
return $result;
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) {
1036+
// Successfully navigated every segment - the path exists in the data.
1037+
return;
1038+
}
1039+
1040+
$segment = $segments[$index];
1041+
$nextIndex = $index + 1;
1042+
1043+
if ($segment === '*') {
1044+
if (! is_array($current)) {
1045+
return;
1046+
}
1047+
1048+
foreach ($current as $key => $value) {
1049+
$keyPrefix = $prefix !== '' ? $prefix . '.' . $key : (string) $key;
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) {
1054+
continue;
1055+
}
1056+
1057+
$this->collectMissingPaths($segments, $nextIndex, $segmentCount, $value, $keyPrefix, $result);
1058+
}
1059+
1060+
return;
1061+
}
1062+
1063+
$newPrefix = $prefix !== '' ? $prefix . '.' . $segment : $segment;
1064+
1065+
if (! is_array($current) || ! array_key_exists($segment, $current)) {
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) {
1070+
$result[] = $newPrefix;
1071+
}
1072+
1073+
return;
1074+
}
1075+
1076+
$this->collectMissingPaths($segments, $nextIndex, $segmentCount, $current[$segment], $newPrefix, $result);
1077+
}
1078+
9901079
/**
9911080
* Resets the class to a blank slate. Should be called whenever
9921081
* you need to process more than one array.

tests/system/Validation/ValidationTest.php

Lines changed: 207 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1850,13 +1850,217 @@ public function testRuleWithAsteriskToMultiDimensionalArray(): void
18501850
);
18511851
$this->assertFalse($this->validation->run($data));
18521852
$this->assertSame(
1853-
// The data for `contacts.*.name` does not exist. So it is interpreted
1854-
// as `null`, and this error message returns.
1855-
['contacts.*.name' => 'The contacts.*.name field is required.'],
1853+
// `contacts.just` exists but has no `name` key, so null is injected
1854+
// and the error is reported on the concrete path.
1855+
['contacts.just.name' => 'The contacts.*.name field is required.'],
18561856
$this->validation->getErrors(),
18571857
);
18581858
}
18591859

1860+
public function testRequiredWildcardFailsWhenSomeElementsMissingKey(): void
1861+
{
1862+
$data = [
1863+
'contacts' => [
1864+
'friends' => [
1865+
['name' => 'Fred', 'age' => 20],
1866+
['age' => 21],
1867+
],
1868+
],
1869+
];
1870+
1871+
$this->validation->setRules(['contacts.friends.*.name' => 'required']);
1872+
$this->assertFalse($this->validation->run($data));
1873+
$this->assertSame(
1874+
['contacts.friends.1.name' => 'The contacts.friends.*.name field is required.'],
1875+
$this->validation->getErrors(),
1876+
);
1877+
}
1878+
1879+
public function testRequiredWildcardFailsForEachMissingElement(): void
1880+
{
1881+
// One element has the key (creating a non-empty initial match set),
1882+
// the other two are missing it - each missing element gets its own error.
1883+
$data = [
1884+
'contacts' => [
1885+
'friends' => [
1886+
['name' => 'Fred', 'age' => 20],
1887+
['age' => 21],
1888+
['age' => 22],
1889+
],
1890+
],
1891+
];
1892+
1893+
$this->validation->setRules(['contacts.friends.*.name' => 'required']);
1894+
$this->assertFalse($this->validation->run($data));
1895+
$this->assertSame(
1896+
[
1897+
'contacts.friends.1.name' => 'The contacts.friends.*.name field is required.',
1898+
'contacts.friends.2.name' => 'The contacts.friends.*.name field is required.',
1899+
],
1900+
$this->validation->getErrors(),
1901+
);
1902+
}
1903+
1904+
public function testWildcardNonRequiredRuleFiresForMissingElements(): void
1905+
{
1906+
// A missing key is treated as null, consistent with non-wildcard behaviour.
1907+
// Use `if_exist` or `permit_empty` to explicitly skip absent keys.
1908+
$data = [
1909+
'contacts' => [
1910+
'friends' => [
1911+
['name' => 'Fred'], // passes in_list
1912+
['age' => 21], // key absent - null injected, in_list fails
1913+
],
1914+
],
1915+
];
1916+
1917+
$this->validation->setRules(['contacts.friends.*.name' => 'in_list[Fred,Wilma]']);
1918+
$this->assertFalse($this->validation->run($data));
1919+
$this->assertSame(
1920+
['contacts.friends.1.name' => 'The contacts.friends.*.name field must be one of: Fred,Wilma.'],
1921+
$this->validation->getErrors(),
1922+
);
1923+
}
1924+
1925+
public function testWildcardIfExistRequiredSkipsMissingElements(): void
1926+
{
1927+
// `if_exist` must short-circuit before `required` fires for elements
1928+
// whose key is absent from the data structure.
1929+
$data = [
1930+
'contacts' => [
1931+
'friends' => [
1932+
['name' => 'Fred'], // exists and non-empty - passes
1933+
['age' => 21], // key absent - if_exist skips it
1934+
],
1935+
],
1936+
];
1937+
1938+
$this->validation->setRules(['contacts.friends.*.name' => 'if_exist|required']);
1939+
$this->assertTrue($this->validation->run($data));
1940+
$this->assertSame([], $this->validation->getErrors());
1941+
}
1942+
1943+
public function testWildcardPermitEmptySkipsMissingElements(): void
1944+
{
1945+
// `permit_empty` treats null as empty and short-circuits remaining rules,
1946+
// so both an explicitly empty value and an absent key (injected as null) pass.
1947+
$data = [
1948+
'contacts' => [
1949+
'friends' => [
1950+
['name' => ''], // exists but empty - permit_empty lets it through
1951+
['age' => 21], // key absent - null injected, permit_empty lets it through
1952+
],
1953+
],
1954+
];
1955+
1956+
$this->validation->setRules(['contacts.friends.*.name' => 'permit_empty|min_length[2]']);
1957+
$this->assertTrue($this->validation->run($data));
1958+
$this->assertSame([], $this->validation->getErrors());
1959+
}
1960+
1961+
public function testWildcardRequiredWithFailsForMissingElementWhenConditionMet(): void
1962+
{
1963+
// The missing key is injected as null. When the condition field is present
1964+
// the rule fires and the missing element generates an error.
1965+
$data = [
1966+
'has_friends' => '1',
1967+
'contacts' => [
1968+
'friends' => [
1969+
['name' => 'Fred', 'age' => 20], // passes
1970+
['age' => 21], // missing name, condition met - error
1971+
],
1972+
],
1973+
];
1974+
1975+
$this->validation->setRules(['contacts.friends.*.name' => 'required_with[has_friends]']);
1976+
$this->assertFalse($this->validation->run($data));
1977+
$this->assertSame(
1978+
['contacts.friends.1.name' => 'The contacts.friends.*.name field is required when has_friends is present.'],
1979+
$this->validation->getErrors(),
1980+
);
1981+
}
1982+
1983+
public function testWildcardRequiredWithPassesForMissingElementWhenConditionNotMet(): void
1984+
{
1985+
// The missing key is injected as null, but required_with passes because
1986+
// the condition field is absent, so no error is generated.
1987+
$data = [
1988+
'contacts' => [
1989+
'friends' => [
1990+
['name' => 'Fred', 'age' => 20], // passes
1991+
['age' => 21], // missing name, condition absent - ok
1992+
],
1993+
],
1994+
];
1995+
1996+
$this->validation->setRules(['contacts.friends.*.name' => 'required_with[has_friends]']);
1997+
$this->assertTrue($this->validation->run($data));
1998+
$this->assertSame([], $this->validation->getErrors());
1999+
}
2000+
2001+
public function testWildcardRequiredNoFalsePositiveForMissingIntermediateSegment(): void
2002+
{
2003+
// users.1 has no `contacts` key at all - an intermediate segment is
2004+
// absent, not the leaf. Only the leaf-absent branch (users.0.contacts.1)
2005+
// should produce an error; the entirely-missing branch must be silent.
2006+
$data = [
2007+
'users' => [
2008+
[
2009+
'contacts' => [
2010+
['name' => 'Alice'], // leaf present
2011+
['age' => 20], // leaf absent - error
2012+
],
2013+
],
2014+
['age' => 30], // intermediate segment `contacts` missing - no error
2015+
],
2016+
];
2017+
2018+
$this->validation->setRules(['users.*.contacts.*.name' => 'required']);
2019+
$this->assertFalse($this->validation->run($data));
2020+
$this->assertSame(
2021+
['users.0.contacts.1.name' => 'The users.*.contacts.*.name field is required.'],
2022+
$this->validation->getErrors(),
2023+
);
2024+
}
2025+
2026+
public function testWildcardFieldExistsFailsWhenSomeElementsMissingKey(): void
2027+
{
2028+
// field_exists uses dotKeyExists against the whole wildcard pattern, so
2029+
// it reports on the template field rather than individual concrete paths
2030+
// (unlike `required`, which reports per concrete path).
2031+
$data = [
2032+
'contacts' => [
2033+
'friends' => [
2034+
['name' => 'Fred', 'age' => 20],
2035+
['age' => 21], // 'name' key absent
2036+
],
2037+
],
2038+
];
2039+
2040+
$this->validation->setRules(['contacts.friends.*.name' => 'field_exists']);
2041+
$this->assertFalse($this->validation->run($data));
2042+
$this->assertSame(
2043+
['contacts.friends.*.name' => 'The contacts.friends.*.name field must exist.'],
2044+
$this->validation->getErrors(),
2045+
);
2046+
}
2047+
2048+
public function testWildcardFieldExistsPassesWhenAllElementsHaveKey(): void
2049+
{
2050+
$data = [
2051+
'contacts' => [
2052+
'friends' => [
2053+
['name' => 'Fred', 'age' => 20],
2054+
['name' => 'Wilma', 'age' => 25],
2055+
],
2056+
],
2057+
];
2058+
2059+
$this->validation->setRules(['contacts.friends.*.name' => 'field_exists']);
2060+
$this->assertTrue($this->validation->run($data));
2061+
$this->assertSame([], $this->validation->getErrors());
2062+
}
2063+
18602064
/**
18612065
* @param array<string, mixed> $data
18622066
* @param array<string, string> $rules

user_guide_src/source/changelogs/v4.7.1.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ Bugs Fixed
6060
- **Toolbar:** Fixed a bug in the Routes panel where only the first route parameter was converted to an input field on hover.
6161
- **Testing:** Fixed a bug in ``FeatureTestTrait::withRoutes()`` where invalid HTTP methods were not properly validated, thus passing them all to ``RouteCollection``.
6262
- **Validation:** Rule ``valid_cc_number`` now has the correct translation.
63+
- **Validation:** Fixed a bug where rules did not fire for array elements missing a key when using wildcard fields (e.g., ``contacts.friends.*.name``).
6364
- **View:** Fixed a bug where ``View`` would throw an error if the ``appOverridesFolder`` config property was not defined.
6465

6566
See the repo's

0 commit comments

Comments
 (0)