Skip to content

Commit 9996b28

Browse files
olsavmicJanTvrdik
andauthored
Add AssertUniqueItems validator (#56)
Co-authored-by: Jan Tvrdík <[email protected]>
1 parent 2c95d69 commit 9996b28

File tree

6 files changed

+279
-3
lines changed

6 files changed

+279
-3
lines changed

src/Compiler/Php/PhpCodeBuilder.php

+34-3
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,12 @@
1919
use PhpParser\Node\Expr\BinaryOp\GreaterOrEqual;
2020
use PhpParser\Node\Expr\BinaryOp\Identical;
2121
use PhpParser\Node\Expr\BinaryOp\NotIdentical;
22+
use PhpParser\Node\Expr\BinaryOp\Plus;
2223
use PhpParser\Node\Expr\BinaryOp\Smaller;
2324
use PhpParser\Node\Expr\BinaryOp\SmallerOrEqual;
2425
use PhpParser\Node\Expr\BooleanNot;
2526
use PhpParser\Node\Expr\Instanceof_;
27+
use PhpParser\Node\Expr\PreInc;
2628
use PhpParser\Node\Expr\Ternary;
2729
use PhpParser\Node\Name;
2830
use PhpParser\Node\Stmt;
@@ -34,6 +36,7 @@
3436
use PhpParser\Node\Stmt\Else_;
3537
use PhpParser\Node\Stmt\ElseIf_;
3638
use PhpParser\Node\Stmt\Expression;
39+
use PhpParser\Node\Stmt\For_;
3740
use PhpParser\Node\Stmt\Foreach_;
3841
use PhpParser\Node\Stmt\If_;
3942
use PhpParser\Node\Stmt\Nop;
@@ -193,7 +196,7 @@ public function ternary(Expr $cond, Expr $ifTrue, Expr $else): Ternary
193196
}
194197

195198
/**
196-
* @param list<Stmt> $then
199+
* @param list<Stmt> $then
197200
* @param list<Stmt>|null $else
198201
*/
199202
public function if(Expr $if, array $then, ?array $else = null): If_
@@ -221,6 +224,29 @@ public function foreach(Expr $expr, Expr $value, Expr $key, array $statements):
221224
return new Foreach_($expr, $value, ['stmts' => $statements, 'keyVar' => $key]);
222225
}
223226

227+
/**
228+
* @param list<Stmt> $statements
229+
*/
230+
public function for(Expr $init, Expr $cond, Expr $loop, array $statements): For_
231+
{
232+
return new For_([
233+
'init' => [$init],
234+
'cond' => [$cond],
235+
'loop' => [$loop],
236+
'stmts' => $statements,
237+
]);
238+
}
239+
240+
public function preIncrement(Expr $var): PreInc
241+
{
242+
return new PreInc($var, []);
243+
}
244+
245+
public function plus(Expr $var, Expr $value): Plus
246+
{
247+
return new Plus($var, $value);
248+
}
249+
224250
/**
225251
* @param array<int|string, scalar|array<mixed>|Expr|Arg|null> $args
226252
*/
@@ -239,6 +265,11 @@ public function assign(Expr $var, Expr $expr): Expression
239265
return new Expression(new Assign($var, $expr));
240266
}
241267

