Skip to content

Commit 0ccecf3

Browse files
committed
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.
1 parent 4618996 commit 0ccecf3

11 files changed

Lines changed: 255 additions & 4 deletions

File tree

docs/validators/Contains.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ Message template for this validator includes `{{containsValue}}`.
5656
See also:
5757

5858
- [ContainsAny](ContainsAny.md)
59+
- [ContainsCount](ContainsCount.md)
5960
- [EndsWith](EndsWith.md)
6061
- [Equals](Equals.md)
6162
- [Equivalent](Equivalent.md)

docs/validators/ContainsAny.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,5 +57,6 @@ See also:
5757

5858
- [AnyOf](AnyOf.md)
5959
- [Contains](Contains.md)
60+
- [ContainsCount](ContainsCount.md)
6061
- [Equivalent](Equivalent.md)
6162
- [In](In.md)

docs/validators/ContainsCount.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# ContainsCount
2+
3+
- `ContainsCount(mixed $containsValue, int $count)`
4+
- `ContainsCount(mixed $containsValue, int $count, bool $identical)`
5+
6+
Validates if the input contains a value a specific number of times.
7+
8+
For strings:
9+
10+
```php
11+
v::containsCount('ipsum', 2)->assert('ipsum lorem ipsum');
12+
// Validation passes successfully
13+
```
14+
15+
For arrays:
16+
17+
```php
18+
v::containsCount('ipsum', 2)->assert(['ipsum', 'lorem', 'ipsum']);
19+
// Validation passes successfully
20+
```
21+
22+
A third parameter may be passed for identical comparison instead of equal comparison.
23+
24+
```php
25+
v::containsCount(1, 1, true)->assert([1, 2, 3]);
26+
// Validation passes successfully
27+
28+
v::containsCount('1', 1, true)->assert([1, 2, 3]);
29+
// → `[1, 2, 3]` must contain "1" 1 times
30+
```
31+
32+
## Templates
33+
34+
### `ContainsCount::TEMPLATE_STANDARD`
35+
36+
| Mode | Template |
37+
| ---------- | -------------------------------------------------------------- |
38+
| `default` | {{subject}} must contain {{containsValue}} {{count}} times |
39+
| `inverted` | {{subject}} must not contain {{containsValue}} {{count}} times |
40+
41+
## Template placeholders
42+
43+
| Placeholder | Description |
44+
| --------------- | ---------------------------------------------------------------- |
45+
| `containsValue` | |
46+
| `subject` | The validated input or the custom validator name (if specified). |
47+
| `count` | Number of times that the needle might appear in the haystack. |
48+
49+
## Categorization
50+
51+
- Arrays
52+
- Strings
53+
54+
## Changelog
55+
56+
| Version | Description |
57+
| ------: | ----------- |
58+
| 3.0.0 | Created |
59+
60+
---
61+
62+
See also:
63+
64+
- [Contains](Contains.md)
65+
- [ContainsAny](ContainsAny.md)
66+
- [EndsWith](EndsWith.md)
67+
- [Equals](Equals.md)
68+
- [Equivalent](Equivalent.md)
69+
- [Identical](Identical.md)
70+
- [In](In.md)
71+
- [Regex](Regex.md)
72+
- [StartsWith](StartsWith.md)
73+
- [Unique](Unique.md)

docs/validators/index.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ In this page you will find a list of validators by their category.
88
- [ArrayVal](validators/ArrayVal.md)
99
- [Contains](validators/Contains.md)
1010
- [ContainsAny](validators/ContainsAny.md)
11+
- [ContainsCount](validators/ContainsCount.md)
1112
- [Each](validators/Each.md)
1213
- [EndsWith](validators/EndsWith.md)
1314
- [In](validators/In.md)
@@ -228,6 +229,7 @@ In this page you will find a list of validators by their category.
228229
- [Consonant](validators/Consonant.md)
229230
- [Contains](validators/Contains.md)
230231
- [ContainsAny](validators/ContainsAny.md)
232+
- [ContainsCount](validators/ContainsCount.md)
231233
- [Control](validators/Control.md)
232234
- [Digit](validators/Digit.md)
233235
- [Emoji](validators/Emoji.md)
@@ -331,6 +333,7 @@ In this page you will find a list of validators by their category.
331333
- [Consonant](validators/Consonant.md)
332334
- [Contains](validators/Contains.md)
333335
- [ContainsAny](validators/ContainsAny.md)
336+
- [ContainsCount](validators/ContainsCount.md)
334337
- [Control](validators/Control.md)
335338
- [Countable](validators/Countable.md)
336339
- [CountryCode](validators/CountryCode.md)

library/Mixins/Builder.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ public static function contains(mixed $containsValue, bool $identical = false):
8484
/** @param non-empty-array<mixed> $needles */
8585
public static function containsAny(array $needles, bool $identical = false): Chain;
8686

87+
public static function containsCount(mixed $containsValue, int $count, bool $identical = false): Chain;
88+
8789
public static function control(string ...$additionalChars): Chain;
8890

8991
public static function countable(): Chain;

library/Mixins/Chain.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,8 @@ public function contains(mixed $containsValue, bool $identical = false): Chain;
8787
/** @param non-empty-array<mixed> $needles */
8888
public function containsAny(array $needles, bool $identical = false): Chain;
8989

