Update DateTimeDiff to generate results with siblings

Since I updated the validation engine[1], it became possible to create
results with siblings. This commit changes the "DateTimeDiff", allowing
it to create a result with a sibling when possible. That will improve
the clarity of the error messages.

While at it, I noticed that we were not translating the type of
interval, so I fixed that and improved the documentation.

[1]: 238f2d506a
This commit is contained in:
Henrique Moody 2024-11-08 09:58:53 +01:00
commit d5cf1311c8
No known key found for this signature in database
GPG key ID: 221E9281655813A6
6 changed files with 192 additions and 133 deletions

View file

@ -2,6 +2,7 @@
- `DateTimeDiff(string $type, Rule $rule)`
- `DateTimeDiff(string $type, Rule $rule, string $format)`
- `DateTimeDiff(string $type, Rule $rule, string $format, DateTimeImmutable $now)`
Validates the difference of date/time against a specific rule.
@ -30,20 +31,62 @@ The supported types are:
## Templates
`DateTimeDiff::TEMPLATE_STANDARD`
The first two templates serve as message suffixes:
| Mode | Template |
|------------|--------------------------------------------------------------|
| `default` | The number of {{type|raw}} between {{now|raw}} and |
| `inverted` | The number of {{type|raw}} between {{now|raw}} and |
```php
v::dateTimeDiff('years', v::equals(2))->assert('1 year ago')
// The number of years between now and 1 year ago must be equal to 2
v::not(v::dateTimeDiff('years', v::lessThan(8)))->assert('7 year ago')
// The number of years between now and 7 year ago must not be less than 8
```
### `DateTimeDiff::TEMPLATE_STANDARD`
Used when `$format` and `$now` are not defined.
| Mode | Template |
|------------|---------------------------------------------------|
| `default` | The number of {{type|trans}} between now and |
| `inverted` | The number of {{type|trans}} between now and |
### `DateTimeDiff::TEMPLATE_CUSTOMIZED`
Used when `$format` or `$now` are defined.
| Mode | Template |
|------------|----------------------------------------------------------------|
| `default` | The number of {{type|trans}} between {{now|raw}} and |
| `inverted` | The number of {{type|trans}} between {{now|raw}} and |
### `DateTimeDiff::TEMPLATE_WRONG_FORMAT`
Used when the input cannot be parsed with the given format.
| Mode | Template |
|------------|---------------------------------------------------------------------------------------------------------------|
| `default` | For comparison with {{now|raw}}, {{name}} must be a valid datetime in the format {{sample|raw}} |
| `inverted` | For comparison with {{now|raw}}, {{name}} must not be a valid datetime in the format {{sample|raw}} |
## Template placeholders
| Placeholder | Description |
|-------------|------------------------------------------------------------------|
| `name` | The validated input or the custom validator name (if specified). |
| `now` | |
| `type` | |
| `now` | The date and time that is considered as now. |
| `sample` | A sample of the datetime. |
| `type` | The type of interval (years, months, etc.). |
## Caveats
When using custom templates, the key must be `dateTimeDiff` + name of the rule you passed, for example:
```php
v::dateTimeDiff('years', v::equals(2))->assert('1 year ago', [
'dateTimeDiffEquals' => 'Please enter a date that is 2 years ago'
]);
// Please enter a date that is 2 years ago.
```
## Categorization

View file

@ -1,55 +0,0 @@
<?php
/*
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
* SPDX-License-Identifier: MIT
*/
declare(strict_types=1);
namespace Respect\Validation\Helpers;
use Respect\Validation\Rule;
use Respect\Validation\Rules\Core\Composite;
use Respect\Validation\Rules\Not;
use Respect\Validation\Validator;
use Throwable;
use function count;
trait CanExtractRules
{
private function extractSiblingSuitableRule(Rule $rule, Throwable $throwable): Rule
{
$this->assertSingleRule($rule, $throwable);
if ($rule instanceof Validator) {
return $rule->getRules()[0];
}
return $rule;
}
private function assertSingleRule(Rule $rule, Throwable $throwable): void
{
if ($rule instanceof Not) {
$this->assertSingleRule($rule->getRule(), $throwable);
return;
}
if ($rule instanceof Validator) {
if (count($rule->getRules()) !== 1) {
throw $throwable;
}
$this->assertSingleRule($rule->getRules()[0], $throwable);
return;
}
if ($rule instanceof Composite) {
throw $throwable;
}
}
}

