Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions system/Validation/Validation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string> $segments
* @param array<array-key, mixed>|mixed $current
*
* @return list<string>
*/
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<string> $segments
* @param int<0, max> $segmentCount
* @param array<array-key, mixed>|mixed $current
* @param list<string> $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.
Expand Down
210 changes: 207 additions & 3 deletions tests/system/Validation/ValidationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, mixed> $data
* @param array<string, string> $rules
Expand Down
1 change: 1 addition & 0 deletions user_guide_src/source/changelogs/v4.7.1.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading