Skip to content

Commit

Permalink
Merge pull request #275 from gsteel/v2/date-comparison
Browse files Browse the repository at this point in the history
Introduce DateComparison validator
  • Loading branch information
gsteel committed Jun 25, 2024
2 parents c37de0f + cb265a8 commit 432d30a
Show file tree
Hide file tree
Showing 8 changed files with 513 additions and 0 deletions.
1 change: 1 addition & 0 deletions docs/book/v2/set.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ The following validators come with the laminas-validator distribution.
- [CreditCard](validators/credit-card.md)
- [CSRF (Cross-site request forgery](validators/csrf.md)
- [Date](validators/date.md)
- [DateComparison](validators/date-comparison.md)
- [RecordExists and NoRecordExists (database)](validators/db.md)
- [Digits](validators/digits.md)
- [EmailAddress](validators/email-address.md)
Expand Down
116 changes: 116 additions & 0 deletions docs/book/v2/validators/date-comparison.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
# Date Comparison Validator

`Laminas\Validator\DateComparison` allows you to validate if a given value is a date that is either:

- Between two pre defined dates
- After a minimum date
- Before a maximum date

By default, comparisons are inclusive.

## Supported Options

The following options are supported for `Laminas\Validator\DateComparison`:

| Option | Data Type | Default Value | Description |
|----------------|-----------------------------|---------------|--------------------------------------------------------------------------|
| `max` | `string\|DateTimeInterface` | `null` | Sets the upper bound for the input. |
| `min` | `string\|DateTimeInterface` | `null` | Sets the lower bound for the input. |
| `inclusiveMin` | `bool` | `true` | Defines if the validation is inclusive of the lower bound, or exclusive. |
| `inclusiveMax` | `bool` | `true` | Defines if the validation is inclusive of the upper bound, or exclusive. |
| `inputFormat` | `string` | `null` | Defines the expected date format if required. |

## Min and Max Date Options

The `min` and `max` options when set must be one of the following:

- An object that implements `DateTimeInterface`
- A date string in ISO format, `YYYY-MM-DD`, i.e. '2020-01-31'
- A date and time string in W3C format, `YYYY-MM-DDTHH:MM:SS`, i.e. '2020-01-31T12:34:56'

At least one of `min`, `max` or both *must* be provided as an option or an exception will be thrown.
It doesn't make sense to use this validator without specifying the boundaries to compare the input to.

## Default Behaviour

Per default, this validator checks if a value is between `min` and `max` where both upper and lower bounds are considered valid.

```php
$valid = new Laminas\Validator\DateComparison([
'min' => '2020-01-01',
'max' => '2020-12-31',
]);
$value = '2020-01-01';
$result = $valid->isValid($value);
// returns true
```

In the above example, the result is `true` due to the reason that the default search is inclusive of the border values.
This means in our case that any date between '1st January 2020' to '31st December 2020' is allowed; any other valid date will return `false`.

## Min and Max Behaviour

In order to validate a date that is after than a lower bound, either omit the `max` option, or set it explicitly to `null`:

```php
$validator = new Laminas\Validator\DateComparison([
'min' => '2020-01-01',
'max' => null,
]);
$validator->isValid('2020-02-03'); // true
```

Conversely, to ensure a date is prior to an upper bound, omit the `min` option or explicitly set it to `null`:

```php
$validator = new Laminas\Validator\DateComparison(['max' => '2020-12-31']);
$validator->isValid('2024-06-07'); // false
```

## Validity of Date Inputs

In order to compare dates correctly, the validator converts the input to a `DateTimeInterface` object, therefore, it must be possible to parse string input as a valid date.

Because it is likely that the validator will be paired with some kind of web form, known formats returned by [`<input type="datetime-local">`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local) or [`<input type="date">`](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/date) are **always supported** without further configuration. For example:

```php
$validator = new Laminas\Validator\DateComparison([
'min' => '2020-01-01',
]);

$validator->isValid('2020-03-04'); // true
$validator->isValid('2020-01-01T12:34:56'); // true
```

If you have inputs in your application where you expect dates to be provided in a different format such as `l jS F Y`, you can use the `inputFormat` option to specify this:

```php
$validator = new Laminas\Validator\DateComparison([
'min' => '2020-01-01',
'inputFormat' => 'l jS F Y',
]);
$validator->isValid('Wednesday 1st January 2020'); // true
```

## Time Zones

Time zones for the `min` and `max` options, and for the validated value are discarded and all dates are compared as UTC date-times.

```php
$africa = new DateTimeZone('Africa/Johannesburg');

$lower = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2020-01-01 10:00:00', $africa);
$upper = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2020-01-01 12:00:00', $africa);

$validator = new Laminas\Validator\DateComparison([
'min' => $lower,
'max' => $upper,
]);

$usa = new DateTimeZone('America/New_York');
$input = DateTimeImmutable::createFromFormat('Y-m-d H:i:s', '2020-01-01 10:45:00', $usa);

$validator->isValid($input); // true
```

In the above example, the validated value is considered as `2020-01-01 10:45:00` in _any_ timezone, and it is between the lower bound of `2020-01-01 10:00:00` and the upper bound of `2020-01-01 12:00:00`
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ nav:
- CreditCard: v2/validators/credit-card.md
- Csrf: v2/validators/csrf.md
- Date: v2/validators/date.md
- DateComparison: v2/validators/date-comparison.md
- "Db\\RecordExists and Db\\NoRecordExists": v2/validators/db.md
- Digits: v2/validators/digits.md
- EmailAddress: v2/validators/email-address.md
Expand Down
5 changes: 5 additions & 0 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -2393,6 +2393,11 @@
<code><![CDATA[timeoutValuesDataProvider]]></code>
</PossiblyUnusedMethod>
</file>
<file src="test/DateComparisonTest.php">
<PossiblyUnusedMethod>
<code><![CDATA[basicDataProvider]]></code>
</PossiblyUnusedMethod>
</file>
<file src="test/DateStepTest.php">
<PossiblyUnusedMethod>
<code><![CDATA[moscowWinterTimeDataProvider]]></code>
Expand Down
212 changes: 212 additions & 0 deletions src/DateComparison.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
<?php

declare(strict_types=1);

namespace Laminas\Validator;

use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use Laminas\Validator\Exception\InvalidArgumentException;

use function assert;
use function get_debug_type;
use function is_string;
use function preg_match;

/**
* @psalm-type OptionsArgument = array{
* min?: string|DateTimeInterface|null,
* max?: string|DateTimeInterface|null,
* inclusiveMin?: bool,
* inclusiveMax?: bool,
* inputFormat?: string|null,
* }
*/
final class DateComparison extends AbstractValidator
{
public const ERROR_INVALID_TYPE = 'invalidType';
public const ERROR_INVALID_DATE = 'invalidDate';
public const ERROR_NOT_GREATER_INCLUSIVE = 'notGreaterInclusive';
public const ERROR_NOT_GREATER = 'notGreater';
public const ERROR_NOT_LESS_INCLUSIVE = 'notLessInclusive';
public const ERROR_NOT_LESS = 'notLess';

/** @var array<string, string> */
protected array $messageTemplates = [
self::ERROR_INVALID_TYPE => 'Expected a string or a date time instance but received "%type"',
self::ERROR_INVALID_DATE => 'Invalid date provided',
self::ERROR_NOT_GREATER_INCLUSIVE => 'A date equal to or after %min% is required',
self::ERROR_NOT_GREATER => 'A date after %min% is required',
self::ERROR_NOT_LESS_INCLUSIVE => 'A date equal to or before %max% is required',
self::ERROR_NOT_LESS => 'A date before %max% is required',
];

/** @var array<string, string> */
protected array $messageVariables = [
'type' => 'type',
'min' => 'minString',
'max' => 'maxString',
];

private readonly ?DateTimeInterface $min;
private readonly ?DateTimeInterface $max;
private readonly bool $inclusiveMin;
private readonly bool $inclusiveMax;
private readonly ?string $inputFormat;

/** Input type used in message variables */
protected ?string $type = null;
protected ?string $minString = null;
protected ?string $maxString = null;

/** @param OptionsArgument $options */
public function __construct(array $options = [])
{
parent::__construct($options);

$this->min = $this->dateInstanceBound($options['min'] ?? null);
$this->max = $this->dateInstanceBound($options['max'] ?? null);
$this->inclusiveMin = $options['inclusiveMin'] ?? true;
$this->inclusiveMax = $options['inclusiveMax'] ?? true;
$this->inputFormat = $options['inputFormat'] ?? null;

if ($this->min === null && $this->max === null) {
throw new InvalidArgumentException(
'At least one date boundary must be supplied',
);
}

$outputFormat = $this->inputFormat ?? 'jS F Y H:i:s';

if ($this->min !== null) {
$this->minString = $this->min->format($outputFormat);
}

if ($this->max !== null) {
$this->maxString = $this->max->format($outputFormat);
}
}

public function isValid(mixed $value): bool
{
$this->type = get_debug_type($value);
$this->value = $value;

if (! is_string($value) && ! $value instanceof DateTimeInterface) {
$this->error(self::ERROR_INVALID_TYPE);

return false;
}

$date = $this->valueToDate($value);
if ($date === null) {
$this->error(self::ERROR_INVALID_DATE);

return false;
}

if ($this->min !== null && $this->inclusiveMin && $date < $this->min) {
$this->error(self::ERROR_NOT_GREATER_INCLUSIVE);

return false;
}

if ($this->min !== null && ! $this->inclusiveMin && $date <= $this->min) {
$this->error(self::ERROR_NOT_GREATER);

return false;
}

if ($this->max !== null && $this->inclusiveMax && $date > $this->max) {
$this->error(self::ERROR_NOT_LESS_INCLUSIVE);

return false;
}

if ($this->max !== null && ! $this->inclusiveMax && $date >= $this->max) {
$this->error(self::ERROR_NOT_LESS);

return false;
}

return true;
}

private function valueToDate(string|DateTimeInterface $input): DateTimeInterface|null
{
if ($input instanceof DateTimeInterface) {
return $this->w3cDateFromString($input->format('Y-m-d\TH:i:s'));
}

if ($this->inputFormat !== null) {
$date = DateTimeImmutable::createFromFormat($this->inputFormat, $input, new DateTimeZone('UTC'));

if ($date instanceof DateTimeImmutable) {
return $date;
}
}

$date = $this->isoDateFromString($input);
if ($date !== null) {
return $date;
}

$date = $this->w3cDateFromString($input);
if ($date !== null) {
return $date;
}

return null;
}

private function dateInstanceBound(string|DateTimeInterface|null $dateTime): DateTimeInterface|null
{
if ($dateTime instanceof DateTimeInterface) {
return $this->w3cDateFromString($dateTime->format('Y-m-d\TH:i:s'));
}

if ($dateTime === null) {
return null;
}

$date = $this->isoDateFromString($dateTime);
if ($date !== null) {
return $date;
}

$date = $this->w3cDateFromString($dateTime);
if ($date !== null) {
return $date;
}

throw new InvalidArgumentException(
'Min/max date bounds must be either DateTime instances, or a string in one of the formats: '
. '"Y-m-d" for a date or "Y-m-d\TH:i:s" for date time',
);
}

private function isoDateFromString(string $input): DateTimeImmutable|null
{
if (! preg_match('/^\d{4}-[0-1]\d-[0-3]\d$/', $input)) {
return null;
}

$date = DateTimeImmutable::createFromFormat('!Y-m-d', $input, new DateTimeZone('UTC'));
assert($date !== false);

return $date;
}

private function w3cDateFromString(string $input): DateTimeImmutable|null
{
if (! preg_match('/^\d{4}-[0-1]\d-[0-3]\dT\d{1,2}:[0-5]\d:[0-5]\d$/', $input)) {
return null;
}

$date = DateTimeImmutable::createFromFormat('Y-m-d\TH:i:s', $input, new DateTimeZone('UTC'));
assert($date !== false);

return $date;
}
}
1 change: 1 addition & 0 deletions src/ValidatorPluginManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ class ValidatorPluginManager extends AbstractPluginManager
Csrf::class => InvokableFactory::class,
DateStep::class => InvokableFactory::class,
Date::class => InvokableFactory::class,
DateComparison::class => InvokableFactory::class,
I18nValidator\DateTime::class => InvokableFactory::class,
Db\NoRecordExists::class => InvokableFactory::class,
Db\RecordExists::class => InvokableFactory::class,
Expand Down
Loading

0 comments on commit 432d30a

Please sign in to comment.