Update validation engine

There are a few "problems" with the current engine:

- Allowing each rule to execute assert() and check() means duplication
  in some cases.

- Because we use exceptions to assert/check, we can only invert a
  validation (with Not) if there are errors. That means that we have
  limited granularity control.

- There is a lot of logic in the exceptions. That means that even after
  it throws an exception, something could still happen. We're stable on
  that front, but I want to simplify them. Besides, debugging exception
  code is painful because the stack trace does not go beyond the
  exception.

Apart from that, there are many limitations with templating, and working
that out in the current implementation makes it much harder.

These changes will improve the library in many aspects, but they will
also change the behavior and break backward compatibility. However,
that's a price I'm willing to pay for the improvements we'll have.

Signed-off-by: Henrique Moody <henriquemoody@gmail.com>
This commit is contained in:
Henrique Moody 2024-02-07 12:22:13 +01:00
parent 3a7ac0240d
commit 238f2d506a
No known key found for this signature in database
GPG key ID: 221E9281655813A6
62 changed files with 2381 additions and 213 deletions

View file

@ -336,4 +336,7 @@ interface ChainedValidator extends Validatable
public function xdigit(string ...$additionalChars): ChainedValidator;
public function yes(bool $useLocale = false): ChainedValidator;
/** @param array<string, mixed> $templates */
public function setTemplates(array $templates): ChainedValidator;
}

View file

@ -0,0 +1,33 @@
<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Validation\Exceptions;
final class ValidatorException extends \Exception implements Exception
{
/** @param array<string, mixed> $messages */
public function __construct(
string $message,
private readonly string $fullMessage,
private readonly array $messages,
) {
parent::__construct($message);
}
public function getFullMessage(): string
{
return $this->fullMessage;
}
/** @return array<string, mixed> */
public function getMessages(): array
{
return $this->messages;
}
}

View file

@ -88,6 +88,16 @@ final class Factory
return $clone;
}
public function getTranslator(): callable
{
return $this->translator;
}
public function getParameterProcessor(): Processor
{
return $this->processor;
}
/**
* @param mixed[] $arguments
*/
@ -123,7 +133,8 @@ final class Factory
if ($validatable->getName() !== null) {
$id = $params['name'] = $validatable->getName();
}
$template = $validatable->getTemplate($input);
$standardTemplate = $reflection->getMethod('getStandardTemplate');
$template = $validatable->getTemplate() ?? $standardTemplate->invoke($validatable, $input);
$templates = $this->templateCollector->extract($validatable);
$formatter = new TemplateRenderer($this->translator, $this->processor);

View file

@ -0,0 +1,29 @@
<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Validation\Helpers;
use Respect\Validation\Result;
use Respect\Validation\Validatable;
trait CanBindEvaluateRule
{
private function bindEvaluate(Validatable $binded, Validatable $binder, mixed $input): Result
{
if ($binder->getName() !== null && $binded->getName() === null) {
$binded->setName($binder->getName());
}
if ($binder->getTemplate() !== null && $binded->getTemplate() === null) {
$binded->setTemplate($binder->getTemplate());
}
return $binded->evaluate($input);
}
}

View file

@ -0,0 +1,32 @@
<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Validation\Message;
use Respect\Validation\Result;
interface Formatter
{
/**
* @param array<string, mixed> $templates
*/
public function main(Result $result, array $templates): string;
/**
* @param array<string, mixed> $templates
*/
public function full(Result $result, array $templates, int $depth = 0): string;
/**
* @param array<string, mixed> $templates
*
* @return array<string, mixed>
*/
public function array(Result $result, array $templates): array;
}

View file

@ -0,0 +1,17 @@
<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Validation\Message;
use Respect\Validation\Result;
interface Renderer
{
public function render(Result $result, ?string $template = null): string;
}

View file