View file

@ -12,30 +12,42 @@ namespace Respect\Validation\Rules;
use DateTimeImmutable;
use DateTimeInterface;
use Respect\Validation\Exceptions\InvalidRuleConstructorException;
use Respect\Validation\Helpers\CanExtractRules;
use Respect\Validation\Helpers\CanValidateDateTime;
use Respect\Validation\Message\Template;
use Respect\Validation\Result;
use Respect\Validation\Rule;
use Respect\Validation\Rules\Core\Standard;
use function array_map;
use function in_array;
use function ucfirst;
#[Template(
'The number of {{type|raw}} between {{now|raw}} and',
'The number of {{type|raw}} between {{now|raw}} and',
'The number of {{type|trans}} between now and',
'The number of {{type|trans}} between now and',
self::TEMPLATE_STANDARD
)]
#[Template(
'The number of {{type|trans}} between {{now|raw}} and',
'The number of {{type|trans}} between {{now|raw}} and',
self::TEMPLATE_CUSTOMIZED
)]
#[Template(
'For comparison with {{now|raw}}, {{name}} must be a valid datetime in the format {{sample|raw}}',
'For comparison with {{now|raw}}, {{name}} must not be a valid datetime in the format {{sample|raw}}',
self::TEMPLATE_WRONG_FORMAT
)]
final class DateTimeDiff extends Standard
{
use CanValidateDateTime;
use CanExtractRules;
private readonly Rule $rule;
public const TEMPLATE_CUSTOMIZED = '__customized__';
public const TEMPLATE_WRONG_FORMAT = '__wrong_format__';
/** @param "years"|"months"|"days"|"hours"|"minutes"|"seconds"|"microseconds" $type */
public function __construct(
private readonly string $type,
Rule $rule,
private readonly Rule $rule,
private readonly ?string $format = null,
private readonly ?DateTimeImmutable $now = null,
) {
@ -47,27 +59,44 @@ final class DateTimeDiff extends Standard
$availableTypes
);
}
$this->rule = $this->extractSiblingSuitableRule(
$rule,
new InvalidRuleConstructorException('DateTimeDiff must contain exactly one rule')
);
}
public function evaluate(mixed $input): Result
{
$now = $this->now ?? new DateTimeImmutable();
$compareTo = $this->createDateTimeObject($input);
if ($compareTo === null) {
return Result::failed($input, $this);
$parameters = ['sample' => $now->format($this->format ?? 'c'), 'now' => $this->nowParameter($now)];
return Result::failed($input, $this, $parameters, self::TEMPLATE_WRONG_FORMAT)
->withId('dateTimeDiff' . ucfirst($this->rule->evaluate($input)->id));
}
$now = $this->now ?? new DateTimeImmutable();
$nextSibling = $this->rule
->evaluate($this->comparisonValue($now, $compareTo))
->withNameIfMissing($input instanceof DateTimeInterface ? $input->format('c') : $input);
return $this->enrichResult(
$this->nowParameter($now),
$input,
$this->rule->evaluate($this->comparisonValue($now, $compareTo))
);
}
$parameters = ['type' => $this->type, 'now' => $this->nowParameter($now)];
private function enrichResult(string $now, mixed $input, Result $result): Result
{
$name = $input instanceof DateTimeInterface ? $input->format('c') : $input;
return (new Result($nextSibling->isValid, $input, $this, $parameters))->withNextSibling($nextSibling);
if (!$result->isSiblingCompatible()) {
return $result
->withNameIfMissing($name)
->withChildren(
...array_map(fn(Result $child) => $this->enrichResult($now, $input, $child), $result->children)
);
}
$parameters = ['type' => $this->type, 'now' => $now];
$template = $now === 'now' ? self::TEMPLATE_STANDARD : self::TEMPLATE_CUSTOMIZED;
return (new Result($result->isValid, $input, $this, $parameters, $template, id: $result->id))
->withPrefixedId('dateTimeDiff')
->withNextSibling($result->withNameIfMissing($name));
}
private function comparisonValue(DateTimeInterface $now, DateTimeInterface $compareTo): int|float