90+
public function containsCount(mixed $containsValue, int $count, bool $identical = false): Chain;
91+
9092
public function control(string ...$additionalChars): Chain;
9193

9294
public function countable(): Chain;
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
/*
4+
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
5+
* SPDX-License-Identifier: MIT
6+
*/
7+
8+
declare(strict_types=1);
9+
10+
namespace Respect\Validation\Validators;
11+
12+
use Attribute;
13+
use Respect\Validation\Message\Template;
14+
use Respect\Validation\Result;
15+
use Respect\Validation\Validator;
16+
17+
use function in_array;
18+
use function is_array;
19+
use function is_scalar;
20+
use function mb_strtolower;
21+
use function mb_substr_count;
22+
23+
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
24+
#[Template(
25+
'{{subject}} must contain {{containsValue}} {{count}} times',
26+
'{{subject}} must not contain {{containsValue}} {{count}} times',
27+
)]
28+
final readonly class ContainsCount implements Validator
29+
{
30+
public function __construct(
31+
private mixed $containsValue,
32+
private int $count,
33+
private bool $identical = false,
34+
) {
35+
}
36+
37+
public function evaluate(mixed $input): Result
38+
{
39+
return Result::of(
40+
$this->countOccurrences($input) === $this->count,
41+
$input,
42+
$this,
43+
['containsValue' => $this->containsValue, 'count' => $this->count],
44+
);
45+
}
46+
47+
private function countOccurrences(mixed $input): int|null
48+
{
49+
if (is_array($input)) {
50+
$count = 0;
51+
foreach ($input as $item) {
52+
if (!in_array($this->containsValue, [$item], $this->identical)) {
53+
continue;
54+
}
55+
56+
$count++;
57+
}
58+
59+
return $count;
60+
}
61+
62+
if (!is_scalar($input) || !is_scalar($this->containsValue)) {
63+
return null;
64+
}
65+
66+
$haystack = (string) $input;
67+
$needle = (string) $this->containsValue;
68+
69+
if ($needle === '') {
70+
return null;
71+
}
72+
73+
if ($this->identical) {
74+
return mb_substr_count($haystack, $needle);
75+
}
76+
77+
return mb_substr_count(mb_strtolower($haystack), mb_strtolower($needle));
78+
}
79+
}

library/Validators/Domain.php

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@
1717
use function array_pop;
1818
use function count;
1919
use function explode;
20-
use function mb_substr_count;
2120

2221
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
2322
#[Template(
@@ -84,9 +83,7 @@ private function createPartsRule(): Validator
8483
new Not(new StartsWith('-')),
8584
new AnyOf(
8685
new Not(new Contains('--')),
87-
new Callback(static function ($str) {
88-
return mb_substr_count($str, '--') == 1;
89-
}),
86+
new ContainsCount('--', 1),
9087
),
9188
new Not(new EndsWith('-')),
9289
),

tests/benchmark/ValidatorBench.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public function provideValidatorInput(): Generator
4646
yield 'Consonant' => [v::consonant(), 'bcdf'];
4747
yield 'Contains' => [v::contains('needle'), 'haystack needle'];
4848
yield 'ContainsAny' => [v::containsAny(['a', 'b']), 'abc'];
49+
yield 'ContainsCount' => [v::containsCount('a', 3), 'banana'];
4950
yield 'Control' => [v::control(), "\n\r"];
5051
yield 'Countable' => [v::countable(), []];
5152
yield 'CountryCode' => [v::countryCode(), 'US'];
@@ -55,6 +56,7 @@ public function provideValidatorInput(): Generator
5556
yield 'DateTime' => [v::dateTime(), '2020-01-01 12:00:00'];
5657
yield 'Decimal' => [v::decimal(2), '1.23'];
5758
yield 'Digit' => [v::digit(), '7'];
59+
yield 'Domain' => [v::domain(), 'example.com'];
5860
yield 'Each' => [v::each(v::stringType()), ['a', 'b']];
5961
yield 'Email' => [v::email(), 'bob@example.com'];
6062
yield 'EndsWith' => [v::endsWith('.com'), 'example.com'];
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/*
4+
* Copyright (c) Alexandre Gomes Gaigalas <alganet@gmail.com>
5+
* SPDX-License-Identifier: MIT
6+
*/
7+
8+
declare(strict_types=1);
9+
10+
test('Scenario #1', catchMessage(
11+
fn() => v::containsCount('foo', 2)->assert('bar'),
12+
fn(string $message) => expect($message)->toBe('"bar" must contain "foo" 2 times'),
13+
));
14+
15+
test('Scenario #2', catchMessage(
16+
fn() => v::not(v::containsCount('foo', 2))->assert('foo bar foo'),
17+
fn(string $message) => expect($message)->toBe('"foo bar foo" must not contain "foo" 2 times'),
18+
));
19+
20+
test('Scenario #3', catchFullMessage(
21+
fn() => v::containsCount('foo', 2)->assert(['foo']),
22+
fn(string $fullMessage) => expect($fullMessage)->toBe('- `["foo"]` must contain "foo" 2 times'),
23+
));
24+
25+
test('Scenario #4', catchFullMessage(
26+
fn() => v::not(v::containsCount('foo', 1, true))->assert(['foo', 'bar']),
27+
fn(string $fullMessage) => expect($fullMessage)->toBe('- `["foo", "bar"]` must not contain "foo" 1 times'),
28+
));

0 commit comments

Comments
 (0)