Skip to content

Commit 0b9fabe

Browse files
committed
Add ArraySchema minContains / maxContains
1 parent d1ccfba commit 0b9fabe

File tree

6 files changed

+316
-2
lines changed

6 files changed

+316
-2
lines changed

.php-cs-fixer.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@
1515
// rules is buggy
1616
unset($config['rules']['simplified_null_return']);
1717

18+
$config['rules']['strict_comparison'] = false;
19+
1820
return (new PhpCsFixer\Config)
1921
->setUnsupportedPhpVersionAllowed(true)
2022
->setIndent($config['indent'])

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ Heavily inspired by the well-known TypeScript library [zod](https://github.com/c
3535
Through [Composer](http://getcomposer.org) as [chubbyphp/chubbyphp-parsing][1].
3636

3737
```sh
38-
composer require chubbyphp/chubbyphp-parsing "^2.5"
38+
composer require chubbyphp/chubbyphp-parsing "^2.6"
3939
```
4040

4141
## Quick Start

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@
5050
},
5151
"extra": {
5252
"branch-alias": {
53-
"dev-master": "2.5-dev"
53+
"dev-master": "2.6-dev"
5454
}
5555
},
5656
"scripts": {

doc/Schema/ArraySchema.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ $schema->maxItems(10); // At most 10 items
2828

2929
```php
3030
$schema->contains(5); // Array must contain value 5
31+
$schema->minContains(5, 2); // Value 5 must appear at least twice
32+
$schema->maxContains(5, 2); // Value 5 may appear at most twice
3133
$schema->uniqueItems(); // Array must contain unique items
3234
```
3335

@@ -152,5 +154,7 @@ $matrixSchema->parse([
152154
| `array.minItems` | Array has fewer items than minimum |
153155
| `array.maxItems` | Array has more items than maximum |
154156
| `array.contains` | Array doesn't contain required value |
157+
| `array.minContains` | Array contains a value less often than required |
158+
| `array.maxContains` | Array contains a value more often than allowed |
155159

156160
Item-level errors will include the array index in the error path (e.g., `items.0`, `items.1`).

src/Schema/ArraySchema.php

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ final class ArraySchema extends AbstractSchemaInnerParse implements SchemaInterf
4343
public const string ERROR_CONTAINS_CODE = 'array.contains';
4444
public const string ERROR_CONTAINS_TEMPLATE = '{{given}} does not contain {{contains}}';
4545

46+
public const string ERROR_MIN_CONTAINS_CODE = 'array.minContains';
47+
public const string ERROR_MIN_CONTAINS_TEMPLATE = '{{given}} contains {{contains}} {{containsCount}} times, min {{minContains}} required';
48+
49+
public const string ERROR_MAX_CONTAINS_CODE = 'array.maxContains';
50+
public const string ERROR_MAX_CONTAINS_TEMPLATE = '{{given}} contains {{contains}} {{containsCount}} times, max {{maxContains}} allowed';
51+
4652
/** @deprecated: see ERROR_CONTAINS_CODE */
4753
public const string ERROR_INCLUDES_CODE = 'array.includes';
4854

@@ -200,6 +206,66 @@ public function contains(mixed $contains, bool $strict = true): static
200206
});
201207
}
202208

209+
public function minContains(mixed $contains, int $minContains, bool $strict = true): static
210+
{
211+
return $this->postParse(static function (array $array) use ($contains, $minContains, $strict) {
212+
$containsCount = 0;
213+
214+
foreach ($array as $value) {
215+
if (($strict && $value === $contains) || (!$strict && $value == $contains)) {
216+
++$containsCount;
217+
}
218+
}
219+
220+
if ($containsCount < $minContains) {
221+
throw new ErrorsException(
222+
new Error(
223+
self::ERROR_MIN_CONTAINS_CODE,
224+
self::ERROR_MIN_CONTAINS_TEMPLATE,
225+
[
226+
'contains' => $contains,
227+
'containsCount' => $containsCount,
228+
'given' => $array,
229+
'minContains' => $minContains,
230+
]
231+
)
232+
);
233+
}
234+
235+
return $array;
236+
});
237+
}
238+
239+
public function maxContains(mixed $contains, int $maxContains, bool $strict = true): static
240+
{
241+
return $this->postParse(static function (array $array) use ($contains, $maxContains, $strict) {
242+
$containsCount = 0;
243+
244+
foreach ($array as $value) {
245+
if (($strict && $value === $contains) || (!$strict && $value == $contains)) {
246+
++$containsCount;
247+
}
248+
}
249+
250+
if ($containsCount > $maxContains) {
251+
throw new ErrorsException(
252+
new Error(
253+
self::ERROR_MAX_CONTAINS_CODE,
254+
self::ERROR_MAX_CONTAINS_TEMPLATE,
255+
[
256+
'contains' => $contains,
257+
'containsCount' => $containsCount,
258+
'given' => $array,
259+
'maxContains' => $maxContains,
260+
]
261+
)
262+
);
263+
}
264+
265+
return $array;
266+
});
267+
}
268+
203269
/**
204270
* @deprecated use contains($contains, $strict)
205271
*/

tests/Unit/Schema/ArraySchemaTest.php

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ public function testImmutability(): void
3232
self::assertNotSame($schema, $schema->minItems(1));
3333
self::assertNotSame($schema, $schema->maxItems(1));
3434
self::assertNotSame($schema, $schema->contains('test'));
35+
self::assertNotSame($schema, $schema->minContains('test', 1));
36+
self::assertNotSame($schema, $schema->maxContains('test', 1));
3537
self::assertNotSame($schema, $schema->uniqueItems());
3638
self::assertNotSame($schema, $schema->filter(static fn (mixed $value) => true));
3739
self::assertNotSame($schema, $schema->map(static fn (mixed $value) => $value));
@@ -550,6 +552,246 @@ public function testParseWithInvalidContains(): void
550552
}
551553
}
552554

