respect-validation/tests/unit/ResultQueryTest.php
Henrique Moody d4605f4493
Add wildcard support to ResultQuery::findByPath()
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)
2026-02-08 22:43:06 +01:00

695 lines
21 KiB
PHP

<?php
/*
* 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>
*/
declare(strict_types=1);
namespace Respect\Validation;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Respect\Validation\Test\Builders\ResultBuilder;
use Respect\Validation\Test\Message\TestingArrayFormatter;
use Respect\Validation\Test\Message\TestingMessageRenderer;
use Respect\Validation\Test\Message\TestingStringFormatter;
use Respect\Validation\Test\TestCase;
use function uniqid;
#[Group('core')]
#[CoversClass(ResultQuery::class)]
final class ResultQueryTest extends TestCase
{
#[Test]
public function itShouldReturnTrueWhenResultHasPassed(): void
{
$result = (new ResultBuilder())->hasPassed(true)->build();
$resultQuery = $this->createResultQuery($result);
self::assertFalse($resultQuery->hasFailed());
}
#[Test]
public function itShouldReturnFalseWhenResultHasNotPassed(): void
{
$result = (new ResultBuilder())->hasPassed(false)->build();
$resultQuery = $this->createResultQuery($result);
self::assertTrue($resultQuery->hasFailed());
}
#[Test]
public function itShouldReturnEmptyMessageWhenResultHasPassed(): void
{
$result = (new ResultBuilder())->hasPassed(true)->build();
$resultQuery = $this->createResultQuery($result);
self::assertSame('', $resultQuery->getMessage());
}
#[Test]
public function itShouldReturnFormattedMessageWhenResultHasNotPassed(): void
{
$renderer = new TestingMessageRenderer();
$formatter = new TestingStringFormatter(uniqid());
$result = (new ResultBuilder())->hasPassed(false)->build();
$resultQuery = $this->createResultQuery($result, renderer: $renderer, messageFormatter: $formatter);
self::assertSame($formatter->format($result, $renderer, []), $resultQuery->getMessage());
}
#[Test]
public function itShouldReturnEmptyFullMessageWhenResultHasPassed(): void
{
$result = (new ResultBuilder())->hasPassed(true)->build();
$resultQuery = $this->createResultQuery($result);
self::assertSame('', $resultQuery->getFullMessage());
}
#[Test]
public function itShouldReturnFormattedFullMessageWhenResultHasNotPassed(): void
{
$renderer = new TestingMessageRenderer();
$formatter = new TestingStringFormatter(uniqid());
$result = (new ResultBuilder())->hasPassed(false)->build();
$resultQuery = $this->createResultQuery($result, renderer: $renderer, fullMessageFormatter: $formatter);
self::assertSame($formatter->format($result, $renderer, []), $resultQuery->getFullMessage());
}
#[Test]
public function itShouldReturnEmptyArrayWhenResultHasPassed(): void
{
$result = (new ResultBuilder())->hasPassed(true)->build();
$resultQuery = $this->createResultQuery($result);
self::assertSame([], $resultQuery->getMessages());
}
#[Test]
public function itShouldReturnFormattedArrayWhenResultHasNotPassed(): void
{
$renderer = new TestingMessageRenderer();
$formatter = new TestingArrayFormatter();
$result = (new ResultBuilder())->hasPassed(false)->build();
$resultQuery = $this->createResultQuery($result, renderer: $renderer, messagesFormatter: $formatter);
self::assertSame($formatter->format($result, $renderer, []), $resultQuery->getMessages());
}
#[Test]
public function itShouldConvertToStringUsingMessage(): void
{
$renderer = new TestingMessageRenderer();
$formatter = new TestingStringFormatter(uniqid());
$result = (new ResultBuilder())->hasPassed(false)->build();
$resultQuery = $this->createResultQuery($result, renderer: $renderer, messageFormatter: $formatter);
self::assertSame($formatter->format($result, $renderer, []), (string) $resultQuery);
}
#[Test]
public function itShouldConvertToEmptyStringWhenResultHasPassed(): void
{
$result = (new ResultBuilder())->hasPassed(true)->build();
$resultQuery = $this->createResultQuery($result);
self::assertSame('', (string) $resultQuery);
}
#[Test]
public function itShouldFindByIdWhenIdMatchesCurrentResult(): void
{
$renderer = new TestingMessageRenderer();
$formatter = new TestingStringFormatter(uniqid());
$id = uniqid();
$result = (new ResultBuilder())
->id($id)
->hasPassed(false)
->build();
$resultQuery = $this->createResultQuery($result, renderer: $renderer, messageFormatter: $formatter);
$found = $resultQuery->findById($id);
self::assertNotNull($found);
self::assertSame($formatter->format($result, $renderer, []), $found->getMessage());
}
#[Test]
public function itShouldFindByIdInDirectChildren(): void
{
$renderer = new TestingMessageRenderer();
$formatter = new TestingStringFormatter(uniqid());
$childId = uniqid();
$child = (new ResultBuilder())
->id($childId)
->hasPassed(false)
->build();
$parent = (new ResultBuilder())
->id(uniqid())
->hasPassed(false)
->children($child)
->build();
$resultQuery = $this->createResultQuery($parent, renderer: $renderer, messageFormatter: $formatter);
$found = $resultQuery->findById($childId);
self::assertNotNull($found);
self::assertSame($formatter->format($child, $renderer, []), $found->getMessage());
}
#[Test]
public function itShouldFindByIdInNestedChildren(): void
{
$renderer = new TestingMessageRenderer();
$formatter = new TestingStringFormatter(uniqid());
$grandchildId = uniqid();
$grandchild = (new ResultBuilder())
->id($grandchildId)
->hasPassed(false)
->build();
$child = (new ResultBuilder())
->id(uniqid())
->hasPassed(false)
->children($grandchild)
->build();
$parent = (new ResultBuilder())
->id(uniqid())
->hasPassed(false)
->children($child)
->build();
$resultQuery = $this->createResultQuery($parent, renderer: $renderer, messageFormatter: $formatter);
$found = $resultQuery->findById($grandchildId);
self::assertNotNull($found);
self::assertSame($formatter->format($grandchild, $renderer, []), $found->getMessage());
}
#[Test]
public function itShouldReturnNullWhenIdNotFound(): void
{
$result = (new ResultBuilder())
->id(uniqid())
->build();
$resultQuery = $this->createResultQuery($result);
self::assertNull($resultQuery->findById(uniqid()));
}
#[Test]
public function itShouldReturnNullWhenFindByIdOnResultWithNoChildren(): void
{
$result = (new ResultBuilder())
->id(uniqid())
->build();
$resultQuery = $this->createResultQuery($result);
self::assertNull($resultQuery->findById(uniqid()));
}
#[Test]
public function itShouldFindByNameWhenNameMatchesCurrentResult(): void
{
$renderer = new TestingMessageRenderer();
$formatter = new TestingStringFormatter(uniqid());
$name = uniqid();
$result = (new ResultBuilder())
->name($name)
->hasPassed(false)
->build();
$resultQuery = $this->createResultQuery($result, renderer: $renderer, messageFormatter: $formatter);
$found = $resultQuery->findByName($name);
self::assertNotNull($found);
self::assertSame($formatter->format($result, $renderer, []), $found->getMessage());
}
#[Test]
public function itShouldFindByNameInDirectChildren(): void
{
$renderer = new TestingMessageRenderer();
$formatter = new TestingStringFormatter(uniqid());
$childName = uniqid();
$child = (new ResultBuilder())
->name($childName)
->hasPassed(false)
->build();
$parent = (new ResultBuilder())
->name(uniqid())
->hasPassed(false)
->children($child)
->build();
$resultQuery = $this->createResultQuery($parent, renderer: $renderer, messageFormatter: $formatter);
$found = $resultQuery->findByName($childName);
self::assertNotNull($found);
self::assertSame($formatter->format($child, $renderer, []), $found->getMessage());
}
#[Test]
public function itShouldFindByNameInNestedChildren(): void
{
$renderer = new TestingMessageRenderer();
$formatter = new TestingStringFormatter(uniqid());
$grandchildName = uniqid();
$grandchild = (new ResultBuilder())
->name($grandchildName)
->hasPassed(false)
->build();
$child = (new ResultBuilder())
->name(uniqid())
->hasPassed(false)
->children($grandchild)
->build();
$parent = (new ResultBuilder())
->name(uniqid())
->hasPassed(false)
->children($child)
->build();
$resultQuery = $this->createResultQuery($parent, renderer: $renderer, messageFormatter: $formatter);
$found = $resultQuery->findByName($grandchildName);
self::assertNotNull($found);
self::assertSame($formatter->format($grandchild, $renderer, []), $found->getMessage());
}
#[Test]
public function itShouldReturnNullWhenNameNotFound(): void
{
$result = (new ResultBuilder())
->name(uniqid())
->build();
$resultQuery = $this->createResultQuery($result);
self::assertNull($resultQuery->findByName(uniqid()));
}
#[Test]
public function itShouldReturnNullWhenFindByNameOnResultWithNoName(): void
{
$result = (new ResultBuilder())->build();
$resultQuery = $this->createResultQuery($result);
self::assertNull($resultQuery->findByName(uniqid()));
}
#[Test]
public function itShouldFindByPathWhenPathMatchesCurrentResult(): void
{
$renderer = new TestingMessageRenderer();
$formatter = new TestingStringFormatter(uniqid());
$path = uniqid();
$result = (new ResultBuilder())
->path(new Path($path))
->hasPassed(false)
->build();
$resultQuery = $this->createResultQuery($result, renderer: $renderer, messageFormatter: $formatter);
$found = $resultQuery->findByPath($path);
self::assertNotNull($found);
self::assertSame($formatter->format($result, $renderer, []), $found->getMessage());
}
#[Test]
public function itShouldFindByPathInDirectChildren(): void
{
$renderer = new TestingMessageRenderer();
$formatter = new TestingStringFormatter(uniqid());
$childPath = uniqid();
$child = (new ResultBuilder())
->path(new Path($childPath))
->hasPassed(false)
->build();
$parent = (new ResultBuilder())
->path(new Path(uniqid()))
->hasPassed(false)
->children($child)
->build();
$resultQuery = $this->createResultQuery($parent, renderer: $renderer, messageFormatter: $formatter);
$found = $resultQuery->findByPath($childPath);
self::assertNotNull($found);
self::assertSame($formatter->format($child, $renderer, []), $found->getMessage());
}
#[Test]
public function itShouldFindByDottedPathInNestedChildren(): void
{
$renderer = new TestingMessageRenderer();
$formatter = new TestingStringFormatter(uniqid());
$childPath = uniqid();
$grandchildPath = uniqid();
// Create path chain: grandchild path has parent pointing to child path
$childPathObj = new Path($childPath);
$grandchildPathObj = new Path($grandchildPath, $childPathObj);
$grandchild = (new ResultBuilder())
->path($grandchildPathObj)
->hasPassed(false)
->build();
$parent = (new ResultBuilder())
->hasPassed(false)
->children($grandchild)
->build();
$resultQuery = $this->createResultQuery($parent, renderer: $renderer, messageFormatter: $formatter);
$found = $resultQuery->findByPath($childPath . '.' . $grandchildPath);
self::assertNotNull($found);
self::assertSame($formatter->format($grandchild, $renderer, []), $found->getMessage());
}
#[Test]
public function itShouldFindByIntegerPath(): void
{
$renderer = new TestingMessageRenderer();
$formatter = new TestingStringFormatter(uniqid());
$integerPath = 0;
$child = (new ResultBuilder())
->path(new Path($integerPath))
->hasPassed(false)
->build();
$parent = (new ResultBuilder())
->hasPassed(false)
->children($child)
->build();
$resultQuery = $this->createResultQuery($parent, renderer: $renderer, messageFormatter: $formatter);
$found = $resultQuery->findByPath($integerPath);
self::assertNotNull($found);
self::assertSame($formatter->format($child, $renderer, []), $found->getMessage());
}
#[Test]
public function itShouldReturnNullWhenPathNotFound(): void
{
$result = (new ResultBuilder())
->path(new Path(uniqid()))
->build();
$resultQuery = $this->createResultQuery($result);
self::assertNull($resultQuery->findByPath(uniqid()));
}
#[Test]
public function itShouldReturnNullWhenFindByPathOnResultWithNoPath(): void
{
$result = (new ResultBuilder())->build();
$resultQuery = $this->createResultQuery($result);
self::assertNull($resultQuery->findByPath(uniqid()));
}
#[Test]
public function itShouldReturnNullWhenDottedPathPartiallyMatches(): void
{
$childPath = uniqid();
$child = (new ResultBuilder())
->path(new Path($childPath))
->hasPassed(false)
->build();
$parent = (new ResultBuilder())
->hasPassed(false)
->children($child)
->build();
$resultQuery = $this->createResultQuery($parent);
self::assertNull($resultQuery->findByPath($childPath . '.' . uniqid()));
}
#[Test]
public function itShouldReturnNullWhenChildPathDoesNotMatch(): void
{
$child = (new ResultBuilder())
->path(new Path(uniqid()))
->hasPassed(false)
->build();
$parent = (new ResultBuilder())
->hasPassed(false)
->children($child)
->build();
$resultQuery = $this->createResultQuery($parent);
self::assertNull($resultQuery->findByPath(uniqid()));
}
#[Test]
public function itShouldPreserveFormattingCapabilitiesAfterFindById(): void
{
$renderer = new TestingMessageRenderer();
$messageFormatter = new TestingStringFormatter(uniqid());
$fullMessageFormatter = new TestingStringFormatter(uniqid());
$childId = uniqid();
$child = (new ResultBuilder())
->id($childId)
->hasPassed(false)
->build();
$parent = (new ResultBuilder())
->id(uniqid())
->hasPassed(false)
->children($child)
->build();
$resultQuery = $this->createResultQuery(
$parent,
renderer: $renderer,
messageFormatter: $messageFormatter,
fullMessageFormatter: $fullMessageFormatter,
);
$found = $resultQuery->findById($childId);
self::assertNotNull($found);
self::assertSame($messageFormatter->format($child, $renderer, []), $found->getMessage());
self::assertSame($fullMessageFormatter->format($child, $renderer, []), $found->getFullMessage());
}
#[Test]
public function itShouldPreserveFormattingCapabilitiesAfterFindByName(): void
{
$renderer = new TestingMessageRenderer();
$messagesFormatter = new TestingArrayFormatter();
$childName = uniqid();
$child = (new ResultBuilder())
->name($childName)
->hasPassed(false)
->build();
$parent = (new ResultBuilder())
->name(uniqid())
->hasPassed(false)
->children($child)
->build();
$resultQuery = $this->createResultQuery(
$parent,
renderer: $renderer,
messagesFormatter: $messagesFormatter,
);
$found = $resultQuery->findByName($childName);
self::assertNotNull($found);
self::assertSame($messagesFormatter->format($child, $renderer, []), $found->getMessages());
}
#[Test]
public function itShouldPreserveFormattingCapabilitiesAfterFindByPath(): void
{
$renderer = new TestingMessageRenderer();
$formatter = new TestingStringFormatter(uniqid());
$childPath = uniqid();
$child = (new ResultBuilder())
->path(new Path($childPath))
->hasPassed(false)
->build();
$parent = (new ResultBuilder())
->hasPassed(false)
->children($child)
->build();
$resultQuery = $this->createResultQuery($parent, renderer: $renderer, messageFormatter: $formatter);
$found = $resultQuery->findByPath($childPath);
self::assertNotNull($found);
self::assertSame($formatter->format($child, $renderer, []), (string) $found);
}
#[Test]
public function itShouldFindByPathWithWildcard(): void
{
$renderer = new TestingMessageRenderer();
$formatter = new TestingStringFormatter(uniqid());
$childPath = uniqid();
$parentPath = uniqid();
$child = (new ResultBuilder())
->path(new Path($childPath, new Path($parentPath)))
->hasPassed(false)
->build();
$parent = (new ResultBuilder())
->hasPassed(false)
->children($child)
->build();
$resultQuery = $this->createResultQuery($parent, renderer: $renderer, messageFormatter: $formatter);
$found = $resultQuery->findByPath('*.' . $childPath);
self::assertNotNull($found);
self::assertSame($formatter->format($child, $renderer, []), (string) $found);
}
#[Test]
public function itShouldFindByPathWithWildcardAtEnd(): void
{
$renderer = new TestingMessageRenderer();
$formatter = new TestingStringFormatter(uniqid());
$parentPath = uniqid();
$child = (new ResultBuilder())
->path(new Path(uniqid(), new Path($parentPath)))
->hasPassed(false)
->build();
$parent = (new ResultBuilder())
->hasPassed(false)
->children($child)
->build();
$resultQuery = $this->createResultQuery($parent, renderer: $renderer, messageFormatter: $formatter);
$found = $resultQuery->findByPath($parentPath . '.*');
self::assertNotNull($found);
self::assertSame($formatter->format($child, $renderer, []), (string) $found);
}
#[Test]
public function itShouldReturnNullWhenPathWithWildcardDoesNotMatch(): void
{
$child = (new ResultBuilder())
->path(new Path(uniqid()))
->build();
$parent = (new ResultBuilder())
->hasPassed(false)
->children($child)
->build();
$resultQuery = $this->createResultQuery($parent);
self::assertNull($resultQuery->findByPath(uniqid() . '.*'));
}
#[Test]
public function itShouldReturnNullWhenPathLengthDoesNotMatch(): void
{
$child = (new ResultBuilder())
->path(new Path(uniqid()))
->hasPassed(false)
->build();
$parent = (new ResultBuilder())
->hasPassed(false)
->children($child)
->build();
$resultQuery = $this->createResultQuery($parent);
self::assertNull($resultQuery->findByPath('*.*'));
}
private function createResultQuery(
Result $result,
TestingMessageRenderer|null $renderer = null,
TestingStringFormatter|null $messageFormatter = null,
TestingStringFormatter|null $fullMessageFormatter = null,
TestingArrayFormatter|null $messagesFormatter = null,
): ResultQuery {
return new ResultQuery(
$result,
$renderer ?? new TestingMessageRenderer(),
$messageFormatter ?? new TestingStringFormatter(),
$fullMessageFormatter ?? new TestingStringFormatter(),
$messagesFormatter ?? new TestingArrayFormatter(),
[],
);
}
}