diff --git a/CHANGELOG.md b/CHANGELOG.md index 84dcba9db..c016680cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - New #665: Add methods `addErrorWithFormatOnly()` and `addErrorWithoutPostProcessing()` to `Result` object (@vjik) - Enh #668: Clarify psalm types in `Result` (@vjik) - New #670, #680: Add `Image` validation rule (@vjik, @arogachev) +- New #678: Add `Date`, `DateTime` and `Time` validation rules (@vjik) ## 1.2.0 February 21, 2024 diff --git a/composer.json b/composer.json index af44699b9..d95422e3b 100644 --- a/composer.json +++ b/composer.json @@ -52,7 +52,7 @@ "yiisoft/yii-debug": "dev-master|dev-php80" }, "suggest": { - "ext-intl": "Allows using IDN validation for emails", + "ext-intl": "Allows using date rules and IDN validation for emails", "ext-fileinfo": "To use image rule", "yiisoft/di": "To create rule handlers via Yii DI" }, diff --git a/docs/guide/en/built-in-rules.md b/docs/guide/en/built-in-rules.md index 59779e554..f89d44c1b 100644 --- a/docs/guide/en/built-in-rules.md +++ b/docs/guide/en/built-in-rules.md @@ -49,6 +49,12 @@ Here is a list of all available built-in rules, divided by category. - [Image](../../../src/Rule/Image/Image.php) +### Date rules + +- [Date](../../../src/Rule/Date/Date.php) +- [DateTime](../../../src/Rule/Date/DateTime.php) +- [Time](../../../src/Rule/Date/Time.php) + ### General purpose rules - [Callback](../../../src/Rule/Callback.php) diff --git a/infection.json.dist b/infection.json.dist index 76ace25f5..a4d21bf85 100644 --- a/infection.json.dist +++ b/infection.json.dist @@ -25,6 +25,17 @@ "Yiisoft\\Validator\\EmptyCondition\\NeverEmpty::__invoke", "Yiisoft\\Validator\\EmptyCondition\\WhenNull::__invoke" ] + }, + "IncrementInteger": { + "ignoreSourceCodeByRegex": [ + ".*setDate\\(2024, 3, 29\\).*", + ".*setTime\\(0, 0\\).*" + ] + }, + "DecrementInteger": { + "ignoreSourceCodeByRegex": [ + ".*setDate\\(2024, 3, 29\\).*" + ] } } } diff --git a/messages/ru/yii-validator.php b/messages/ru/yii-validator.php index 816ec3124..c3d1f70c8 100644 --- a/messages/ru/yii-validator.php +++ b/messages/ru/yii-validator.php @@ -132,4 +132,23 @@ 'The allowed types are integer, float and string.' => 'Разрешённые типы: integer, float и string.', 'Value must be no less than {min}.' => 'Значение должно быть не меньше {min}.', 'Value must be no greater than {max}.' => 'Значение должно быть не больше {max}.', + + /** + * @see \Yiisoft\Validator\Rule\Date\Date + * @see \Yiisoft\Validator\Rule\Date\DateTime + * @see \Yiisoft\Validator\Rule\Date\Time + */ + 'The value must be no early than {limit}.' => 'Значение должно быть не ранее {limit}.', + 'The value must be no late than {limit}.' => 'Значение должно быть не позднее {limit}.', + + /** + * @see \Yiisoft\Validator\Rule\Date\Date + * @see \Yiisoft\Validator\Rule\Date\DateTime + */ + 'Invalid date value.' => 'Некорректное значение даты.', + + /** + * @see \Yiisoft\Validator\Rule\Date\Time + */ + 'Invalid time value.' => 'Некорректное значение времени.', ]; diff --git a/src/Exception/UnexpectedRuleException.php b/src/Exception/UnexpectedRuleException.php index e17c14af7..43c3ee726 100644 --- a/src/Exception/UnexpectedRuleException.php +++ b/src/Exception/UnexpectedRuleException.php @@ -36,28 +36,22 @@ */ final class UnexpectedRuleException extends InvalidArgumentException { + /** + * @param string|string[] $expectedClassName Expected class name(s) of a rule. + * @param object $actualObject An actual given object that's not an instance of `$expectedClassName`. + * @param int $code The Exception code. + * @param Throwable|null $previous The previous throwable used for the exception chaining. + */ public function __construct( - /** - * @var string Expected class name of a rule. - */ - string $expectedClassName, - /** - * @var object An actual given object that's not an instance of `$expectedClassName`. - */ + string|array $expectedClassName, object $actualObject, - /** - * @var int The Exception code. - */ int $code = 0, - /** - * @var Throwable|null The previous throwable used for the exception chaining. - */ ?Throwable $previous = null, ) { parent::__construct( sprintf( 'Expected "%s", but "%s" given.', - $expectedClassName, + implode('", "', (array) $expectedClassName), $actualObject::class ), $code, diff --git a/src/Rule/Date/BaseDate.php b/src/Rule/Date/BaseDate.php new file mode 100644 index 000000000..cbf324de6 --- /dev/null +++ b/src/Rule/Date/BaseDate.php @@ -0,0 +1,104 @@ +format; + } + + /** + * @return non-empty-string|null + */ + public function getTimeZone(): ?string + { + return $this->timeZone; + } + + public function getLocale(): ?string + { + return $this->locale; + } + + public function getName(): string + { + return 'date'; + } + + public function getMin(): DateTimeInterface|int|string|null + { + return $this->min; + } + + public function getMax(): DateTimeInterface|int|string|null + { + return $this->max; + } + + public function getMessageFormat(): ?string + { + return $this->messageFormat; + } + + public function getIncorrectInputMessage(): ?string + { + return $this->incorrectInputMessage; + } + + public function getTooEarlyMessage(): ?string + { + return $this->tooEarlyMessage; + } + + public function getTooLateMessage(): ?string + { + return $this->tooLateMessage; + } +} diff --git a/src/Rule/Date/BaseDateHandler.php b/src/Rule/Date/BaseDateHandler.php new file mode 100644 index 000000000..50eda11cc --- /dev/null +++ b/src/Rule/Date/BaseDateHandler.php @@ -0,0 +1,265 @@ +getTimeZone() ?? $this->timeZone; + if ($timeZone !== null) { + $timeZone = new DateTimeZone($timeZone); + } + + $result = new Result(); + + $date = $this->prepareValue($value, $rule, $timeZone, false); + if ($date === null) { + $result->addError( + $rule->getIncorrectInputMessage() ?? $this->incorrectInputMessage, + ['attribute' => $context->getTranslatedAttribute()] + ); + return $result; + } + + $min = $this->prepareValue($rule->getMin(), $rule, $timeZone, true); + if ($min !== null && $date < $min) { + $result->addError( + $rule->getTooEarlyMessage() ?? $this->tooEarlyMessage, + [ + 'attribute' => $context->getTranslatedAttribute(), + 'value' => $this->formatDate($date, $rule, $timeZone), + 'limit' => $this->formatDate($min, $rule, $timeZone), + ] + ); + return $result; + } + + $max = $this->prepareValue($rule->getMax(), $rule, $timeZone, true); + if ($max !== null && $date > $max) { + $result->addError( + $rule->getTooLateMessage() ?? $this->tooLateMessage, + [ + 'attribute' => $context->getTranslatedAttribute(), + 'value' => $this->formatDate($date, $rule, $timeZone), + 'limit' => $this->formatDate($max, $rule, $timeZone), + ] + ); + return $result; + } + + return $result; + } + + private function prepareValue( + mixed $value, + Date|DateTime|Time $rule, + ?DateTimeZone $timeZone, + bool $strict + ): ?DateTimeInterface { + $format = $rule->getFormat(); + + if (is_int($value)) { + return $this->makeDateTimeFromTimestamp($value, $timeZone); + } + + if ($value === null) { + return $value; + } + + if ($value instanceof DateTimeInterface) { + $result = $value; + } elseif (is_string($value)) { + if (is_string($format) && str_starts_with($format, 'php:')) { + $result = $this->prepareValueWithPhpFormat($value, substr($format, 4), $timeZone); + } else { + $result = $this->prepareValueWithIntlFormat( + $value, + $format, + $this->getDateTypeFromRule($rule), + $this->getTimeTypeFromRule($rule), + $timeZone, + $rule->getLocale() ?? $this->locale, + ); + } + } else { + $result = null; + } + + if ($result !== null) { + if ($rule instanceof Date) { + $result = DateTimeImmutable::createFromInterface($result)->setTime(0, 0); + } elseif ($rule instanceof Time) { + $result = DateTimeImmutable::createFromInterface($result)->setDate(2024, 3, 29); + } + } + + return $result === null && $strict + ? throw new LogicException('Invalid date value.') + : $result; + } + + private function prepareValueWithPhpFormat( + string $value, + string $format, + ?DateTimeZone $timeZone + ): ?DateTimeInterface { + $date = DateTimeImmutable::createFromFormat($format, $value, $timeZone); + if ($date === false) { + return null; + } + + $errors = DateTimeImmutable::getLastErrors(); + if ($errors !== false && !empty($errors['warning_count'])) { + return null; + } + + return $date; + } + + /** + * @psalm-param IntlDateFormatterFormat $dateType + * @psalm-param IntlDateFormatterFormat $timeType + */ + private function prepareValueWithIntlFormat( + string $value, + ?string $format, + int $dateType, + int $timeType, + ?DateTimeZone $timeZone, + ?string $locale, + ): ?DateTimeInterface { + $formatter = $this->makeFormatter($format, $locale, $dateType, $timeType, $timeZone); + $formatter->setLenient(false); + $timestamp = $formatter->parse($value); + return is_int($timestamp) ? $this->makeDateTimeFromTimestamp($timestamp, $timeZone) : null; + } + + private function formatDate(DateTimeInterface $date, Date|DateTime|Time $rule, ?DateTimeZone $timeZone): string + { + $formatterDateType = $this->getMessageDateTypeFromRule($rule) + ?? $this->messageDateType + ?? $this->getDateTypeFromRule($rule); + $formatterTimeType = $this->getMessageTimeTypeFromRule($rule) + ?? $this->messageTimeType + ?? $this->getTimeTypeFromRule($rule); + + $format = $rule->getMessageFormat() ?? $this->messageFormat; + if (is_string($format) && str_starts_with($format, 'php:')) { + return $date->format(substr($format, 4)); + } + + $formatter = $this->makeFormatter( + $format, + $rule->getLocale() ?? $this->locale, + $formatterDateType, + $formatterTimeType, + $timeZone, + ); + + return $formatter->format($date); + } + + private function makeFormatter( + ?string $format, + ?string $locale, + int $dateType, + int $timeType, + ?DateTimeZone $timeZone + ): IntlDateFormatter { + if ($format === null) { + return new IntlDateFormatter($locale, $dateType, $timeType, $timeZone); + } + + return new IntlDateFormatter( + $locale, + IntlDateFormatter::NONE, + IntlDateFormatter::NONE, + $timeZone, + pattern: $format + ); + } + + private function makeDateTimeFromTimestamp(int $timestamp, ?DateTimeZone $timeZone): DateTimeImmutable + { + return (new DateTimeImmutable(timezone: $timeZone))->setTimestamp($timestamp); + } + + /** + * @psalm-return IntlDateFormatterFormat + */ + private function getDateTypeFromRule(Date|DateTime|Time $rule): int + { + return $rule instanceof Time + ? IntlDateFormatter::NONE + : $rule->getDateType() ?? $this->dateType; + } + + /** + * @psalm-return IntlDateFormatterFormat + */ + private function getTimeTypeFromRule(Date|DateTime|Time $rule): int + { + return $rule instanceof Date + ? IntlDateFormatter::NONE + : $rule->getTimeType() ?? $this->timeType; + } + + /** + * @psalm-return IntlDateFormatterFormat|null + */ + private function getMessageDateTypeFromRule(Date|DateTime|Time $rule): ?int + { + return $rule instanceof Time ? null : $rule->getMessageDateType(); + } + + /** + * @psalm-return IntlDateFormatterFormat|null + */ + private function getMessageTimeTypeFromRule(Date|DateTime|Time $rule): ?int + { + return $rule instanceof Date ? null : $rule->getMessageTimeType(); + } +} diff --git a/src/Rule/Date/Date.php b/src/Rule/Date/Date.php new file mode 100644 index 000000000..505aa49ed --- /dev/null +++ b/src/Rule/Date/Date.php @@ -0,0 +1,173 @@ + new Yiisoft\Validator\Rule\Date\Date(format: 'php:Y-m-d'), + * ]; + * ``` + * + * In the example above, the PHP attributes equivalent will be: + * + * ```php + * use Yiisoft\Validator\Validator; + * use Yiisoft\Validator\Rule\Date\Date; + * + * final class User + * { + * public function __construct( + * #[Date(format: 'php:Y-m-d')] + * public string $date, + * ) {} + * } + * + * $user = new User(date: '2022-01-01'); + * + * $validator = (new Validator())->validate($user); + * ``` + * + * @see DateHandler + * + * @psalm-import-type IntlDateFormatterFormat from BaseDate + * @psalm-import-type SkipOnEmptyValue from SkipOnEmptyInterface + * @psalm-import-type WhenType from WhenInterface + */ +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] +final class Date extends BaseDate +{ + /** + * @param string|null $format The date format that the value being validated should follow. This can be a date + * pattern as described in the + * [ICU manual](https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax). + * + * Alternatively this can be a string prefixed with `php:` representing a format that can be recognized by + * the PHP Datetime class. Please refer to + * {@link https://www.php.net/manual/datetimeimmutable.createfromformat.php} on supported formats. + * + * Here are some example values: + * + * ```php + * 'MM/dd/yyyy' // date in ICU format + * 'php:m/d/Y' // the same date in PHP format + * ``` + * + * @param int|null $dateType Format of the date determined by one of the `IntlDateFormatter` constants. It is used when + * {@see $format} is not set. + * + * @param string|null $timeZone The timezone to use for parsing and formatting date values. This can be any value + * that may be passed to + * [date_default_timezone_set()](https://www.php.net/manual/function.date-default-timezone-set.php)> e.g. `UTC`, + * `Europe/Berlin` or `America/Chicago`. Refer to the + * [php manual](https://secure.php.net/manual/en/timezones.php) for available timezones. + * + * @param string|null $locale Locale to use when formatting or parsing or `null` to use the value specified in the + * ini setting `intl.default_locale`. + * + * @param DateTimeInterface|int|string|null $min Lower limit of the date. Defaults to `null`, meaning no lower + * limit. This can be a unix timestamp or a string representing a date value or `DateTimeInterface` instance. + * + * @param DateTimeInterface|int|string|null $max Upper limit of the date. Defaults to `null`, meaning no upper + * limit. This can be a unix timestamp or a string representing a date value or `DateTimeInterface` instance. + * + * @param string|null $messageFormat Date format that is used in error messages. + * + * @param int|null $messageDateType One of the `IntlDateFormatter` constants that used determines date format for + * error messages used when {@see $messageFormat} is not set. + * + * @param string|null $incorrectInputMessage A message used when the validated value is not valid date. You may use + * the following placeholders in the message: + * - `{attribute}`: the translated label of the attribute being validated. + * + * @param string|null $tooEarlyMessage A message used when the validated date is less than {@see $min}. You may use + * the following placeholders in the message: + * - `{attribute}`: the translated label of the attribute being validated. + * - `{value}`: the validated date. + * - `{limit}`: expected minimum date. + * + * @param string|null $tooLateMessage A message used when the validated date is more than {@see $max}. You may use + * the following placeholders in the message: + * - `{attribute}`: the translated label of the attribute being validated. + * - `{value}`: the validated date. + * - `{limit}`: expected maximum date. + * + * @param mixed|null $skipOnEmpty Whether to skip this rule if the value validated is empty. + * See {@see SkipOnEmptyInterface}. + * + * @param bool $skipOnError Whether to skip this rule if any of the previous rules gave an error. + * See {@see SkipOnErrorInterface}. + * + * @param Closure|null $when A callable to define a condition for applying the rule. See {@see WhenInterface}. + * + * @psalm-param non-empty-string|null $timeZone + * @psalm-param IntlDateFormatterFormat|null $dateType + * @psalm-param IntlDateFormatterFormat|null $messageDateType + * @psalm-param SkipOnEmptyValue $skipOnEmpty + * @psalm-param WhenType $when + */ + public function __construct( + ?string $format = null, + private ?int $dateType = null, + ?string $timeZone = null, + ?string $locale = null, + int|string|DateTimeInterface|null $min = null, + int|string|DateTimeInterface|null $max = null, + ?string $messageFormat = null, + private ?int $messageDateType = null, + ?string $incorrectInputMessage = null, + ?string $tooEarlyMessage = null, + ?string $tooLateMessage = null, + mixed $skipOnEmpty = null, + bool $skipOnError = false, + Closure|null $when = null, + ) { + parent::__construct( + $format, + $timeZone, + $locale, + $min, + $max, + $messageFormat, + $incorrectInputMessage, + $tooEarlyMessage, + $tooLateMessage, + $skipOnEmpty, + $skipOnError, + $when, + ); + } + + /** + * @psalm-return IntlDateFormatterFormat|null + */ + public function getDateType(): ?int + { + return $this->dateType; + } + + /** + * @psalm-return IntlDateFormatterFormat|null + */ + public function getMessageDateType(): ?int + { + return $this->messageDateType; + } + + public function getHandler(): string + { + return DateHandler::class; + } +} diff --git a/src/Rule/Date/DateHandler.php b/src/Rule/Date/DateHandler.php new file mode 100644 index 000000000..ff0794fb5 --- /dev/null +++ b/src/Rule/Date/DateHandler.php @@ -0,0 +1,41 @@ + new Yiisoft\Validator\Rule\Date\DateTime(format: 'php:Y-m-d, H:i'), + * ]; + * ``` + * + * In the example above, the PHP attributes equivalent will be: + * + * ```php + * use Yiisoft\Validator\Validator; + * use Yiisoft\Validator\Rule\Date\DateTime; + * + * final class User + * { + * public function __construct( + * #[DateTime(format: 'php:Y-m-d, H:i')] + * public string $date, + * ) {} + * } + * + * $user = new User(date: '2022-01-01, 23:15'); + * + * $validator = (new Validator())->validate($user); + * ``` + * + * @see DateTimeHandler + * + * @psalm-import-type IntlDateFormatterFormat from BaseDate + * @psalm-import-type SkipOnEmptyValue from SkipOnEmptyInterface + * @psalm-import-type WhenType from WhenInterface + */ +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] +final class DateTime extends BaseDate +{ + /** + * @param string|null $format The date format that the value being validated should follow. This can be a date + * pattern as described in the + * [ICU manual](https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax). + * + * Alternatively this can be a string prefixed with `php:` representing a format that can be recognized by + * the PHP Datetime class. Please refer to + * {@link https://www.php.net/manual/datetimeimmutable.createfromformat.php} on supported formats. + * + * Here are some example values: + * + * ```php + * 'MM/dd/yyyy, HH:mm' // date in ICU format + * 'php:m/d/Y, H:i' // the same date in PHP format + * ``` + * + * @param int|null $dateType Format of the date determined by one of the `IntlDateFormatter` constants. It is used when + * {@see $format} is not set. + * + * @param int|null $timeType Format of the time determined by one of the `IntlDateFormatter` constants. It is used when + * {@see $format} is not set. + * + * @param string|null $timeZone The timezone to use for parsing and formatting date values. This can be any value + * that may be passed to + * [date_default_timezone_set()](https://www.php.net/manual/function.date-default-timezone-set.php)> e.g. `UTC`, + * `Europe/Berlin` or `America/Chicago`. Refer to the + * [php manual](https://secure.php.net/manual/en/timezones.php) for available timezones. + * + * @param string|null $locale Locale to use when formatting or parsing or `null` to use the value specified in the + * ini setting `intl.default_locale`. + * + * @param DateTimeInterface|int|string|null $min Lower limit of the date. Defaults to `null`, meaning no lower + * limit. This can be a unix timestamp or a string representing a date value or `DateTimeInterface` instance. + * + * @param DateTimeInterface|int|string|null $max Upper limit of the date. Defaults to `null`, meaning no upper + * limit. This can be a unix timestamp or a string representing a date value or `DateTimeInterface` instance. + * + * @param string|null $messageFormat Date format that is used in error messages. + * + * @param int|null $messageDateType One of the `IntlDateFormatter` constants that used determines date format for + * error messages used when {@see $messageFormat} is not set. + * + * @param int|null $messageTimeType One of the `IntlDateFormatter` constants that used determines time format for + * error messages used when {@see $messageFormat} is not set. + * + * @param string|null $incorrectInputMessage A message used when the validated value is not valid date. You may use + * the following placeholders in the message: + * - `{attribute}`: the translated label of the attribute being validated. + * + * @param string|null $tooEarlyMessage A message used when the validated date is less than {@see $min}. You may use + * the following placeholders in the message: + * - `{attribute}`: the translated label of the attribute being validated. + * - `{value}`: the validated date. + * - `{limit}`: expected minimum date. + * + * @param string|null $tooLateMessage A message used when the validated date is more than {@see $max}. You may use + * the following placeholders in the message: + * - `{attribute}`: the translated label of the attribute being validated. + * - `{value}`: the validated date. + * - `{limit}`: expected maximum date. + * + * @param mixed|null $skipOnEmpty Whether to skip this rule if the value validated is empty. + * See {@see SkipOnEmptyInterface}. + * + * @param bool $skipOnError Whether to skip this rule if any of the previous rules gave an error. + * See {@see SkipOnErrorInterface}. + * + * @param Closure|null $when A callable to define a condition for applying the rule. See {@see WhenInterface}. + * + * @psalm-param non-empty-string|null $timeZone + * @psalm-param IntlDateFormatterFormat|null $dateType + * @psalm-param IntlDateFormatterFormat|null $timeType + * @psalm-param IntlDateFormatterFormat|null $messageDateType + * @psalm-param IntlDateFormatterFormat|null $messageTimeType + * @psalm-param SkipOnEmptyValue $skipOnEmpty + * @psalm-param WhenType $when + */ + public function __construct( + ?string $format = null, + private ?int $dateType = null, + private ?int $timeType = null, + ?string $timeZone = null, + ?string $locale = null, + int|string|DateTimeInterface|null $min = null, + int|string|DateTimeInterface|null $max = null, + ?string $messageFormat = null, + private ?int $messageDateType = null, + private ?int $messageTimeType = null, + ?string $incorrectInputMessage = null, + ?string $tooEarlyMessage = null, + ?string $tooLateMessage = null, + mixed $skipOnEmpty = null, + bool $skipOnError = false, + Closure|null $when = null, + ) { + parent::__construct( + $format, + $timeZone, + $locale, + $min, + $max, + $messageFormat, + $incorrectInputMessage, + $tooEarlyMessage, + $tooLateMessage, + $skipOnEmpty, + $skipOnError, + $when, + ); + } + + /** + * @psalm-return IntlDateFormatterFormat|null + */ + public function getDateType(): ?int + { + return $this->dateType; + } + + /** + * @psalm-return IntlDateFormatterFormat|null + */ + public function getTimeType(): ?int + { + return $this->timeType; + } + + /** + * @psalm-return IntlDateFormatterFormat|null + */ + public function getMessageDateType(): ?int + { + return $this->messageDateType; + } + + /** + * @psalm-return IntlDateFormatterFormat|null + */ + public function getMessageTimeType(): ?int + { + return $this->messageTimeType; + } + + public function getHandler(): string + { + return DateTimeHandler::class; + } +} diff --git a/src/Rule/Date/DateTimeHandler.php b/src/Rule/Date/DateTimeHandler.php new file mode 100644 index 000000000..84e729b60 --- /dev/null +++ b/src/Rule/Date/DateTimeHandler.php @@ -0,0 +1,44 @@ + new Yiisoft\Validator\Rule\Date\Time(format: 'php:H:i'), + * ]; + * ``` + * + * In the example above, the PHP attributes equivalent will be: + * + * ```php + * use Yiisoft\Validator\Validator; + * use Yiisoft\Validator\Rule\Date\Date; + * + * final class User + * { + * public function __construct( + * #[Date(format: 'php:H:i')] + * public string $time, + * ) {} + * } + * + * $user = new User(time: '12:35'); + * + * $validator = (new Validator())->validate($user); + * ``` + * + * @see TimeHandler + * + * @psalm-import-type IntlDateFormatterFormat from BaseDate + * @psalm-import-type SkipOnEmptyValue from SkipOnEmptyInterface + * @psalm-import-type WhenType from WhenInterface + */ +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] +final class Time extends BaseDate +{ + /** + * @param string|null $format The time format that the value being validated should follow. This can be a time + * pattern as described in the + * [ICU manual](https://unicode-org.github.io/icu/userguide/format_parse/datetime/#datetime-format-syntax). + * + * Alternatively this can be a string prefixed with `php:` representing a format that can be recognized by + * the PHP Datetime class. Please refer to + * {@link https://www.php.net/manual/datetimeimmutable.createfromformat.php} on supported formats. + * + * Here are some example values: + * + * ```php + * 'HH:mm' // time in ICU format + * 'php:H:i' // time in PHP format + * ``` + * + * @param int|null $timeType Format of the time determined by one of the `IntlDateFormatter` constants. It is used + * when {@see $format} is not set. + * + * @param string|null $timeZone The timezone to use for parsing and formatting date values. This can be any value + * that may be passed to + * [date_default_timezone_set()](https://www.php.net/manual/function.date-default-timezone-set.php)> e.g. `UTC`, + * `Europe/Berlin` or `America/Chicago`. Refer to the + * [php manual](https://secure.php.net/manual/en/timezones.php) for available timezones. + * + * @param string|null $locale Locale to use when formatting or parsing or `null` to use the value specified in the + * ini setting `intl.default_locale`. + * + * @param DateTimeInterface|int|string|null $min Lower limit of the time. Defaults to `null`, meaning no lower + * limit. This can be a unix timestamp or a string representing a date value or `DateTimeInterface` instance. + * + * @param DateTimeInterface|int|string|null $max Upper limit of the time. Defaults to `null`, meaning no upper + * limit. This can be a unix timestamp or a string representing a date value or `DateTimeInterface` instance. + * + * @param string|null $messageFormat Format of time that is used in error messages. + * + * @param int|null $messageTimeType One of the `IntlDateFormatter` constants that used determines time format for + * error messages used when {@see $messageFormat} is not set. + * + * @param string|null $incorrectInputMessage A message used when the validated value is not valid time. You may use + * the following placeholders in the message: + * - `{attribute}`: the translated label of the attribute being validated. + * + * @param string|null $tooEarlyMessage A message used when the validated time is less than {@see $min}. You may use + * the following placeholders in the message: + * - `{attribute}`: the translated label of the attribute being validated. + * - `{value}`: the validated time. + * - `{limit}`: expected minimum time. + * + * @param string|null $tooLateMessage A message used when the validated time is more than {@see $max}. You may use + * the following placeholders in the message: + * - `{attribute}`: the translated label of the attribute being validated. + * - `{value}`: the validated time. + * - `{limit}`: expected maximum time. + * + * @param mixed|null $skipOnEmpty Whether to skip this rule if the value validated is empty. + * See {@see SkipOnEmptyInterface}. + * + * @param bool $skipOnError Whether to skip this rule if any of the previous rules gave an error. + * See {@see SkipOnErrorInterface}. + * + * @param Closure|null $when A callable to define a condition for applying the rule. See {@see WhenInterface}. + * + * @psalm-param non-empty-string|null $timeZone + * @psalm-param IntlDateFormatterFormat|null $timeType + * @psalm-param IntlDateFormatterFormat|null $messageTimeType + * @psalm-param SkipOnEmptyValue $skipOnEmpty + * @psalm-param WhenType $when + */ + public function __construct( + ?string $format = null, + private ?int $timeType = null, + ?string $timeZone = null, + ?string $locale = null, + int|string|DateTimeInterface|null $min = null, + int|string|DateTimeInterface|null $max = null, + ?string $messageFormat = null, + private ?int $messageTimeType = null, + ?string $incorrectInputMessage = null, + ?string $tooEarlyMessage = null, + ?string $tooLateMessage = null, + mixed $skipOnEmpty = null, + bool $skipOnError = false, + Closure|null $when = null, + ) { + parent::__construct( + $format, + $timeZone, + $locale, + $min, + $max, + $messageFormat, + $incorrectInputMessage, + $tooEarlyMessage, + $tooLateMessage, + $skipOnEmpty, + $skipOnError, + $when, + ); + } + + /** + * @psalm-return IntlDateFormatterFormat|null + */ + public function getTimeType(): ?int + { + return $this->timeType; + } + + /** + * @psalm-return IntlDateFormatterFormat|null + */ + public function getMessageTimeType(): ?int + { + return $this->messageTimeType; + } + + public function getHandler(): string + { + return TimeHandler::class; + } +} diff --git a/src/Rule/Date/TimeHandler.php b/src/Rule/Date/TimeHandler.php new file mode 100644 index 000000000..b7eb4e7a8 --- /dev/null +++ b/src/Rule/Date/TimeHandler.php @@ -0,0 +1,41 @@ +validate($data, $rules); + public function testValidationPassed( + mixed $data, + array|RuleInterface|null $rules = null, + ?array $ruleHandlers = null + ): void { + $validator = new Validator( + ruleHandlerResolver: $ruleHandlers === null ? null : new SimpleRuleHandlerContainer($ruleHandlers) + ); + $result = $validator->validate($data, $rules); $this->assertSame([], $result->getErrorMessagesIndexedByPath()); } @@ -30,11 +38,26 @@ abstract public function dataValidationFailed(): array; public function testValidationFailed( mixed $data, array|RuleInterface|null $rules, - array $errorMessagesIndexedByPath + array $errorMessagesIndexedByPath, + ?array $ruleHandlers = null ): void { - $result = (new Validator())->validate($data, $rules); + $validator = new Validator( + ruleHandlerResolver: $ruleHandlers === null ? null : new SimpleRuleHandlerContainer($ruleHandlers) + ); + $result = $validator->validate($data, $rules); $this->assertFalse($result->isValid()); - $this->assertSame($errorMessagesIndexedByPath, $result->getErrorMessagesIndexedByPath()); + $this->assertSame($errorMessagesIndexedByPath, $this->getErrorMessages($result)); + } + + private function getErrorMessages(Result $result): array + { + return array_map( + static fn(array $errors) => array_map( + static fn(string $error) => str_replace(' ', ' ', $error), + $errors + ), + $result->getErrorMessagesIndexedByPath(), + ); } } diff --git a/tests/Rule/CompareTest.php b/tests/Rule/CompareTest.php index 24bc942ed..02142baf3 100644 --- a/tests/Rule/CompareTest.php +++ b/tests/Rule/CompareTest.php @@ -543,9 +543,12 @@ public function hasAttribute(string $attribute): bool * @dataProvider dataValidationPassed * @dataProvider dataValidationPassedWithDifferentTypes */ - public function testValidationPassed(mixed $data, array|RuleInterface|null $rules = null): void - { - parent::testValidationPassed($data, $rules); + public function testValidationPassed( + mixed $data, + array|RuleInterface|null $rules = null, + ?array $ruleHandlers = null + ): void { + parent::testValidationPassed($data, $rules, $ruleHandlers); } public function dataValidationFailed(): array @@ -974,8 +977,9 @@ public function testValidationFailed( mixed $data, array|RuleInterface|null $rules, array $errorMessagesIndexedByPath, + ?array $ruleHandlers = null ): void { - parent::testValidationFailed($data, $rules, $errorMessagesIndexedByPath); + parent::testValidationFailed($data, $rules, $errorMessagesIndexedByPath, $ruleHandlers); } private function extendDataWithDifferentTypes(array $initialData): array diff --git a/tests/Rule/Date/DateTest.php b/tests/Rule/Date/DateTest.php new file mode 100644 index 000000000..fa217253a --- /dev/null +++ b/tests/Rule/Date/DateTest.php @@ -0,0 +1,205 @@ +assertSame('date', $rule->getName()); + } + + public function dataValidationPassed(): array + { + return [ + 'php-format' => ['2021-01-01', new Date(format: 'php:Y-m-d')], + 'intl-format' => ['2021-01-01', new Date(format: 'yyyy-MM-dd')], + 'datetime' => [new DateTimeImmutable('2021-01-01'), new Date()], + 'min' => ['2021-01-01', new Date(format: 'yyyy-MM-dd', min: '2020-01-01')], + 'max' => ['2021-01-01', new Date(format: 'yyyy-MM-dd', max: '2022-01-01')], + 'min-equal' => ['2021-01-01', new Date(format: 'yyyy-MM-dd', min: '2021-01-01')], + 'max-equal' => ['2021-01-01', new Date(format: 'yyyy-MM-dd', max: '2021-01-01')], + 'timezone' => [ + '12.11.2003', + new Date( + format: 'php:d.m.Y', + timeZone: 'UTC', + min: new DateTimeImmutable('12.11.2003, 1:00:00', new DateTimeZone('GMT+3')), + ), + ], + 'timestamp' => [1711705158, new Date(min: 1711705100)], + 'zero-time' => [ + '2021-01-01', + new Date(format: 'php:Y-m-d', max: new DateTimeImmutable('2021-01-01, 00:00:00')), + ], + 'rule-locale' => [ + '29.03.2024', + new Date(locale: 'ru'), + ], + ]; + } + + public function dataValidationFailed(): array + { + $invalidDateMessage = ['' => ['Invalid date value.']]; + return [ + 'php-format-invalid' => ['2021.01.01', new Date(format: 'php:Y-m-d'), $invalidDateMessage], + 'intl-format-invalid' => ['2021.01.01', new Date(format: 'yyyy-MM-dd'), $invalidDateMessage], + 'invalid-date' => ['2021.02.30', new Date(format: 'yyyy-MM-dd'), $invalidDateMessage], + 'invalid-value' => [new stdClass(), new Date(), $invalidDateMessage], + 'invalid-value-custom-message' => [ + ['a' => new stdClass()], + ['a' => new Date(incorrectInputMessage: 'Invalid — {attribute}.')], + ['a' => ['Invalid — a.']], + ], + 'min' => [ + '2024-03-29', + new Date(format: 'yyyy-MM-dd', min: '2025-01-01'), + ['' => ['The value must be no early than 1/1/25.']], + ], + 'min-custom-message' => [ + ['a' => '2024-03-29'], + [ + 'a' => new Date( + format: 'php:Y-m-d', + min: '2025-01-01', + tooEarlyMessage: 'Attr — {attribute}. Date — {value}. Min — {limit}.', + ), + ], + ['a' => ['Attr — a. Date — 3/29/24. Min — 1/1/25.']], + ], + 'max' => [ + '2024-03-29', + new Date(format: 'php:Y-m-d', max: '2024-01-01'), + ['' => ['The value must be no late than 1/1/24.']], + ], + 'max-custom-message' => [ + ['a' => '2024-03-29'], + [ + 'a' => new Date( + format: 'php:Y-m-d', + max: '2024-01-01', + tooLateMessage: 'Attr — {attribute}. Date — {value}. Max — {limit}.', + ), + ], + ['a' => ['Attr — a. Date — 3/29/24. Max — 1/1/24.']], + ], + 'rule-and-handler-locales' => [ + '2024-03-29', + new Date(format: 'php:Y-m-d', locale: 'ru', max: '2024-01-01'), + ['' => ['The value must be no late than 01.01.2024.']], + [DateHandler::class => new DateHandler(locale: 'en')], + ], + 'handler-locale' => [ + '2024-03-29', + new Date(format: 'php:Y-m-d', max: '2024-01-01'), + ['' => ['The value must be no late than 01.01.2024.']], + [DateHandler::class => new DateHandler(locale: 'ru')], + ], + 'timestamp' => [ + 1711705158, + new Date(min: 1711705200), + ['' => ['The value must be no early than 3/29/24.']], + ], + 'without-message-date-type' => [ + '29*03*2024', + new Date(format: 'php:d*m*Y', max: '11*11*2023', ), + ['' => ['The value must be no late than 11/11/23.']], + [DateHandler::class => new DateHandler(messageDateType: null)], + ], + 'rule-message-format' => [ + '29*03*2024', + new Date(format: 'php:d*m*Y', max: '11*11*2023', messageFormat: 'php:d=m=Y'), + ['' => ['The value must be no late than 11=11=2023.']], + [DateHandler::class => new DateHandler(messageFormat: 'php:d_m_Y')], + ], + 'handler-message-type' => [ + 'Mar 29, 2024', + new Date(max: 'Dec 11, 2019', dateType: IntlDateFormatter::MEDIUM), + ['' => ['The value must be no late than Wednesday, December 11, 2019.']], + [DateHandler::class => new DateHandler(messageDateType: IntlDateFormatter::FULL)], + ], + 'rule-message-type-override-handler' => [ + '3/29/2024', + new Date(max: '12/11/2019', messageDateType: IntlDateFormatter::SHORT), + ['' => ['The value must be no late than 12/11/19.']], + [DateHandler::class => new DateHandler(messageDateType: IntlDateFormatter::FULL)], + ], + 'rule-locale-override-handler' => [ + '12.11.2002', + new Date(max: '10.11.2002', locale: 'ru'), + ['' => ['The value must be no late than 10.11.2002.']], + [DateHandler::class => new DateHandler(locale: 'en')], + ], + ]; + } + + public function testDifferentRuleInHandlerItems(): array + { + $rule = new RuleWithCustomHandler(DateHandler::class); + $validator = new Validator(); + + $this->expectException(UnexpectedRuleException::class); + $this->expectExceptionMessage( + 'Expected "' . Date::class . '", "' . DateTime::class . '", "' . Time::class . '", but "' . RuleWithCustomHandler::class . '" given.' + ); + $validator->validate([], $rule); + } + + public function testInvalidMinValue(): void + { + $rule = new Date(format: 'php:Y-m-d', min: '12.11.2023'); + $validator = new Validator(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Invalid date value.'); + $validator->validate('2024-11-01', $rule); + } + + public function testInvalidMaxValue(): void + { + $rule = new Date(format: 'php:Y-m-d', max: '12.11.2023'); + $validator = new Validator(); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('Invalid date value.'); + $validator->validate('2024-11-01', $rule); + } + + public function testSkipOnError(): void + { + $this->testSkipOnErrorInternal(new Date(), new Date(skipOnError: true)); + } + + public function testWhen(): void + { + $this->testWhenInternal( + new Date(), + new Date( + when: static fn(mixed $value): bool => $value !== null + ) + ); + } +} diff --git a/tests/Rule/Date/DateTimeTest.php b/tests/Rule/Date/DateTimeTest.php new file mode 100644 index 000000000..e18d7ea8b --- /dev/null +++ b/tests/Rule/Date/DateTimeTest.php @@ -0,0 +1,112 @@ +assertSame('date', $rule->getName()); + } + + public function dataValidationPassed(): array + { + return [ + 'php-format' => ['2021-01-01, 12:35', new DateTime(format: 'php:Y-m-d, H:i')], + 'intl-format' => ['2021-01-01, 12:35', new DateTime(format: 'yyyy-MM-dd, HH:mm')], + 'datetime' => [new DateTimeImmutable('2021-01-01, 12:35'), new DateTime()], + 'min' => ['2021-01-01, 12:35', new DateTime(format: 'yyyy-MM-dd, HH:mm', min: '2020-01-01, 16:00')], + 'max' => ['2021-01-01, 12:35', new DateTime(format: 'yyyy-MM-dd, HH:mm', max: '2022-01-01, 16:00')], + 'min-equal' => ['2021-01-01, 12:35', new DateTime(format: 'yyyy-MM-dd, HH:mm', min: '2021-01-01, 12:35')], + 'max-equal' => ['2021-01-01, 12:35', new DateTime(format: 'yyyy-MM-dd, HH:mm', max: '2021-01-01, 12:35')], + 'timezone' => [ + '12.11.2003, 15:00:00', + new DateTime( + format: 'php:d.m.Y, H:i:s', + timeZone: 'UTC', + min: new DateTimeImmutable('12.11.2003, 16:00:00', new DateTimeZone('GMT+3')), + ), + ], + 'timestamp' => [1711705158, new DateTime(min: 1711705100)], + 'rule-timezone-override-handler' => [ + '12.11.2003, 15:00:00', + new DateTime( + format: 'php:d.m.Y, H:i:s', + timeZone: 'UTC', + min: new DateTimeImmutable('12.11.2003, 16:00:00', new DateTimeZone('GMT+3')), + ), + [DateTimeHandler::class => new DateTimeHandler(timeZone: 'GMT+3')], + ], + ]; + } + + public function dataValidationFailed(): array + { + $invalidDateMessage = ['' => ['Invalid date value.']]; + return [ + 'php-format-invalid' => ['2021.01.01, 12:35', new DateTime(format: 'php:Y-m-d, H:i'), $invalidDateMessage], + 'php-format-invalid-2' => [ + '2021-17-35 16:60:97', + new DateTime(format: 'php:Y-m-d H:i:s'), + $invalidDateMessage, + ], + 'intl-format-invalid' => [ + '2021.01.01, 12:35', + new DateTime(format: 'yyyy-MM-dd, HH:mm'), + $invalidDateMessage, + ], + 'invalid-date' => ['2021.02.12, 25:24', new DateTime(format: 'yyyy-MM-dd, HH:mm'), $invalidDateMessage], + 'min' => [ + '2024-03-29, 12:35', + new DateTime(format: 'yyyy-MM-dd, HH:mm', min: '2025-01-01, 11:00'), + ['' => ['The value must be no early than 1/1/25, 11:00 AM.']], + ], + 'max' => [ + '2024-03-29, 12:50', + new DateTime(format: 'php:Y-m-d, H:i', max: '2024-01-01, 00:00'), + ['' => ['The value must be no late than 1/1/24, 12:00 AM.']], + ], + 'timestamp' => [ + 1711705158, + new DateTime(format: 'php:d.m.Y, H:i:s', min: 1711705200), + ['' => ['The value must be no early than 3/29/24, 9:40 AM.']], + ], + 'without-message-date-and-time-type' => [ + '29*03*2024*12*35', + new DateTime(format: 'php:d*m*Y*H*i', max: '11*11*2023*12*35'), + ['' => ['The value must be no late than 11/11/23, 12:35 PM.']], + [DateTimeHandler::class => new DateTimeHandler(messageDateType: null, messageTimeType: null)], + ], + ]; + } + + public function testSkipOnError(): void + { + $this->testSkipOnErrorInternal(new Date(), new Date(skipOnError: true)); + } + + public function testWhen(): void + { + $this->testWhenInternal( + new Date(), + new Date( + when: static fn(mixed $value): bool => $value !== null + ) + ); + } +} diff --git a/tests/Rule/Date/TimeTest.php b/tests/Rule/Date/TimeTest.php new file mode 100644 index 000000000..f58104edc --- /dev/null +++ b/tests/Rule/Date/TimeTest.php @@ -0,0 +1,113 @@ +assertSame('date', $rule->getName()); + } + + public function dataValidationPassed(): array + { + return [ + 'php-format' => ['12:34', new Time(format: 'php:H:i')], + 'intl-format' => ['12:30', new Time(format: 'HH:mm')], + 'datetime' => [new DateTimeImmutable('2021-01-01, 12:34'), new Time()], + 'min' => ['12:00', new Time(format: 'HH:mm', min: '11:00')], + 'max' => ['12:00', new Time(format: 'HH:mm', max: '13:00')], + 'min-equal' => ['12:00', new Time(format: 'HH:mm', min: '12:00')], + 'max-equal' => ['12:00', new Time(format: 'HH:mm', max: '12:00')], + 'timezone' => [ + '15:00:00', + new Time( + format: 'php:H:i:s', + timeZone: 'UTC', + min: new DateTimeImmutable('12.11.2100, 16:00:00', new DateTimeZone('GMT+3')), + ), + ], + 'timestamp' => [1711705158, new Time(min: 1711705100)], + ]; + } + + public function dataValidationFailed(): array + { + $invalidDateMessage = ['' => ['Invalid time value.']]; + return [ + 'php-format-invalid' => ['12-35', new Time(format: 'php:H:i'), $invalidDateMessage], + 'intl-format-invalid' => ['12-35', new Time(format: 'HH:mm'), $invalidDateMessage], + 'invalid-date' => ['25-35', new Time(format: 'HH:mm'), $invalidDateMessage], + 'min' => [ + '15:30', + new Time(format: 'HH:mm', min: '15:40'), + ['' => ['The value must be no early than 3:40 PM.']], + ], + 'max' => [ + '15:30', + new Time(format: 'php:H:i', max: '12:00'), + ['' => ['The value must be no late than 12:00 PM.']], + ], + 'timestamp' => [ + 1711705158, + new Time(format: 'php:d.m.Y, H:i:s', min: 1711705200), + ['' => ['The value must be no early than 9:40 AM.']], + ], + 'without-message-time-type' => [ + '13*30', + new Time(format: 'php:H*i', max: '11*30'), + ['' => ['The value must be no late than 11:30 AM.']], + [TimeHandler::class => new TimeHandler(messageTimeType: null)], + ], + 'rule-message-format' => [ + '13*30', + new Time(format: 'php:H*i', max: '11*30', messageFormat: 'php:H-i'), + ['' => ['The value must be no late than 11-30.']], + [TimeHandler::class => new TimeHandler(messageFormat: 'php:H_i')], + ], + 'handler-message-type' => [ + '13*30', + new Time(format: 'php:H*i', max: '11*30', timeType: IntlDateFormatter::SHORT), + ['' => ['The value must be no late than 11:30:00 AM Coordinated Universal Time.']], + [TimeHandler::class => new TimeHandler(messageTimeType: IntlDateFormatter::FULL)], + ], + 'rule-message-type-override-handler' => [ + '13*30', + new Time(format: 'php:H*i', max: '11*30', messageTimeType: IntlDateFormatter::SHORT), + ['' => ['The value must be no late than 11:30 AM.']], + [TimeHandler::class => new TimeHandler(messageTimeType: IntlDateFormatter::FULL)], + ], + ]; + } + + public function testSkipOnError(): void + { + $this->testSkipOnErrorInternal(new Date(), new Date(skipOnError: true)); + } + + public function testWhen(): void + { + $this->testWhenInternal( + new Date(), + new Date( + when: static fn(mixed $value): bool => $value !== null + ) + ); + } +}