Skip to content
This repository was archived by the owner on Mar 13, 2025. It is now read-only.

Commit 9068d38

Browse files
committed
feat: added Length rule
1 parent d139d22 commit 9068d38

File tree

7 files changed

+274
-5
lines changed

7 files changed

+274
-5
lines changed

composer.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@
1515
"php": ">=8.1",
1616
"egulias/email-validator": "^4.0",
1717
"symfony/intl": "^6.3",
18-
"symfony/polyfill-ctype": "^1.27"
18+
"symfony/polyfill-ctype": "^1.27",
19+
"symfony/polyfill-intl-grapheme": "^1.29"
1920
},
2021
"require-dev": {
2122
"phpunit/phpunit": "^10.0",

src/ChainedValidatorInterface.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ public function greaterThanOrEqual(
5757
?string $message = null
5858
): ChainedValidatorInterface&Validator;
5959

60+
public function length(
61+
?int $min = null,
62+
?int $max = null,
63+
string $charset = 'UTF-8',
64+
string $countUnit = 'codepoints',
65+
?callable $normalizer = null,
66+
?string $minMessage = null,
67+
?string $maxMessage = null,
68+
?string $exactMessage = null,
69+
?string $charsetMessage = null
70+
): ChainedValidatorInterface&Validator;
71+
6072
public function lessThan(
6173
mixed $constraint,
6274
?string $message = null

src/Exception/LengthException.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<?php
2+
3+
namespace ProgrammatorDev\Validator\Exception;
4+
5+
class LengthException extends ValidationException {}

src/Rule/Length.php

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
<?php
2+
3+
namespace ProgrammatorDev\Validator\Rule;
4+
5+
use ProgrammatorDev\Validator\Exception\LengthException;
6+
use ProgrammatorDev\Validator\Exception\UnexpectedOptionException;
7+
use ProgrammatorDev\Validator\Exception\UnexpectedTypeException;
8+
use ProgrammatorDev\Validator\Exception\UnexpectedValueException;
9+
use ProgrammatorDev\Validator\Validator;
10+
11+
class Length extends AbstractRule implements RuleInterface
12+
{
13+
public const COUNT_UNIT_BYTES = 'bytes';
14+
public const COUNT_UNIT_CODEPOINTS = 'codepoints';
15+
public const COUNT_UNIT_GRAPHEMES = 'graphemes';
16+
17+
private const COUNT_UNITS = [
18+
self::COUNT_UNIT_BYTES,
19+
self::COUNT_UNIT_CODEPOINTS,
20+
self::COUNT_UNIT_GRAPHEMES
21+
];
22+
23+
/** @var ?callable */
24+
private $normalizer;
25+
private string $minMessage = 'The {{ name }} value should have {{ min }} characters or more, {{ numChars }} characters given.';
26+
private string $maxMessage = 'The {{ name }} value should have {{ max }} characters or less, {{ numChars }} characters given.';
27+
private string $exactMessage = 'The {{ name }} value should have exactly {{ min }} characters, {{ numChars }} characters given.';
28+
private string $charsetMessage = 'The {{ name }} value does not match the expected {{ charset }} charset.';
29+
30+
public function __construct(
31+
private readonly ?int $min = null,
32+
private readonly ?int $max = null,
33+
private readonly string $charset = 'UTF-8',
34+
private readonly string $countUnit = self::COUNT_UNIT_CODEPOINTS,
35+
?callable $normalizer = null,
36+
?string $minMessage = null,
37+
?string $maxMessage = null,
38+
?string $exactMessage = null,
39+
?string $charsetMessage = null
40+
)
41+
{
42+
$this->normalizer = $normalizer;
43+
$this->minMessage = $minMessage ?? $this->minMessage;
44+
$this->maxMessage = $maxMessage ?? $this->maxMessage;
45+
$this->exactMessage = $exactMessage ?? $this->exactMessage;
46+
$this->charsetMessage = $charsetMessage ?? $this->charsetMessage;
47+
}
48+
49+
public function assert(mixed $value, ?string $name = null): void
50+
{
51+
if ($this->min === null && $this->max === null) {
52+
throw new UnexpectedValueException('At least one of the options "min" or "max" must be given.');
53+
}
54+
55+
if (
56+
$this->min !== null
57+
&& $this->max !== null
58+
&& !Validator::greaterThanOrEqual($this->min)->validate($this->max)
59+
) {
60+
throw new UnexpectedValueException('Maximum value must be greater than or equal to minimum value.');
61+
}
62+
63+
$encodings = mb_list_encodings();
64+
65+
if (!\in_array($this->charset, $encodings)) {
66+
throw new UnexpectedOptionException('charset', $encodings, $this->charset);
67+
}
68+
69+
if (!\in_array($this->countUnit, self::COUNT_UNITS)) {
70+
throw new UnexpectedOptionException('countUnit', self::COUNT_UNITS, $this->countUnit);
71+
}
72+
73+
if (!\is_scalar($value) && !$value instanceof \Stringable) {
74+
throw new UnexpectedTypeException('string|\Stringable', get_debug_type($value));
75+
}
76+
77+
$value = (string) $value;
78+
79+
if ($this->normalizer !== null) {
80+
$value = ($this->normalizer)($value);
81+
}
82+
83+
if (!mb_detect_encoding($value, $this->charset)) {
84+
throw new LengthException(
85+
message: $this->charsetMessage,
86+
parameters: [
87+
'name' => $name,
88+
'value' => $value,
89+
'charset' => $this->charset
90+
]
91+
);
92+
}
93+
94+
$numChars = match ($this->countUnit) {
95+
self::COUNT_UNIT_BYTES => \strlen($value),
96+
self::COUNT_UNIT_CODEPOINTS => \mb_strlen($value, $this->charset),
97+
self::COUNT_UNIT_GRAPHEMES => \grapheme_strlen($value),
98+
};
99+
100+
if ($this->min !== null && $numChars < $this->min) {
101+
$message = ($this->min === $this->max) ? $this->exactMessage : $this->minMessage;
102+
103+
throw new LengthException(
104+
message: $message,
105+
parameters: [
106+
'value' => $value,
107+
'name' => $name,
108+
'min' => $this->min,
109+
'max' => $this->max,
110+
'numChars' => $numChars,
111+
'charset' => $this->charset,
112+
'countUnit' => $this->countUnit
113+
]
114+
);
115+
}
116+
117+
if ($this->max !== null && $numChars > $this->max) {
118+
$message = ($this->min === $this->max) ? $this->exactMessage : $this->maxMessage;
119+
120+
throw new LengthException(
121+
message: $message,
122+
parameters: [
123+
'value' => $value,
124+
'name' => $name,
125+
'min' => $this->min,
126+
'max' => $this->max,
127+
'numChars' => $numChars,
128+
'charset' => $this->charset,
129+
'countUnit' => $this->countUnit
130+
]
131+
);
132+
}
133+
}
134+
}

src/StaticValidatorInterface.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,18 @@ public static function greaterThanOrEqual(
5656
?string $message = null
5757
): ChainedValidatorInterface&Validator;
5858

59+
public static function length(
60+
?int $min = null,
61+
?int $max = null,
62+
string $charset = 'UTF-8',
63+
string $countUnit = 'codepoints',
64+
?callable $normalizer = null,
65+
?string $minMessage = null,
66+
?string $maxMessage = null,
67+
?string $exactMessage = null,
68+
?string $charsetMessage = null
69+
): ChainedValidatorInterface&Validator;
70+
5971
public static function lessThan(
6072
mixed $constraint,
6173
?string $message = null

tests/CountTest.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,13 @@ class CountTest extends AbstractTest
1818

1919
public static function provideRuleUnexpectedValueData(): \Generator
2020
{
21-
$unexpectedOptionMessage = '/At least one of the options "min" or "max" must be given./';
22-
$unexpectedTypeMessage = '/Expected value of type "array|\Countable", "(.*)" given./';
21+
$unexpectedMissingMinMaxMessage = '/At least one of the options "min" or "max" must be given./';
2322
$unexpectedMinMaxMessage = '/Maximum value must be greater than or equal to minimum value./';
23+
$unexpectedTypeMessage = '/Expected value of type "array|\Countable", "(.*)" given./';
2424

25-
yield 'missing options' => [new Count(), [1, 2, 3], $unexpectedOptionMessage];
26-
yield 'invalid type value' => [new Count(min: 5, max: 10), 1, $unexpectedTypeMessage];
25+
yield 'missing min max' => [new Count(), [1, 2, 3], $unexpectedMissingMinMaxMessage];
2726
yield 'min greater than max constraint' => [new Count(min: 3, max: 2), [1, 2, 3], $unexpectedMinMaxMessage];
27+
yield 'invalid type value' => [new Count(min: 5, max: 10), 1, $unexpectedTypeMessage];
2828
}
2929

3030
public static function provideRuleFailureConditionData(): \Generator

tests/LengthTest.php

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
<?php
2+
3+
namespace ProgrammatorDev\Validator\Test;
4+
5+
use ProgrammatorDev\Validator\Exception\LengthException;
6+
use ProgrammatorDev\Validator\Rule\Length;
7+
use ProgrammatorDev\Validator\Test\Util\TestRuleFailureConditionTrait;
8+
use ProgrammatorDev\Validator\Test\Util\TestRuleMessageOptionTrait;
9+
use ProgrammatorDev\Validator\Test\Util\TestRuleSuccessConditionTrait;
10+
use ProgrammatorDev\Validator\Test\Util\TestRuleUnexpectedValueTrait;
11+
12+
class LengthTest extends AbstractTest
13+
{
14+
use TestRuleUnexpectedValueTrait;
15+
use TestRuleFailureConditionTrait;
16+
use TestRuleSuccessConditionTrait;
17+
use TestRuleMessageOptionTrait;
18+
19+
public static function provideRuleUnexpectedValueData(): \Generator
20+
{
21+
$value = 'abcde';
22+
23+
$unexpectedMissingMinMaxMessage = '/At least one of the options "min" or "max" must be given./';
24+
$unexpectedMinMaxMessage = '/Maximum value must be greater than or equal to minimum value./';
25+
$unexpectedOptionMessage = '/Invalid (.*) "(.*)". Accepted values are: "(.*)"./';
26+
$unexpectedTypeMessage = '/Expected value of type "array|\Stringable", "(.*)" given./';
27+
28+
yield 'missing min max' => [new Length(), $value, $unexpectedMissingMinMaxMessage];
29+
yield 'min greater than max constraint' => [new Length(min: 3, max: 2), $value, $unexpectedMinMaxMessage];
30+
yield 'invalid charset value' => [new Length(min: 2, charset: 'INVALID'), $value, $unexpectedOptionMessage];
31+
yield 'invalid count unit value' => [new Length(min: 2, countUnit: 'invalid'), $value, $unexpectedOptionMessage];
32+
yield 'invalid value type' => [new Length(min: 2), [$value], $unexpectedTypeMessage];
33+
}
34+
35+
public static function provideRuleFailureConditionData(): \Generator
36+
{
37+
$value = 'abcde';
38+
$exception = LengthException::class;
39+
40+
$minMessage = '/The (.*) value should have (.*) characters or more, (.*) characters given./';
41+
$maxMessage = '/The (.*) value should have (.*) characters or less, (.*) characters given./';
42+
$exactMessage = '/The (.*) value should have exactly (.*) characters, (.*) characters given./';
43+
$charsetMessage = '/The (.*) value does not match the expected (.*) charset./';
44+
45+
yield 'min constraint' => [new Length(min: 10), $value, $exception, $minMessage];
46+
yield 'max constraint' => [new Length(max: 2), $value, $exception, $maxMessage];
47+
yield 'min and max constraint' => [new Length(min: 10, max: 20), $value, $exception, $minMessage];
48+
yield 'exact constraint' => [new Length(min: 2, max: 2), $value, $exception, $exactMessage];
49+
yield 'charset' => [new Length(min: 2, charset: 'ASCII'), 'テスト', $exception, $charsetMessage];
50+
yield 'count unit' => [new Length(min: 1, max: 1, countUnit: 'bytes'), '🔥', $exception, $exactMessage];
51+
}
52+
53+
public static function provideRuleSuccessConditionData(): \Generator
54+
{
55+
$value = 'abcde';
56+
57+
yield 'min constraint' => [new Length(min: 2), $value];
58+
yield 'max constraint' => [new Length(max: 10), $value];
59+
yield 'min and max constraint' => [new Length(min: 4, max: 6), $value];
60+
yield 'exact constraint' => [new Length(min: 5, max: 5), $value];
61+
yield 'charset' => [new Length(min: 1, charset: 'ISO-8859-1'), $value];
62+
yield 'count unit' => [new Length(min: 4, max: 4, countUnit: 'bytes'), '🔥'];
63+
yield 'stringable' => [new Length(min: 4), 12345];
64+
}
65+
66+
public static function provideRuleMessageOptionData(): \Generator
67+
{
68+
$value = 'abcde';
69+
70+
yield 'min message' => [
71+
new Length(
72+
min: 10,
73+
minMessage: 'The {{ name }} value with count unit {{ countUnit }} should have at least {{ min }} characters.'
74+
),
75+
$value,
76+
'The test value with count unit "codepoints" should have at least 10 characters.'
77+
];
78+
yield 'max message' => [
79+
new Length(
80+
max: 2,
81+
maxMessage: 'The {{ name }} value with count unit {{ countUnit }} should have at most {{ max }} characters.'
82+
),
83+
$value,
84+
'The test value with count unit "codepoints" should have at most 2 characters.'
85+
];
86+
yield 'exact message' => [
87+
new Length(
88+
min: 2,
89+
max: 2,
90+
exactMessage: 'The {{ name }} value with count unit {{ countUnit }} should have exactly {{ min }} characters.'
91+
),
92+
$value,
93+
'The test value with count unit "codepoints" should have exactly 2 characters.'
94+
];
95+
yield 'charset message' => [
96+
new Length(
97+
min: 2,
98+
charset: 'ASCII',
99+
charsetMessage: 'The {{ name }} value is not a {{ charset }} charset.'
100+
),
101+
'テスト',
102+
'The test value is not a "ASCII" charset.'
103+
];
104+
}
105+
}

0 commit comments

Comments
 (0)