Skip to content

Commit

Permalink
Merge pull request #274 from gsteel/v2/number-comparison
Browse files Browse the repository at this point in the history
Introduce NumberComparison validator
  • Loading branch information
Ocramius committed Jun 25, 2024
2 parents 432d30a + c69dfb5 commit 66ab091
Show file tree
Hide file tree
Showing 8 changed files with 330 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 @@ -28,6 +28,7 @@ The following validators come with the laminas-validator distribution.
- [IsInstanceOf](validators/isinstanceof.md)
- [LessThan](validators/less-than.md)
- [NotEmpty](validators/not-empty.md)
- [NumberComparison](validators/number-comparison.md)
- [Regex](validators/regex.md)
- [Sitemap](validators/sitemap.md)
- [Step](validators/step.md)
Expand Down
76 changes: 76 additions & 0 deletions docs/book/v2/validators/number-comparison.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Number Comparison Validator

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

- Between a min and max value
- Greater than a min value
- Less than a max value

By default, comparisons are inclusive.

CAUTION: **Only supports number validation**
`Laminas\Validator\NumberComparison` supports only the validation of numbers.
Strings or dates can not be validated with this validator.

## Supported Options

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

| Option | Data Type | Default Value | Description |
|----------------|-----------|---------------|--------------------------------------------------------------------------|
| `max` | `numeric` | `null` | Sets the upper bound for the input. |
| `min` | `numeric` | `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. |

## Basic Usage

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\NumberComparison(['min' => 0, 'max' => 10]);
$value = 10;
$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 value from `0` to `10` is allowed; values like `-1` and `11` will return `false`.

## Excluding Upper and Lower Bounds

Sometimes it is useful to validate a value by excluding the bounds. See the following example:

```php
$valid = new Laminas\Validator\NumberComparison([
'min' => 0,
'max' => 10,
'inclusiveMin' => false,
'inclusiveMax' => false,
]);

