diff --git a/docs/rules/DateTimeDiff.md b/docs/rules/DateTimeDiff.md new file mode 100644 index 000000000..6fc44bc9d --- /dev/null +++ b/docs/rules/DateTimeDiff.md @@ -0,0 +1,63 @@ +# 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()][]). + +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), '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), 'm')->validate('5 months ago'); // true +``` + +The supported types are: + +* `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 + +- 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 +[DateInterval]: https://www.php.net/manual/en/class.dateinterval.php 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..f9aaa9317 100644 --- a/library/Helpers/CanValidateDateTime.php +++ b/library/Helpers/CanValidateDateTime.php @@ -9,22 +9,23 @@ namespace Respect\Validation\Helpers; +use DateInterval; 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 get_object_vars; +use function in_array; use function preg_match; 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; @@ -59,6 +60,14 @@ 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. + */ + 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; @@ -75,4 +84,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..7b7c8a4a2 --- /dev/null +++ b/library/Rules/DateTimeDiff.php @@ -0,0 +1,153 @@ + + * 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 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 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( + Validatable $rule, + private readonly string $type = 'y', + private readonly ?string $format = null, + private readonly ?DateTimeImmutable $now = null, + ) { + if (!$this->isDateIntervalType($this->type)) { + throw new InvalidRuleConstructorException( + '"%s" is not a valid type of age (Available: %s)', + $this->type, + ['y', 'm', 'd', 'days', 'h', 'i', 's', 'f'] + ); + } + $this->rule = $this->extractSiblingSuitableRule( + $rule, + new InvalidRuleConstructorException('DateTimeDiff must contain exactly one rule') + ); + } + + public function evaluate(mixed $input): Result + { + $compareTo = $this->createDateTimeObject($input); + if ($compareTo === null) { + return Result::failed($input, $this); + } + + $dateTimeResult = $this->bindEvaluate( + binded: new DateTime($this->format), + binder: $this, + input: $input + ); + if (!$dateTimeResult->isValid) { + return $dateTimeResult; + } + + $now = $this->now ?? new DateTimeImmutable(); + + $nextSibling = $this->rule + ->evaluate($this->comparisonValue($now, $compareTo)) + ->withNameIfMissing($input instanceof DateTimeInterface ? $input->format('c') : $input); + + $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): int|float + { + return $compareTo->diff($now)->{$this->type}; + } + + 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; + } + + 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', + default => 'unknown type', + }; + } +} diff --git a/tests/integration/rules/dateTimeDiff.phpt b/tests/integration/rules/dateTimeDiff.phpt new file mode 100644 index 000000000..284942db3 --- /dev/null +++ b/tests/integration/rules/dateTimeDiff.phpt @@ -0,0 +1,123 @@ +--FILE-- + [v::dateTimeDiff(v::equals(2)), '1 year 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 = "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'], +]); +?> +--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 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 full days between now and 3 days ago must equal 4', +] + +With $type = "days" and difference of months +⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺⎺ +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 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 +[ + 'notDateTimeDiff' => '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..ef78f7b8c --- /dev/null +++ b/tests/unit/Rules/DateTimeDiffTest.php @@ -0,0 +1,115 @@ + + * 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: .+\)/'); + + 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), '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()], + ]; + } + + /** @return array */ + public static function providerForInvalidInput(): array + { + return [ + 'valid date, with failing rule' => [ + 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()) + ); + } +}