View file

@ -13,9 +13,33 @@ run([
'With $type = "minutes"' => [v::dateTimeDiff('minutes', v::equals(6)), '5 minutes ago'],
'With $type = "microseconds"' => [v::dateTimeDiff('microseconds', v::equals(7)), '6 microseconds ago'],
'With custom $format' => [v::dateTimeDiff('years', v::lessThan(8), 'd/m/Y'), '09/12/1988'],
'With input in incorrect $format' => [v::dateTimeDiff('years', v::equals(2), 'Y-m-d'), '1 year ago'],
'With custom $now' => [v::dateTimeDiff('years', v::lessThan(9), null, new DateTimeImmutable()), '09/12/1988'],
'With custom template' => [v::dateTimeDiff('years', v::equals(2)->setTemplate('Custom template')), '1 year ago'],
'Wrapped by "not"' => [v::not(v::dateTimeDiff('years', v::lessThan(8))), '7 year ago'],
'Wrapping "not"' => [v::dateTimeDiff('years', v::not(v::lessThan(9))), '8 year ago'],
'Wrapped with custom template' => [
v::dateTimeDiff('years', v::equals(2)->setTemplate('Wrapped with custom template')),
'1 year ago',
],
'Wrapper with custom template' => [
v::dateTimeDiff('years', v::equals(2))->setTemplate('Wrapper with custom template'),
'1 year ago',
],
'Not a sibling compatible' => [
v::dateTimeDiff('years', v::primeNumber()->between(2, 5)),
'1 year ago',
],
'Not a sibling compatible with templates' => [
v::dateTimeDiff('years', v::primeNumber()->between(2, 5)),
'1 year ago',
[
'dateTimeDiff' => [
'primeNumber' => 'Interval must be a valid prime number',
'between' => 'Interval must be between 2 and 5',
],
],
],
]);
?>
--EXPECTF--
@ -24,7 +48,7 @@ With $type = "years"
The number of years between now and 1 year ago must be equal to 2
- The number of years between now and 1 year ago must be equal to 2
[
'dateTimeDiff' => 'The number of years between now and 1 year ago must be equal to 2',
'dateTimeDiffEquals' => 'The number of years between now and 1 year ago must be equal to 2',
]
With $type = "months"
@ -32,7 +56,7 @@ With $type = "months"
The number of months between now and 2 months ago must be equal to 3
- The number of months between now and 2 months ago must be equal to 3
[
'dateTimeDiff' => 'The number of months between now and 2 months ago must be equal to 3',
'dateTimeDiffEquals' => 'The number of months between now and 2 months ago must be equal to 3',
]
With $type = "days"
@ -40,7 +64,7 @@ With $type = "days"
The number of days between now and 3 days ago must be equal to 4
- The number of days between now and 3 days ago must be equal to 4
[
'dateTimeDiff' => 'The number of days between now and 3 days ago must be equal to 4',
'dateTimeDiffEquals' => 'The number of days between now and 3 days ago must be equal to 4',
]
With $type = "hours"
@ -48,7 +72,7 @@ With $type = "hours"
The number of hours between now and 4 hours ago must be equal to 5
- The number of hours between now and 4 hours ago must be equal to 5
[
'dateTimeDiff' => 'The number of hours between now and 4 hours ago must be equal to 5',
'dateTimeDiffEquals' => 'The number of hours between now and 4 hours ago must be equal to 5',
]
With $type = "minutes"
@ -56,7 +80,7 @@ With $type = "minutes"
The number of minutes between now and 5 minutes ago must be equal to 6
- The number of minutes between now and 5 minutes ago must be equal to 6
[
'dateTimeDiff' => 'The number of minutes between now and 5 minutes ago must be equal to 6',
'dateTimeDiffEquals' => 'The number of minutes between now and 5 minutes ago must be equal to 6',
]
With $type = "microseconds"
@ -64,7 +88,7 @@ With $type = "microseconds"
The number of microseconds between now and 6 microseconds ago must be equal to 7
- The number of microseconds between now and 6 microseconds ago must be equal to 7
[
'dateTimeDiff' => 'The number of microseconds between now and 6 microseconds ago must be equal to 7',
'dateTimeDiffEquals' => 'The number of microseconds between now and 6 microseconds ago must be equal to 7',
]
With custom $format
@ -72,7 +96,15 @@ With custom $format
The number of years between %d/%d/%d and 09/12/1988 must be less than 8
- The number of years between %d/%d/%d and 09/12/1988 must be less than 8
[
'dateTimeDiff' => 'The number of years between %d/%d/%d and 09/12/1988 must be less than 8',
'dateTimeDiffLessThan' => 'The number of years between %d/%d/%d and 09/12/1988 must be less than 8',
]
With input in incorrect $format
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
For comparison with %d-%d-%d, "1 year ago" must be a valid datetime in the format 2024-12-09
- For comparison with %d-%d-%d, "1 year ago" must be a valid datetime in the format 2024-12-09
[
'dateTimeDiffEquals' => 'For comparison with %d-%d-%d, "1 year ago" must be a valid datetime in the format 2024-12-09',
]
With custom $now
@ -80,7 +112,15 @@ With custom $now
The number of years between %d-%d-%d %d:%d:%d.%d and 09/12/1988 must be less than 9
- The number of years between %d-%d-%d %d:%d:%d.%d and 09/12/1988 must be less than 9
[
'dateTimeDiff' => 'The number of years between %d-%d-%d %d:%d:%d.%d and 09/12/1988 must be less than 9',
'dateTimeDiffLessThan' => 'The number of years between %d-%d-%d %d:%d:%d.%d and 09/12/1988 must be less than 9',
]
With custom template
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
Custom template
- Custom template
[
'equals' => 'Custom template',
]
Wrapped by "not"
@ -88,7 +128,7 @@ Wrapped by "not"
The number of years between now and 7 year ago must not be less than 8
- The number of years between now and 7 year ago must not be less than 8
[
'notDateTimeDiff' => 'The number of years between now and 7 year ago must not be less than 8',
'notDateTimeDiffLessThan' => 'The number of years between now and 7 year ago must not be less than 8',
]
Wrapping "not"
@ -96,5 +136,45 @@ Wrapping "not"
The number of years between now and 8 year ago must not be less than 9
- The number of years between now and 8 year ago must not be less than 9
[
'dateTimeDiff' => 'The number of years between now and 8 year ago must not be less than 9',
'dateTimeDiffNotLessThan' => 'The number of years between now and 8 year ago must not be less than 9',
]
Wrapped with custom template
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
Wrapped with custom template
- Wrapped with custom template
[
'equals' => 'Wrapped with custom template',
]
Wrapper with custom template
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
Wrapper with custom template
- Wrapper with custom template
[
'dateTimeDiffEquals' => 'Wrapper with custom template',
]
Not a sibling compatible
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
The number of years between now and 1 year ago must be a prime number
- All of the required rules must pass for 1 year ago
- The number of years between now and 1 year ago must be a prime number
- The number of years between now and 1 year ago must be between 2 and 5
[
'__root__' => 'All of the required rules must pass for 1 year ago',
'dateTimeDiffPrimeNumber' => 'The number of years between now and 1 year ago must be a prime number',
'dateTimeDiffBetween' => 'The number of years between now and 1 year ago must be between 2 and 5',
]
Not a sibling compatible with templates
⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺
The number of years between now and 1 year ago must be a prime number
- All of the required rules must pass for 1 year ago
- The number of years between now and 1 year ago must be a prime number
- The number of years between now and 1 year ago must be between 2 and 5
[
'__root__' => 'All of the required rules must pass for 1 year ago',
'dateTimeDiffPrimeNumber' => 'The number of years between now and 1 year ago must be a prime number',
'dateTimeDiffBetween' => 'The number of years between now and 1 year ago must be between 2 and 5',
]