555+
public function testParseWithValidMinContains(): void
556+
{
557+
$dateTime1 = new \DateTimeImmutable('2024-01-20T09:15:00+00:00');
558+
$dateTime2 = new \DateTimeImmutable('2024-01-21T09:15:00+00:00');
559+
560+
$input = [$dateTime1, $dateTime2, $dateTime2];
561+
562+
$schema = (new ArraySchema(new DateTimeSchema()))->minContains($dateTime2, 2);
563+
564+
self::assertSame($input, $schema->parse($input));
565+
}
566+
567+
public function testParseWithValidMinContainsWithEqualButNotSame(): void
568+
{
569+
$dateTime1 = new \DateTimeImmutable('2024-01-20T09:15:00+00:00');
570+
$dateTime2 = new \DateTimeImmutable('2024-01-21T09:15:00+00:00');
571+
572+
$dateTime2Equal = new \DateTimeImmutable('2024-01-21T09:15:00+00:00');
573+
574+
$input = [$dateTime1, $dateTime2, $dateTime2Equal];
575+
576+
$schema = (new ArraySchema(new DateTimeSchema()))->minContains($dateTime2Equal, 2, false);
577+
578+
self::assertSame($input, $schema->parse($input));
579+
}
580+
581+
public function testParseWithInvalidMinContainsWithEqualButNotSameAndStrictFalse(): void
582+
{
583+
$dateTime1 = new \DateTimeImmutable('2024-01-20T09:15:00+00:00');
584+
$dateTime2 = new \DateTimeImmutable('2024-01-21T09:15:00+00:00');
585+
586+
$dateTime2Equal = new \DateTimeImmutable('2024-01-21T09:15:00+00:00');
587+
588+
$dateTime2Equal2 = new \DateTimeImmutable('2024-01-21T09:15:00+00:00');
589+
590+
$input = [$dateTime1, $dateTime2, $dateTime2Equal2];
591+
592+
$schema = (new ArraySchema(new DateTimeSchema()))->minContains($dateTime2Equal, 2, false);
593+
594+
try {
595+
(new ArraySchema(new DateTimeSchema()))->minContains($dateTime2Equal, 2)->parse($input);
596+
597+
throw new \Exception('code should not be reached');
598+
} catch (ErrorsException $errorsException) {
599+
self::assertSame(
600+
[
601+
[
602+
'path' => '',
603+
'error' => [
604+
'code' => 'array.minContains',
605+
'template' => '{{given}} contains {{contains}} {{containsCount}} times, min {{minContains}} required',
606+
'variables' => [
607+
'contains' => json_decode(json_encode($dateTime2Equal), true),
608+
'containsCount' => 0,
609+
'given' => json_decode(json_encode($input), true),
610+
'minContains' => 2,
611+
],
612+
],
613+
],
614+
],
615+
$errorsException->errors->jsonSerialize()
616+
);
617+
}
618+
619+
self::assertSame($input, $schema->parse($input));
620+
}
621+
622+
public function testParseWithInvalidMinContainsWithStrictFalseAndNonMatchingValues(): void
623+
{
624+
$dateTime1 = new \DateTimeImmutable('2024-01-20T09:15:00+00:00');
625+
$dateTime2 = new \DateTimeImmutable('2024-01-21T09:15:00+00:00');
626+
$dateTime3 = new \DateTimeImmutable('2024-01-22T09:15:00+00:00');
627+
628+
$dateTime2Equal = new \DateTimeImmutable('2024-01-21T09:15:00+00:00');
629+
630+
$input = [$dateTime1, $dateTime2, $dateTime3];
631+
632+
$schema = (new ArraySchema(new DateTimeSchema()))->minContains($dateTime2Equal, 2, false);
633+
634+
try {
635+
$schema->parse($input);
636+
637+
throw new \Exception('code should not be reached');
638+
} catch (ErrorsException $errorsException) {
639+
self::assertSame(
640+
[
641+
[
642+
'path' => '',
643+
'error' => [
644+
'code' => 'array.minContains',
645+
'template' => '{{given}} contains {{contains}} {{containsCount}} times, min {{minContains}} required',
646+
'variables' => [
647+
'contains' => json_decode(json_encode($dateTime2Equal), true),
648+
'containsCount' => 1,
649+
'given' => json_decode(json_encode($input), true),
650+
'minContains' => 2,
651+
],
652+
],
653+
],
654+
],
655+
$errorsException->errors->jsonSerialize()
656+
);
657+
}
658+
}
659+
660+
public function testParseWithInvalidMinContains(): void
661+
{
662+
$dateTime1 = new \DateTimeImmutable('2024-01-20T09:15:00+00:00');
663+
$dateTime2 = new \DateTimeImmutable('2024-01-21T09:15:00+00:00');
664+
665+
$input = [$dateTime1, $dateTime2, $dateTime1];
666+
667+
$schema = (new ArraySchema(new DateTimeSchema()))->minContains($dateTime2, 2);
668+
669+
try {
670+
$schema->parse($input);
671+
672+
throw new \Exception('code should not be reached');
673+
} catch (ErrorsException $errorsException) {
674+
self::assertSame(
675+
[
676+
[
677+
'path' => '',
678+
'error' => [
679+
'code' => 'array.minContains',
680+
'template' => '{{given}} contains {{contains}} {{containsCount}} times, min {{minContains}} required',
681+
'variables' => [
682+
'contains' => json_decode(json_encode($dateTime2), true),
683+
'containsCount' => 1,
684+
'given' => json_decode(json_encode($input), true),
685+
'minContains' => 2,
686+
],
687+
],
688+
],
689+
],
690+
$errorsException->errors->jsonSerialize()
691+
);
692+
}
693+
}
694+
695+
public function testParseWithValidMaxContains(): void
696+
{
697+
$dateTime1 = new \DateTimeImmutable('2024-01-20T09:15:00+00:00');
698+
$dateTime2 = new \DateTimeImmutable('2024-01-21T09:15:00+00:00');
699+
700+
$input = [$dateTime1, $dateTime2, $dateTime2];
701+
702+
$schema = (new ArraySchema(new DateTimeSchema()))->maxContains($dateTime2, 2);
703+
704+
self::assertSame($input, $schema->parse($input));
705+
}
706+
707+
public function testParseWithValidMaxContainsWithEqualButNotSame(): void
708+
{
709+
$dateTime1 = new \DateTimeImmutable('2024-01-20T09:15:00+00:00');
710+
$dateTime2 = new \DateTimeImmutable('2024-01-21T09:15:00+00:00');
711+
712+
$dateTime2Equal = new \DateTimeImmutable('2024-01-21T09:15:00+00:00');
713+
714+
$input = [$dateTime1, $dateTime2, $dateTime2Equal];
715+
716+
$schema = (new ArraySchema(new DateTimeSchema()))->maxContains($dateTime2Equal, 2, false);
717+
718+
self::assertSame($input, $schema->parse($input));
719+
}
720+
721+
public function testParseWithInvalidMaxContainsWithEqualButNotSameAndStrictFalse(): void
722+
{
723+
$dateTime1 = new \DateTimeImmutable('2024-01-20T09:15:00+00:00');
724+
$dateTime2 = new \DateTimeImmutable('2024-01-21T09:15:00+00:00');
725+
726+
$dateTime2Equal = new \DateTimeImmutable('2024-01-21T09:15:00+00:00');
727+
728+
$input = [$dateTime1, $dateTime2, $dateTime2Equal];
729+
730+
$schema = (new ArraySchema(new DateTimeSchema()))->maxContains($dateTime2Equal, 1, false);
731+
732+
self::assertSame($input, (new ArraySchema(new DateTimeSchema()))->maxContains($dateTime2Equal, 1)->parse($input));
733+
734+
try {
735+
$schema->parse($input);
736+
737+
throw new \Exception('code should not be reached');
738+
} catch (ErrorsException $errorsException) {
739+
self::assertSame(
740+
[
741+
[
742+
'path' => '',
743+
'error' => [
744+
'code' => 'array.maxContains',
745+
'template' => '{{given}} contains {{contains}} {{containsCount}} times, max {{maxContains}} allowed',
746+
'variables' => [
747+
'contains' => json_decode(json_encode($dateTime2Equal), true),
748+
'containsCount' => 2,
749+
'given' => json_decode(json_encode($input), true),
750+
'maxContains' => 1,
751+
],
752+
],
753+
],
754+
],
755+
$errorsException->errors->jsonSerialize()
756+
);
757+
}
758+
}
759+
760+
public function testParseWithInvalidMaxContains(): void
761+
{
762+
$dateTime1 = new \DateTimeImmutable('2024-01-20T09:15:00+00:00');
763+
$dateTime2 = new \DateTimeImmutable('2024-01-21T09:15:00+00:00');
764+
765+
$input = [$dateTime1, $dateTime2, $dateTime2, $dateTime2, $dateTime2];
766+
767+
$schema = (new ArraySchema(new DateTimeSchema()))->maxContains($dateTime2, 2);
768+
769+
try {
770+
$schema->parse($input);
771+
772+
throw new \Exception('code should not be reached');
773+
} catch (ErrorsException $errorsException) {
774+
self::assertSame(
775+
[
776+
[
777+
'path' => '',
778+
'error' => [
779+
'code' => 'array.maxContains',
780+
'template' => '{{given}} contains {{contains}} {{containsCount}} times, max {{maxContains}} allowed',
781+
'variables' => [
782+
'contains' => json_decode(json_encode($dateTime2), true),
783+
'containsCount' => 4,
784+
'given' => json_decode(json_encode($input), true),
785+
'maxContains' => 2,
786+
],
787+
],
788+
],
789+
],
790+
$errorsException->errors->jsonSerialize()
791+
);
792+
}
793+
}
794+
553795
public function testParseWithValidIncludes(): void
554796
{
555797
$dateTime1 = new \DateTimeImmutable('2024-01-20T09:15:00+00:00');

0 commit comments

Comments
 (0)