$valid->isValid(10); // false
$valid->isValid(0); // false
$valid->isValid(9); // true
```

The example above is almost identical to our first example, but we now exclude the bounds as valid values; as such, the values `0` and `10` are no longer allowed and will return `false`.

## Min and Max behaviour

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

```php
$validator = new Laminas\Validator\NumberComparison(['min' => 10, 'max' => null]);
$validator->isValid(12345); // true
```

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

```php
$validator = new Laminas\Validator\NumberComparison(['max' => 5]);
$validator->isValid(99); // false
```

You *must* provide one of the `min` or the `max` _(or both)_ options or an exception will be thrown.
It doesn't make sense to compare the input to nothing for this validator.
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ nav:
- IsJsonString: v2/validators/is-json-string.md
- LessThan: v2/validators/less-than.md
- NotEmpty: v2/validators/not-empty.md
- NumberComparison: v2/validators/number-comparison.md
- Regex: v2/validators/regex.md
- Sitemap: v2/validators/sitemap.md
- Step: v2/validators/step.md
Expand Down
14 changes: 14 additions & 0 deletions psalm-baseline.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1854,6 +1854,15 @@
<code><![CDATA[is_object($value) && method_exists($value, '__toString')]]></code>
</RedundantCondition>
</file>
<file src="src/NumberComparison.php">
<MixedAssignment>
<code><![CDATA[$inclusiveMax]]></code>
<code><![CDATA[$inclusiveMin]]></code>
<code><![CDATA[$max]]></code>
<code><![CDATA[$max]]></code>
<code><![CDATA[$min]]></code>
</MixedAssignment>
</file>
<file src="src/Regex.php">
<DeprecatedConstant>
<code><![CDATA[self::ERROROUS]]></code>
Expand Down Expand Up @@ -3018,6 +3027,11 @@
<code><![CDATA[zeroOnlyProvider]]></code>
</PossiblyUnusedMethod>
</file>
<file src="test/NumberComparisonTest.php">
<PossiblyUnusedMethod>
<code><![CDATA[basicTestDataProvider]]></code>
</PossiblyUnusedMethod>
</file>
<file src="test/RegexTest.php">
<DeprecatedConstant>
<code><![CDATA[Regex::ERROROUS]]></code>
Expand Down
124 changes: 124 additions & 0 deletions src/NumberComparison.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
<?php

declare(strict_types=1);

namespace Laminas\Validator;

use Laminas\Validator\Exception\InvalidArgumentException;

use function is_numeric;

/**
* @psalm-type OptionsArgument = array{
* min?: numeric|null,
* max?: numeric|null,
* inclusiveMin?: bool,
* inclusiveMax?: bool,
* ...<string, mixed>
* }
* @psalm-type Options = array{
* min: numeric|null,
* max: numeric|null,
* inclusiveMin: bool,
* inclusiveMax: bool,
* }
*/
final class NumberComparison extends AbstractValidator
{
public const ERROR_NOT_NUMERIC = 'notNumeric';
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_NOT_NUMERIC => 'Expected a numeric value',
self::ERROR_NOT_GREATER_INCLUSIVE => 'Values must be greater than or equal to %min%. Received "%value%"',
self::ERROR_NOT_GREATER => 'Values must be greater than %min%. Received "%value%',
self::ERROR_NOT_LESS_INCLUSIVE => 'Values must be less than or equal to %max%. Received "%value%"',
self::ERROR_NOT_LESS => 'Values must be less than %max%. Received "%value%"',
];

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

/** @var Options */
protected array $options = [
'min' => null,
'max' => null,
'inclusiveMin' => true,
'inclusiveMax' => true,
];

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

$min = $options['min'] ?? null;
$max = $options['max'] ?? null;

if (! is_numeric($min) && ! is_numeric($max)) {
throw new InvalidArgumentException(
'A numeric option value for either min, max or both must be provided',
);
}

if ($min !== null && $max !== null && $min > $max) {
throw new InvalidArgumentException(
'The minimum constraint cannot be greater than the maximum constraint',
);
}

$this->options['min'] = $min;
$this->options['max'] = $max;
$this->options['inclusiveMin'] = $options['inclusiveMin'] ?? true;
$this->options['inclusiveMax'] = $options['inclusiveMax'] ?? true;
}

public function isValid(mixed $value): bool
{
if (! is_numeric($value)) {
$this->error(self::ERROR_NOT_NUMERIC);

return false;
}

$this->setValue($value);

$min = $this->options['min'];
$max = $this->options['max'];
$inclusiveMin = $this->options['inclusiveMin'];
$inclusiveMax = $this->options['inclusiveMax'];

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

return false;
}

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

return false;
}

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

return false;
}

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

return false;
}

return true;
}
}
1 change: 1 addition & 0 deletions src/ValidatorPluginManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,7 @@ class ValidatorPluginManager extends AbstractPluginManager
IsJsonString::class => InvokableFactory::class,
LessThan::class => InvokableFactory::class,
NotEmpty::class => InvokableFactory::class,
NumberComparison::class => InvokableFactory::class,
I18nValidator\PhoneNumber::class => InvokableFactory::class,
I18nValidator\PostCode::class => InvokableFactory::class,
Regex::class => InvokableFactory::class,
Expand Down
107 changes: 107 additions & 0 deletions test/NumberComparisonTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

declare(strict_types=1);

namespace LaminasTest\Validator;

use Laminas\Validator\Exception\InvalidArgumentException;
use Laminas\Validator\NumberComparison;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;

use const PHP_INT_MAX;

class NumberComparisonTest extends TestCase
{
public static function basicTestDataProvider(): array
{
return [
[null, 10, true, 9, true, null],
[null, 10, true, 10, true, null],
[null, 10, true, 11, false, NumberComparison::ERROR_NOT_LESS_INCLUSIVE],
[null, 10, false, 9, true, null],
[null, 10, false, 10, false, NumberComparison::ERROR_NOT_LESS],
[null, 10, false, 11, false, NumberComparison::ERROR_NOT_LESS],
[10, null, true, 11, true, null],
[10, null, true, 10, true, null],
[10, null, true, 9, false, NumberComparison::ERROR_NOT_GREATER_INCLUSIVE],
[10, null, false, 11, true, null],
[10, null, false, 10, false, NumberComparison::ERROR_NOT_GREATER],
[10, null, false, 9, false, NumberComparison::ERROR_NOT_GREATER],
// Numerics should validate successfully
[10, null, true, PHP_INT_MAX, true, null],
[10, null, true, '99', true, null],
[10, null, true, 99.9, true, null],
[10, null, true, '99.9', true, null],
// Non numerics should fail regardless of options
[10, null, true, true, false, NumberComparison::ERROR_NOT_NUMERIC],
[10, null, true, null, false, NumberComparison::ERROR_NOT_NUMERIC],
[10, null, true, '', false, NumberComparison::ERROR_NOT_NUMERIC],
[10, null, true, 'muppets', false, NumberComparison::ERROR_NOT_NUMERIC],
[10, null, true, ['foo'], false, NumberComparison::ERROR_NOT_NUMERIC],
[10, null, true, [1], false, NumberComparison::ERROR_NOT_NUMERIC],
// Floats behave as expected both as options and input
[10.0, 20.0, true, 10.1, true, null],
[10.0, 20.0, true, 15, true, null],
[10.0, 20.0, true, '15', true, null],
[10.0, 20.0, true, 19.9999999, true, null],
[10.0, 20.0, true, 4.2, false, NumberComparison::ERROR_NOT_GREATER_INCLUSIVE],
[10.0, 20.0, true, 20.000001, false, NumberComparison::ERROR_NOT_LESS_INCLUSIVE],
// Numeric strings behave as expected both as options and input
['10', '20', true, 10.1, true, null],
['10', '20', true, 15, true, null],
['10', '20', true, '15', true, null],
['10', '20', true, '19.9999999', true, null],
['10', '20', true, '4.2', false, NumberComparison::ERROR_NOT_GREATER_INCLUSIVE],
['10', '20', true, '20.000001', false, NumberComparison::ERROR_NOT_LESS_INCLUSIVE],
];
}

/**
* @param numeric|null $min
* @param numeric|null $max
*/
#[DataProvider('basicTestDataProvider')]
public function testBasic(
int|float|string|null $min,
int|float|string|null $max,
bool $inclusive,
mixed $input,
bool $expect,
string|null $errorKey,
): void {
$validator = new NumberComparison([
'min' => $min,
'max' => $max,
'inclusiveMin' => $inclusive,
'inclusiveMax' => $inclusive,
]);

self::assertSame($expect, $validator->isValid($input));

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

self::assertArrayHasKey($errorKey, $validator->getMessages());
}

public function testOmittingBothMinAndMaxIsExceptional(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('A numeric option value for either min, max or both must be provided');

new NumberComparison();
}

public function testThatMinAndMaxMustBeSane(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('The minimum constraint cannot be greater than the maximum constraint');

new NumberComparison([
'min' => 10,
'max' => 5,
]);
}
}
6 changes: 6 additions & 0 deletions test/ValidatorPluginManagerCompatibilityTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Laminas\ServiceManager\Test\CommonPluginManagerTrait;
use Laminas\Validator\DateComparison;
use Laminas\Validator\Exception\RuntimeException;
use Laminas\Validator\NumberComparison;
use Laminas\Validator\ValidatorInterface;
use Laminas\Validator\ValidatorPluginManager;
use PHPUnit\Framework\TestCase;
Expand Down Expand Up @@ -97,6 +98,11 @@ public static function aliasProvider(): array
continue;
}

// Skipping due to required options
if ($target === NumberComparison::class) {
continue;
}

$out[$alias] = [$alias, $target];
}

Expand Down

0 comments on commit 66ab091

Please sign in to comment.