From 9e631a8ba18329ecae419fe4f165b656bfb5d406 Mon Sep 17 00:00:00 2001 From: Henrique Moody Date: Mon, 25 Mar 2024 21:18:17 +0100 Subject: [PATCH 1/9] Create "DateTimeDiff" rule Signed-off-by: Henrique Moody --- docs/rules/DateTimeDiff.md | 54 +++++++++ library/Helpers/CanExtractRules.php | 37 ++++++ library/Helpers/CanValidateDateTime.php | 14 ++- library/Rules/Core/Wrapper.php | 5 + library/Rules/DateTimeDiff.php | 134 ++++++++++++++++++++++ tests/integration/rules/dateTimeDiff.phpt | 105 +++++++++++++++++ tests/unit/Rules/DateTimeDiffTest.php | 107 +++++++++++++++++ 7 files changed, 452 insertions(+), 4 deletions(-) create mode 100644 docs/rules/DateTimeDiff.md create mode 100644 library/Rules/DateTimeDiff.php create mode 100644 tests/integration/rules/dateTimeDiff.phpt create mode 100644 tests/unit/Rules/DateTimeDiffTest.php diff --git a/docs/rules/DateTimeDiff.md b/docs/rules/DateTimeDiff.md new file mode 100644 index 000000000..e9a6c43de --- /dev/null +++ b/docs/rules/DateTimeDiff.md @@ -0,0 +1,54 @@ +# DateTimeDiff + +- `DateTimeDiff(Validatable $rule)` +- `DateTimeDiff(Validatable $rule, string $type)` +- `DateTimeDiff(Validatable $rule, string $type, string $format)` + +Validates the difference of date/time against a specific rule. + +The `$format` argument should follow PHP's [date()][] function. When the `$format` is not given, this rule accepts +[Supported Date and Time Formats][] by PHP (see [strtotime()][]). + +```php +v::dateTimeDiff(v::equal(7))->validate('7 years ago - 1 minute'); // true +v::dateTimeDiff(v::equal(7))->validate('7 years ago + 1 minute'); // false + +v::dateTimeDiff(v::greaterThan(18), 'years', 'd/m/Y')->validate('09/12/1990'); // true +v::dateTimeDiff(v::greaterThan(18), 'years', 'd/m/Y')->validate('09/12/2023'); // false + +v::dateTimeDiff(v::between(1, 18), 'months')->validate('5 months ago'); // true +``` + +The supported types are: + +* `years` +* `months` +* `days` +* `hours` +* `minutes` +* `seconds` +* `microseconds` + +## Categorization + +- Date and Time + +## Changelog + +| Version | Description | +| ------: |--------------------------------------------| +| 3.0.0 | Created from `Age`, `MinAge`, and `MaxAge` | + +*** +See also: + +- [Date](Date.md) +- [DateTime](DateTime.md) +- [Max](Max.md) +- [Min](Min.md) +- [Time](Time.md) + +[date()]: http://php.net/date +[DateTimeInterface]: http://php.net/DateTimeInterface +[strtotime()]: http://php.net/strtotime +[Supported Date and Time Formats]: http://php.net/datetime.formats diff --git a/library/Helpers/CanExtractRules.php b/library/Helpers/CanExtractRules.php index 56388511e..ac57b6031 100644 --- a/library/Helpers/CanExtractRules.php +++ b/library/Helpers/CanExtractRules.php @@ -10,8 +10,11 @@ namespace Respect\Validation\Helpers; use Respect\Validation\Exceptions\ComponentException; +use Respect\Validation\Rules\Core\Composite; +use Respect\Validation\Rules\Not; use Respect\Validation\Validatable; use Respect\Validation\Validator; +use Throwable; use function array_map; use function count; @@ -37,6 +40,40 @@ private function extractSingle(Validatable $rule, string $class): Validatable return $rule; } + private function extractSiblingSuitableRule(Validatable $rule, Throwable $throwable): Validatable + { + $this->assertSingleRule($rule, $throwable); + + if ($rule instanceof Validator) { + return $rule->getRules()[0]; + } + + return $rule; + } + + private function assertSingleRule(Validatable $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; + } + } + /** * @param array $rules * diff --git a/library/Helpers/CanValidateDateTime.php b/library/Helpers/CanValidateDateTime.php index d916bb22d..aebc0776c 100644 --- a/library/Helpers/CanValidateDateTime.php +++ b/library/Helpers/CanValidateDateTime.php @@ -21,10 +21,7 @@ trait CanValidateDateTime { private function isDateTime(string $format, string $value): bool { - $exceptionalFormats = [ - 'c' => 'Y-m-d\TH:i:sP', - 'r' => 'D, d M Y H:i:s O', - ]; + $exceptionalFormats = $this->getExceptionalFormats(); $format = $exceptionalFormats[$format] ?? $format; @@ -75,4 +72,13 @@ private function isDateInformation(array $info): bool return checkdate($info['month'] ?: 1, 1, $info['year'] ?: 1); } + + /** @return array */ + private function getExceptionalFormats(): array + { + return [ + 'c' => 'Y-m-d\TH:i:sP', + 'r' => 'D, d M Y H:i:s O', + ]; + } } diff --git a/library/Rules/Core/Wrapper.php b/library/Rules/Core/Wrapper.php index b6ce71f7d..78f1c0760 100644 --- a/library/Rules/Core/Wrapper.php +++ b/library/Rules/Core/Wrapper.php @@ -50,4 +50,9 @@ public function setTemplate(string $template): static return $this; } + + public function getRule(): Validatable + { + return $this->rule; + } } diff --git a/library/Rules/DateTimeDiff.php b/library/Rules/DateTimeDiff.php new file mode 100644 index 000000000..cbca38ab9 --- /dev/null +++ b/library/Rules/DateTimeDiff.php @@ -0,0 +1,134 @@ + + * SPDX-License-Identifier: MIT + */ + +declare(strict_types=1); + +namespace Respect\Validation\Rules; + +use DateTimeImmutable; +use DateTimeInterface; +use Respect\Validation\Exceptions\InvalidRuleConstructorException; +use Respect\Validation\Helpers\CanBindEvaluateRule; +use Respect\Validation\Helpers\CanExtractRules; +use Respect\Validation\Helpers\CanValidateDateTime; +use Respect\Validation\Message\Template; +use Respect\Validation\Result; +use Respect\Validation\Rules\Core\Standard; +use Respect\Validation\Validatable; + +use function in_array; +use function is_scalar; + +#[Template( + 'The number of {{type|raw}} between {{now|raw}} and', + 'The number of {{type|raw}} between {{now|raw}} and', +)] +final class DateTimeDiff extends Standard +{ + use CanBindEvaluateRule; + use CanValidateDateTime; + use CanExtractRules; + + private readonly Validatable $rule; + + /** @param "years"|"months"|"days"|"hours"|"minutes"|"seconds"|"microseconds" $type */ + public function __construct( + Validatable $rule, + private readonly string $type = 'years', + private readonly ?string $format = null, + private readonly ?DateTimeImmutable $now = null, + ) { + $availableTypes = ['years', 'months', 'days', 'hours', 'minutes', 'seconds', 'microseconds']; + if (!in_array($this->type, $availableTypes, true)) { + throw new InvalidRuleConstructorException( + '"%s" is not a valid type of age (Available: %s)', + $this->type, + $availableTypes + ); + } + $this->rule = $this->extractSiblingSuitableRule( + $rule, + new InvalidRuleConstructorException('DateTimeDiff must contain exactly one rule') + ); + } + + public function evaluate(mixed $input): Result + { + $dateTime = $this->createDateTimeObject($input); + if ($dateTime === null) { + return Result::failed($input, $this); + } + + $dateTimeResult = $this->bindEvaluate(new DateTime($this->format), $this, $input); + if (!$dateTimeResult->isValid) { + return $dateTimeResult; + } + + $now = $this->now ?? new DateTimeImmutable(); + $dateTime = $this->createDateTimeObject($input); + if ($dateTime === null) { + return Result::failed($input, $this); + } + + $nextSibling = $this->rule + ->evaluate($this->comparisonValue($now, $dateTime)) + ->withNameIfMissing($input instanceof DateTimeInterface ? $input->format('c') : $input); + + $parameters = ['type' => $this->type, 'now' => $this->nowParameter($now)]; + + return (new Result($nextSibling->isValid, $input, $this, $parameters))->withNextSibling($nextSibling); + } + + private function comparisonValue(DateTimeInterface $now, DateTimeInterface $compareTo): int|float + { + return match ($this->type) { + 'years' => $compareTo->diff($now)->y, + 'months' => $compareTo->diff($now)->m, + 'days' => $compareTo->diff($now)->d, + 'hours' => $compareTo->diff($now)->h, + 'minutes' => $compareTo->diff($now)->i, + 'seconds' => $compareTo->diff($now)->s, + 'microseconds' => $compareTo->diff($now)->f, + }; + } + + private function nowParameter(DateTimeInterface $now): string + { + if ($this->format === null && $this->now === null) { + return 'now'; + } + + if ($this->format === null) { + return $now->format('Y-m-d H:i:s.u'); + } + + return $now->format($this->format); + } + + private function createDateTimeObject(mixed $input): ?DateTimeInterface + { + if ($input instanceof DateTimeInterface) { + return $input; + } + + if (!is_scalar($input)) { + return null; + } + + if ($this->format === null) { + return new DateTimeImmutable((string) $input); + } + + $format = $this->getExceptionalFormats()[$this->format] ?? $this->format; + $dateTime = DateTimeImmutable::createFromFormat($format, (string) $input); + if ($dateTime === false) { + return null; + } + + return $dateTime; + } +} diff --git a/tests/integration/rules/dateTimeDiff.phpt b/tests/integration/rules/dateTimeDiff.phpt new file mode 100644 index 000000000..4a7c37225 --- /dev/null +++ b/tests/integration/rules/dateTimeDiff.phpt @@ -0,0 +1,105 @@ +--FILE-- + [v::dateTimeDiff(v::equals(2)), '1 year ago'], + 'With $type = "months"' => [v::dateTimeDiff(v::equals(3), 'months'), '2 months ago'], + 'With $type = "days"' => [v::dateTimeDiff(v::equals(4), 'days'), '3 days ago'], + 'With $type = "hours"' => [v::dateTimeDiff(v::equals(5), 'hours'), '4 hours ago'], + 'With $type = "minutes"' => [v::dateTimeDiff(v::equals(6), 'minutes'), '5 minutes ago'], + 'With $type = "microseconds"' => [v::dateTimeDiff(v::equals(7), 'microseconds'), '6 microseconds ago'], + 'With custom $format' => [v::dateTimeDiff(v::lessThan(8), 'years', 'd/m/Y'), '09/12/1988'], + 'With custom $now' => [v::dateTimeDiff(v::lessThan(9), 'years', null, new DateTimeImmutable()), '09/12/1988'], + 'Wrapped by "not"' => [v::not(v::dateTimeDiff(v::lessThan(8))), '7 year ago'], + 'Wrapping "not"' => [v::dateTimeDiff(v::not(v::lessThan(9))), '8 year ago'], +]); +?> +--EXPECTF-- +Without customizations +⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺ +The number of years between now and 1 year ago must equal 2 +- The number of years between now and 1 year ago must equal 2 +[ + 'dateTimeDiff' => 'The number of years between now and 1 year ago must equal 2', +] + +With $type = "months" +⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺ +The number of months between now and 2 months ago must equal 3 +- The number of months between now and 2 months ago must equal 3 +[ + 'dateTimeDiff' => 'The number of months between now and 2 months ago must equal 3', +] + +With $type = "days" +⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺ +The number of days between now and 3 days ago must equal 4 +- The number of days between now and 3 days ago must equal 4 +[ + 'dateTimeDiff' => 'The number of days between now and 3 days ago must equal 4', +] + +With $type = "hours" +⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺ +The number of hours between now and 4 hours ago must equal 5 +- The number of hours between now and 4 hours ago must equal 5 +[ + 'dateTimeDiff' => 'The number of hours between now and 4 hours ago must equal 5', +] + +With $type = "minutes" +⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺ +The number of minutes between now and 5 minutes ago must equal 6 +- The number of minutes between now and 5 minutes ago must equal 6 +[ + 'dateTimeDiff' => 'The number of minutes between now and 5 minutes ago must equal 6', +] + +With $type = "microseconds" +⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺ +The number of microseconds between now and 6 microseconds ago must equal 7 +- The number of microseconds between now and 6 microseconds ago must equal 7 +[ + 'dateTimeDiff' => 'The number of microseconds between now and 6 microseconds ago must equal 7', +] + +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', +] + +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', +] + +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 +[ + 'dateTimeDiff' => 'The number of years between now and 7 year ago must not be less than 8', +] + +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', +] + diff --git a/tests/unit/Rules/DateTimeDiffTest.php b/tests/unit/Rules/DateTimeDiffTest.php new file mode 100644 index 000000000..15753bcda --- /dev/null +++ b/tests/unit/Rules/DateTimeDiffTest.php @@ -0,0 +1,107 @@ + + * SPDX-License-Identifier: MIT + */ + +declare(strict_types=1); + +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\Test\Rules\Stub; +use Respect\Validation\Test\RuleTestCase; +use Respect\Validation\Validatable; +use Respect\Validation\Validator; + +use function array_map; +use function iterator_to_array; + +#[Group('rule')] +#[CoversClass(DateTimeDiff::class)] +final class DateTimeDiffTest extends RuleTestCase +{ + #[Test] + public function isShouldThrowAnExceptionWhenTypeIsNotValid(): void + { + $this->expectException(InvalidRuleConstructorException::class); + $this->expectExceptionMessageMatches('/"invalid" is not a valid type of age \(Available: .+\)/'); + + // @phpstan-ignore-next-line + new DateTimeDiff(Stub::daze(), 'invalid'); + } + + #[Test] + #[DataProvider('providerForSiblingSuitableRules')] + public function isShouldAcceptRulesThatCanBeAddedAsNextSibling(Validatable $rule): void + { + $this->expectNotToPerformAssertions(); + + new DateTimeDiff($rule); + } + + #[Test] + #[DataProvider('providerForSiblingUnsuitableRules')] + public function isShouldNotAcceptRulesThatCanBeAddedAsNextSibling(Validatable $rule): void + { + $this->expectException(InvalidRuleConstructorException::class); + $this->expectExceptionMessage('DateTimeDiff must contain exactly one rule'); + + new DateTimeDiff($rule); + } + + /** @return array */ + 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 */ + 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 */ + public static function providerForValidInput(): array + { + return [ + 'years' => [new DateTimeDiff(Stub::pass(1)), new DateTimeImmutable()], + 'months' => [new DateTimeDiff(Stub::pass(1), 'months'), new DateTimeImmutable()], + 'days' => [new DateTimeDiff(Stub::pass(1), 'days'), new DateTimeImmutable()], + 'hours' => [new DateTimeDiff(Stub::pass(1), 'hours'), new DateTimeImmutable()], + 'minutes' => [new DateTimeDiff(Stub::pass(1), 'minutes'), new DateTimeImmutable()], + 'seconds' => [new DateTimeDiff(Stub::pass(1), 'seconds'), new DateTimeImmutable()], + 'microseconds' => [new DateTimeDiff(Stub::pass(1), 'microseconds'), new DateTimeImmutable()], + ]; + } + + /** @return array */ + public static function providerForInvalidInput(): array + { + return [ + 'valid date, with failing rule' => [ + new DateTimeDiff(Stub::fail(1), 'years'), + new DateTimeImmutable(), + ], + ] + array_map( + static fn (array $args): array => [new DateTimeDiff(Stub::fail(1)), new DateTimeImmutable()], + iterator_to_array(self::providerForNonScalarValues()) + ); + } +} From b0d2e883e814169ef91c17bb1b8b4e656cbb4399 Mon Sep 17 00:00:00 2001 From: gvieiragoulart Date: Mon, 8 Jul 2024 21:29:28 -0300 Subject: [PATCH 2/9] type params, delete unnecessary code --- library/Rules/DateTimeDiff.php | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/library/Rules/DateTimeDiff.php b/library/Rules/DateTimeDiff.php index cbca38ab9..09d40dbef 100644 --- a/library/Rules/DateTimeDiff.php +++ b/library/Rules/DateTimeDiff.php @@ -35,7 +35,7 @@ final class DateTimeDiff extends Standard private readonly Validatable $rule; - /** @param "years"|"months"|"days"|"hours"|"minutes"|"seconds"|"microseconds" $type */ + /** @param string $type "years"|"months"|"days"|"hours"|"minutes"|"seconds"|"microseconds" */ public function __construct( Validatable $rule, private readonly string $type = 'years', @@ -58,24 +58,24 @@ public function __construct( public function evaluate(mixed $input): Result { - $dateTime = $this->createDateTimeObject($input); - if ($dateTime === null) { + $compareTo = $this->createDateTimeObject($input); + if ($compareTo === null) { return Result::failed($input, $this); } - $dateTimeResult = $this->bindEvaluate(new DateTime($this->format), $this, $input); + $dateTimeResult = $this->bindEvaluate( + binded: new DateTime($this->format), + binder: $this, + input: $input + ); if (!$dateTimeResult->isValid) { return $dateTimeResult; } $now = $this->now ?? new DateTimeImmutable(); - $dateTime = $this->createDateTimeObject($input); - if ($dateTime === null) { - return Result::failed($input, $this); - } $nextSibling = $this->rule - ->evaluate($this->comparisonValue($now, $dateTime)) + ->evaluate($this->comparisonValue($now, $compareTo)) ->withNameIfMissing($input instanceof DateTimeInterface ? $input->format('c') : $input); $parameters = ['type' => $this->type, 'now' => $this->nowParameter($now)]; @@ -83,12 +83,12 @@ public function evaluate(mixed $input): Result return (new Result($nextSibling->isValid, $input, $this, $parameters))->withNextSibling($nextSibling); } - private function comparisonValue(DateTimeInterface $now, DateTimeInterface $compareTo): int|float + private function comparisonValue(DateTimeInterface $now, DateTimeInterface $compareTo) { return match ($this->type) { 'years' => $compareTo->diff($now)->y, 'months' => $compareTo->diff($now)->m, - 'days' => $compareTo->diff($now)->d, + 'days' => $compareTo->diff($now)->days, 'hours' => $compareTo->diff($now)->h, 'minutes' => $compareTo->diff($now)->i, 'seconds' => $compareTo->diff($now)->s, From 85d5b9ed630341006e8eaba7fb09ebe5daf5cf95 Mon Sep 17 00:00:00 2001 From: gvieiragoulart Date: Mon, 8 Jul 2024 21:50:42 -0300 Subject: [PATCH 3/9] include not to pass test --- library/Rules/DateTimeDiff.php | 6 +++++- tests/integration/rules/dateTimeDiff.phpt | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/library/Rules/DateTimeDiff.php b/library/Rules/DateTimeDiff.php index 09d40dbef..085f31f33 100644 --- a/library/Rules/DateTimeDiff.php +++ b/library/Rules/DateTimeDiff.php @@ -35,7 +35,11 @@ final class DateTimeDiff extends Standard private readonly Validatable $rule; - /** @param string $type "years"|"months"|"days"|"hours"|"minutes"|"seconds"|"microseconds" */ + /** + * @param string $type "years"|"months"|"days"|"hours"|"minutes"|"seconds"|"microseconds" + * @param string|null $format Example: "Y-m-d H:i:s.u" + * @param DateTimeImmutable|null $now The value that will be compared to the input + */ public function __construct( Validatable $rule, private readonly string $type = 'years', diff --git a/tests/integration/rules/dateTimeDiff.phpt b/tests/integration/rules/dateTimeDiff.phpt index 4a7c37225..ff08b66f6 100644 --- a/tests/integration/rules/dateTimeDiff.phpt +++ b/tests/integration/rules/dateTimeDiff.phpt @@ -92,7 +92,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 [ - 'dateTimeDiff' => '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', ] Wrapping "not" From 205a0b7e3571dad9ff71e729c665a3d7330dff1b Mon Sep 17 00:00:00 2001 From: gvieiragoulart Date: Fri, 12 Jul 2024 02:28:39 -0300 Subject: [PATCH 4/9] test days with difference of months --- tests/integration/rules/dateTimeDiff.phpt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/integration/rules/dateTimeDiff.phpt b/tests/integration/rules/dateTimeDiff.phpt index ff08b66f6..0227cac05 100644 --- a/tests/integration/rules/dateTimeDiff.phpt +++ b/tests/integration/rules/dateTimeDiff.phpt @@ -13,6 +13,7 @@ run([ 'Without customizations' => [v::dateTimeDiff(v::equals(2)), '1 year ago'], 'With $type = "months"' => [v::dateTimeDiff(v::equals(3), 'months'), '2 months ago'], 'With $type = "days"' => [v::dateTimeDiff(v::equals(4), 'days'), '3 days ago'], + 'With $type = "days" and difference of months' => [v::dateTimeDiff(v::not(v::lessThan(95)), 'days'), '3 months ago'], 'With $type = "hours"' => [v::dateTimeDiff(v::equals(5), 'hours'), '4 hours ago'], 'With $type = "minutes"' => [v::dateTimeDiff(v::equals(6), 'minutes'), '5 minutes ago'], 'With $type = "microseconds"' => [v::dateTimeDiff(v::equals(7), 'microseconds'), '6 microseconds ago'], @@ -47,6 +48,14 @@ The number of days between now and 3 days ago must equal 4 'dateTimeDiff' => 'The number of days between now and 3 days ago must equal 4', ] +With $type = "days" and difference of months +⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺ +The number of days between now and 3 months ago must not be less than 95 +- The number of days between now and 3 months ago must not be less than 95 +[ + 'dateTimeDiff' => 'The number of days between now and 3 months ago must not be less than 95', +] + With $type = "hours" ⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺ The number of hours between now and 4 hours ago must equal 5 From 4ebf053fab574f6bab4366a925b669a74be879d6 Mon Sep 17 00:00:00 2001 From: gvieiragoulart Date: Sat, 13 Jul 2024 18:19:23 -0300 Subject: [PATCH 5/9] cange the validation to be and DateInterval type and add "d" type --- library/Helpers/CanValidateDateTime.php | 16 +++++++++ library/Rules/DateTimeDiff.php | 40 +++++++++++++---------- tests/integration/rules/dateTimeDiff.phpt | 33 ++++++++++++------- tests/unit/Rules/DateTimeDiffTest.php | 15 +++++---- 4 files changed, 68 insertions(+), 36 deletions(-) diff --git a/library/Helpers/CanValidateDateTime.php b/library/Helpers/CanValidateDateTime.php index aebc0776c..12740ac03 100644 --- a/library/Helpers/CanValidateDateTime.php +++ b/library/Helpers/CanValidateDateTime.php @@ -9,6 +9,7 @@ namespace Respect\Validation\Helpers; +use DateInterval; use DateTime; use DateTimeZone; @@ -16,6 +17,10 @@ use function date_default_timezone_get; use function date_parse_from_format; use function preg_match; +use function array_keys; +use function in_array; +use function get_object_vars; + trait CanValidateDateTime { @@ -56,6 +61,17 @@ private function isDateTimeParsable(array $info): bool return $info['error_count'] === 0 && $info['warning_count'] === 0; } + /** + * Validates if the given string is a valid DateInterval type. + * + * @param string $age + * @return bool + */ + private function isDateIntervalType(string $age): bool + { + return in_array($age, array_keys(get_object_vars((new DateInterval('P1Y'))))); + } + private function isDateFormat(string $format): bool { return preg_match('/[djSFmMnYy]/', $format) > 0; diff --git a/library/Rules/DateTimeDiff.php b/library/Rules/DateTimeDiff.php index 085f31f33..cd8c28290 100644 --- a/library/Rules/DateTimeDiff.php +++ b/library/Rules/DateTimeDiff.php @@ -20,7 +20,6 @@ use Respect\Validation\Rules\Core\Standard; use Respect\Validation\Validatable; -use function in_array; use function is_scalar; #[Template( @@ -36,22 +35,20 @@ final class DateTimeDiff extends Standard private readonly Validatable $rule; /** - * @param string $type "years"|"months"|"days"|"hours"|"minutes"|"seconds"|"microseconds" - * @param string|null $format Example: "Y-m-d H:i:s.u" + * @param string $type DateInterval format examples: (y, m, d, days, h, i, s, f) * @param DateTimeImmutable|null $now The value that will be compared to the input */ public function __construct( Validatable $rule, - private readonly string $type = 'years', + private readonly string $type = 'y', private readonly ?string $format = null, private readonly ?DateTimeImmutable $now = null, ) { - $availableTypes = ['years', 'months', 'days', 'hours', 'minutes', 'seconds', 'microseconds']; - if (!in_array($this->type, $availableTypes, true)) { + if (!$this->isDateIntervalType($this->type)) { throw new InvalidRuleConstructorException( '"%s" is not a valid type of age (Available: %s)', $this->type, - $availableTypes + ['y', 'm', 'd', 'days', 'h', 'i', 's', 'f'] ); } $this->rule = $this->extractSiblingSuitableRule( @@ -82,22 +79,17 @@ public function evaluate(mixed $input): Result ->evaluate($this->comparisonValue($now, $compareTo)) ->withNameIfMissing($input instanceof DateTimeInterface ? $input->format('c') : $input); - $parameters = ['type' => $this->type, 'now' => $this->nowParameter($now)]; + $parameters = [ + 'type' => $this->getTranslatedType($this->type), + 'now' => $this->nowParameter($now) + ]; return (new Result($nextSibling->isValid, $input, $this, $parameters))->withNextSibling($nextSibling); } private function comparisonValue(DateTimeInterface $now, DateTimeInterface $compareTo) { - return match ($this->type) { - 'years' => $compareTo->diff($now)->y, - 'months' => $compareTo->diff($now)->m, - 'days' => $compareTo->diff($now)->days, - 'hours' => $compareTo->diff($now)->h, - 'minutes' => $compareTo->diff($now)->i, - 'seconds' => $compareTo->diff($now)->s, - 'microseconds' => $compareTo->diff($now)->f, - }; + return $compareTo->diff($now)->{$this->type}; } private function nowParameter(DateTimeInterface $now): string @@ -135,4 +127,18 @@ private function createDateTimeObject(mixed $input): ?DateTimeInterface return $dateTime; } + + private function getTranslatedType(string $type): string + { + return match ($type) { + 'y' => 'years', + 'm' => 'months', + 'd' => 'days', + 'days' => 'full days', + 'h' => 'hours', + 'i' => 'minutes', + 's' => 'seconds', + 'f' => 'microseconds', + }; + } } diff --git a/tests/integration/rules/dateTimeDiff.phpt b/tests/integration/rules/dateTimeDiff.phpt index 0227cac05..284942db3 100644 --- a/tests/integration/rules/dateTimeDiff.phpt +++ b/tests/integration/rules/dateTimeDiff.phpt @@ -11,14 +11,15 @@ date_default_timezone_set('UTC'); run([ 'Without customizations' => [v::dateTimeDiff(v::equals(2)), '1 year ago'], - 'With $type = "months"' => [v::dateTimeDiff(v::equals(3), 'months'), '2 months ago'], + 'With $type = "months"' => [v::dateTimeDiff(v::equals(3), 'm'), '2 months ago'], 'With $type = "days"' => [v::dateTimeDiff(v::equals(4), 'days'), '3 days ago'], 'With $type = "days" and difference of months' => [v::dateTimeDiff(v::not(v::lessThan(95)), 'days'), '3 months ago'], - 'With $type = "hours"' => [v::dateTimeDiff(v::equals(5), 'hours'), '4 hours ago'], - 'With $type = "minutes"' => [v::dateTimeDiff(v::equals(6), 'minutes'), '5 minutes ago'], - 'With $type = "microseconds"' => [v::dateTimeDiff(v::equals(7), 'microseconds'), '6 microseconds ago'], - 'With custom $format' => [v::dateTimeDiff(v::lessThan(8), 'years', 'd/m/Y'), '09/12/1988'], - 'With custom $now' => [v::dateTimeDiff(v::lessThan(9), 'years', null, new DateTimeImmutable()), '09/12/1988'], + 'With $type = "d"' => [v::dateTimeDiff(v::equals(4), 'd'), '3 days ago'], + 'With $type = "hours"' => [v::dateTimeDiff(v::equals(5), 'h'), '4 hours ago'], + 'With $type = "minutes"' => [v::dateTimeDiff(v::equals(6), 'i'), '5 minutes ago'], + 'With $type = "microseconds"' => [v::dateTimeDiff(v::equals(7), 'f'), '6 microseconds ago'], + 'With custom $format' => [v::dateTimeDiff(v::lessThan(8), 'y', 'd/m/Y'), '09/12/1988'], + 'With custom $now' => [v::dateTimeDiff(v::lessThan(9), 'y', null, new DateTimeImmutable()), '09/12/1988'], 'Wrapped by "not"' => [v::not(v::dateTimeDiff(v::lessThan(8))), '7 year ago'], 'Wrapping "not"' => [v::dateTimeDiff(v::not(v::lessThan(9))), '8 year ago'], ]); @@ -42,18 +43,26 @@ The number of months between now and 2 months ago must equal 3 With $type = "days" ⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺ -The number of days between now and 3 days ago must equal 4 -- The number of days between now and 3 days ago must equal 4 +The number of full days between now and 3 days ago must equal 4 +- The number of full days between now and 3 days ago must equal 4 [ - 'dateTimeDiff' => 'The number of days between now and 3 days ago must equal 4', + 'dateTimeDiff' => 'The number of full days between now and 3 days ago must equal 4', ] With $type = "days" and difference of months ⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺ -The number of days between now and 3 months ago must not be less than 95 -- The number of days between now and 3 months ago must not be less than 95 +The number of full days between now and 3 months ago must not be less than 95 +- The number of full days between now and 3 months ago must not be less than 95 +[ + 'dateTimeDiff' => 'The number of full days between now and 3 months ago must not be less than 95', +] + +With $type = "d" +⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺ +The number of days between now and 3 days ago must equal 4 +- The number of days between now and 3 days ago must equal 4 [ - 'dateTimeDiff' => 'The number of days between now and 3 months ago must not be less than 95', + 'dateTimeDiff' => 'The number of days between now and 3 days ago must equal 4', ] With $type = "hours" diff --git a/tests/unit/Rules/DateTimeDiffTest.php b/tests/unit/Rules/DateTimeDiffTest.php index 15753bcda..0225c36f8 100644 --- a/tests/unit/Rules/DateTimeDiffTest.php +++ b/tests/unit/Rules/DateTimeDiffTest.php @@ -82,12 +82,13 @@ public static function providerForValidInput(): array { return [ 'years' => [new DateTimeDiff(Stub::pass(1)), new DateTimeImmutable()], - 'months' => [new DateTimeDiff(Stub::pass(1), 'months'), new DateTimeImmutable()], - 'days' => [new DateTimeDiff(Stub::pass(1), 'days'), new DateTimeImmutable()], - 'hours' => [new DateTimeDiff(Stub::pass(1), 'hours'), new DateTimeImmutable()], - 'minutes' => [new DateTimeDiff(Stub::pass(1), 'minutes'), new DateTimeImmutable()], - 'seconds' => [new DateTimeDiff(Stub::pass(1), 'seconds'), new DateTimeImmutable()], - 'microseconds' => [new DateTimeDiff(Stub::pass(1), 'microseconds'), new DateTimeImmutable()], + 'months' => [new DateTimeDiff(Stub::pass(1), 'm'), new DateTimeImmutable()], + 'total number of full days' => [new DateTimeDiff(Stub::pass(1), 'days'), new DateTimeImmutable()], + 'number of days' => [new DateTimeDiff(Stub::pass(1), 'd'), new DateTimeImmutable()], + 'hours' => [new DateTimeDiff(Stub::pass(1), 'h'), new DateTimeImmutable()], + 'minutes' => [new DateTimeDiff(Stub::pass(1), 'i'), new DateTimeImmutable()], + 'seconds' => [new DateTimeDiff(Stub::pass(1), 's'), new DateTimeImmutable()], + 'microseconds' => [new DateTimeDiff(Stub::pass(1), 'f'), new DateTimeImmutable()], ]; } @@ -96,7 +97,7 @@ public static function providerForInvalidInput(): array { return [ 'valid date, with failing rule' => [ - new DateTimeDiff(Stub::fail(1), 'years'), + new DateTimeDiff(Stub::fail(1), 'y'), new DateTimeImmutable(), ], ] + array_map( From 906f7229965a6f1fb809e1d544f1b3c2043b2a98 Mon Sep 17 00:00:00 2001 From: gvieiragoulart Date: Sun, 14 Jul 2024 19:04:16 -0300 Subject: [PATCH 6/9] write md docs --- docs/rules/DateTimeDiff.md | 29 +++++++++++++++++++---------- library/Rules/DateTimeDiff.php | 10 +++++++++- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/docs/rules/DateTimeDiff.md b/docs/rules/DateTimeDiff.md index e9a6c43de..6fc44bc9d 100644 --- a/docs/rules/DateTimeDiff.md +++ b/docs/rules/DateTimeDiff.md @@ -9,25 +9,33 @@ Validates the difference of date/time against a specific rule. The `$format` argument should follow PHP's [date()][] function. When the `$format` is not given, this rule accepts [Supported Date and Time Formats][] by PHP (see [strtotime()][]). +The `$type` argument should follow PHP's [DateInterval] properties. When the `$type` is not given, its default value is `y`. + ```php v::dateTimeDiff(v::equal(7))->validate('7 years ago - 1 minute'); // true v::dateTimeDiff(v::equal(7))->validate('7 years ago + 1 minute'); // false -v::dateTimeDiff(v::greaterThan(18), 'years', 'd/m/Y')->validate('09/12/1990'); // true -v::dateTimeDiff(v::greaterThan(18), 'years', 'd/m/Y')->validate('09/12/2023'); // false +v::dateTimeDiff(v::greaterThan(18), 'y', 'd/m/Y')->validate('09/12/1990'); // true +v::dateTimeDiff(v::greaterThan(18), 'y', 'd/m/Y')->validate('09/12/2023'); // false -v::dateTimeDiff(v::between(1, 18), 'months')->validate('5 months ago'); // true +v::dateTimeDiff(v::between(1, 18), 'm')->validate('5 months ago'); // true ``` The supported types are: -* `years` -* `months` -* `days` -* `hours` -* `minutes` -* `seconds` -* `microseconds` +* `years` as `y` +* `months` as `m` +* `days` as `days` and `d` +* `hours` as `h` +* `minutes` as `i` +* `seconds` as `s` +* `microseconds` as `f` + +Difference between `d` and `days` + +`d` (days): Represents the difference in days within the same month or year. For example, if the difference between two dates is 1 month and 10 days, the value of d will be 10. + +`days` (full days): Represents the total difference in days between two dates, regardless of months or years. For example, if the difference between two dates is 1 month and 10 days, the value of days will be the total number of days between these dates. ## Categorization @@ -52,3 +60,4 @@ See also: [DateTimeInterface]: http://php.net/DateTimeInterface [strtotime()]: http://php.net/strtotime [Supported Date and Time Formats]: http://php.net/datetime.formats +[DateInterval]: https://www.php.net/manual/en/class.dateinterval.php diff --git a/library/Rules/DateTimeDiff.php b/library/Rules/DateTimeDiff.php index cd8c28290..f16ae30a4 100644 --- a/library/Rules/DateTimeDiff.php +++ b/library/Rules/DateTimeDiff.php @@ -35,7 +35,15 @@ final class DateTimeDiff extends Standard private readonly Validatable $rule; /** - * @param string $type DateInterval format examples: (y, m, d, days, h, i, s, f) + * @param string $type DateInterval format examples: + * - 'y': years + * - 'm': months + * - 'd': days (within the same month or year) + * - 'days': full days (total difference in days) + * - 'h': hours + * - 'i': minutes + * - 's': seconds + * - 'f': microseconds * @param DateTimeImmutable|null $now The value that will be compared to the input */ public function __construct( From caee0e428cae2a2fc7129286ee52be03d5500a47 Mon Sep 17 00:00:00 2001 From: gvieiragoulart Date: Tue, 23 Jul 2024 20:42:25 -0300 Subject: [PATCH 7/9] improve tests --- library/Rules/DateTimeDiff.php | 2 +- tests/unit/Rules/DateTimeDiffTest.php | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/library/Rules/DateTimeDiff.php b/library/Rules/DateTimeDiff.php index f16ae30a4..eed9b5d84 100644 --- a/library/Rules/DateTimeDiff.php +++ b/library/Rules/DateTimeDiff.php @@ -95,7 +95,7 @@ public function evaluate(mixed $input): Result return (new Result($nextSibling->isValid, $input, $this, $parameters))->withNextSibling($nextSibling); } - private function comparisonValue(DateTimeInterface $now, DateTimeInterface $compareTo) + private function comparisonValue(DateTimeInterface $now, DateTimeInterface $compareTo): int|float { return $compareTo->diff($now)->{$this->type}; } diff --git a/tests/unit/Rules/DateTimeDiffTest.php b/tests/unit/Rules/DateTimeDiffTest.php index 0225c36f8..5a2d4be18 100644 --- a/tests/unit/Rules/DateTimeDiffTest.php +++ b/tests/unit/Rules/DateTimeDiffTest.php @@ -100,6 +100,14 @@ public static function providerForInvalidInput(): array new DateTimeDiff(Stub::fail(1), 'y'), new DateTimeImmutable(), ], + 'invalid date, with passing rule' => [ + new DateTimeDiff(Stub::pass(1), 'y', "Y-m-d"), + 'invalid date', + ], + 'invalid date, with failing rule' => [ + new DateTimeDiff(Stub::fail(1), 'y', "Y-m-d"), + new DateTimeImmutable(), + ], ] + array_map( static fn (array $args): array => [new DateTimeDiff(Stub::fail(1)), new DateTimeImmutable()], iterator_to_array(self::providerForNonScalarValues()) From 5ea05c9fbb1704f593426b34894a3d12a30b8c7c Mon Sep 17 00:00:00 2001 From: gvieiragoulart Date: Tue, 23 Jul 2024 21:01:34 -0300 Subject: [PATCH 8/9] fix phpcs --- library/Helpers/CanValidateDateTime.php | 10 +++------- library/Rules/DateTimeDiff.php | 10 +++++----- tests/unit/Rules/DateTimeDiffTest.php | 4 ++-- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/library/Helpers/CanValidateDateTime.php b/library/Helpers/CanValidateDateTime.php index 12740ac03..f9aaa9317 100644 --- a/library/Helpers/CanValidateDateTime.php +++ b/library/Helpers/CanValidateDateTime.php @@ -13,14 +13,13 @@ use DateTime; use DateTimeZone; +use function array_keys; use function checkdate; use function date_default_timezone_get; use function date_parse_from_format; -use function preg_match; -use function array_keys; -use function in_array; use function get_object_vars; - +use function in_array; +use function preg_match; trait CanValidateDateTime { @@ -63,9 +62,6 @@ private function isDateTimeParsable(array $info): bool /** * Validates if the given string is a valid DateInterval type. - * - * @param string $age - * @return bool */ private function isDateIntervalType(string $age): bool { diff --git a/library/Rules/DateTimeDiff.php b/library/Rules/DateTimeDiff.php index eed9b5d84..6a09a650b 100644 --- a/library/Rules/DateTimeDiff.php +++ b/library/Rules/DateTimeDiff.php @@ -34,7 +34,7 @@ final class DateTimeDiff extends Standard private readonly Validatable $rule; - /** + /** * @param string $type DateInterval format examples: * - 'y': years * - 'm': months @@ -73,8 +73,8 @@ public function evaluate(mixed $input): Result } $dateTimeResult = $this->bindEvaluate( - binded: new DateTime($this->format), - binder: $this, + binded: new DateTime($this->format), + binder: $this, input: $input ); if (!$dateTimeResult->isValid) { @@ -88,8 +88,8 @@ public function evaluate(mixed $input): Result ->withNameIfMissing($input instanceof DateTimeInterface ? $input->format('c') : $input); $parameters = [ - 'type' => $this->getTranslatedType($this->type), - 'now' => $this->nowParameter($now) + 'type' => $this->getTranslatedType($this->type), + 'now' => $this->nowParameter($now), ]; return (new Result($nextSibling->isValid, $input, $this, $parameters))->withNextSibling($nextSibling); diff --git a/tests/unit/Rules/DateTimeDiffTest.php b/tests/unit/Rules/DateTimeDiffTest.php index 5a2d4be18..f2a690c6e 100644 --- a/tests/unit/Rules/DateTimeDiffTest.php +++ b/tests/unit/Rules/DateTimeDiffTest.php @@ -101,11 +101,11 @@ public static function providerForInvalidInput(): array new DateTimeImmutable(), ], 'invalid date, with passing rule' => [ - new DateTimeDiff(Stub::pass(1), 'y', "Y-m-d"), + new DateTimeDiff(Stub::pass(1), 'y', 'Y-m-d'), 'invalid date', ], 'invalid date, with failing rule' => [ - new DateTimeDiff(Stub::fail(1), 'y', "Y-m-d"), + new DateTimeDiff(Stub::fail(1), 'y', 'Y-m-d'), new DateTimeImmutable(), ], ] + array_map( From 94d9d4649f3c34a995544eb61c552fd57285b91d Mon Sep 17 00:00:00 2001 From: gvieiragoulart Date: Tue, 23 Jul 2024 21:04:59 -0300 Subject: [PATCH 9/9] fix phpstan --- library/Rules/DateTimeDiff.php | 1 + tests/unit/Rules/DateTimeDiffTest.php | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/library/Rules/DateTimeDiff.php b/library/Rules/DateTimeDiff.php index 6a09a650b..7b7c8a4a2 100644 --- a/library/Rules/DateTimeDiff.php +++ b/library/Rules/DateTimeDiff.php @@ -147,6 +147,7 @@ private function getTranslatedType(string $type): string 'i' => 'minutes', 's' => 'seconds', 'f' => 'microseconds', + default => 'unknown type', }; } } diff --git a/tests/unit/Rules/DateTimeDiffTest.php b/tests/unit/Rules/DateTimeDiffTest.php index f2a690c6e..ef78f7b8c 100644 --- a/tests/unit/Rules/DateTimeDiffTest.php +++ b/tests/unit/Rules/DateTimeDiffTest.php @@ -33,7 +33,6 @@ public function isShouldThrowAnExceptionWhenTypeIsNotValid(): void $this->expectException(InvalidRuleConstructorException::class); $this->expectExceptionMessageMatches('/"invalid" is not a valid type of age \(Available: .+\)/'); - // @phpstan-ignore-next-line new DateTimeDiff(Stub::daze(), 'invalid'); }