Skip to content

Commit 94e1218

Browse files
committed
fix: validation when key does not exists
1 parent d9724c7 commit 94e1218

2 files changed

Lines changed: 278 additions & 0 deletions

File tree

system/Validation/Validation.php

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

181+
// For required* rules: when at least one sibling path already
182+
// matched (partial-missing scenario), also emit null for array
183+
// elements that are structurally present but lack the leaf key,
184+
// so that the required rule can fire for each of them.
185+
if ($values !== [] && $this->rulesHaveRequired($rules)) {
186+
foreach ($this->walkForAllPossiblePaths(explode('.', $field), $data, '') as $path) {
187+
$values[$path] = null;
188+
}
189+
}
190+
181191
// if keys not found
182192
$values = $values !== [] ? $values : [$field => null];
183193
} else {
@@ -987,6 +997,110 @@ protected function splitRules(string $rules): array
987997
return array_unique($rules);
988998
}
989999

1000+
/**
1001+
* Returns true if any rule in the set is required, required_with, or required_without.
1002+
* Used to decide whether to emit null for missing wildcard leaf keys.
1003+
*
1004+
* @param list<string> $rules
1005+
*/
1006+
private function rulesHaveRequired(array $rules): bool
1007+
{
1008+
foreach ($rules as $rule) {
1009+
if (! is_string($rule)) {
1010+
continue;
1011+
}
1012+
1013+
$ruleName = strstr($rule, '[', true);
1014+
$name = $ruleName !== false ? $ruleName : $rule;
1015+
1016+
if (in_array($name, ['required', 'required_with', 'required_without'], true)) {
1017+
return true;
1018+
}
1019+
}
1020+
1021+
return false;
1022+
}
1023+
1024+
/**
1025+
* Entry point: allocates a single accumulator and delegates to the
1026+
* recursive collector, so no intermediate arrays are built or unpacked.
1027+
*
1028+
* @param list<string> $segments
1029+
* @param array<array-key, mixed>|mixed $current
1030+
*
1031+
* @return list<string>
1032+
*/
1033+
private function walkForAllPossiblePaths(array $segments, mixed $current, string $prefix): array
1034+
{
1035+
$result = [];
1036+
$this->collectMissingPaths($segments, 0, count($segments), $current, $prefix, $result);
1037+
1038+
return $result;
1039+
}
1040+
1041+
/**
1042+
* Recursively walks the data structure, expanding wildcard segments over
1043+
* all array keys, and appends to $result by reference. Only concrete leaf
1044+
* paths where the key is genuinely absent are recorded - intermediate
1045+
* missing segments are silently skipped so `*` never appears in a result.
1046+
*
1047+
* @param list<string> $segments
1048+
* @param int<0, max> $segmentCount
1049+
* @param array<array-key, mixed>|mixed $current
1050+
* @param list<string> $result
1051+
*/
1052+
private function collectMissingPaths(
1053+
array $segments,
1054+
int $index,
1055+
int $segmentCount,
1056+
mixed $current,
1057+
string $prefix,
1058+
array &$result,
1059+
): void {
1060+
if ($index >= $segmentCount) {
1061+
// Successfully navigated every segment - the path exists in the data.
1062+
return;
1063+
}
1064+
1065+
$segment = $segments[$index];
1066+
$nextIndex = $index + 1;
1067+
1068+
if ($segment === '*') {
1069+
if (! is_array($current)) {
1070+
return;
1071+
}
1072+
1073+
foreach ($current as $key => $value) {
1074+
$keyPrefix = $prefix !== '' ? $prefix . '.' . $key : (string) $key;
1075+
1076+
// Non-array elements with remaining segments are a structural
1077+
// mismatch (e.g. the DBGroup sentinel, scalar siblings) - skip.
1078+
if (! is_array($value) && $nextIndex < $segmentCount) {
1079+
continue;
1080+
}
1081+
1082+
$this->collectMissingPaths($segments, $nextIndex, $segmentCount, $value, $keyPrefix, $result);
1083+
}
1084+
1085+
return;
1086+
}
1087+
1088+
$newPrefix = $prefix !== '' ? $prefix . '.' . $segment : $segment;
1089+
1090+
if (! is_array($current) || ! array_key_exists($segment, $current)) {
1091+
// Only record a missing path for the leaf key. When an intermediate
1092+
// segment is absent there is nothing to validate in that branch,
1093+
// so skip it to avoid false-positive errors.
1094+
if ($nextIndex === $segmentCount) {
1095+
$result[] = $newPrefix;
1096+
}
1097+
1098+
return;
1099+
}
1100+
1101+
$this->collectMissingPaths($segments, $nextIndex, $segmentCount, $current[$segment], $newPrefix, $result);
1102+
}
1103+
9901104
/**
9911105
* Resets the class to a blank slate. Should be called whenever
9921106
* you need to process more than one array.

tests/system/Validation/ValidationTest.php

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1857,6 +1857,170 @@ public function testRuleWithAsteriskToMultiDimensionalArray(): void
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 testWildcardNonRequiredRuleSkipsMissingElements(): void
1905+
{
1906+
// Without a required* rule, elements whose key does not exist must
1907+
// never be queued for validation - no false positives.
1908+
$data = [
1909+
'contacts' => [
1910+
'friends' => [
1911+
['name' => 'Fred'], // passes in_list
1912+
['age' => 21], // key absent, must be skipped entirely
1913+
],
1914+
],
1915+
];
1916+
1917+
$this->validation->setRules(['contacts.friends.*.name' => 'in_list[Fred,Wilma]']);
1918+
$this->assertTrue($this->validation->run($data));
1919+
$this->assertSame([], $this->validation->getErrors());
1920+
}
1921+
1922+
public function testWildcardIfExistRequiredSkipsMissingElements(): void
1923+
{
1924+
// `if_exist` must short-circuit before `required` fires for elements
1925+
// whose key is absent from the data structure.
1926+
$data = [
1927+
'contacts' => [
1928+
'friends' => [
1929+
['name' => 'Fred'], // exists and non-empty - passes
1930+
['age' => 21], // key absent - if_exist skips it
1931+
],
1932+
],
1933+
];
1934+
1935+
$this->validation->setRules(['contacts.friends.*.name' => 'if_exist|required']);
1936+
$this->assertTrue($this->validation->run($data));
1937+
$this->assertSame([], $this->validation->getErrors());
1938+
}
1939+
1940+
public function testWildcardPermitEmptySkipsMissingElements(): void
1941+
{
1942+
// `permit_empty` without any required* rule: an empty existing value
1943+
// passes and a missing element is never queued.
1944+
$data = [
1945+
'contacts' => [
1946+
'friends' => [
1947+
['name' => ''], // exists but empty - permit_empty lets it through
1948+
['age' => 21], // key absent - not queued (no required* rule)
1949+
],
1950+
],
1951+
];
1952+
1953+
$this->validation->setRules(['contacts.friends.*.name' => 'permit_empty|min_length[2]']);
1954+
$this->assertTrue($this->validation->run($data));
1955+
$this->assertSame([], $this->validation->getErrors());
1956+
}
1957+
1958+
public function testWildcardRequiredWithFailsForMissingElementWhenConditionMet(): void
1959+
{
1960+
// `required_with` is a required* variant, so missing elements ARE queued.
1961+
// When the condition field is present the rule fires and the missing
1962+
// element generates an error.
1963+
$data = [
1964+
'has_friends' => '1',
1965+
'contacts' => [
1966+
'friends' => [
1967+
['name' => 'Fred', 'age' => 20], // passes
1968+
['age' => 21], // missing name, condition met - error
1969+
],
1970+
],
1971+
];
1972+
1973+
$this->validation->setRules(['contacts.friends.*.name' => 'required_with[has_friends]']);
1974+
$this->assertFalse($this->validation->run($data));
1975+
$this->assertSame(
1976+
['contacts.friends.1.name' => 'The contacts.friends.*.name field is required when has_friends is present.'],
1977+
$this->validation->getErrors(),
1978+
);
1979+
}
1980+
1981+
public function testWildcardRequiredWithPassesForMissingElementWhenConditionNotMet(): void
1982+
{
1983+
// Same structure but the condition field is absent, so required_with
1984+
// does not apply and the missing element generates no error.
1985+
$data = [
1986+
'contacts' => [
1987+
'friends' => [
1988+
['name' => 'Fred', 'age' => 20], // passes
1989+
['age' => 21], // missing name, but condition absent - ok
1990+
],
1991+
],
1992+
];
1993+
1994+
$this->validation->setRules(['contacts.friends.*.name' => 'required_with[has_friends]']);
1995+
$this->assertTrue($this->validation->run($data));
1996+
$this->assertSame([], $this->validation->getErrors());
1997+
}
1998+
1999+
public function testWildcardRequiredNoFalsePositiveForMissingIntermediateSegment(): void
2000+
{
2001+
// users.1 has no `contacts` key at all - an intermediate segment is
2002+
// absent, not the leaf. Only the leaf-absent branch (users.0.contacts.1)
2003+
// should produce an error; the entirely-missing branch must be silent.
2004+
$data = [
2005+
'users' => [
2006+
[
2007+
'contacts' => [
2008+
['name' => 'Alice'], // leaf present
2009+
['age' => 20], // leaf absent - error
2010+
],
2011+
],
2012+
['age' => 30], // intermediate segment `contacts` missing - no error
2013+
],
2014+
];
2015+
2016+
$this->validation->setRules(['users.*.contacts.*.name' => 'required']);
2017+
$this->assertFalse($this->validation->run($data));
2018+
$this->assertSame(
2019+
['users.0.contacts.1.name' => 'The users.*.contacts.*.name field is required.'],
2020+
$this->validation->getErrors(),
2021+
);
2022+
}
2023+
18602024
/**
18612025
* @param array<string, mixed> $data
18622026
* @param array<string, string> $rules

0 commit comments

Comments
 (0)