@ -0,0 +1,182 @@
<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Validation\Message;
use Respect\Validation\Exceptions\ComponentException;
use Respect\Validation\Result;
use function array_filter;
use function array_key_exists;
use function array_values;
use function count;
use function current;
use function is_array;
use function is_string;
use function Respect\Stringifier\stringify;
use function rtrim;
use function sprintf;
use function str_repeat;
use const PHP_EOL;
final class StandardFormatter implements Formatter
{
public function __construct(
private readonly Renderer $renderer,
) {
}
/**
* @param array<string, mixed> $templates
*/
public function main(Result $result, array $templates): string
{
$selectedTemplates = $this->selectTemplates($result, $templates);
if (!$this->isFinalTemplate($result, $selectedTemplates)) {
foreach ($this->extractDeduplicatedChildren($result) as $child) {
return $this->main($child, $selectedTemplates);
}
}
return $this->renderer->render($this->getTemplated($result, $selectedTemplates));
}
/**
* @param array<string, mixed> $templates
*/
public function full(Result $result, array $templates, int $depth = 0): string
{
$selectedTemplates = $this->selectTemplates($result, $templates);
$isFinalTemplate = $this->isFinalTemplate($result, $selectedTemplates);
$rendered = '';
if ($result->isAlwaysVisible() || $isFinalTemplate) {
$indentation = str_repeat(' ', $depth * 2);
$rendered .= sprintf(
'%s- %s' . PHP_EOL,
$indentation,
$this->renderer->render($this->getTemplated($result, $selectedTemplates)),
);
$depth++;
}
if (!$isFinalTemplate) {
foreach ($this->extractDeduplicatedChildren($result) as $child) {
$rendered .= $this->full($child, $selectedTemplates, $depth);
$rendered .= PHP_EOL;
}
}
return rtrim($rendered, PHP_EOL);
}
/**
* @param array<string, mixed> $templates
*
* @return array<string, mixed>
*/
public function array(Result $result, array $templates): array
{
$selectedTemplates = $this->selectTemplates($result, $templates);
$deduplicatedChildren = $this->extractDeduplicatedChildren($result);
if (count($deduplicatedChildren) === 0 || $this->isFinalTemplate($result, $selectedTemplates)) {
return [$result->id => $this->renderer->render($this->getTemplated($result, $selectedTemplates))];
}
$messages = [];
foreach ($deduplicatedChildren as $child) {
$messages[$child->id] = $this->array($child, $this->selectTemplates($child, $selectedTemplates));
if (count($messages[$child->id]) !== 1) {
continue;
}
$messages[$child->id] = current($messages[$child->id]);
}
return $messages;
}
/** @param array<string, mixed> $templates */
private function getTemplated(Result $result, array $templates): Result
{
if ($result->hasCustomTemplate()) {
return $result;
}
if (!isset($templates[$result->id]) && isset($templates['__self__'])) {
return $result->withTemplate($templates['__self__']);
}
if (!isset($templates[$result->id])) {
return $result;
}
$template = $templates[$result->id];
if (is_string($template)) {
return $result->withTemplate($template);
}
throw new ComponentException(
sprintf('Template for "%s" must be a string, %s given', $result->id, stringify($template))
);
}
/**
* @param array<string, mixed> $templates
*/
private function isFinalTemplate(Result $result, array $templates): bool
{
if (isset($templates[$result->id]) && is_string($templates[$result->id])) {
return true;
}
if (count($templates) !== 1) {
return false;
}
return isset($templates['__self__']) || isset($templates[$result->id]);
}
/**
* @param array<string, mixed> $templates
*
* @return array<string, mixed>
*/
private function selectTemplates(Result $message, array $templates): array
{
if (isset($templates[$message->id]) && is_array($templates[$message->id])) {
return $templates[$message->id];
}
return $templates;
}
/** @return array<Result> */
private function extractDeduplicatedChildren(Result $result): array
{
/** @var array<string, Result> $deduplicatedResults */
$deduplicatedResults = [];
$duplicateCounters = [];
foreach ($result->children as $child) {
$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_values(array_filter($deduplicatedResults));
}
}

View file

@ -0,0 +1,106 @@
<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Validation\Message;
use ReflectionClass;
use Respect\Validation\Exceptions\ComponentException;
use Respect\Validation\Message\Parameter\Processor;
use Respect\Validation\Mode;
use Respect\Validation\Result;
use Respect\Validation\Rule;
use Throwable;
use function call_user_func;
use function preg_replace_callback;
use function sprintf;
final class StandardRenderer implements Renderer
{
/** @var array<string, array<Template>> */
private array $templates = [];
/** @var callable */
private $translator;
public function __construct(
callable $translator,
private readonly Processor $processor
) {
$this->translator = $translator;
}
public function render(Result $result, ?string $template = null): string
{
$parameters = $result->parameters;
$parameters['name'] ??= $result->name ?? $this->processor->process('input', $result->input);
$parameters['input'] = $result->input;
$rendered = (string) preg_replace_callback(
'/{{(\w+)(\|([^}]+))?}}/',
function (array $matches) use ($parameters) {
if (!isset($parameters[$matches[1]])) {
return $matches[0];
}
return $this->processor->process($matches[1], $parameters[$matches[1]], $matches[3] ?? null);
},
$this->translate($template ?? $this->getTemplateMessage($result))
);
if (!$result->hasCustomTemplate() && $result->nextSibling !== null) {
$rendered .= ' ' . $this->render($result->nextSibling);
}
return $rendered;
}
private function translate(string $message): string
{
try {
return call_user_func($this->translator, $message);
} catch (Throwable $throwable) {
throw new ComponentException(sprintf('Failed to translate "%s"', $message), 0, $throwable);
}
}
/** @return array<Template> */
private function extractTemplates(Rule $rule): array
{
if (!isset($this->templates[$rule::class])) {
$reflection = new ReflectionClass($rule);
foreach ($reflection->getAttributes(Template::class) as $attribute) {
$this->templates[$rule::class][] = $attribute->newInstance();
}
}
return $this->templates[$rule::class] ?? [];
}
private function getTemplateMessage(Result $result): string
{
if ($result->hasCustomTemplate()) {
return $result->template;
}
foreach ($this->extractTemplates($result->rule) as $template) {
if ($template->id !== $result->template) {
continue;
}
if ($result->mode == Mode::NEGATIVE) {
return $template->negative;
}
return $template->default;
}
return $result->template;
}
}

16
library/Mode.php Normal file
View file

@ -0,0 +1,16 @@
<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Validation;
enum Mode
{
case DEFAULT;
case NEGATIVE;
}

156
library/Result.php Normal file
View file

@ -0,0 +1,156 @@
<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Validation;
use function array_filter;
use function array_map;
use function count;
use function lcfirst;
use function preg_match;
use function strrchr;
use function substr;
final class Result
{
/** @var array<Result> */
public readonly array $children;
public readonly string $id;
public readonly ?string $name;
public readonly string $template;
/** @param array<string, mixed> $parameters */
public function __construct(
public readonly bool $isValid,
public readonly mixed $input,
public readonly Rule $rule,
string $template = Rule::TEMPLATE_STANDARD,
public readonly array $parameters = [],
public readonly Mode $mode = Mode::DEFAULT,
?string $name = null,
?string $id = null,
public readonly ?Result $nextSibling = null,
Result ...$children,
) {
$this->name = $rule->getName() ?? $name;
$this->template = $rule->getTemplate() ?? $template;
$this->id = $id ?? $this->name ?? lcfirst(substr((string) strrchr($rule::class, '\\'), 1));
$this->children = $children;
}
public static function failed(mixed $input, Rule $rule, string $template = Rule::TEMPLATE_STANDARD): self
{
return new self(false, $input, $rule, $template);
}
public static function passed(mixed $input, Rule $rule, string $template = Rule::TEMPLATE_STANDARD): self
{
return new self(true, $input, $rule, $template);
}
public function withTemplate(string $template): self
{
return $this->clone(template: $template);
}
public function withId(string $id): self
{
return $this->clone(id: $id);
}
public function withChildren(Result ...$children): self
{
return $this->clone(children: $children);
}
/** @param array<string, mixed> $parameters */
public function withParameters(array $parameters): self
{
return $this->clone(parameters: $parameters);
}
public function withNameIfMissing(string $name): self
{
return $this->clone(
name: $this->name ?? $name,
children: array_map(static fn (Result $child) => $child->withNameIfMissing($name), $this->children),
);
}
public function withNextSibling(Result $nextSibling): self
{
return $this->clone(nextSibling: $nextSibling);
}
public function withInvertedMode(): self
{
return $this->clone(
isValid: !$this->isValid,
mode: $this->mode == Mode::DEFAULT ? Mode::NEGATIVE : Mode::DEFAULT,
nextSibling: $this->nextSibling?->withInvertedMode(),
children: array_map(static fn (Result $child) => $child->withInvertedMode(), $this->children),
);
}
public function withMode(Mode $mode): self
{
return $this->clone(mode: $mode);
}
public function hasCustomTemplate(): bool
{
return preg_match('/__[0-9a-z_]+_/', $this->template) === 0;
}
public function isAlwaysVisible(): bool
{
if ($this->isValid) {
return false;
}
if ($this->hasCustomTemplate()) {
return true;
}
$childrenAlwaysVisible = array_filter($this->children, static fn (Result $child) => $child->isAlwaysVisible());
return count($childrenAlwaysVisible) !== 1;
}
/**
* @param array<string, mixed>|null $parameters
* @param array<Result>|null $children
*/
private function clone(
?bool $isValid = null,
?string $template = null,
?array $parameters = null,
?Mode $mode = null,
?string $name = null,
?string $id = null,
?Result $nextSibling = null,
?array $children = null
): self {
return new self(
$isValid ?? $this->isValid,
$this->input,
$this->rule,
$template ?? $this->template,
$parameters ?? $this->parameters,
$mode ?? $this->mode,
$name ?? $this->name,
$id ?? $this->id,
$nextSibling ?? $this->nextSibling,
...($children ?? $this->children)
);
}
}

25
library/Rule.php Normal file
View file

@ -0,0 +1,25 @@
<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Validation;
interface Rule
{
public const TEMPLATE_STANDARD = '__standard__';
public function evaluate(mixed $input): Result;
public function getName(): ?string;
public function setName(string $name): static;
public function getTemplate(): ?string;
public function setTemplate(string $template): static;
}

View file

@ -25,7 +25,7 @@ abstract class AbstractComposite extends AbstractRule
$this->rules = $rules;
}
public function setName(string $name): Validatable
public function setName(string $name): static
{
$parentName = $this->getName();
foreach ($this->rules as $rule) {
@ -104,11 +104,11 @@ abstract class AbstractComposite extends AbstractRule
private function updateExceptionTemplate(ValidationException $exception): void
{
if ($this->template === null || $exception->hasCustomTemplate()) {
if ($this->getTemplate() === null || $exception->hasCustomTemplate()) {
return;
}
$exception->updateTemplate($this->template);
$exception->updateTemplate($this->getTemplate());
if (!$exception instanceof NestedValidationException) {
return;

View file

@ -10,6 +10,7 @@ declare(strict_types=1);
namespace Respect\Validation\Rules;
use Respect\Validation\Exceptions\ValidationException;
use Respect\Validation\Result;
use Respect\Validation\Validatable;
abstract class AbstractEnvelope extends AbstractRule
@ -28,6 +29,12 @@ abstract class AbstractEnvelope extends AbstractRule
return $this->validatable->validate($input);
}
public function evaluate(mixed $input): Result
{
return (new Result($this->validatable->evaluate($input)->isValid, $input, $this))
->withParameters($this->parameters);
}
/**
* @param mixed[] $extraParameters
*/

View file

@ -11,10 +11,9 @@ namespace Respect\Validation\Rules;
use Respect\Validation\Exceptions\NestedValidationException;
use Respect\Validation\Exceptions\ValidationException;
use Respect\Validation\Result;
use Respect\Validation\Validatable;
use function is_scalar;
abstract class AbstractRelated extends AbstractRule
{
public const TEMPLATE_NOT_PRESENT = '__not_present__';
@ -29,12 +28,26 @@ abstract class AbstractRelated extends AbstractRule
private readonly ?Validatable $rule = null,
private readonly bool $mandatory = true
) {
$this->setName($rule?->getName() ?? (string) $reference);
}
if ($rule && $rule->getName() !== null) {
$this->setName($rule->getName());
} elseif (is_scalar($reference)) {
$this->setName((string) $reference);
public function evaluate(mixed $input): Result
{
$name = $this->getName() ?? (string) $this->reference;
$hasReference = $this->hasReference($input);
if ($this->mandatory && !$hasReference) {
return Result::failed($input, $this, self::TEMPLATE_NOT_PRESENT)->withNameIfMissing($name);
}
if ($this->rule === null || !$hasReference) {
return Result::passed($input, $this, self::TEMPLATE_NOT_PRESENT)->withNameIfMissing($name);
}
$result = $this->rule->evaluate($this->getReferenceValue($input));
return (new Result($result->isValid, $input, $this, self::TEMPLATE_INVALID))
->withChildren($result)
->withNameIfMissing($name);
}
public function getReference(): mixed
@ -47,13 +60,10 @@ abstract class AbstractRelated extends AbstractRule
return $this->mandatory;
}
public function setName(string $name): Validatable
public function setName(string $name): static
{
parent::setName($name);
if ($this->rule instanceof Validatable) {
$this->rule->setName($name);
}
$this->rule?->setName($name);
return $this;
}

View file

@ -11,14 +11,15 @@ namespace Respect\Validation\Rules;
use Respect\Validation\Exceptions\ValidationException;
use Respect\Validation\Factory;
use Respect\Validation\Result;
use Respect\Validation\Validatable;
abstract class AbstractRule implements Validatable
{
protected ?string $name = null;
protected ?string $template = null;
private ?string $name = null;
public function assert(mixed $input): void
{
if ($this->validate($input)) {
@ -28,6 +29,12 @@ abstract class AbstractRule implements Validatable
throw $this->reportError($input);
}
public function evaluate(mixed $input): Result
{
return (new Result($this->validate($input), $input, $this, $this->getStandardTemplate($input)))
->withParameters($this->getParams());
}
public function check(mixed $input): void
{
$this->assert($input);
@ -46,23 +53,23 @@ abstract class AbstractRule implements Validatable
return Factory::getDefaultInstance()->exception($this, $input, $extraParameters);
}
public function setName(string $name): Validatable
public function setName(string $name): static
{
$this->name = $name;
return $this;
}
public function setTemplate(string $template): Validatable
public function setTemplate(string $template): static
{
$this->template = $template;
return $this;
}
public function getTemplate(mixed $input): string
public function getTemplate(): ?string
{
return $this->template ?? $this->getStandardTemplate($input);
return $this->template;
}
/**

View file

@ -9,6 +9,7 @@ declare(strict_types=1);
namespace Respect\Validation\Rules;
use Respect\Validation\Result;
use Respect\Validation\Validatable;
abstract class AbstractWrapper extends AbstractRule
@ -18,6 +19,11 @@ abstract class AbstractWrapper extends AbstractRule
) {
}
public function evaluate(mixed $input): Result
{
return $this->validatable->evaluate($input);
}
public function assert(mixed $input): void
{
$this->validatable->assert($input);
@ -33,10 +39,17 @@ abstract class AbstractWrapper extends AbstractRule
return $this->validatable->validate($input);
}
public function setName(string $name): Validatable
public function setName(string $name): static
{
$this->validatable->setName($name);
return parent::setName($name);
}
public function setTemplate(string $template): static
{
$this->validatable->setTemplate($template);
return parent::setTemplate($template);
}
}

View file

@ -12,7 +12,12 @@ namespace Respect\Validation\Rules;
use Respect\Validation\Attributes\ExceptionClass;
use Respect\Validation\Exceptions\NestedValidationException;
use Respect\Validation\Message\Template;
use Respect\Validation\Result;
use Respect\Validation\Rule;
use function array_filter;
use function array_map;
use function array_reduce;
use function count;
#[ExceptionClass(NestedValidationException::class)]
@ -26,11 +31,24 @@ use function count;
'None of these rules must pass for {{name}}',
self::TEMPLATE_NONE,
)]
class AllOf extends AbstractComposite
final class AllOf extends AbstractComposite
{
public const TEMPLATE_NONE = '__none__';
public const TEMPLATE_SOME = '__some__';
public function evaluate(mixed $input): Result
{
$children = array_map(static fn (Rule $rule) => $rule->evaluate($input), $this->getRules());
$valid = array_reduce($children, static fn (bool $carry, Result $result) => $carry && $result->isValid, true);
$failed = array_filter($children, static fn (Result $result): bool => !$result->isValid);
$template = self::TEMPLATE_SOME;
if (count($children) === count($failed)) {
$template = self::TEMPLATE_NONE;
}
return (new Result($valid, $input, $this, $template))->withChildren(...$children);
}
public function assert(mixed $input): void
{
try {

View file

@ -13,7 +13,11 @@ use Respect\Validation\Attributes\ExceptionClass;
use Respect\Validation\Exceptions\NestedValidationException;
use Respect\Validation\Exceptions\ValidationException;
use Respect\Validation\Message\Template;
use Respect\Validation\Result;
use Respect\Validation\Rule;
use function array_map;
use function array_reduce;
use function count;
#[ExceptionClass(NestedValidationException::class)]
@ -23,6 +27,14 @@ use function count;
)]
final class AnyOf extends AbstractComposite
{
public function evaluate(mixed $input): Result
{
$children = array_map(static fn (Rule $rule) => $rule->evaluate($input), $this->getRules());
$valid = array_reduce($children, static fn (bool $carry, Result $result) => $carry || $result->isValid, false);
return (new Result($valid, $input, $this))->withChildren(...$children);
}
public function assert(mixed $input): void
{
try {

View file

@ -11,6 +11,7 @@ namespace Respect\Validation\Rules;
use Respect\Validation\Exceptions\ValidationException;
use Respect\Validation\Message\Template;
use Respect\Validation\Result;
use Respect\Validation\Validatable;
use Throwable;
@ -36,6 +37,18 @@ final class Call extends AbstractRule
$this->callable = $callable;
}
public function evaluate(mixed $input): Result
{
$this->setErrorHandler($input);
try {
return $this->rule->evaluate(call_user_func($this->callable, $input));
} catch (Throwable) {
restore_error_handler();
return Result::failed($input, $this)->withParameters(['callable' => $this->callable]);
}
}
public function assert(mixed $input): void
{
$this->setErrorHandler($input);

View file

@ -13,8 +13,11 @@ use Respect\Validation\Attributes\ExceptionClass;
use Respect\Validation\Exceptions\NestedValidationException;
use Respect\Validation\Exceptions\ValidationException;
use Respect\Validation\Message\Template;
use Respect\Validation\Result;
use Respect\Validation\Validatable;
use function array_filter;
use function array_map;
use function array_merge;
use function array_pop;
use function count;
@ -29,11 +32,11 @@ use function mb_substr_count;
)]
final class Domain extends AbstractRule
{
private readonly Validatable $genericRule;
private readonly AllOf $genericRule;
private readonly Validatable $tldRule;
private readonly Validatable $partsRule;
private readonly AllOf $partsRule;
public function __construct(bool $tldCheck = true)
{
@ -61,6 +64,41 @@ final class Domain extends AbstractRule
$this->throwExceptions($exceptions, $input);
}
public function evaluate(mixed $input): Result
{
$failedGenericResults = array_filter(array_map(
static fn (Validatable $rule) => $rule->evaluate($input),
$this->genericRule->getRules()
), static fn (Result $result) => !$result->isValid);
if (count($failedGenericResults)) {
return (new Result(false, $input, $this))->withChildren(...$failedGenericResults);
}
$children = [];
$valid = true;
$parts = explode('.', (string) $input);
if (count($parts) >= 2) {
$tld = array_pop($parts);
foreach ($this->tldRule instanceof AllOf ? $this->tldRule->getRules() : [$this->tldRule] as $rule) {
$childResult = $rule->evaluate($tld);
$valid = $valid && $childResult->isValid;
$children[] = $childResult;
}
}
foreach ($parts as $part) {
foreach ($this->partsRule->getRules() as $rule) {
$childResult = $rule->evaluate($part);
$valid = $valid && $childResult->isValid;
$children[] = $childResult;
}
}
return (new Result($valid, $input, $this))
->withChildren(...array_filter($children, static fn (Result $child) => !$child->isValid));
}
public function validate(mixed $input): bool
{
try {
@ -103,7 +141,7 @@ final class Domain extends AbstractRule
}
}
private function createGenericRule(): Validatable
private function createGenericRule(): AllOf
{
return new AllOf(
new StringType(),
@ -126,7 +164,7 @@ final class Domain extends AbstractRule
);
}
private function createPartsRule(): Validatable
private function createPartsRule(): AllOf
{
return new AllOf(
new Alnum('-'),

View file

@ -14,6 +14,7 @@ use Respect\Validation\Exceptions\EachException;
use Respect\Validation\Exceptions\ValidationException;
use Respect\Validation\Helpers\CanValidateIterable;
use Respect\Validation\Message\Template;
use Respect\Validation\Result;
use Respect\Validation\Validatable;
#[ExceptionClass(EachException::class)]
@ -30,6 +31,27 @@ final class Each extends AbstractRule
) {
}
public function evaluate(mixed $input): Result
{
if (!$this->isIterable($input)) {
return Result::failed($input, $this);
}
$children = [];
$isValid = true;
foreach ($input as $inputItem) {
$childResult = $this->rule->evaluate($inputItem);
$isValid = $isValid && $childResult->isValid;
$children[] = $childResult;
}
if ($isValid) {
return Result::passed($input, $this)->withChildren(...$children);
}
return Result::failed($input, $this)->withChildren(...$children);
}
public function assert(mixed $input): void
{
if (!$this->isIterable($input)) {

View file

@ -12,16 +12,20 @@ namespace Respect\Validation\Rules;
use Respect\Validation\Exceptions\ComponentException;
use Respect\Validation\Exceptions\ValidationException;
use Respect\Validation\Factory;
use Respect\Validation\Helpers\CanBindEvaluateRule;
use Respect\Validation\Message\Template;
use Respect\Validation\Result;
use Respect\Validation\Validatable;
use Respect\Validation\Validator;
use function array_keys;
use function array_map;
use function in_array;
use function Respect\Stringifier\stringify;
#[Template(
'Key {{name}} must be present',
'Key {{name}} must not be present',
'The value of',
'The value of',
self::TEMPLATE_STANDARD,
)]
#[Template(
@ -31,6 +35,8 @@ use function Respect\Stringifier\stringify;
)]
final class KeyValue extends AbstractRule
{
use CanBindEvaluateRule;
public const TEMPLATE_COMPONENT = '__component__';
public function __construct(
@ -40,6 +46,28 @@ final class KeyValue extends AbstractRule
) {
}
public function evaluate(mixed $input): Result
{
$result = $this->bindEvaluate(new AllOf(new Key($this->comparedKey), new Key($this->baseKey)), $this, $input);
if (!$result->isValid) {
return $result;
}
try {
$rule = Validator::__callStatic($this->ruleName, [$input[$this->baseKey]]);
$nextSibling = $rule->evaluate($input[$this->comparedKey]);
$nextSiblingParameters = ['name' => $this->getName() ?? (string) $this->comparedKey];
$nextSiblingParameters += array_map(fn ($value) => $this->baseKey, $nextSibling->parameters);
return (new Result($nextSibling->isValid, $input, $this))
->withNextSibling($nextSibling->withParameters($nextSiblingParameters))
->withNameIfMissing((string) $this->comparedKey);
} catch (ComponentException) {
return Result::failed($input, $this, self::TEMPLATE_COMPONENT)
->withParameters(['baseKey' => $this->baseKey, 'comparedKey' => $this->comparedKey]);
}
}
public function assert(mixed $input): void
{
$rule = $this->getRule($input);

View file

@ -12,7 +12,11 @@ namespace Respect\Validation\Rules;
use Respect\Validation\Attributes\ExceptionClass;
use Respect\Validation\Exceptions\NestedValidationException;
use Respect\Validation\Message\Template;
use Respect\Validation\Result;
use Respect\Validation\Rule;
use function array_map;
use function array_reduce;
use function count;
#[ExceptionClass(NestedValidationException::class)]
@ -22,6 +26,14 @@ use function count;
)]
final class NoneOf extends AbstractComposite
{
public function evaluate(mixed $input): Result
{
$children = array_map(static fn (Rule $rule) => $rule->evaluate($input)->withInvertedMode(), $this->getRules());
$valid = array_reduce($children, static fn (bool $carry, Result $result) => $carry && $result->isValid, true);
return (new Result($valid, $input, $this))->withChildren(...$children);
}
public function assert(mixed $input): void
{
try {

View file

@ -15,6 +15,7 @@ use Respect\Validation\Exceptions\NestedValidationException;
use Respect\Validation\Exceptions\ValidationException;
use Respect\Validation\Message\Template;
use Respect\Validation\NonNegatable;
use Respect\Validation\Result;
use Respect\Validation\Validatable;
use function array_shift;
@ -50,13 +51,20 @@ final class Not extends AbstractRule
return $this->rule;
}
public function setName(string $name): Validatable
public function setName(string $name): static
{
$this->rule->setName($name);
return parent::setName($name);
}
public function setTemplate(string $template): static
{
$this->rule->setTemplate($template);
return parent::setTemplate($template);
}
public function validate(mixed $input): bool
{
return $this->rule->validate($input) === false;
@ -79,6 +87,11 @@ final class Not extends AbstractRule
throw $exception;
}
public function evaluate(mixed $input): Result
{
return $this->rule->evaluate($input)->withInvertedMode();
}
private function absorbAllOf(AllOf $rule, mixed $input): Validatable
{
$rules = $rule->getRules();

View file

@ -13,7 +13,11 @@ use Respect\Validation\Attributes\ExceptionClass;
use Respect\Validation\Exceptions\NestedValidationException;
use Respect\Validation\Exceptions\ValidationException;
use Respect\Validation\Message\Template;
use Respect\Validation\Result;
use Respect\Validation\Rule;
use function array_map;
use function array_reduce;
use function array_shift;
use function count;
@ -24,6 +28,14 @@ use function count;
)]
final class OneOf extends AbstractComposite
{
public function evaluate(mixed $input): Result
{
$children = array_map(static fn (Rule $rule) => $rule->evaluate($input), $this->getRules());
$count = array_reduce($children, static fn (int $carry, Result $result) => $carry + (int) $result->isValid, 0);
return (new Result($count === 1, $input, $this))->withChildren(...$children);
}
public function assert(mixed $input): void
{
try {

View file

@ -11,6 +11,7 @@ namespace Respect\Validation\Rules;
use Respect\Validation\Helpers\CanValidateUndefined;
use Respect\Validation\Message\Template;
use Respect\Validation\Result;
#[Template(
'The value must be optional',
@ -28,6 +29,15 @@ final class Optional extends AbstractWrapper
public const TEMPLATE_NAMED = '__named__';
public function evaluate(mixed $input): Result
{
if ($this->isUndefined($input)) {
return Result::passed($input, $this);
}
return parent::evaluate($input);
}
public function assert(mixed $input): void
{
if ($this->isUndefined($input)) {

View file

@ -9,15 +9,20 @@ declare(strict_types=1);
namespace Respect\Validation\Rules;
use Respect\Validation\Helpers\CanBindEvaluateRule;
use Respect\Validation\Message\Template;
use Respect\Validation\Mode;
use Respect\Validation\Result;
use Respect\Validation\Validatable;
#[Template(
'{{name}} must be valid',
'{{name}} must not be valid',
'after asserting that',
'after failing to assert that',
)]
final class When extends AbstractRule
{
use CanBindEvaluateRule;
private readonly Validatable $else;
public function __construct(
@ -33,6 +38,22 @@ final class When extends AbstractRule
$this->else = $else;
}
public function evaluate(mixed $input): Result
{
$whenResult = $this->bindEvaluate($this->when, $this, $input);
if ($whenResult->isValid) {
$thenResult = $this->bindEvaluate($this->then, $this, $input);
$thisResult = new Result($thenResult->isValid, $input, $this);
return $thenResult->withNextSibling($thisResult->withNextSibling($whenResult));
}
$elseResult = $this->bindEvaluate($this->else, $this, $input);
$thisResult = (new Result($elseResult->isValid, $input, $this))->withMode(Mode::NEGATIVE);
return $elseResult->withNextSibling($thisResult->withNextSibling($whenResult));
}
public function validate(mixed $input): bool
{
if ($this->when->validate($input)) {

View file

@ -11,26 +11,18 @@ namespace Respect\Validation;
use Respect\Validation\Exceptions\ValidationException;
interface Validatable
interface Validatable extends Rule
{
public const TEMPLATE_STANDARD = '__standard__';
public function assert(mixed $input): void;
public function check(mixed $input): void;
public function getName(): ?string;
/**
* @param mixed[] $extraParameters
*/
public function reportError(mixed $input, array $extraParameters = []): ValidationException;
public function setName(string $name): Validatable;
public function setTemplate(string $template): Validatable;
public function getTemplate(mixed $input): string;
public function getTemplate(): ?string;
/**
* @return array<string, mixed>

View file

@ -11,11 +11,17 @@ namespace Respect\Validation;
use Respect\Validation\Attributes\ExceptionClass;
use Respect\Validation\Exceptions\NestedValidationException;
use Respect\Validation\Exceptions\ValidationException;
use Respect\Validation\Exceptions\ValidatorException;
use Respect\Validation\Helpers\CanBindEvaluateRule;
use Respect\Validation\Message\Formatter;
use Respect\Validation\Message\StandardFormatter;
use Respect\Validation\Message\StandardRenderer;
use Respect\Validation\Message\Template;
use Respect\Validation\Rules\AbstractRule;
use Respect\Validation\Rules\AllOf;
use function count;
use function current;
/**
* @mixin StaticValidator
@ -31,27 +37,91 @@ use function count;
'These rules must not pass for {{name}}',
self::TEMPLATE_SOME,
)]
final class Validator extends AllOf
final class Validator extends AbstractRule
{
use CanBindEvaluateRule;
public const TEMPLATE_NONE = '__none__';
public const TEMPLATE_SOME = '__some__';
public static function create(): self
{
return new self();
/** @var array<Validatable> */
private array $rules = [];
/** @var array<string, mixed> */
private array $templates = [];
public function __construct(
private readonly Factory $factory,
private readonly Formatter $formatter,
) {
}
public function check(mixed $input): void
public static function create(Validatable ...$rules): self
{
try {
parent::check($input);
} catch (ValidationException $exception) {
if (count($this->getRules()) == 1 && $this->template) {
$exception->updateTemplate($this->template);
}
$validator = new self(
Factory::getDefaultInstance(),
new StandardFormatter(
new StandardRenderer(
Factory::getDefaultInstance()->getTranslator(),
Factory::getDefaultInstance()->getParameterProcessor(),
)
)
);
$validator->rules = $rules;
throw $exception;
return $validator;
}
public function evaluate(mixed $input): Result
{
return $this->bindEvaluate($this->rule(), $this, $input);
}
public function validate(mixed $input): bool
{
return $this->evaluate($input)->isValid;
}
public function assert(mixed $input): void
{
$result = $this->evaluate($input);
if ($result->isValid) {
return;
}
$templates = $this->templates;
if (count($templates) === 0 && $this->template != null) {
$templates = ['__self__' => $this->template];
}
throw new ValidatorException(
$this->formatter->main($result, $templates),
$this->formatter->full($result, $templates),
$this->formatter->array($result, $templates),
);
}
/** @param array<string, mixed> $templates */
public function setTemplates(array $templates): self
{
$this->templates = $templates;
return $this;
}
/** @return array<Validatable> */
public function getRules(): array
{
return $this->rules;
}
private function rule(): Validatable
{
if (count($this->rules) === 1) {
return current($this->rules);
}
return new AllOf(...$this->rules);
}
/**
@ -67,7 +137,7 @@ final class Validator extends AllOf
*/
public function __call(string $ruleName, array $arguments): self
{
$this->addRule(Factory::getDefaultInstance()->rule($ruleName, $arguments));
$this->rules[] = $this->factory->rule($ruleName, $arguments);
return $this;
}

View file

@ -28,6 +28,15 @@ exceptionMessages(
->key('schema', v::stringType(), true),
true
)
->setTemplates([
'mysql' => [
'user' => 'Value should be a MySQL username',
'host' => '`{{name}}` should be a MySQL host',
],
'postgresql' => [
'schema' => 'You must provide a valid PostgreSQL schema',
],
])
->assert([
'mysql' => [
'host' => 42,
@ -38,22 +47,13 @@ exceptionMessages(
'password' => 42,
],
]);
},
[
'mysql' => [
'user' => 'Value should be a MySQL username',
'host' => '{{input}} should be a MySQL host',
],
'postgresql' => [
'schema' => 'You must provide a valid PostgreSQL schema',
],
]
}
);
?>
--EXPECT--
[
'mysql' => [
'host' => '42 should be a MySQL host',
'host' => '`host` should be a MySQL host',
'user' => 'Value should be a MySQL username',
'password' => 'password must be present',
'schema' => 'schema must be of type string',

View file

@ -1,8 +1,3 @@
--DESCRIPTION--
The previous output was:
default must be of type string
- default must be of type string
--FILE--
<?php
@ -19,9 +14,9 @@ use Respect\Validation\Validator;
require 'vendor/autoload.php';
$validator = new Validator(
$validator = Validator::create(
new Each(
new Validator(
Validator::create(
new Key(
'default',
new OneOf(

View file

@ -30,7 +30,27 @@ exceptionMessages(static function () use ($cars): void {
--EXPECT--
[
'each' => [
'validator.0' => 'All of the required rules must pass for `["manufacturer": "Ford", "model": "not real"]`',
'validator.1' => 'All of the required rules must pass for `["manufacturer": "Honda", "model": "not valid"]`',
'oneOf.3' => [
'allOf.1' => [
'manufacturer' => 'manufacturer must equal "Honda"',
'model' => 'model must be in `["Accord", "Fit"]`',
],
'allOf.2' => [
'manufacturer' => 'manufacturer must equal "Toyota"',
'model' => 'model must be in `["Rav4", "Camry"]`',
],
'allOf.3' => 'model must be in `["F150", "Bronco"]`',
],
'oneOf.4' => [
'allOf.1' => 'model must be in `["Accord", "Fit"]`',
'allOf.2' => [
'manufacturer' => 'manufacturer must equal "Toyota"',
'model' => 'model must be in `["Rav4", "Camry"]`',
],
'allOf.3' => [
'manufacturer' => 'manufacturer must equal "Ford"',
'model' => 'model must be in `["F150", "Bronco"]`',
],
],
],
]

View file

@ -20,7 +20,7 @@ exceptionFullMessage(static function (): void {
- All of the required rules must pass for `stdClass { +$author="foo" }`
- Property title must be present
- Property description must be present
- All of the required rules must pass for author
- author must be of type integer
- author must have a length between 1 and 2
- All of the required rules must pass for author
- author must be of type integer
- author must have a length between 1 and 2
- Property user must be present

View file

@ -23,6 +23,5 @@ $validator->key('schema', v::stringType());
exceptionFullMessage(static fn() => $validator->assert($config));
?>
--EXPECT--
- These rules must pass for Settings
- host must be of type string
- user must be present
- host must be of type string
- user must be present

View file

@ -15,7 +15,5 @@ exceptionFullMessage(static fn() => $validator->assert(['age' => 1]));
exceptionFullMessage(static fn() => $validator->assert(['reference' => 'QSF1234']));
?>
--EXPECT--
- These rules must pass for `["age": 1]`
- reference must be present
- These rules must pass for `["reference": "QSF1234"]`
- age must be present
- reference must be present
- age must be present

View file

@ -10,4 +10,4 @@ use Respect\Validation\Validator as v;
exceptionMessage(static fn() => v::when(v::alwaysInvalid(), v::alwaysValid())->check('foo'));
?>
--EXPECT--
"foo" is not valid
"foo" is not valid after failing to assert that "foo" is always invalid

View file

@ -30,6 +30,6 @@ exceptionMessage(static function () use ($input): void {
->assert($input);
});
?>
--EXPECT--
All of the required rules must pass for "http://www.google.com/search?q=respect.github.com"
All of the required rules must pass for "http://www.google.com/search?q=respect.github.com"
--EXPECTF--
1 must be an array value
scheme must start with "https"

View file

@ -7,8 +7,7 @@
declare(strict_types=1);
use Respect\Validation\Exceptions\NestedValidationException;
use Respect\Validation\Exceptions\ValidationException;
use Respect\Validation\Exceptions\ValidatorException;
use Respect\Validation\Validator;
use Symfony\Component\VarExporter\VarExporter;
@ -19,24 +18,18 @@ function exceptionMessage(callable $callable, string $fallbackMessage = 'No exce
try {
$callable();
echo $fallbackMessage . PHP_EOL;
} catch (ValidationException $exception) {
} catch (ValidatorException $exception) {
echo $exception->getMessage() . PHP_EOL;
}
}
/**
* @param array<string, array<string, string>> $templates
*/
function exceptionMessages(
callable $callable,
array $templates = [],
string $fallbackMessage = 'No exception was thrown'
): void {
function exceptionMessages(callable $callable, string $fallbackMessage = 'No exception was thrown'): void
{
try {
$callable();
echo $fallbackMessage . PHP_EOL;
} catch (NestedValidationException $exception) {
echo VarExporter::export($exception->getMessages($templates)) . PHP_EOL;
} catch (ValidatorException $exception) {
echo VarExporter::export($exception->getMessages()) . PHP_EOL;
}
}
@ -45,7 +38,7 @@ function exceptionFullMessage(callable $callable, string $fallbackMessage = 'No
try {
$callable();
echo $fallbackMessage . PHP_EOL;
} catch (NestedValidationException $exception) {
} catch (ValidatorException $exception) {
echo $exception->getFullMessage() . PHP_EOL;
}
}
@ -62,11 +55,15 @@ function run(array $scenarios): void
$rule->setTemplate($template);
}
if (is_array($template)) {
$rule->setTemplates($template);
}
$fallbackMessage = 'No exception was thrown with: ' . stringify($input);
exceptionMessage(static fn() => $rule->check($input), $fallbackMessage);
exceptionFullMessage(static fn() => $rule->assert($input), $fallbackMessage);
exceptionMessages(static fn() => $rule->assert($input), is_array($template) ? $template : [], $fallbackMessage);
exceptionMessages(static fn() => $rule->assert($input), $fallbackMessage);
echo PHP_EOL;
}
}

View file

@ -23,5 +23,7 @@ exceptionMessage(static fn() => $validator->check(2));
exceptionFullMessage(static fn() => $validator->assert(2));
?>
--EXPECT--
2 must not be positive
- 2 must not be positive
2 must not be an integer number
- These rules must not pass for 2
- 2 must not be an integer number
- 2 must not be positive

View file

@ -17,4 +17,4 @@ exceptionMessage(static function (): void {
});
?>
--EXPECT--
2 must not be positive
2 must not be an integer number

View file

@ -11,12 +11,12 @@ exceptionMessages(
static fn() => v::alnum()
->noWhitespace()
->length(1, 15)
->assert('really messed up screen#name'),
[
'alnum' => '{{name}} must contain only letters and digits',
'noWhitespace' => '{{name}} cannot contain spaces',
'length' => '{{name}} must not have more than 15 chars',
]
->setTemplates([
'alnum' => '{{name}} must contain only letters and digits',
'noWhitespace' => '{{name}} cannot contain spaces',
'length' => '{{name}} must not have more than 15 chars',
])
->assert('really messed up screen#name')
);
?>
--EXPECT--

View file

@ -16,7 +16,11 @@ run([
'With multiple templates' => [
v::allOf(v::stringType(), v::uppercase()),
5,
['allOf' => 'Unfortunately, we cannot template this'],
[
'__self__' => 'Two things are wrong',
'stringType' => 'Template for "stringType"',
'uppercase' => 'Template for "uppercase"',
],
],
]);
?>
@ -26,7 +30,7 @@ Single rule
1 must be of type string
- 1 must be of type string
[
'allOf' => '1 must be of type string',
'stringType' => '1 must be of type string',
]
Two rules
@ -36,15 +40,19 @@ Two rules
- "2" must be of type integer
- "2" must be negative
[
'allOf' => '"2" must be negative',
'intType' => '"2" must be of type integer',
'negative' => '"2" must be negative',
]
Wrapped by "not"
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
3 must not be of type integer
- 3 must not be of type integer
- These rules must not pass for 3
- 3 must not be of type integer
- 3 must not be positive
[
'intType' => '3 must not be of type integer',
'positive' => '3 must not be positive',
]
Wrapping "not"
@ -52,7 +60,7 @@ Wrapping "not"
4 must not be of type integer
- 4 must not be of type integer
[
'allOf' => '4 must not be of type integer',
'intType' => '4 must not be of type integer',
]
With a single template
@ -65,10 +73,11 @@ This is a single template
With multiple templates
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
5 must be of type string
- All of the required rules must pass for 5
- 5 must be of type string
- 5 must be uppercase
Template for "stringType"
- Two things are wrong
- Template for "stringType"
- Template for "uppercase"
[
'allOf' => '5 must be uppercase',
'stringType' => 'Template for "stringType"',
'uppercase' => 'Template for "uppercase"',
]

View file

@ -16,8 +16,8 @@ exceptionFullMessage(static fn() => v::call('array_shift', v::alwaysValid())->as
?>
--EXPECT--
"two words" must not contain whitespace
" some\\thing " must not be valid when executed with `stripslashes(string $string): string`
" something " must not be of type string
`[]` must be valid when executed with `stripslashes(string $string): string`
- "1234" must be of type integer
- 1.2 must not be valid when executed with `is_float(?mixed $value): bool`
- `true` must not be of type boolean
- `INF` must be valid when executed with `array_shift(array &$array): ?mixed`

View file

@ -14,6 +14,6 @@ exceptionFullMessage(static fn() => v::not(v::each(v::dateTime()))->assert(['201
?>
--EXPECT--
Each item in `null` must be valid
Each item in `["2018-10-10"]` must not validate
"2018-10-10" must not be a valid date/time
- Each item in `null` must be valid
- Each item in `["2018-10-10"]` must not validate
- "2018-10-10" must not be a valid date/time

View file

@ -19,13 +19,13 @@ exceptionFullMessage(static fn() => v::keyValue('foo', 'equals', 'bar')->assert(
exceptionFullMessage(static fn() => v::not(v::keyValue('foo', 'equals', 'bar'))->assert(['foo' => 1, 'bar' => 1]));
?>
--EXPECT--
Key "foo" must be present
Key "bar" must be present
foo must be present
bar must be present
"bar" must be valid to validate "foo"
foo must equal "bar"
foo must not equal "bar"
- Key "foo" must be present
- Key "bar" must be present
The value of foo must equal "bar"
The value of foo must not equal "bar"
- foo must be present
- bar must be present
- "bar" must be valid to validate "foo"
- foo must equal "bar"
- foo must not equal "bar"
- The value of foo must equal "bar"
- The value of foo must not equal "bar"

View file

@ -0,0 +1,23 @@
--FILE--
<?php
declare(strict_types=1);
require 'vendor/autoload.php';
use Respect\Validation\Validator as v;
exceptionMessage(static fn() => v::noneOf(v::intType(), v::positive())->check(42));
exceptionMessage(static fn() => v::not(v::noneOf(v::intType(), v::positive()))->check('-1'));
exceptionFullMessage(static fn() => v::noneOf(v::intType(), v::positive())->assert(42));
exceptionFullMessage(static fn() => v::not(v::noneOf(v::intType(), v::positive()))->assert('-1'));
?>
--EXPECT--
42 must not be of type integer
"-1" must be of type integer
- None of these rules must pass for 42
- 42 must not be of type integer
- 42 must not be positive
- All of these rules must pass for "-1"
- "-1" must be of type integer
- "-1" must be positive

View file

@ -19,9 +19,9 @@ exceptionFullMessage(static fn() => v::not(v::optional(v::alpha()))->setName('Na
--EXPECT--
1234 must contain only letters (a-z)
Name must contain only letters (a-z)
The value must not be optional
Name must not be optional
"abcd" must not contain letters (a-z)
Name must not contain letters (a-z)
- 1234 must contain only letters (a-z)
- Name must contain only letters (a-z)
- The value must not be optional
- Name must not be optional
- "abcd" must not contain letters (a-z)
- Name must not contain letters (a-z)

View file

@ -7,23 +7,82 @@ require 'vendor/autoload.php';
use Respect\Validation\Validator as v;
exceptionMessage(static fn() => v::when(v::alwaysValid(), v::intVal())->check('abc'));
exceptionMessage(static fn() => v::when(v::alwaysInvalid(), v::alwaysValid(), v::intVal())->check('def'));
exceptionMessage(static fn() => v::not(v::when(v::alwaysValid(), v::stringVal()))->check('ghi'));
exceptionMessage(static fn() => v::not(v::when(v::alwaysInvalid(), v::alwaysValid(), v::stringVal()))->check('jkl'));
exceptionFullMessage(static fn() => v::when(v::alwaysValid(), v::intVal())->assert('mno'));
exceptionFullMessage(static fn() => v::when(v::alwaysInvalid(), v::alwaysValid(), v::intVal())->assert('pqr'));
exceptionFullMessage(static fn() => v::not(v::when(v::alwaysValid(), v::stringVal()))->assert('stu'));
exceptionFullMessage(static function (): void {
v::not(v::when(v::alwaysInvalid(), v::alwaysValid(), v::stringVal()))->assert('vwx');
});
run([
'When valid use "then"' => [v::when(v::intVal(), v::positive(), v::notEmpty()), -1],
'When invalid use "else"' => [v::when(v::intVal(), v::positive(), v::notEmpty()), ''],
'When valid use "then" using single template' => [
v::when(v::intVal(), v::positive(), v::notEmpty()),
-1,
'That did not go as planned',
],
'When invalid use "else" using single template' => [
v::when(v::intVal(), v::positive(), v::notEmpty()),
'',
'That could have been better',
],
'When valid use "then" using array template' => [
v::when(v::intVal(), v::positive(), v::notEmpty()),
-1,
[
'notEmpty' => '--Never shown--',
'positive' => 'Not positive',
],
],
'When invalid use "else" using array template' => [
v::when(v::intVal(), v::positive(), v::notEmpty()),
'',
[
'notEmpty' => 'Not empty',
'positive' => '--Never shown--',
],
],
]);
?>
--EXPECT--
"abc" must be an integer number
"def" must be an integer number
"ghi" must not be valid
"jkl" must not be valid
- "mno" must be an integer number
- "pqr" must be an integer number
- "stu" must not be valid
- "vwx" must not be valid
When valid use "then"
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
-1 must be positive after asserting that -1 must be an integer number
- -1 must be positive after asserting that -1 must be an integer number
[
'positive' => '-1 must be positive after asserting that -1 must be an integer number',
]
When invalid use "else"
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
The value must not be empty after failing to assert that "" must be an integer number
- The value must not be empty after failing to assert that "" must be an integer number
[
'notEmpty' => 'The value must not be empty after failing to assert that "" must be an integer number',
]
When valid use "then" using single template
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
That did not go as planned
- That did not go as planned
[
'positive' => 'That did not go as planned',
]
When invalid use "else" using single template
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
That could have been better
- That could have been better
[
'notEmpty' => 'That could have been better',
]
When valid use "then" using array template
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
Not positive
- Not positive
[
'positive' => 'Not positive',
]
When invalid use "else" using array template
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
Not empty
- Not empty
[
'notEmpty' => 'Not empty',
]

View file

@ -1,5 +1,3 @@
--TEST--
setTemplate() with multiple validators should use template as full message
--FILE--
<?php

View file

@ -0,0 +1,142 @@
<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Validation\Test\Builders;
use Respect\Validation\Mode;
use Respect\Validation\Result;
use Respect\Validation\Rule;
use Respect\Validation\Test\Rules\Stub;
use Respect\Validation\Validatable;
final class ResultBuilder
{
private bool $isValid = false;
private mixed $input = 'input';
private Mode $mode = Mode::DEFAULT;
private string $template = Rule::TEMPLATE_STANDARD;
/** @var array<string, mixed> */
private array $parameters = [];
private ?string $name = null;
private ?string $id = null;
private Validatable $rule;
private ?Result $nextSibling = null;
/** @var array<Result> */
private array $children = [];
public function __construct()
{
$this->rule = Stub::daze();
}
public function build(): Result
{
return new Result(
$this->isValid,
$this->input,
$this->rule,
$this->template,
$this->parameters,
$this->mode,
$this->name,
$this->id,
$this->nextSibling,
...$this->children
);
}
public function isAlwaysVisible(): self
{
return $this->withCustomTemplate();
}
public function isNotAlwaysVisible(): self
{
$this->template = 'Custom template';
$this->children = [
(new self())->withCustomTemplate()->build(),
(new self())->children((new self())->withCustomTemplate()->build())->build(),
];
return $this;
}
public function template(string $template): self
{
$this->template = $template;
return $this;
}
public function withCustomTemplate(): self
{
$this->template = 'Custom template';
return $this;
}
/** @param array<string, mixed> $parameters */
public function parameters(array $parameters): self
{
$this->parameters = $parameters;
return $this;
}
public function name(string $name): self
{
$this->name = $name;
return $this;
}
public function id(string $id): self
{
$this->id = $id;
return $this;
}
public function input(mixed $input): self
{
$this->input = $input;
return $this;
}
public function children(Result ...$children): self
{
$this->children = $children;
return $this;
}
public function mode(Mode $mode): self
{
$this->mode = $mode;
return $this;
}
public function nextSibling(Result $build): self
{
$this->nextSibling = $build;
return $this;
}
}

View file

@ -0,0 +1,21 @@
<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Validation\Test\Message;
use Respect\Validation\Message\Renderer;
use Respect\Validation\Result;
final class TestingMessageRenderer implements Renderer
{
public function render(Result $result, ?string $template = null): string
{
return $template ?? $result->template;
}
}

View file

@ -71,24 +71,30 @@ abstract class RuleTestCase extends TestCase
public static function assertValidInput(Validatable $rule, mixed $input): void
{
$result = $rule->evaluate($input);
self::assertTrue(
$rule->validate($input),
$result->isValid,
sprintf(
'%s should pass with %s',
'%s should pass with input %s and parameters %s',
substr((string) strrchr($rule::class, '\\'), 1),
stringify($rule->reportError($input)->getParams())
stringify($input),
stringify($result->parameters)
)
);
}
public static function assertInvalidInput(Validatable $rule, mixed $input): void
{
$result = $rule->evaluate($input);
self::assertFalse(
$rule->validate($input),
$result->isValid,
sprintf(
'%s should not pass with %s',
'%s should fail with input %s and parameters %s',
substr((string) strrchr($rule::class, '\\'), 1),
stringify($rule->reportError($input)->getParams())
stringify($input),
stringify($result->parameters)
)
);
}

View file

@ -12,15 +12,17 @@ namespace Respect\Validation\Test\Rules;
use PHPUnit\Framework\Assert;
use ReflectionClass;
use Respect\Validation\Exceptions\ValidationException;
use Respect\Validation\Message\Template;
use Respect\Validation\Rules\AbstractRule;
use Respect\Validation\Test\Exceptions\StubException;
use function array_fill;
use function array_shift;
/**
* @since 2.0.0
*/
#[Template(
'{{name}} must be a valid stub',
'{{name}} must not be a valid stub',
)]
final class Stub extends AbstractRule
{
/** @var array<bool> */

View file

@ -0,0 +1,212 @@
<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Validation\Message\StandardFormatter;
use Respect\Validation\Result;
use Respect\Validation\Test\Builders\ResultBuilder;
trait ArrayProvider
{
use ResultCreator;
/** @return array<string, array{0: Result, 1: array<string, mixed>, 2?: array<string, mixed>}> */
public static function provideForArray(): array
{
return [
'without children, without templates' => [
(new ResultBuilder())->id('only')->template('__parent_original__')->build(),
['only' => '__parent_original__'],
],
'without children, with templates' => [
(new ResultBuilder())->id('only')->build(),
['only' => 'Custom template'],
['only' => 'Custom template'],
],
'with single-level children, without templates' => [
self::singleLevelChildrenMessage(),
[
'1st' => '__1st_original__',
'2nd' => '__2nd_original__',
'3rd' => '__3rd_original__',
],
],
'with single-level children, with templates' => [
self::singleLevelChildrenMessage(),
[
'1st' => '1st custom',
'2nd' => '2nd custom',
'3rd' => '3rd custom',
],
[
'__self__' => 'Parent custom',
'1st' => '1st custom',
'2nd' => '2nd custom',
'3rd' => '3rd custom',
],
],
'with single-level children, with partial templates' => [
self::singleLevelChildrenMessage(),
[
'1st' => '1st custom',
'2nd' => '__2nd_original__',
'3rd' => '3rd custom',
],
[
'1st' => '1st custom',
'3rd' => '3rd custom',
],
],
'with single-level children, with overwritten template' => [
self::singleLevelChildrenMessage(),
['parent' => 'Parent custom'],
['parent' => 'Parent custom'],
],
'with single-nested child, without templates' => [
self::multiLevelChildrenWithSingleNestedChildMessage(),
[
'1st' => '__1st_original__',
'2nd' => '__2nd_1st_original__',
'3rd' => '__3rd_original__',
],
],
'with single-nested child, with templates' => [
self::multiLevelChildrenWithSingleNestedChildMessage(),
[
'1st' => '1st custom',
'2nd' => '2nd > 1st custom',
'3rd' => '3rd custom',
],
[
'__self__' => 'Parent custom',
'1st' => '1st custom',
'2nd' => [
'2nd_1st' => '2nd > 1st custom',
],
'3rd' => '3rd custom',
],
],
'with single-nested child, with partial templates' => [
self::multiLevelChildrenWithSingleNestedChildMessage(),
[
'1st' => '1st custom',
'2nd' => '__2nd_1st_original__',
'3rd' => '3rd custom',
],
[
'__self__' => 'Parent custom',
'1st' => '1st custom',
'2nd' => [
'2nd_2nd' => '2nd > 2nd not shown',
],
'3rd' => '3rd custom',
],
],
'with single-nested child, with overwritten templates' => [
self::multiLevelChildrenWithSingleNestedChildMessage(),
[
'1st' => '1st custom',
'2nd' => '2nd custom',
'3rd' => '3rd custom',
],
[
'1st' => '1st custom',
'2nd' => '2nd custom',
'3rd' => '3rd custom',
],
],
'with multi-nested children, without templates' => [
self::multiLevelChildrenWithMultiNestedChildrenMessage(),
[
'1st' => '__1st_original__',
'2nd' => [
'2nd_1st' => '__2nd_1st_original__',
'2nd_2nd' => '__2nd_2nd_original__',
],
'3rd' => '__3rd_original__',
],
],
'with multi-nested children, with templates' => [
self::multiLevelChildrenWithMultiNestedChildrenMessage(),
[
'1st' => '1st custom',
'2nd' => [
'2nd_1st' => '2nd > 1st custom',
'2nd_2nd' => '2nd > 2nd custom',
],
'3rd' => '3rd custom',
],
[
'1st' => '1st custom',
'2nd' => [
'2nd_1st' => '2nd > 1st custom',
'2nd_2nd' => '2nd > 2nd custom',
],
'3rd' => '3rd custom',
],
],
'with multi-nested children, with partial templates' => [
self::multiLevelChildrenWithMultiNestedChildrenMessage(),
[
'1st' => '1st custom',
'2nd' => [
'2nd_1st' => '__2nd_1st_original__',
'2nd_2nd' => '2nd > 2nd custom',
],
'3rd' => '3rd custom',
],
[
'parent' => [
'__self__' => 'Parent custom',
'1st' => '1st custom',
'2nd' => [
'2nd_2nd' => '2nd > 2nd custom',
],
'3rd' => '3rd custom',
],
],
],
'with children with the same id, without templates' => [
self::singleLevelChildrenWithSameId(),
[
'child.1' => '__1st_original__',
'child.2' => '__2nd_original__',
'child.3' => '__3rd_original__',
],
],
'with children with the same id, with templates' => [
self::singleLevelChildrenWithSameId(),
[
'child.1' => '1st custom',
'child.2' => '2nd custom',
'child.3' => '3rd custom',
],
[
'child.1' => '1st custom',
'child.2' => '2nd custom',
'child.3' => '3rd custom',
],
],
'with children with the same id, with partial templates' => [
self::singleLevelChildrenWithSameId(),
[
'child.1' => '1st custom',
'child.2' => '2nd custom',
'child.3' => '__3rd_original__',
],
[
'child.1' => '1st custom',
'child.2' => '2nd custom',
],
],
];
}
}

View file

@ -0,0 +1,259 @@
<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Validation\Message\StandardFormatter;
use Respect\Validation\Result;
use Respect\Validation\Test\Builders\ResultBuilder;
trait FullProvider
{
use ResultCreator;
/** @return array<string, array{0: Result, 1: string, 2?: array<string, mixed>}> */
public static function provideForFull(): array
{
return [
'without children, without templates' => [
(new ResultBuilder())->template('Message')->build(),
'- Message',
],
'without children, with templates' => [
(new ResultBuilder())->id('foo')->build(),
'- Custom message',
['foo' => 'Custom message'],
],
'with single-level children, without templates' => [
self::singleLevelChildrenMessage(),
<<<MESSAGE
- __parent_original__
- __1st_original__
- __2nd_original__
- __3rd_original__
MESSAGE,
],
'with single-level children, with templates' => [
self::singleLevelChildrenMessage(),
<<<MESSAGE
- Parent custom
- 1st custom
- 2nd custom
- 3rd custom
MESSAGE,
[
'parent' => [
'__self__' => 'Parent custom',
'1st' => '1st custom',
'2nd' => '2nd custom',
'3rd' => '3rd custom',
],
],
],
'with single-level children, with partial templates' => [
self::singleLevelChildrenMessage(),
<<<MESSAGE
- __parent_original__
- 1st custom
- __2nd_original__
- 3rd custom
MESSAGE,
[
'parent' => [
'1st' => '1st custom',
'3rd' => '3rd custom',
],
],
],
'with single-level children, with overwritten template' => [
self::singleLevelChildrenMessage(),
'- Parent custom',
[
'parent' => 'Parent custom',
],
],
'with single-nested child, without templates' => [
self::multiLevelChildrenWithSingleNestedChildMessage(),
<<<MESSAGE
- __parent_original__
- __1st_original__
- __2nd_1st_original__
- __3rd_original__
MESSAGE,
],
'with single-nested child, with templates' => [
self::multiLevelChildrenWithSingleNestedChildMessage(),
<<<MESSAGE
- Parent custom
- 1st custom
- 2nd > 1st custom
- 3rd custom
MESSAGE,
[
'parent' => [
'__self__' => 'Parent custom',
'1st' => '1st custom',
'2nd' => [
'2nd_1st' => '2nd > 1st custom',
],
'3rd' => '3rd custom',
],
],
],
'with single-nested child, with partial templates' => [
self::multiLevelChildrenWithSingleNestedChildMessage(),
<<<MESSAGE
- Parent custom
- 1st custom
- __2nd_1st_original__
- 3rd custom
MESSAGE,
[
'parent' => [
'__self__' => 'Parent custom',
'1st' => '1st custom',
'2nd' => [
'2nd_2nd' => '2nd > 2nd not shown',
],
'3rd' => '3rd custom',
],
],
],
'with single-nested child, with overwritten templates' => [
self::multiLevelChildrenWithSingleNestedChildMessage(),
<<<MESSAGE
- Parent custom
- 1st custom
- 2nd custom
- 3rd custom
MESSAGE,
[
'parent' => [
'__self__' => 'Parent custom',
'1st' => '1st custom',
'2nd' => '2nd custom',
'3rd' => '3rd custom',
],
],
],
'with multi-nested children, without templates' => [
self::multiLevelChildrenWithMultiNestedChildrenMessage(),
<<<MESSAGE
- __parent_original__
- __1st_original__
- __2nd_original__
- __2nd_1st_original__
- __2nd_2nd_original__
- __3rd_original__
MESSAGE,
],
'with multi-nested children, with templates' => [
self::multiLevelChildrenWithMultiNestedChildrenMessage(),
<<<MESSAGE
- Parent custom
- 1st custom
- 2nd custom
- 2nd > 1st custom
- 2nd > 2nd custom
- 3rd custom
MESSAGE,
[
'parent' => [
'__self__' => 'Parent custom',
'1st' => '1st custom',
'2nd' => [
'__self__' => '2nd custom',
'2nd_1st' => '2nd > 1st custom',
'2nd_2nd' => '2nd > 2nd custom',
],
'3rd' => '3rd custom',
],
],
],
'with multi-nested children, with partial templates' => [
self::multiLevelChildrenWithMultiNestedChildrenMessage(),
<<<MESSAGE
- Parent custom
- 1st custom
- __2nd_original__
- __2nd_1st_original__
- 2nd > 2nd custom
- 3rd custom
MESSAGE,
[
'parent' => [
'__self__' => 'Parent custom',
'1st' => '1st custom',
'2nd' => [
'2nd_2nd' => '2nd > 2nd custom',
],
'3rd' => '3rd custom',
],
],
],
'with multi-nested children, with overwritten templates' => [
self::multiLevelChildrenWithMultiNestedChildrenMessage(),
<<<MESSAGE
- Parent custom
- 1st custom
- 2nd custom
- 3rd custom
MESSAGE,
[
'parent' => [
'__self__' => 'Parent custom',
'1st' => '1st custom',
'2nd' => '2nd custom',
'3rd' => '3rd custom',
],
],
],
'with children with the same id, without templates' => [
self::singleLevelChildrenWithSameId(),
<<<MESSAGE
- __parent_original__
- __1st_original__
- __2nd_original__
- __3rd_original__
MESSAGE,
],
'with children with the same id, with templates' => [
self::singleLevelChildrenWithSameId(),
<<<MESSAGE
- Parent custom
- 1st custom
- 2nd custom
- 3rd custom
MESSAGE,
[
'parent' => [
'__self__' => 'Parent custom',
'child.1' => '1st custom',
'child.2' => '2nd custom',
'child.3' => '3rd custom',
],
],
],
'with children with the same id, with partial templates' => [
self::singleLevelChildrenWithSameId(),
<<<MESSAGE
- __parent_original__
- 1st custom
- 2nd custom
- __3rd_original__
MESSAGE,
[
'parent' => [
'child.1' => '1st custom',
'child.2' => '2nd custom',
],
],
],
];
}
}

View file

@ -0,0 +1,73 @@
<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Validation\Message\StandardFormatter;
use Respect\Validation\Result;
use Respect\Validation\Rule;
use Respect\Validation\Test\Builders\ResultBuilder;
trait MainProvider
{
use ResultCreator;
/** @return array<string, array{0: Result, 1: string, 2?: array<string, mixed>}> */
public static function provideForMain(): array
{
return [
'without children, without templates' => [
(new ResultBuilder())->build(),
Rule::TEMPLATE_STANDARD,
],
'without children, with templates' => [
(new ResultBuilder())->build(),
'This is a new template',
[(new ResultBuilder())->build()->id => 'This is a new template'],
],
'with children, without templates' => [
(new ResultBuilder())
->id('parent')->id('parent')->template('__parent_original__')
->children(
(new ResultBuilder())->id('1st')->template('__1st_original__')->build(),
(new ResultBuilder())->id('1st')->template('__2nd_original__')->build(),
)
->build(),
'__1st_original__',
],
'with children, with templates' => [
(new ResultBuilder())->id('parent')->template('__parent_original__')
->children(
(new ResultBuilder())->id('1st')->template('__1st_original__')->build(),
(new ResultBuilder())->id('1st')->template('__2nd_original__')->build(),
)
->build(),
'Parent custom',
[
'__self__' => 'Parent custom',
'1st' => '1st custom',
'2nd' => '2nd custom',
],
],
'with nested children, without templates' => [
(new ResultBuilder())->id('parent')->template('__parent_original__')
->children(
(new ResultBuilder())->id('1st')->template('__1st_original__')
->children(
(new ResultBuilder())->id('1st_1st')->template('__1st_1st_original__')->build(),
(new ResultBuilder())->id('1st_2nd')->template('__1st_2nd_original__')->build(),
)
->build(),
(new ResultBuilder())->id('1st')->template('__2nd_original__')->build(),
)
->build(),
'__1st_1st_original__',
],
];
}
}

View file

@ -0,0 +1,71 @@
<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Validation\Message\StandardFormatter;
use Respect\Validation\Result;
use Respect\Validation\Test\Builders\ResultBuilder;
trait ResultCreator
{
private static function singleLevelChildrenMessage(): Result
{
return (new ResultBuilder())->id('parent')->template('__parent_original__')
->children(
(new ResultBuilder())->id('1st')->template('__1st_original__')->build(),
(new ResultBuilder())->id('2nd')->template('__2nd_original__')->build(),
(new ResultBuilder())->id('3rd')->template('__3rd_original__')->build(),
)
->build();
}
private static function multiLevelChildrenWithSingleNestedChildMessage(): Result
{
return (new ResultBuilder())->id('parent')->template('__parent_original__')
->children(
(new ResultBuilder())->id('1st')->template('__1st_original__')->build(),
(new ResultBuilder())->id('2nd')->template('__2nd_original__')
->children(
(new ResultBuilder())->id('2nd_1st')->template('__2nd_1st_original__')->build()
)
->build(),
(new ResultBuilder())->id('3rd')->template('__3rd_original__')->build(),
)
->build();
}
private static function multiLevelChildrenWithMultiNestedChildrenMessage(): Result
{
return (new ResultBuilder())->id('parent')->template('__parent_original__')
->children(
(new ResultBuilder())->id('1st')->template('__1st_original__')->build(),
(new ResultBuilder())
->id('2nd')
->template('__2nd_original__')
->children(
(new ResultBuilder())->id('2nd_1st')->template('__2nd_1st_original__')->build(),
(new ResultBuilder())->id('2nd_2nd')->template('__2nd_2nd_original__')->build(),
)
->build(),
(new ResultBuilder())->id('3rd')->template('__3rd_original__')->build(),
)
->build();
}
private static function singleLevelChildrenWithSameId(): Result
{
return (new ResultBuilder())->id('parent')->template('__parent_original__')
->children(
(new ResultBuilder())->id('child')->template('__1st_original__')->build(),
(new ResultBuilder())->id('child')->template('__2nd_original__')->build(),
(new ResultBuilder())->id('child')->template('__3rd_original__')->build()
)
->build();
}
}

View file

@ -0,0 +1,109 @@
<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Validation\Message;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Test;
use Respect\Validation\Exceptions\ComponentException;
use Respect\Validation\Message\StandardFormatter\ArrayProvider;
use Respect\Validation\Message\StandardFormatter\FullProvider;
use Respect\Validation\Message\StandardFormatter\MainProvider;
use Respect\Validation\Result;
use Respect\Validation\Test\Builders\ResultBuilder;
use Respect\Validation\Test\Message\TestingMessageRenderer;
use Respect\Validation\Test\TestCase;
use stdClass;
use function Respect\Stringifier\stringify;
use function sprintf;
#[CoversClass(StandardFormatter::class)]
final class StandardFormatterTest extends TestCase
{
use ArrayProvider;
use FullProvider;
use MainProvider;
/** @param array<string, mixed> $templates */
#[Test]
#[DataProvider('provideForMain')]
public function itShouldFormatMainMessage(Result $result, string $expected, array $templates = []): void
{
$renderer = new StandardFormatter(new TestingMessageRenderer());
self::assertSame($expected, $renderer->main($result, $templates));
}
#[Test]
public function itShouldThrowAnExceptionWhenTryingToFormatAsMainAndTemplateIsInvalid(): void
{
$renderer = new StandardFormatter(new TestingMessageRenderer());
$result = (new ResultBuilder())->id('foo')->build();
$template = new stdClass();
$this->expectException(ComponentException::class);
$this->expectExceptionMessage(sprintf('Template for "foo" must be a string, %s given', stringify($template)));
$renderer->main($result, ['foo' => $template]);
}
/** @param array<string, mixed> $templates */
#[Test]
#[DataProvider('provideForFull')]
public function itShouldFormatFullMessage(Result $result, string $expected, array $templates = []): void
{
$renderer = new StandardFormatter(new TestingMessageRenderer());
self::assertSame($expected, $renderer->full($result, $templates));
}
#[Test]
public function itShouldThrowAnExceptionWhenTryingToFormatAsFullAndTemplateIsInvalid(): void
{
$renderer = new StandardFormatter(new TestingMessageRenderer());
$result = (new ResultBuilder())->id('foo')->build();
$template = new stdClass();
$this->expectException(ComponentException::class);
$this->expectExceptionMessage(sprintf('Template for "foo" must be a string, %s given', stringify($template)));
$renderer->full($result, ['foo' => $template]);
}
/**
* @param array<string, mixed> $expected
* @param array<string, mixed> $templates
*/
#[Test]
#[DataProvider('provideForArray')]
public function itShouldFormatArrayMessage(Result $result, array $expected, array $templates = []): void
{
$renderer = new StandardFormatter(new TestingMessageRenderer());
self::assertSame($expected, $renderer->array($result, $templates));
}
#[Test]
public function itShouldThrowAnExceptionWhenTryingToFormatAsArrayAndTemplateIsInvalid(): void
{
$renderer = new StandardFormatter(new TestingMessageRenderer());
$result = (new ResultBuilder())->id('foo')->build();
$template = new stdClass();
$this->expectException(ComponentException::class);
$this->expectExceptionMessage(sprintf('Template for "foo" must be a string, %s given', stringify($template)));
$renderer->array($result, ['foo' => $template]);
}
}

View file

@ -0,0 +1,293 @@
<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Validation\Message;
use Exception;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use Respect\Validation\Exceptions\ComponentException;
use Respect\Validation\Mode;
use Respect\Validation\Test\Builders\ResultBuilder;
use Respect\Validation\Test\Message\Parameter\TestingProcessor;
use Respect\Validation\Test\TestCase;
use function sprintf;
#[CoversClass(StandardRenderer::class)]
final class StandardRendererTest extends TestCase
{
#[Test]
public function itShouldRenderResultWithCustomTemplate(): void
{
$renderer = new StandardRenderer(static fn(string $value) => $value, new TestingProcessor());
$result = (new ResultBuilder())->template('This is my template')->build();
self::assertSame($result->template, $renderer->render($result));
}
#[Test]
public function itShouldRenderResultOverwritingCustomTemplateWhenTemplateIsPassedAsAnArgument(): void
{
$renderer = new StandardRenderer(static fn(string $value) => $value, new TestingProcessor());
$template = 'This is my brand new template';
$result = (new ResultBuilder())->template('This is my template')->build();
self::assertSame($template, $renderer->render($result, $template));
}
#[Test]
public function itShouldRenderResultProcessingParametersInTheTemplate(): void
{
$processor = new TestingProcessor();
$renderer = new StandardRenderer(static fn(string $value) => $value, $processor);
$key = 'foo';
$value = 42;
$result = (new ResultBuilder())
->template('Will replace {{foo}}')
->parameters([$key => $value])
->build();
self::assertSame(
'Will replace ' . $processor->process($key, $value),
$renderer->render($result)
);
}
#[Test]
public function itShouldRenderResultProcessingNameAsSomeParameterInTheTemplate(): void
{
$processor = new TestingProcessor();
$renderer = new StandardRenderer(static fn(string $value) => $value, $processor);
$result = (new ResultBuilder())
->template('Will replace {{name}}')
->name('my name')
->build();
self::assertSame(
'Will replace ' . $processor->process('name', $result->name),
$renderer->render($result)
);
}
#[Test]
public function itShouldRenderResultProcessingInputAsNameWhenResultHasNoName(): void
{
$processor = new TestingProcessor();
$renderer = new StandardRenderer(static fn(string $value) => $value, $processor);
$result = (new ResultBuilder())
->template('Will replace {{name}}')
->input(42)
->build();
self::assertSame(
sprintf(
'Will replace %s',
$processor->process('name', $processor->process('input', $result->input))
),
$renderer->render($result)
);
}
#[Test]
public function itShouldRenderResultProcessingInputAsSomeParameterInTheTemplate(): void
{
$processor = new TestingProcessor();
$renderer = new StandardRenderer(static fn(string $value) => $value, $processor);
$result = (new ResultBuilder())
->template('Will replace {{input}}')
->input(42)
->build();
self::assertSame(
'Will replace ' . $processor->process('input', $result->input),
$renderer->render($result)
);
}
#[Test]
public function itShouldRenderResultNotOverwritingNameParameterWithRealName(): void
{
$parameterNameValue = 'fake name';
$processor = new TestingProcessor();
$renderer = new StandardRenderer(static fn(string $value) => $value, $processor);
$result = (new ResultBuilder())
->template('Will replace {{name}}')
->name('real name')
->parameters(['name' => $parameterNameValue])
->build();
self::assertSame(
'Will replace ' . $processor->process('name', $parameterNameValue),
$renderer->render($result)
);
}
#[Test]
public function itShouldRenderResultNotOverwritingInputParameterWithRealInput(): void
{
$processor = new TestingProcessor();
$renderer = new StandardRenderer(static fn(string $value) => $value, $processor);
$result = (new ResultBuilder())
->template('Will replace {{input}}')
->input('real input')
->parameters(['input' => 'fake input'])
->build();
self::assertSame(
'Will replace ' . $processor->process('input', 'real input'),
$renderer->render($result)
);
}
#[Test]
public function itShouldRenderResultProcessingNonExistingParameters(): void
{
$renderer = new StandardRenderer(static fn(string $value) => $value, new TestingProcessor());
$result = (new ResultBuilder())
->template('Will not replace {{unknown}}')
->build();
self::assertSame('Will not replace {{unknown}}', $renderer->render($result));
}
#[Test]
public function itShouldRenderResultTranslatingTemplate(): void
{
$template = 'This is my template with {{foo}}';
$translations = [$template => 'This is my translated template with {{foo}}'];
$renderer = new StandardRenderer(
static fn(string $value) => $translations[$value],
new TestingProcessor()
);
$result = (new ResultBuilder())
->template($template)
->build();
self::assertSame($translations[$template], $renderer->render($result));
}
#[Test]
public function itShouldThrowAnExceptionWhenTranslatorDoesNotWork(): void
{
$renderer = new StandardRenderer(
static fn(string $value) => throw new Exception(),
new TestingProcessor()
);
$result = (new ResultBuilder())->template('Template')->build();
$this->expectException(ComponentException::class);
$this->expectExceptionMessage(sprintf('Failed to translate "%s"', $result->template));
$renderer->render($result);
}
#[Test]
public function itShouldRenderResultWithTemplateAttachedToRule(): void
{
$processor = new TestingProcessor();
$renderer = new StandardRenderer(static fn(string $value) => $value, $processor);
$result = (new ResultBuilder())->build();
self::assertSame(
sprintf(
'%s must be a valid stub',
$processor->process('name', $processor->process('input', $result->input))
),
$renderer->render($result)
);
}
#[Test]
public function itShouldRenderResultWithTemplateAttachedToRuleWithNegativeMode(): void
{
$processor = new TestingProcessor();
$renderer = new StandardRenderer(static fn(string $value) => $value, $processor);
$result = (new ResultBuilder())->mode(Mode::NEGATIVE)->build();
self::assertSame(
sprintf(
'%s must not be a valid stub',
$processor->process('name', $processor->process('input', $result->input))
),
$renderer->render($result)
);
}
#[Test]
public function itShouldRenderResultWithNonCustomTemplateWhenCannotFindAttachedTemplate(): void
{
$processor = new TestingProcessor();
$renderer = new StandardRenderer(static fn(string $value) => $value, $processor);
$result = (new ResultBuilder())->template('__not_standard__')->mode(Mode::NEGATIVE)->build();
self::assertSame(
$result->template,
$renderer->render($result)
);
}
#[Test]
public function itShouldRenderResultWithItsSiblingsWhenItHasNoCustomTemplate(): void
{
$renderer = new StandardRenderer(static fn(string $value) => $value, new TestingProcessor());
$result = (new ResultBuilder())->template('__1st__')
->nextSibling(
(new ResultBuilder())->template('__2nd__')
->nextSibling(
(new ResultBuilder())->template('__3rd__')->build(),
)
->build(),
)
->build();
$expect = '__1st__ __2nd__ __3rd__';
self::assertSame($expect, $renderer->render($result));
}
#[Test]
public function itShouldRenderResultWithoutItsSiblingsWhenItHasCustomTemplate(): void
{
$template = 'Custom template';
$result = (new ResultBuilder())->template($template)
->nextSibling((new ResultBuilder())->template('and this is a sibling')->build())
->build();
$renderer = new StandardRenderer(static fn(string $value) => $value, new TestingProcessor());
self::assertSame($template, $renderer->render($result));
}
}

View file

@ -62,9 +62,9 @@ final class EachTest extends RuleTestCase
$this->assertEquals(
$e->getMessages(),
[
'stub.0' => '1 must be valid',
'stub.1' => '2 must be valid',
'stub.2' => '3 must be valid',
'stub.0' => '1 must be a valid stub',
'stub.1' => '2 must be a valid stub',
'stub.2' => '3 must be a valid stub',
]
);
}

View file

@ -14,57 +14,29 @@ use PHPUnit\Framework\Attributes\Group;
use Respect\Validation\Test\Rules\Stub;
use Respect\Validation\Test\RuleTestCase;
use function rand;
#[Group('rule')]
#[CoversClass(When::class)]
final class WhenTest extends RuleTestCase
{
/** @return iterable<array{When, mixed}> */
public static function providerForValidInput(): iterable
/** @return array<array{When, mixed}> */
public static function providerForValidInput(): array
{
return [
'all true' => [
new When(Stub::pass(1), Stub::pass(1), Stub::pass(1)),
true,
],
'bool (when = true, then = true, else = false)' => [
new When(Stub::pass(1), Stub::pass(1), Stub::fail(1)),
true,
],
'bool (when = false, then = true, else = true)' => [
new When(Stub::fail(1), Stub::pass(1), Stub::pass(1)),
true,
],
'bool (when = false, then = false, else = true)' => [
new When(Stub::fail(1), Stub::fail(1), Stub::pass(1)),
true,
],
'bool (when = false, then = true, else = null)' => [
new When(Stub::pass(1), Stub::pass(1), null),
true,
],
'when fail, then pass' => [new When(Stub::pass(1), Stub::pass(1)), rand()],
'when pass, then pass, else daze' => [new When(Stub::pass(1), Stub::pass(1), Stub::daze()), rand()],
'when fail, then daze, else pass' => [new When(Stub::fail(1), Stub::daze(), Stub::pass(1)), rand()],
];
}
/** @return iterable<array{When, mixed}> */
public static function providerForInvalidInput(): iterable
/** @return array<array{When, mixed}> */
public static function providerForInvalidInput(): array
{
return [
'bool (when = true, then = false, else = false)' => [
new When(Stub::pass(1), Stub::fail(1), Stub::fail(1)),
false,
],
'bool (when = true, then = false, else = true)' => [
new When(Stub::pass(1), Stub::fail(1), Stub::pass(1)),
false,
],
'bool (when = false, then = false, else = false)' => [
new When(Stub::fail(1), Stub::fail(1), Stub::fail(1)),
false,
],
'bool (when = true, then = false, else = null)' => [
new When(Stub::pass(1), Stub::fail(1), null),
false,
],
'when pass, then fail' => [new When(Stub::pass(1), Stub::fail(1)), rand()],
'when pass, then fail, else daze' => [new When(Stub::pass(1), Stub::fail(1), Stub::daze()), rand()],
'when fail, then daze, else fail' => [new When(Stub::fail(1), Stub::daze(), Stub::fail(1)), rand()],
];
}
}

View file

@ -33,7 +33,7 @@ final class ValidatorTest extends TestCase
#[Test]
public function shouldReturnValidatorInstanceWhenTheNotRuleIsCalledWithArguments(): void
{
$validator = new Validator();
$validator = Validator::create();
self::assertSame($validator, $validator->not($validator->notEmpty()));
}