View file

@ -15,12 +15,17 @@ ValidatorDefaults::setTranslator(new ArrayTranslator([
'{{name}} must be a valid telephone number for country {{countryName|trans}}'
=> '{{name}} deve ser um número de telefone válido para o país {{countryName|trans}}',
'United States' => 'Estados Unidos',
'years' => 'anos',
'The number of {{type|trans}} between now and' => 'O número de {{type|trans}} entre agora e',
'{{name}} must be equal to {{compareTo}}' => '{{name}} deve ser igual a {{compareTo}}',
]));
exceptionFullMessage(static fn() => Validator::stringType()->lengthBetween(2, 15)->phone('US')->assert(0));
exceptionMessage(static fn() => v::dateTimeDiff('years', v::equals(2))->assert('1972-02-09'));
?>
--EXPECT--
- Todas as regras requeridas devem passar para 0
- 0 must be a string
- O comprimento de 0 deve possuir de 2 a 15 caracteres
- 0 deve ser um número de telefone válido para o país Estados Unidos
- 0 deve ser um número de telefone válido para o país Estados Unidos
O número de anos entre agora e 1972-02-09 deve ser igual a 2

View file

@ -11,14 +11,11 @@ namespace Respect\Validation\Rules;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\Attributes\Group;
use PHPUnit\Framework\Attributes\Test;
use Respect\Validation\Exceptions\InvalidRuleConstructorException;
use Respect\Validation\Rule;
use Respect\Validation\Test\Rules\Stub;
use Respect\Validation\Test\RuleTestCase;
use Respect\Validation\Validator;
use function array_map;
use function iterator_to_array;
@ -37,46 +34,6 @@ final class DateTimeDiffTest extends RuleTestCase
new DateTimeDiff('invalid', Stub::daze());
}
#[Test]
#[DataProvider('providerForSiblingSuitableRules')]
public function isShouldAcceptRulesThatCanBeAddedAsNextSibling(Rule $rule): void
{
$this->expectNotToPerformAssertions();
new DateTimeDiff('years', $rule);
}
#[Test]
#[DataProvider('providerForSiblingUnsuitableRules')]
public function isShouldNotAcceptRulesThatCanBeAddedAsNextSibling(Rule $rule): void
{
$this->expectException(InvalidRuleConstructorException::class);
$this->expectExceptionMessage('DateTimeDiff must contain exactly one rule');
new DateTimeDiff('years', $rule);
}
/** @return array<array{Rule}> */
public static function providerForSiblingSuitableRules(): array
{
return [
'single' => [Stub::daze()],
'single in validator' => [Validator::create(Stub::daze())],
'single wrapped by "Not"' => [new Not(Stub::daze())],
'validator wrapping not, wrapping single' => [Validator::create(new Not(Stub::daze()))],
'not wrapping validator, wrapping single' => [new Not(Validator::create(Stub::daze()))],
];
}
/** @return array<array{Rule}> */
public static function providerForSiblingUnsuitableRules(): array
{
return [
'double wrapped by validator' => [Validator::create(Stub::daze(), Stub::daze())],
'double wrapped by validator, wrapped by "Not"' => [new Not(Validator::create(Stub::daze(), Stub::daze()))],
];
}
/** @return array<string|int, array{DateTimeDiff, mixed}> */
public static function providerForValidInput(): array
{