diff --git a/library/FailedResultIterator.php b/library/FailedResultIterator.php new file mode 100644 index 000000000..83f49c92a --- /dev/null +++ b/library/FailedResultIterator.php @@ -0,0 +1,127 @@ + + * SPDX-License-Identifier: MIT + */ + +namespace Respect\Validation; + +use Countable; +use Iterator; +use RecursiveIterator; + +use function array_filter; +use function array_key_exists; +use function array_map; +use function array_values; +use function count; +use function current; +use function key; +use function next; +use function reset; + +/** + * @implements Iterator + */ +final class FailedResultIterator implements Iterator, Countable, RecursiveIterator +{ + private array $children; + + public function __construct( + private readonly Result $result, + ) { + $this->children = $this->extractDeduplicatedChildren(); + } + + public function extractDeduplicatedChildren(): array + { + /** @var array $deduplicatedResults */ + $deduplicatedResults = []; + $duplicateCounters = []; + foreach ($this->result->children as $child) { + if ($child->path !== null) { + $deduplicatedResults[$child->path->value] = $child->isValid ? null : $child; + continue; + } + + $id = $child->id; + if (isset($duplicateCounters[$id])) { + $id .= '.' . ++$duplicateCounters[$id]; + } elseif (array_key_exists($id, $deduplicatedResults)) { + $deduplicatedResults[$id . '.1'] = $deduplicatedResults[$id]?->withId($id . '.1'); + unset($deduplicatedResults[$id]); + $duplicateCounters[$id] = 2; + $id .= '.2'; + } + + $deduplicatedResults[$id] = $child->isValid ? null : $child->withId($id); + } + + return array_map( + function (Result $child): Result { + if ($this->result->path !== null && $child->path !== null && $child->path->isEqual($this->result->path)) { + return $child->withPath($this->result->path); + } + + if ($this->result->path !== null && $child->path === null) { + return $child->withPath($this->result->path); + } + + return $child; + }, + array_values(array_filter($deduplicatedResults)) + ); + } + + public function current(): Result|false + { + return current($this->children); + } + + public function getArrayCopy(): array + { + return $this->children; + } + + public function next(): void + { + next($this->children); + } + + public function key(): ?int + { + return key($this->children); + } + + public function valid(): bool + { + return key($this->children) !== null; + } + + public function rewind(): void + { + reset($this->children); + } + + public function count(): int + { + return count($this->children); + } + + public function hasChildren(): bool + { + return $this->result->children !== []; + } + + public function getChildren(): ?self + { + if (!$this->hasChildren()) { + return null; + } + + return new self($this->result); + } +} diff --git a/library/Message/Placeholder/Path.php b/library/Message/Placeholder/Path.php new file mode 100644 index 000000000..8a01b3a83 --- /dev/null +++ b/library/Message/Placeholder/Path.php @@ -0,0 +1,35 @@ + + * SPDX-License-Identifier: MIT + */ + +declare(strict_types=1); + +namespace Respect\Validation\Message\Placeholder; + +final class Path +{ + public function __construct( + public readonly int|string $value, + public readonly ?Path $child = null + ) { + } + + public function withParent(int|string $value): self + { + return new self($value, $this); + } + + public function isEqual(Path $path): bool + { + return $this->value === $path->value + && $this->child?->isEqual($path->child) ?? true; + } + + public function getDeepest(): Path + { + return $this->child?->getDeepest() ?? $this; + } +} diff --git a/library/Message/StandardFormatter.php b/library/Message/StandardFormatter.php index 68111edb9..794dc345e 100644 --- a/library/Message/StandardFormatter.php +++ b/library/Message/StandardFormatter.php @@ -10,13 +10,11 @@ namespace Respect\Validation\Message; use Respect\Validation\Exceptions\ComponentException; +use Respect\Validation\FailedResultIterator; use Respect\Validation\Result; use function array_filter; -use function array_key_exists; -use function array_map; use function array_reduce; -use function array_values; use function count; use function current; use function is_array; @@ -42,8 +40,8 @@ public function main(Result $result, array $templates, Translator $translator): { $selectedTemplates = $this->selectTemplates($result, $templates); if (!$this->isFinalTemplate($result, $selectedTemplates)) { - foreach ($this->extractDeduplicatedChildren($result) as $child) { - return $this->main($this->resultWithPath($result, $child), $selectedTemplates, $translator); + foreach (new FailedResultIterator($result) as $child) { + return $this->main($child, $selectedTemplates, $translator); } } @@ -78,17 +76,14 @@ public function full( } if (!$isFinalTemplate) { - $results = array_map( - fn(Result $child) => $this->resultWithPath($result, $child), - $this->extractDeduplicatedChildren($result) - ); + $results = new FailedResultIterator($result); foreach ($results as $child) { $rendered .= $this->full( $child, $selectedTemplates, $translator, $depth, - ...array_filter($results, static fn (Result $sibling) => $sibling !== $child) + ...array_filter($results->getArrayCopy(), static fn (Result $sibling) => $sibling !== $child) ); $rendered .= PHP_EOL; } @@ -105,10 +100,10 @@ public function full( public function array(Result $result, array $templates, Translator $translator): array { $selectedTemplates = $this->selectTemplates($result, $templates); - $deduplicatedChildren = $this->extractDeduplicatedChildren($result); + $deduplicatedChildren = new FailedResultIterator($result); if (count($deduplicatedChildren) === 0 || $this->isFinalTemplate($result, $selectedTemplates)) { return [ - $result->getDeepestPath() ?? $result->id => $this->renderer->render( + $result->path?->getDeepest()->value ?? $result->id => $this->renderer->render( $this->getTemplated($result->withDeepestPath(), $selectedTemplates), $translator ), @@ -117,7 +112,7 @@ public function array(Result $result, array $templates, Translator $translator): $messages = []; foreach ($deduplicatedChildren as $child) { - $key = $child->getDeepestPath() ?? $child->id; + $key = $child?->path?->getDeepest()->value ?? $child->id; $messages[$key] = $this->array( $this->resultWithPath($result, $child), $this->selectTemplates($child, $selectedTemplates), @@ -144,7 +139,7 @@ public function array(Result $result, array $templates, Translator $translator): return $messages; } - public function resultWithPath(Result $parent, Result $child): Result + private function resultWithPath(Result $parent, Result $child): Result { if ($parent->path !== null && $child->path !== null && $child->path !== $parent->path) { return $child->withPath($parent->path); @@ -199,7 +194,7 @@ private function getTemplated(Result $result, array $templates): Result return $result; } - foreach ([$result->path, $result->name, $result->id, '__root__'] as $key) { + foreach ([$result->path?->value, $result->name, $result->id, '__root__'] as $key) { if (!isset($templates[$key])) { continue; } @@ -221,7 +216,7 @@ private function getTemplated(Result $result, array $templates): Result */ private function isFinalTemplate(Result $result, array $templates): bool { - $keys = [$result->path, $result->name, $result->id]; + $keys = [$result->path?->value, $result->name, $result->id]; foreach ($keys as $key) { if (isset($templates[$key]) && is_string($templates[$key])) { return true; @@ -248,7 +243,7 @@ private function isFinalTemplate(Result $result, array $templates): bool */ private function selectTemplates(Result $result, array $templates): array { - foreach ([$result->path, $result->name, $result->id] as $key) { + foreach ([$result->path?->value, $result->name, $result->id] as $key) { if (isset($templates[$key]) && is_array($templates[$key])) { return $templates[$key]; } @@ -256,32 +251,4 @@ private function selectTemplates(Result $result, array $templates): array return $templates; } - - /** @return array */ - private function extractDeduplicatedChildren(Result $result): array - { - /** @var array $deduplicatedResults */ - $deduplicatedResults = []; - $duplicateCounters = []; - foreach ($result->children as $child) { - $id = $child->getDeepestPath() ?? $child->id; - if (isset($duplicateCounters[$id])) { - $id .= '.' . ++$duplicateCounters[$id]; - } elseif (array_key_exists($id, $deduplicatedResults)) { - $deduplicatedResults[$id . '.1'] = $deduplicatedResults[$id]?->withId($id . '.1'); - unset($deduplicatedResults[$id]); - $duplicateCounters[$id] = 2; - $id .= '.2'; - } - - if ($child->path === null) { - $deduplicatedResults[$id] = $child->isValid ? null : $child->withId($id); - continue; - } - - $deduplicatedResults[$id] = $child->isValid ? null : $child; - } - - return array_values(array_filter($deduplicatedResults)); - } } diff --git a/library/Message/StandardRenderer.php b/library/Message/StandardRenderer.php index 969cc5afc..6d42d6f49 100644 --- a/library/Message/StandardRenderer.php +++ b/library/Message/StandardRenderer.php @@ -12,6 +12,7 @@ use ReflectionClass; use Respect\Stringifier\Stringifier; use Respect\Validation\Message\Placeholder\Listed; +use Respect\Validation\Message\Placeholder\Path; use Respect\Validation\Message\Placeholder\Quoted; use Respect\Validation\Result; use Respect\Validation\Rule; @@ -36,7 +37,7 @@ public function __construct( public function render(Result $result, Translator $translator, ?string $template = null): string { $parameters = $result->parameters; - $parameters['path'] = $result->path !== null ? Quoted::fromPath($result->path) : null; + $parameters['path'] = $result->path; $parameters['input'] = $result->input; $builtName = $result->name ?? $parameters['path'] ?? $this->placeholder('input', $result->input, $translator); diff --git a/library/Message/StandardStringifier.php b/library/Message/StandardStringifier.php index 3f3d27c49..37ab5f4d0 100644 --- a/library/Message/StandardStringifier.php +++ b/library/Message/StandardStringifier.php @@ -32,6 +32,7 @@ use Respect\Stringifier\Stringifiers\StringableObjectStringifier; use Respect\Stringifier\Stringifiers\ThrowableObjectStringifier; use Respect\Validation\Message\Stringifier\ListedStringifier; +use Respect\Validation\Message\Stringifier\PathStringifier; use Respect\Validation\Message\Stringifier\QuotedStringifier; final class StandardStringifier implements Stringifier @@ -88,6 +89,7 @@ private function createStringifier(Quoter $quoter): Stringifier $stringifier->prependStringifier(new ThrowableObjectStringifier($jsonEncodableStringifier, $quoter)); $stringifier->prependStringifier(new DateTimeStringifier($quoter, DateTimeInterface::ATOM)); $stringifier->prependStringifier(new IteratorObjectStringifier($stringifier, $quoter)); + $stringifier->prependStringifier(new PathStringifier($quoter)); $stringifier->prependStringifier(new QuotedStringifier($quoter)); $stringifier->prependStringifier(new ListedStringifier($stringifier)); diff --git a/library/Message/Stringifier/PathStringifier.php b/library/Message/Stringifier/PathStringifier.php new file mode 100644 index 000000000..2a85d99e9 --- /dev/null +++ b/library/Message/Stringifier/PathStringifier.php @@ -0,0 +1,37 @@ + + * SPDX-License-Identifier: MIT + */ + +namespace Respect\Validation\Message\Stringifier; + +use Respect\Stringifier\Quoter; +use Respect\Stringifier\Stringifier; +use Respect\Validation\Message\Placeholder\Path; + +final class PathStringifier implements Stringifier +{ + public function __construct( + private readonly Quoter $quoter + ) { + } + + public function stringify(mixed $raw, int $depth): ?string + { + if (!$raw instanceof Path) { + return null; + } + + $path = $raw; + $string = $path->value; + while ($path = $path->child) { + $string .= '.' . $path->value; + } + + return $this->quoter->quote('.' . $string, $depth); + } +} diff --git a/library/Result.php b/library/Result.php index eae2c97d3..9a9bbfb72 100644 --- a/library/Result.php +++ b/library/Result.php @@ -9,6 +9,7 @@ namespace Respect\Validation; +use Respect\Validation\Message\Placeholder\Path; use Respect\Validation\Rules\Core\Nameable; use function array_filter; @@ -40,7 +41,7 @@ public function __construct( public readonly ?string $name = null, ?string $id = null, public readonly ?Result $adjacent = null, - public readonly string|int|null $path = null, + public readonly ?Path $path = null, Result ...$children, ) { $this->id = $id ?? lcfirst(substr((string) strrchr($rule::class, '\\'), 1)); @@ -111,39 +112,40 @@ public function withIdFrom(Rule $rule): self return $this->clone(id: lcfirst(substr((string) strrchr($rule::class, '\\'), 1))); } - public function withPath(string|int $path): self + public function withPath(Path $path): self { return $this->clone( adjacent: $this->adjacent?->withPath($path), - path: $this->path === null ? $path : $path . '.' . $this->path, + path: $path, ); } - public function withDeepestPath(): self + public function withChildPath(string|int $path): self { - $path = $this->getDeepestPath(); - if ($path === null || $path === (string) $this->path) { - return $this; - } - return $this->clone( - adjacent: $this->adjacent?->withPath($path), - path: $path, + adjacent: $this->adjacent?->withChildPath($path), + path: new Path($path, $this->path), ); } - public function getDeepestPath(): ?string + public function withParentPath(string|int $path): self { - if ($this->path === null) { - return null; - } + return $this->clone( + adjacent: $this->adjacent?->withParentPath($path), + path: $this->path?->withParent($path) ?? new Path($path), + ); + } - $paths = explode('.', (string) $this->path); - if (count($paths) === 1) { - return (string) $this->path; + public function withDeepestPath(): self + { + if ($this->path === null || $this->path->child === null || $this->path->getDeepest() === $this->path) { + return $this; } - return end($paths); + return $this->clone( + adjacent: $this->adjacent?->withDeepestPath(), + path: $this->path?->getDeepest(), + ); } public function withPrefix(string $prefix): self @@ -250,7 +252,7 @@ private function clone( ?string $name = null, ?string $id = null, ?Result $adjacent = null, - string|int|null $path = null, + ?Path $path = null, ?array $children = null ): self { return new self( diff --git a/library/Rules/Each.php b/library/Rules/Each.php index 6ccb2f234..d0c035bcd 100644 --- a/library/Rules/Each.php +++ b/library/Rules/Each.php @@ -28,7 +28,7 @@ protected function evaluateNonEmptyArray(array $input): Result { $children = []; foreach ($input as $key => $value) { - $children[] = $this->rule->evaluate($value)->withPath($key); + $children[] = $this->rule->evaluate($value)->withParentPath($key); } $isValid = array_reduce($children, static fn ($carry, $childResult) => $carry && $childResult->isValid, true); diff --git a/library/Rules/Key.php b/library/Rules/Key.php index 2fe90fe76..8d847cad4 100644 --- a/library/Rules/Key.php +++ b/library/Rules/Key.php @@ -37,6 +37,6 @@ public function evaluate(mixed $input): Result return $keyExistsResult->withNameFrom($this->rule); } - return $this->rule->evaluate($input[$this->key])->withPath($this->key); + return $this->rule->evaluate($input[$this->key])->withParentPath($this->key); } } diff --git a/library/Rules/KeyExists.php b/library/Rules/KeyExists.php index 46d5e1164..26d4f45db 100644 --- a/library/Rules/KeyExists.php +++ b/library/Rules/KeyExists.php @@ -11,6 +11,7 @@ use ArrayAccess; use Attribute; +use Respect\Validation\Message\Placeholder\Path; use Respect\Validation\Message\Template; use Respect\Validation\Result; use Respect\Validation\Rules\Core\KeyRelated; @@ -38,7 +39,7 @@ public function getKey(): int|string public function evaluate(mixed $input): Result { - return new Result($this->hasKey($input), $input, $this, path: $this->key); + return new Result($this->hasKey($input), $input, $this, path: new Path($this->key)); } private function hasKey(mixed $input): bool diff --git a/library/Rules/Property.php b/library/Rules/Property.php index 365413b61..e36bd4d38 100644 --- a/library/Rules/Property.php +++ b/library/Rules/Property.php @@ -34,7 +34,7 @@ public function evaluate(mixed $input): Result return $this->rule ->evaluate($this->extractPropertyValue($input, $this->propertyName)) - ->withPath($this->propertyName); + ->withParentPath($this->propertyName); } private function extractPropertyValue(object $input, string $property): mixed diff --git a/library/Rules/PropertyExists.php b/library/Rules/PropertyExists.php index a3f00cdd6..9de205359 100644 --- a/library/Rules/PropertyExists.php +++ b/library/Rules/PropertyExists.php @@ -11,6 +11,7 @@ use Attribute; use ReflectionObject; +use Respect\Validation\Message\Placeholder\Path; use Respect\Validation\Message\Template; use Respect\Validation\Result; use Respect\Validation\Rules\Core\Standard; @@ -35,7 +36,7 @@ public function evaluate(mixed $input): Result $this->hasProperty($input), $input, $this, - path: $this->propertyName, + path: new Path($this->propertyName), ); } diff --git a/library/Transformers/Deprecated/KeyValueRule.php b/library/Transformers/Deprecated/KeyValueRule.php index b9f40fdbf..91c805bfd 100644 --- a/library/Transformers/Deprecated/KeyValueRule.php +++ b/library/Transformers/Deprecated/KeyValueRule.php @@ -9,7 +9,7 @@ namespace Respect\Validation\Transformers\Deprecated; -use Respect\Validation\Message\Placeholder\Quoted; +use Respect\Validation\Message\Placeholder\Path; use Respect\Validation\Rules\AlwaysInvalid; use Respect\Validation\Rules\Key; use Respect\Validation\Rules\KeyExists; @@ -56,7 +56,7 @@ static function ($input) use ($comparedKey, $ruleName, $baseKey) { return new Templated( new AlwaysInvalid(), '{{baseKey}} must be valid to validate {{comparedKey}}', - ['comparedKey' => Quoted::fromPath($comparedKey), 'baseKey' => Quoted::fromPath($baseKey)] + ['comparedKey' => new Path($comparedKey), 'baseKey' => new Path($baseKey)], ); } } diff --git a/tests/feature/Issues/Issue1289Test.php b/tests/feature/Issues/Issue1289Test.php index 240493d37..e5ab94534 100644 --- a/tests/feature/Issues/Issue1289Test.php +++ b/tests/feature/Issues/Issue1289Test.php @@ -56,11 +56,7 @@ [ 0 => [ '__root__' => '`.0` must pass the rules', - 'default' => [ - '__root__' => '`.default` must pass one of the rules', - 'stringType' => '`.default` must be a string', - 'boolType' => '`.default` must be a boolean', - ], + 'default' => '`.default` must be a boolean', 'description' => '`.description` must be a string value', ], ], diff --git a/tests/feature/Issues/Issue1376Test.php b/tests/feature/Issues/Issue1376Test.php index 639089c52..5550b7b96 100644 --- a/tests/feature/Issues/Issue1376Test.php +++ b/tests/feature/Issues/Issue1376Test.php @@ -28,11 +28,7 @@ '__root__' => '`stdClass { +$author="foo" }` must pass all the rules', 'title' => '`.title` must be present', 'description' => '`.description` must be present', - 'author' => [ - '__root__' => '`.author` must pass all the rules', - 'intType' => '`.author` must be an integer', - 'lengthBetween' => 'The length of `.author` must be between 1 and 2', - ], + 'author' => 'The length of `.author` must be between 1 and 2', 'user' => '`.user` must be present', ], )); diff --git a/tests/feature/Rules/AttributesTest.php b/tests/feature/Rules/AttributesTest.php index 4d855452a..ee86836cd 100644 --- a/tests/feature/Rules/AttributesTest.php +++ b/tests/feature/Rules/AttributesTest.php @@ -52,11 +52,7 @@ [ '__root__' => '`Respect\Validation\Test\Stubs\WithAttributes { +$name="" +$birthdate="not a date" +$email="not an email" +$phone ... }` must pass the rules', 'name' => '`.name` must not be empty', - 'birthdate' => [ - '__root__' => '`.birthdate` must pass all the rules', - 'date' => '`.birthdate` must be a valid date in the format "2005-12-30"', - 'dateTimeDiffLessThanOrEqual' => 'For comparison with now, `.birthdate` must be a valid datetime', - ], + 'birthdate' => 'For comparison with now, `.birthdate` must be a valid datetime', 'email' => '`.email` must be a valid email address or must be null', 'phone' => '`.phone` must be a valid telephone number or must be null', ], diff --git a/tests/feature/Rules/EachTest.php b/tests/feature/Rules/EachTest.php index 7476d19e6..3e02081b6 100644 --- a/tests/feature/Rules/EachTest.php +++ b/tests/feature/Rules/EachTest.php @@ -254,16 +254,8 @@ FULL_MESSAGE, [ '__root__' => 'Each item in `[2, 4]` must be valid', - 0 => [ - '__root__' => '`.0` must pass all the rules', - 'between' => '`.0` must be between 5 and 7', - 'odd' => '`.0` must be an odd number', - ], - 1 => [ - '__root__' => '`.1` must pass all the rules', - 'between' => '`.1` must be between 5 and 7', - 'odd' => '`.1` must be an odd number', - ], + 0 => '`.0` must be an odd number', + 1 => '`.1` must be an odd number', ], ));