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), ''], + ]; + } +}