Merged
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,7 @@ Bugs Fixed
- **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:** Rule ``valid_cc_number`` now has the correct translation.
- **Validation:** Fixed a bug where 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,7 @@ Bugs Fixed
- **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:** Rule ``valid_cc_number`` now has the correct translation.
- **Validation:** Fixed a bug where 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,7 @@ Bugs Fixed
- **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:** Rule ``valid_cc_number`` now has the correct translation.
- **Validation:** Fixed a bug where 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,7 @@ Bugs Fixed
- **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:** Rule ``valid_cc_number`` now has the correct translation.
- **Validation:** Fixed a bug where 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,7 @@ Bugs Fixed
- **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:** Rule ``valid_cc_number`` now has the correct translation.
- **Validation:** Fixed a bug where 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,7 @@ Bugs Fixed
- **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:** Rule ``valid_cc_number`` now has the correct translation.
- **Validation:** Fixed a bug where 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,7 @@ Bugs Fixed
- **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:** Rule ``valid_cc_number`` now has the correct translation.
- **Validation:** Fixed a bug where 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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 numberDiff line numberDiff line change
Expand Up@@ -60,6 +60,7 @@ Bugs Fixed
- **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:** Rule ``valid_cc_number`` now has the correct translation.
- **Validation:** Fixed a bug where 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