From f5e22f128eb2bbd4844ef5c91941e12f27b3d693 Mon Sep 17 00:00:00 2001 From: Alexandre Gomes Gaigalas Date: Thu, 15 Jan 2026 03:52:37 -0300 Subject: [PATCH] Introduce ContainsCount validator This validator is similar to Contains, but also checks how many times the needle appears. Additionally, the Domain validator was changed to use it instead of relying on an unserializable callback, thus, making it serializable. --- docs/validators/Contains.md | 1 + docs/validators/ContainsAny.md | 1 + docs/validators/ContainsCount.md | 80 +++++++++++++ docs/validators/index.md | 3 + library/Mixins/AllBuilder.php | 2 + library/Mixins/AllChain.php | 2 + library/Mixins/Builder.php | 2 + library/Mixins/Chain.php | 2 + library/Mixins/KeyBuilder.php | 7 ++ library/Mixins/KeyChain.php | 7 ++ library/Mixins/NotBuilder.php | 2 + library/Mixins/NotChain.php | 2 + library/Mixins/NullOrBuilder.php | 2 + library/Mixins/NullOrChain.php | 2 + library/Mixins/PropertyBuilder.php | 7 ++ library/Mixins/PropertyChain.php | 7 ++ library/Mixins/UndefOrBuilder.php | 2 + library/Mixins/UndefOrChain.php | 2 + library/Validators/ContainsCount.php | 106 ++++++++++++++++++ library/Validators/Domain.php | 5 +- tests/benchmark/ValidatorBench.php | 2 + .../feature/Validators/ContainsCountTest.php | 33 ++++++ tests/unit/Validators/ContainsCountTest.php | 63 +++++++++++ 23 files changed, 338 insertions(+), 4 deletions(-) create mode 100644 docs/validators/ContainsCount.md create mode 100644 library/Validators/ContainsCount.php create mode 100644 tests/feature/Validators/ContainsCountTest.php create mode 100644 tests/unit/Validators/ContainsCountTest.php diff --git a/docs/validators/Contains.md b/docs/validators/Contains.md index 2be52debe..9c8a478f8 100644 --- a/docs/validators/Contains.md +++ b/docs/validators/Contains.md @@ -56,6 +56,7 @@ Message template for this validator includes `{{containsValue}}`. See also: - [ContainsAny](ContainsAny.md) +- [ContainsCount](ContainsCount.md) - [EndsWith](EndsWith.md) - [Equals](Equals.md) - [Equivalent](Equivalent.md) diff --git a/docs/validators/ContainsAny.md b/docs/validators/ContainsAny.md index 7022cd20d..09a119caa 100644 --- a/docs/validators/ContainsAny.md +++ b/docs/validators/ContainsAny.md @@ -57,5 +57,6 @@ See also: - [AnyOf](AnyOf.md) - [Contains](Contains.md) +- [ContainsCount](ContainsCount.md) - [Equivalent](Equivalent.md) - [In](In.md) diff --git a/docs/validators/ContainsCount.md b/docs/validators/ContainsCount.md new file mode 100644 index 000000000..ec74d08cb --- /dev/null +++ b/docs/validators/ContainsCount.md @@ -0,0 +1,80 @@ +# ContainsCount + +- `ContainsCount(mixed $containsValue, int $count)` +- `ContainsCount(mixed $containsValue, int $count, bool $identical)` + +Validates if the input contains a value a specific number of times. + +For strings: + +```php +v::containsCount('ipsum', 2)->assert('ipsum lorem ipsum'); +// Validation passes successfully +``` + +For arrays: + +```php +v::containsCount('ipsum', 2)->assert(['ipsum', 'lorem', 'ipsum']); +// Validation passes successfully +``` + +A third parameter may be passed for identical comparison instead of equal comparison. + +```php +v::containsCount(1, 1, true)->assert([1, 2, 3]); +// Validation passes successfully + +v::containsCount('1', 1, true)->assert([1, 2, 3]); +// → `[1, 2, 3]` must contain "1" only once +``` + +## Templates + +### `ContainsCount::TEMPLATE_TIMES` + +| Mode | Template | +| ---------- | ---------------------------------------------------------------- | +| `default` | {{subject}} must contain {{containsValue}} {{count}} time(s) | +| `inverted` | {{subject}} must not contain {{containsValue}} {{count}} time(s) | + +### `ContainsCount::TEMPLATE_ONCE` + +| Mode | Template | +| ---------- | -------------------------------------------------------- | +| `default` | {{subject}} must contain {{containsValue}} only once | +| `inverted` | {{subject}} must not contain {{containsValue}} only once | + +## Template placeholders + +| Placeholder | Description | +| --------------- | ---------------------------------------------------------------- | +| `containsValue` | The value to search for in the input. | +| `subject` | The validated input or the custom validator name (if specified). | +| `count` | Number of times that the needle might appear in the haystack. | + +## Categorization + +- Arrays +- Strings + +## Changelog + +| Version | Description | +| ------: | ----------- | +| 3.0.0 | Created | + +--- + +See also: + +- [Contains](Contains.md) +- [ContainsAny](ContainsAny.md) +- [EndsWith](EndsWith.md) +- [Equals](Equals.md) +- [Equivalent](Equivalent.md) +- [Identical](Identical.md) +- [In](In.md) +- [Regex](Regex.md) +- [StartsWith](StartsWith.md) +- [Unique](Unique.md) diff --git a/docs/validators/index.md b/docs/validators/index.md index c147e0cad..1427a46c4 100644 --- a/docs/validators/index.md +++ b/docs/validators/index.md @@ -8,6 +8,7 @@ In this page you will find a list of validators by their category. - [ArrayVal](validators/ArrayVal.md) - [Contains](validators/Contains.md) - [ContainsAny](validators/ContainsAny.md) +- [ContainsCount](validators/ContainsCount.md) - [Each](validators/Each.md) - [EndsWith](validators/EndsWith.md) - [In](validators/In.md) @@ -228,6 +229,7 @@ In this page you will find a list of validators by their category. - [Consonant](validators/Consonant.md) - [Contains](validators/Contains.md) - [ContainsAny](validators/ContainsAny.md) +- [ContainsCount](validators/ContainsCount.md) - [Control](validators/Control.md) - [Digit](validators/Digit.md) - [Emoji](validators/Emoji.md) @@ -331,6 +333,7 @@ In this page you will find a list of validators by their category. - [Consonant](validators/Consonant.md) - [Contains](validators/Contains.md) - [ContainsAny](validators/ContainsAny.md) +- [ContainsCount](validators/ContainsCount.md) - [Control](validators/Control.md) - [Countable](validators/Countable.md) - [CountryCode](validators/CountryCode.md) diff --git a/library/Mixins/AllBuilder.php b/library/Mixins/AllBuilder.php index 6a1c5e0b0..77756e235 100644 --- a/library/Mixins/AllBuilder.php +++ b/library/Mixins/AllBuilder.php @@ -70,6 +70,8 @@ public static function allContains(mixed $containsValue, bool $identical = false /** @param non-empty-array $needles */ public static function allContainsAny(array $needles, bool $identical = false): Chain; + public static function allContainsCount(mixed $containsValue, int $count, bool $identical = false): Chain; + public static function allControl(string ...$additionalChars): Chain; public static function allCountable(): Chain; diff --git a/library/Mixins/AllChain.php b/library/Mixins/AllChain.php index f3d1a6ad2..5ade342e7 100644 --- a/library/Mixins/AllChain.php +++ b/library/Mixins/AllChain.php @@ -70,6 +70,8 @@ public function allContains(mixed $containsValue, bool $identical = false): Chai /** @param non-empty-array $needles */ public function allContainsAny(array $needles, bool $identical = false): Chain; + public function allContainsCount(mixed $containsValue, int $count, bool $identical = false): Chain; + public function allControl(string ...$additionalChars): Chain; public function allCountable(): Chain; diff --git a/library/Mixins/Builder.php b/library/Mixins/Builder.php index 895096289..c897382d0 100644 --- a/library/Mixins/Builder.php +++ b/library/Mixins/Builder.php @@ -84,6 +84,8 @@ public static function contains(mixed $containsValue, bool $identical = false): /** @param non-empty-array $needles */ public static function containsAny(array $needles, bool $identical = false): Chain; + public static function containsCount(mixed $containsValue, int $count, bool $identical = false): Chain; + public static function control(string ...$additionalChars): Chain; public static function countable(): Chain; diff --git a/library/Mixins/Chain.php b/library/Mixins/Chain.php index aa05d64e0..ab75603ff 100644 --- a/library/Mixins/Chain.php +++ b/library/Mixins/Chain.php @@ -87,6 +87,8 @@ public function contains(mixed $containsValue, bool $identical = false): Chain; /** @param non-empty-array $needles */ public function containsAny(array $needles, bool $identical = false): Chain; + public function containsCount(mixed $containsValue, int $count, bool $identical = false): Chain; + public function control(string ...$additionalChars): Chain; public function countable(): Chain; diff --git a/library/Mixins/KeyBuilder.php b/library/Mixins/KeyBuilder.php index 1c0148631..c49366791 100644 --- a/library/Mixins/KeyBuilder.php +++ b/library/Mixins/KeyBuilder.php @@ -88,6 +88,13 @@ public static function keyContains(int|string $key, mixed $containsValue, bool $ /** @param non-empty-array $needles */ public static function keyContainsAny(int|string $key, array $needles, bool $identical = false): Chain; + public static function keyContainsCount( + int|string $key, + mixed $containsValue, + int $count, + bool $identical = false, + ): Chain; + public static function keyControl(int|string $key, string ...$additionalChars): Chain; public static function keyCountable(int|string $key): Chain; diff --git a/library/Mixins/KeyChain.php b/library/Mixins/KeyChain.php index 1fb2a3b1f..ff51129e4 100644 --- a/library/Mixins/KeyChain.php +++ b/library/Mixins/KeyChain.php @@ -88,6 +88,13 @@ public function keyContains(int|string $key, mixed $containsValue, bool $identic /** @param non-empty-array $needles */ public function keyContainsAny(int|string $key, array $needles, bool $identical = false): Chain; + public function keyContainsCount( + int|string $key, + mixed $containsValue, + int $count, + bool $identical = false, + ): Chain; + public function keyControl(int|string $key, string ...$additionalChars): Chain; public function keyCountable(int|string $key): Chain; diff --git a/library/Mixins/NotBuilder.php b/library/Mixins/NotBuilder.php index 4b74cdd85..291467d8b 100644 --- a/library/Mixins/NotBuilder.php +++ b/library/Mixins/NotBuilder.php @@ -72,6 +72,8 @@ public static function notContains(mixed $containsValue, bool $identical = false /** @param non-empty-array $needles */ public static function notContainsAny(array $needles, bool $identical = false): Chain; + public static function notContainsCount(mixed $containsValue, int $count, bool $identical = false): Chain; + public static function notControl(string ...$additionalChars): Chain; public static function notCountable(): Chain; diff --git a/library/Mixins/NotChain.php b/library/Mixins/NotChain.php index b7451696b..086fc4b1d 100644 --- a/library/Mixins/NotChain.php +++ b/library/Mixins/NotChain.php @@ -72,6 +72,8 @@ public function notContains(mixed $containsValue, bool $identical = false): Chai /** @param non-empty-array $needles */ public function notContainsAny(array $needles, bool $identical = false): Chain; + public function notContainsCount(mixed $containsValue, int $count, bool $identical = false): Chain; + public function notControl(string ...$additionalChars): Chain; public function notCountable(): Chain; diff --git a/library/Mixins/NullOrBuilder.php b/library/Mixins/NullOrBuilder.php index 5b5b6b2d3..0af6b75ae 100644 --- a/library/Mixins/NullOrBuilder.php +++ b/library/Mixins/NullOrBuilder.php @@ -84,6 +84,8 @@ public static function nullOrContains(mixed $containsValue, bool $identical = fa /** @param non-empty-array $needles */ public static function nullOrContainsAny(array $needles, bool $identical = false): Chain; + public static function nullOrContainsCount(mixed $containsValue, int $count, bool $identical = false): Chain; + public static function nullOrControl(string ...$additionalChars): Chain; public static function nullOrCountable(): Chain; diff --git a/library/Mixins/NullOrChain.php b/library/Mixins/NullOrChain.php index ac07de4ba..9fbe517f2 100644 --- a/library/Mixins/NullOrChain.php +++ b/library/Mixins/NullOrChain.php @@ -72,6 +72,8 @@ public function nullOrContains(mixed $containsValue, bool $identical = false): C /** @param non-empty-array $needles */ public function nullOrContainsAny(array $needles, bool $identical = false): Chain; + public function nullOrContainsCount(mixed $containsValue, int $count, bool $identical = false): Chain; + public function nullOrControl(string ...$additionalChars): Chain; public function nullOrCountable(): Chain; diff --git a/library/Mixins/PropertyBuilder.php b/library/Mixins/PropertyBuilder.php index 3776fadf8..34d8a7310 100644 --- a/library/Mixins/PropertyBuilder.php +++ b/library/Mixins/PropertyBuilder.php @@ -92,6 +92,13 @@ public static function propertyContains( /** @param non-empty-array $needles */ public static function propertyContainsAny(string $propertyName, array $needles, bool $identical = false): Chain; + public static function propertyContainsCount( + string $propertyName, + mixed $containsValue, + int $count, + bool $identical = false, + ): Chain; + public static function propertyControl(string $propertyName, string ...$additionalChars): Chain; public static function propertyCountable(string $propertyName): Chain; diff --git a/library/Mixins/PropertyChain.php b/library/Mixins/PropertyChain.php index 01d28304e..8db248ae1 100644 --- a/library/Mixins/PropertyChain.php +++ b/library/Mixins/PropertyChain.php @@ -88,6 +88,13 @@ public function propertyContains(string $propertyName, mixed $containsValue, boo /** @param non-empty-array $needles */ public function propertyContainsAny(string $propertyName, array $needles, bool $identical = false): Chain; + public function propertyContainsCount( + string $propertyName, + mixed $containsValue, + int $count, + bool $identical = false, + ): Chain; + public function propertyControl(string $propertyName, string ...$additionalChars): Chain; public function propertyCountable(string $propertyName): Chain; diff --git a/library/Mixins/UndefOrBuilder.php b/library/Mixins/UndefOrBuilder.php index 5c2db3c33..4a0568ab0 100644 --- a/library/Mixins/UndefOrBuilder.php +++ b/library/Mixins/UndefOrBuilder.php @@ -82,6 +82,8 @@ public static function undefOrContains(mixed $containsValue, bool $identical = f /** @param non-empty-array $needles */ public static function undefOrContainsAny(array $needles, bool $identical = false): Chain; + public static function undefOrContainsCount(mixed $containsValue, int $count, bool $identical = false): Chain; + public static function undefOrControl(string ...$additionalChars): Chain; public static function undefOrCountable(): Chain; diff --git a/library/Mixins/UndefOrChain.php b/library/Mixins/UndefOrChain.php index 01bafeeca..70e79e671 100644 --- a/library/Mixins/UndefOrChain.php +++ b/library/Mixins/UndefOrChain.php @@ -70,6 +70,8 @@ public function undefOrContains(mixed $containsValue, bool $identical = false): /** @param non-empty-array $needles */ public function undefOrContainsAny(array $needles, bool $identical = false): Chain; + public function undefOrContainsCount(mixed $containsValue, int $count, bool $identical = false): Chain; + public function undefOrControl(string ...$additionalChars): Chain; public function undefOrCountable(): Chain; diff --git a/library/Validators/ContainsCount.php b/library/Validators/ContainsCount.php new file mode 100644 index 000000000..dc0b188f1 --- /dev/null +++ b/library/Validators/ContainsCount.php @@ -0,0 +1,106 @@ + + * SPDX-License-Identifier: MIT + */ + +declare(strict_types=1); + +namespace Respect\Validation\Validators; + +use Attribute; +use Respect\Validation\Message\Template; +use Respect\Validation\Result; +use Respect\Validation\Validator; + +use function array_reduce; +use function is_array; +use function is_scalar; +use function mb_strtolower; +use function mb_substr_count; + +#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] +#[Template( + '{{subject}} must contain {{containsValue}} {{count}} time(s)', + '{{subject}} must not contain {{containsValue}} {{count}} time(s)', + self::TEMPLATE_TIMES, +)] +#[Template( + '{{subject}} must contain {{containsValue}} only once', + '{{subject}} must not contain {{containsValue}} only once', + self::TEMPLATE_ONCE, +)] +final readonly class ContainsCount implements Validator +{ + public const string TEMPLATE_TIMES = '__times__'; + public const string TEMPLATE_ONCE = '__once__'; + + public function __construct( + private mixed $containsValue, + private int $count, + private bool $identical = false, + ) { + } + + public function evaluate(mixed $input): Result + { + $parameters = [ + 'containsValue' => $this->containsValue, + 'count' => $this->count, + ]; + + $template = $this->count === 1 ? self::TEMPLATE_ONCE : self::TEMPLATE_TIMES; + + if (is_array($input)) { + return Result::of( + $this->countArrayOccurrences($input) === $this->count, + $input, + $this, + $parameters, + $template, + ); + } + + if (!is_scalar($input) || !is_scalar($this->containsValue)) { + return Result::failed($input, $this, $parameters, $template); + } + + $needle = (string) $this->containsValue; + + if ($needle === '') { + return Result::failed($input, $this, $parameters, $template); + } + + return Result::of( + $this->countStringOccurrences((string) $input, $needle) === $this->count, + $input, + $this, + $parameters, + $template, + ); + } + + /** @param array $input */ + private function countArrayOccurrences(array $input): int + { + return array_reduce( + $input, + $this->identical ? function (int $carry, mixed $item): int { + return $carry + ($item === $this->containsValue ? 1 : 0); + } : function (int $carry, mixed $item): int { + return $carry + ($item == $this->containsValue ? 1 : 0); + }, + 0, + ); + } + + private function countStringOccurrences(string $haystack, string $needle): int + { + if ($this->identical) { + return mb_substr_count($haystack, $needle); + } + + return mb_substr_count(mb_strtolower($haystack), mb_strtolower($needle)); + } +} diff --git a/library/Validators/Domain.php b/library/Validators/Domain.php index cc5efa3ee..b95e49ffc 100644 --- a/library/Validators/Domain.php +++ b/library/Validators/Domain.php @@ -17,7 +17,6 @@ use function array_pop; use function count; use function explode; -use function mb_substr_count; #[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)] #[Template( @@ -84,9 +83,7 @@ private function createPartsRule(): Validator new Not(new StartsWith('-')), new AnyOf( new Not(new Contains('--')), - new Callback(static function ($str) { - return mb_substr_count($str, '--') == 1; - }), + new ContainsCount('--', 1), ), new Not(new EndsWith('-')), ), diff --git a/tests/benchmark/ValidatorBench.php b/tests/benchmark/ValidatorBench.php index 2826e0d98..51e68a274 100644 --- a/tests/benchmark/ValidatorBench.php +++ b/tests/benchmark/ValidatorBench.php @@ -46,6 +46,7 @@ public function provideValidatorInput(): Generator yield 'Consonant' => [v::consonant(), 'bcdf']; yield 'Contains' => [v::contains('needle'), 'haystack needle']; yield 'ContainsAny' => [v::containsAny(['a', 'b']), 'abc']; + yield 'ContainsCount' => [v::containsCount('a', 3), 'banana']; yield 'Control' => [v::control(), "\n\r"]; yield 'Countable' => [v::countable(), []]; yield 'CountryCode' => [v::countryCode(), 'US']; @@ -55,6 +56,7 @@ public function provideValidatorInput(): Generator yield 'DateTime' => [v::dateTime(), '2020-01-01 12:00:00']; yield 'Decimal' => [v::decimal(2), '1.23']; yield 'Digit' => [v::digit(), '7']; + yield 'Domain' => [v::domain(), 'example.com']; yield 'Each' => [v::each(v::stringType()), ['a', 'b']]; yield 'Email' => [v::email(), 'bob@example.com']; yield 'EndsWith' => [v::endsWith('.com'), 'example.com']; diff --git a/tests/feature/Validators/ContainsCountTest.php b/tests/feature/Validators/ContainsCountTest.php new file mode 100644 index 000000000..5c9a3d5ce --- /dev/null +++ b/tests/feature/Validators/ContainsCountTest.php @@ -0,0 +1,33 @@ + + * SPDX-License-Identifier: MIT + */ + +declare(strict_types=1); + +test('Scenario #1', catchMessage( + fn() => v::containsCount('foo', 2)->assert('bar'), + fn(string $message) => expect($message)->toBe('"bar" must contain "foo" 2 time(s)'), +)); + +test('Scenario #2', catchMessage( + fn() => v::not(v::containsCount('foo', 2))->assert('foo bar foo'), + fn(string $message) => expect($message)->toBe('"foo bar foo" must not contain "foo" 2 time(s)'), +)); + +test('Scenario #3', catchFullMessage( + fn() => v::containsCount('foo', 2)->assert(['foo']), + fn(string $fullMessage) => expect($fullMessage)->toBe('- `["foo"]` must contain "foo" 2 time(s)'), +)); + +test('Scenario #4', catchFullMessage( + fn() => v::not(v::containsCount('foo', 1, true))->assert(['foo', 'bar']), + fn(string $fullMessage) => expect($fullMessage)->toBe('- `["foo", "bar"]` must not contain "foo" only once'), +)); + +test('Scenario #5', catchFullMessage( + fn() => v::containsCount('foo', 1, true)->assert(['foo', 'foo']), + fn(string $fullMessage) => expect($fullMessage)->toBe('- `["foo", "foo"]` must contain "foo" only once'), +)); diff --git a/tests/unit/Validators/ContainsCountTest.php b/tests/unit/Validators/ContainsCountTest.php new file mode 100644 index 000000000..22f1e392e --- /dev/null +++ b/tests/unit/Validators/ContainsCountTest.php @@ -0,0 +1,63 @@ + + * SPDX-License-Identifier: MIT + */ + +declare(strict_types=1); + +namespace Respect\Validation\Validators; + +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; +use Respect\Validation\Test\RuleTestCase; +use stdClass; + +#[Group('validator')] +#[CoversClass(ContainsCount::class)] +final class ContainsCountTest extends RuleTestCase +{ + /** @return iterable */ + public static function providerForValidInput(): iterable + { + return [ + [new ContainsCount('foo', 2), ['foo', 'bar', 'foo']], + [new ContainsCount('foo', 1), 'foo bar'], + [new ContainsCount('foo', 2), 'foo bar foo'], + [new ContainsCount('a', 3), 'banana'], + + [new ContainsCount('1', 1, true), ['1', 2, 3]], + [new ContainsCount(1, 1, true), [1, 2, 3]], + + [new ContainsCount('A', 3), 'banana'], + [new ContainsCount('foo', 2), 'FOO bar foo'], + + [new ContainsCount('A', 0, true), 'banana'], + [new ContainsCount('a', 3, true), 'banana'], + + [new ContainsCount('foo', 0), 'bar'], + ]; + } + + /** @return iterable */ + public static function providerForInvalidInput(): iterable + { + return [ + [new ContainsCount('foo', 2), ['foo', 'bar']], + [new ContainsCount('foo', 2), 'foo bar'], + [new ContainsCount('a', 2), 'banana'], + + [new ContainsCount('1', 1, true), [1, 2, 3]], + + [new ContainsCount('A', 2), 'banana'], + + [new ContainsCount('A', 3, true), 'banana'], + + [new ContainsCount('foo', 1), null], + [new ContainsCount('foo', 1), new stdClass()], + + [new ContainsCount('', 1), ''], + ]; + } +}