mirror of
https://github.com/Respect/Validation.git
synced 2026-03-14 22:35:45 +01:00
Before this change, querying validation results required knowing the exact path to a specific result node—including numeric indices for array elements (e.g., `items.1.email`). This made it impractical to locate the first failing result in dynamic collections where the failing index is not known ahead of time. Wildcard segments (`*`) allow matching any single path component, so patterns like `items.*`, `*.email`, or `data.*.value` will traverse the result tree and return the first node whose path matches. This is particularly valuable when validating arrays of items with `each()`, because consumers can now ask "give me the first failure under items" without iterating manually. The implementation replaces the previous flat `array_find` lookup with a recursive depth-first traversal that, when a wildcard is present, compares each node's full path against the pattern using segment-level matching. Non-wildcard lookups continue to use exact array equality, so there is no behavioral change for existing callers. Assisted-by: Claude Code (claude-opus-4-6)
198 lines
5 KiB
PHP
198 lines
5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/*
|
|
* SPDX-License-Identifier: MIT
|
|
* SPDX-FileCopyrightText: (c) Respect Project Contributors
|
|
* SPDX-FileContributor: Alexandre Gomes Gaigalas <alganet@gmail.com>
|
|
* SPDX-FileContributor: Henrique Moody <henriquemoody@gmail.com>
|
|
*/
|
|
|
|
namespace Respect\Validation;
|
|
|
|
use Respect\Validation\Message\ArrayFormatter;
|
|
use Respect\Validation\Message\Renderer;
|
|
use Respect\Validation\Message\StringFormatter;
|
|
use Stringable;
|
|
|
|
use function array_map;
|
|
use function array_reverse;
|
|
use function count;
|
|
use function ctype_digit;
|
|
use function explode;
|
|
use function is_int;
|
|
use function is_string;
|
|
|
|
final readonly class ResultQuery implements Stringable
|
|
{
|
|
/** @param array<string|int, mixed> $templates */
|
|
public function __construct(
|
|
private Result $result,
|
|
private Renderer $renderer,
|
|
private StringFormatter $messageFormatter,
|
|
private StringFormatter $fullMessageFormatter,
|
|
private ArrayFormatter $messagesFormatter,
|
|
private array $templates,
|
|
) {
|
|
}
|
|
|
|
public function findById(string $id): self|null
|
|
{
|
|
if ($this->result->id->value === $id) {
|
|
return $this;
|
|
}
|
|
|
|
foreach ($this->result->children as $child) {
|
|
$resultQuery = clone ($this, ['result' => $child]);
|
|
if ($child->id->value === $id) {
|
|
return $resultQuery;
|
|
}
|
|
|
|
return $resultQuery->findById($id);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public function findByName(string $name): self|null
|
|
{
|
|
if ($this->result->name?->value === $name) {
|
|
return $this;
|
|
}
|
|
|
|
foreach ($this->result->children as $child) {
|
|
$resultQuery = clone ($this, ['result' => $child]);
|
|
if ($child->name?->value === $name) {
|
|
return $resultQuery;
|
|
}
|
|
|
|
return $resultQuery->findByName($name);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public function findByPath(string|int $path): self|null
|
|
{
|
|
$paths = is_int($path) ? [$path] : $this->getSearchPathsFromScalar($path);
|
|
$result = $this->findBySearchPaths($this->result, $paths);
|
|
|
|
return match ($result) {
|
|
null => null,
|
|
$this->result => $this,
|
|
default => clone ($this, ['result' => $result]),
|
|
};
|
|
}
|
|
|
|
public function hasFailed(): bool
|
|
{
|
|
return $this->result->hasPassed == false;
|
|
}
|
|
|
|
public function getMessage(): string
|
|
{
|
|
if ($this->result->hasPassed) {
|
|
return '';
|
|
}
|
|
|
|
return $this->messageFormatter->format($this->result, $this->renderer, $this->templates);
|
|
}
|
|
|
|
public function getFullMessage(): string
|
|
{
|
|
if ($this->result->hasPassed) {
|
|
return '';
|
|
}
|
|
|
|
return $this->fullMessageFormatter->format($this->result, $this->renderer, $this->templates);
|
|
}
|
|
|
|
/** @return array<string|int, mixed> */
|
|
public function getMessages(): array
|
|
{
|
|
if ($this->result->hasPassed) {
|
|
return [];
|
|
}
|
|
|
|
return $this->messagesFormatter->format($this->result, $this->renderer, $this->templates);
|
|
}
|
|
|
|
/** @param array<string|int> $searchPaths */
|
|
private function findBySearchPaths(Result $result, array $searchPaths): Result|null
|
|
{
|
|
if ($this->pathMatchesSearchPaths($this->getSearchPathsFromPath($result->path), $searchPaths)) {
|
|
return $result;
|
|
}
|
|
|
|
foreach ($result->children as $child) {
|
|
$found = $this->findBySearchPaths($child, $searchPaths);
|
|
if ($found !== null) {
|
|
return $found;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param array<string|int> $paths
|
|
* @param array<string|int> $searchPaths
|
|
*/
|
|
private function pathMatchesSearchPaths(array $paths, array $searchPaths): bool
|
|
{
|
|
$pathLength = count($paths);
|
|
$searchPathsLength = count($searchPaths);
|
|
|
|
if ($pathLength !== $searchPathsLength) {
|
|
return false;
|
|
}
|
|
|
|
foreach ($searchPaths as $i => $patternPart) {
|
|
if ($patternPart === '*') {
|
|
continue;
|
|
}
|
|
|
|
if ($paths[$i] !== $patternPart) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/** @return array<string|int> */
|
|
private function getSearchPathsFromScalar(string|int $path): array
|
|
{
|
|
if (!is_string($path)) {
|
|
return [$path];
|
|
}
|
|
|
|
return array_map(
|
|
static fn(string $part): string|int => ctype_digit($part) ? (int) $part : $part,
|
|
explode('.', $path),
|
|
);
|
|
}
|
|
|
|
/** @return array<string|int> */
|
|
private function getSearchPathsFromPath(Path|null $path): array
|
|
{
|
|
if ($path === null) {
|
|
return [];
|
|
}
|
|
|
|
$parts = [];
|
|
$current = $path;
|
|
while ($current !== null) {
|
|
$parts[] = $current->value;
|
|
$current = $current->parent;
|
|
}
|
|
|
|
return array_reverse($parts);
|
|
}
|
|
|
|
public function __toString(): string
|
|
{
|
|
return $this->getMessage();
|
|
}
|
|
}
|