268+
public function assignExpr(Expr $var, Expr $expr): Assign
269+
{
270+
return new Assign($var, $expr);
271+
}
272+
242273
public function return(Expr $expr): Return_
243274
{
244275
return new Return_($expr);
@@ -303,7 +334,7 @@ public function uniqVariableNames(string ...$names): array
303334

304335
/**
305336
* @template T
306-
* @param callable(): T $cb
337+
* @param callable(): T $cb
307338
* @return T
308339
*/
309340
public function withVariableScope(callable $cb): mixed
@@ -406,7 +437,7 @@ public function importType(TypeNode $type): void
406437
*/
407438
public function phpDoc(array $lines): string
408439
{
409-
$lines = array_filter($lines, static fn (?string $line): bool => $line !== null);
440+
$lines = array_filter($lines, static fn(?string $line): bool => $line !== null);
410441

411442
if (count($lines) === 0) {
412443
return '';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ShipMonk\InputMapper\Compiler\Validator\Array;
4+
5+
use Attribute;
6+
use PhpParser\Node\Expr;
7+
use PhpParser\Node\Stmt;
8+
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
9+
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
10+
use ShipMonk\InputMapper\Compiler\Php\PhpCodeBuilder;
11+
use ShipMonk\InputMapper\Compiler\Validator\ValidatorCompiler;
12+
use ShipMonk\InputMapper\Runtime\Exception\MappingFailedException;
13+
14+
#[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)]
15+
class AssertUniqueItems implements ValidatorCompiler
16+
{
17+
18+
/**
19+
* @return list<Stmt>
20+
*/
21+
public function compile(Expr $value, TypeNode $type, Expr $path, PhpCodeBuilder $builder): array
22+
{
23+
[$indexVariableName, $itemVariableName, $innerLoopIndexVariableName] = $builder->uniqVariableNames(
24+
'index',
25+
'item',
26+
'innerIndex',
27+
);
28+
29+
$statements = [];
30+
31+
$length = $builder->funcCall($builder->importFunction('count'), [$value]);
32+
33+
$statements[] = $builder->foreach($value, $builder->var($itemVariableName), $builder->var($indexVariableName), [
34+
$builder->for(
35+
$builder->assignExpr(
36+
$builder->var($innerLoopIndexVariableName),
37+
$builder->plus($builder->var($indexVariableName), $builder->val(1)),
38+
),
39+
$builder->lt($builder->var($innerLoopIndexVariableName), $length),
40+
$builder->preIncrement($builder->var($innerLoopIndexVariableName)),
41+
[
42+
$builder->if(
43+
$builder->same(
44+
$builder->var($itemVariableName),
45+
$builder->arrayDimFetch($value, $builder->var($innerLoopIndexVariableName)),
46+
),
47+
[
48+
$builder->throw(
49+
$builder->staticCall(
50+
$builder->importClass(MappingFailedException::class),
51+
'duplicateValue',
52+
[
53+
$builder->var($itemVariableName),
54+
$path,
55+
$builder->val('list with unique items'),
56+
],
57+
),
58+
),
59+
],
60+
),
61+
],
62+
),
63+
]);
64+
65+
return $statements;
66+
}
67+
68+
public function getInputType(): TypeNode
69+
{
70+
return new IdentifierTypeNode('list');
71+
}
72+
73+
}

src/Runtime/Exception/MappingFailedException.php

+15
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,21 @@ public static function incorrectValue(
7878
return new self($path, $reason, $previous);
7979
}
8080

81+
/**
82+
* @param list<string|int> $path
83+
*/
84+
public static function duplicateValue(
85+
mixed $data,
86+
array $path,
87+
string $expectedValueDescription,
88+
?Throwable $previous = null
89+
): self
90+
{
91+
$describedValue = self::describeValue($data);
92+
$reason = "Expected {$expectedValueDescription}, got {$describedValue} multiple times";
93+
return new self($path, $reason, $previous);
94+
}
95+
8196
/**
8297
* @param list<string|int> $path
8398
*/
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ShipMonkTests\InputMapper\Compiler\Validator\Array;
4+
5+
use ShipMonk\InputMapper\Compiler\Mapper\Array\MapList;
6+
use ShipMonk\InputMapper\Compiler\Mapper\Scalar\MapInt;
7+
use ShipMonk\InputMapper\Compiler\Mapper\Scalar\MapString;
8+
use ShipMonk\InputMapper\Compiler\Validator\Array\AssertUniqueItems;
9+
use ShipMonk\InputMapper\Runtime\Exception\MappingFailedException;
10+
use ShipMonkTests\InputMapper\Compiler\Validator\ValidatorCompilerTestCase;
11+
12+
class AssertUniqueItemsTest extends ValidatorCompilerTestCase
13+
{
14+
15+
public function testUniqueItemsIntValidator(): void
16+
{
17+
$mapperCompiler = new MapList(new MapInt());
18+
$validatorCompiler = new AssertUniqueItems();
19+
$validator = $this->compileValidator('UniqueItemsIntValidator', $mapperCompiler, $validatorCompiler);
20+
21+
$validator->map([1, 2, 3]);
22+
23+
self::assertException(
24+
MappingFailedException::class,
25+
'Failed to map data at path /: Expected list with unique items, got 1 multiple times',
26+
static fn() => $validator->map([1, 2, 1]),
27+
);
28+
}
29+
30+
public function testUniqueItemsStringValidator(): void
31+
{
32+
$mapperCompiler = new MapList(new MapString());
33+
$validatorCompiler = new AssertUniqueItems();
34+
$validator = $this->compileValidator('UniqueItemsStringValidator', $mapperCompiler, $validatorCompiler);
35+
36+
$validator->map(['abc', 'def', 'fg']);
37+
38+
self::assertException(
39+
MappingFailedException::class,
40+
'Failed to map data at path /: Expected list with unique items, got "def" multiple times',
41+
static fn() => $validator->map(['abc', 'def', 'def', 'fgq']),
42+
);
43+
}
44+
45+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php declare (strict_types=1);
2+
3+
namespace ShipMonkTests\InputMapper\Compiler\Validator\Array\Data;
4+
5+
use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\ValidatedMapperCompiler;
6+
use ShipMonk\InputMapper\Runtime\Exception\MappingFailedException;
7+
use ShipMonk\InputMapper\Runtime\Mapper;
8+
use ShipMonk\InputMapper\Runtime\MapperProvider;
9+
use function array_is_list;
10+
use function count;
11+
use function is_array;
12+
use function is_int;
13+
14+
/**
15+
* Generated mapper by {@see ValidatedMapperCompiler}. Do not edit directly.
16+
*
17+
* @implements Mapper<list<int>>
18+
*/
19+
class UniqueItemsIntValidatorMapper implements Mapper
20+
{
21+
public function __construct(private readonly MapperProvider $provider)
22+
{
23+
}
24+
25+
/**
26+
* @param list<string|int> $path
27+
* @return list<int>
28+
* @throws MappingFailedException
29+
*/
30+
public function map(mixed $data, array $path = []): array
31+
{
32+
if (!is_array($data) || !array_is_list($data)) {
33+
throw MappingFailedException::incorrectType($data, $path, 'list');
34+
}
35+
36+
$mapped = [];
37+
38+
foreach ($data as $index => $item) {
39+
if (!is_int($item)) {
40+
throw MappingFailedException::incorrectType($item, [...$path, $index], 'int');
41+
}
42+
43+
$mapped[] = $item;
44+
}
45+
46+
foreach ($mapped as $index2 => $item2) {
47+
for ($innerIndex = $index2 + 1; $innerIndex < count($mapped); ++$innerIndex) {
48+
if ($item2 === $mapped[$innerIndex]) {
49+
throw MappingFailedException::duplicateValue($item2, $path, 'list with unique items');
50+
}
51+
}
52+
}
53+
54+
return $mapped;
55+
}
56+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
<?php declare (strict_types=1);
2+
3+
namespace ShipMonkTests\InputMapper\Compiler\Validator\Array\Data;
4+
5+
use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\ValidatedMapperCompiler;
6+
use ShipMonk\InputMapper\Runtime\Exception\MappingFailedException;
7+
use ShipMonk\InputMapper\Runtime\Mapper;
8+
use ShipMonk\InputMapper\Runtime\MapperProvider;
9+
use function array_is_list;
10+
use function count;
11+
use function is_array;
12+
use function is_string;
13+
14+
/**
15+
* Generated mapper by {@see ValidatedMapperCompiler}. Do not edit directly.
16+
*
17+
* @implements Mapper<list<string>>
18+
*/
19+
class UniqueItemsStringValidatorMapper implements Mapper
20+
{
21+
public function __construct(private readonly MapperProvider $provider)
22+
{
23+
}
24+
25+
/**
26+
* @param list<string|int> $path
27+
* @return list<string>
28+
* @throws MappingFailedException
29+
*/
30+
public function map(mixed $data, array $path = []): array
31+
{
32+
if (!is_array($data) || !array_is_list($data)) {
33+
throw MappingFailedException::incorrectType($data, $path, 'list');
34+
}
35+
36+
$mapped = [];
37+
38+
foreach ($data as $index => $item) {
39+
if (!is_string($item)) {
40+
throw MappingFailedException::incorrectType($item, [...$path, $index], 'string');
41+
}
42+
43+
$mapped[] = $item;
44+
}
45+
46+
foreach ($mapped as $index2 => $item2) {
47+
for ($innerIndex = $index2 + 1; $innerIndex < count($mapped); ++$innerIndex) {
48+
if ($item2 === $mapped[$innerIndex]) {
49+
throw MappingFailedException::duplicateValue($item2, $path, 'list with unique items');
50+
}
51+
}
52+
}
53+
54+
return $mapped;
55+
}
56+
}

0 commit comments

Comments
 (0)