From a3e703e519d2423a97cf27d46a93f64db44b3f0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Ols=CC=8Cavsky=CC=81?= Date: Fri, 31 May 2024 11:50:08 +0200 Subject: [PATCH] Add AssertUniqueItems validator --- src/Compiler/Php/PhpCodeBuilder.php | 37 +++++++++- .../Validator/Array/AssertUniqueItems.php | 74 +++++++++++++++++++ .../Exception/MappingFailedException.php | 15 ++++ .../Validator/Array/AssertUniqueItemsTest.php | 45 +++++++++++ .../Data/UniqueItemsIntValidatorMapper.php | 56 ++++++++++++++ .../Data/UniqueItemsStringValidatorMapper.php | 56 ++++++++++++++ 6 files changed, 280 insertions(+), 3 deletions(-) create mode 100644 src/Compiler/Validator/Array/AssertUniqueItems.php create mode 100644 tests/Compiler/Validator/Array/AssertUniqueItemsTest.php create mode 100644 tests/Compiler/Validator/Array/Data/UniqueItemsIntValidatorMapper.php create mode 100644 tests/Compiler/Validator/Array/Data/UniqueItemsStringValidatorMapper.php diff --git a/src/Compiler/Php/PhpCodeBuilder.php b/src/Compiler/Php/PhpCodeBuilder.php index 3dcd1dc..bfd8cc7 100644 --- a/src/Compiler/Php/PhpCodeBuilder.php +++ b/src/Compiler/Php/PhpCodeBuilder.php @@ -19,10 +19,12 @@ use PhpParser\Node\Expr\BinaryOp\GreaterOrEqual; use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\BinaryOp\NotIdentical; +use PhpParser\Node\Expr\BinaryOp\Plus; use PhpParser\Node\Expr\BinaryOp\Smaller; use PhpParser\Node\Expr\BinaryOp\SmallerOrEqual; use PhpParser\Node\Expr\BooleanNot; use PhpParser\Node\Expr\Instanceof_; +use PhpParser\Node\Expr\PreInc; use PhpParser\Node\Expr\Ternary; use PhpParser\Node\Name; use PhpParser\Node\Stmt; @@ -34,6 +36,7 @@ use PhpParser\Node\Stmt\Else_; use PhpParser\Node\Stmt\ElseIf_; use PhpParser\Node\Stmt\Expression; +use PhpParser\Node\Stmt\For_; use PhpParser\Node\Stmt\Foreach_; use PhpParser\Node\Stmt\If_; use PhpParser\Node\Stmt\Nop; @@ -193,7 +196,7 @@ public function ternary(Expr $cond, Expr $ifTrue, Expr $else): Ternary } /** - * @param list $then + * @param list $then * @param list|null $else */ 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): return new Foreach_($expr, $value, ['stmts' => $statements, 'keyVar' => $key]); } + /** + * @param list $statements + */ + public function for(Expr $init, Expr $cond, Expr $loop, array $statements): For_ + { + return new For_([ + 'init' => [$init], + 'cond' => [$cond], + 'loop' => [$loop], + 'stmts' => $statements, + ]); + } + + public function preIncrement(Expr $var): PreInc + { + return new PreInc($var, []); + } + + public function plus(Expr $var, Expr $value): Plus + { + return new Plus($var, $value); + } + /** * @param array|Expr|Arg|null> $args */ @@ -239,6 +265,11 @@ public function assign(Expr $var, Expr $expr): Expression return new Expression(new Assign($var, $expr)); } + public function assignExpr(Expr $var, Expr $expr): Assign + { + return new Assign($var, $expr); + } + public function return(Expr $expr): Return_ { return new Return_($expr); @@ -303,7 +334,7 @@ public function uniqVariableNames(string ...$names): array /** * @template T - * @param callable(): T $cb + * @param callable(): T $cb * @return T */ public function withVariableScope(callable $cb): mixed @@ -406,7 +437,7 @@ public function importType(TypeNode $type): void */ public function phpDoc(array $lines): string { - $lines = array_filter($lines, static fn (?string $line): bool => $line !== null); + $lines = array_filter($lines, static fn(?string $line): bool => $line !== null); if (count($lines) === 0) { return ''; diff --git a/src/Compiler/Validator/Array/AssertUniqueItems.php b/src/Compiler/Validator/Array/AssertUniqueItems.php new file mode 100644 index 0000000..f313b35 --- /dev/null +++ b/src/Compiler/Validator/Array/AssertUniqueItems.php @@ -0,0 +1,74 @@ + + */ + public function compile(Expr $value, TypeNode $type, Expr $path, PhpCodeBuilder $builder): array + { + [$indexVariableName, $itemVariableName, $innerLoopIndexVariableName] = $builder->uniqVariableNames( + 'index', + 'item', + 'innerIndex', + 'innerLoopItem', + ); + + $statements = []; + + $length = $builder->funcCall($builder->importFunction('count'), [$value]); + + $statements[] = $builder->foreach($value, $builder->var($itemVariableName), $builder->var($indexVariableName), [ + $builder->for( + $builder->assignExpr( + $builder->var($innerLoopIndexVariableName), + $builder->plus($builder->var($indexVariableName), $builder->val(1)), + ), + $builder->lt($builder->var($innerLoopIndexVariableName), $length), + $builder->preIncrement($builder->var($innerLoopIndexVariableName)), + [ + $builder->if( + $builder->same( + $builder->var($itemVariableName), + $builder->arrayDimFetch($value, $builder->var($innerLoopIndexVariableName)), + ), + [ + $builder->throw( + $builder->staticCall( + $builder->importClass(MappingFailedException::class), + 'duplicateValue', + [ + $builder->var($itemVariableName), + $path, + $builder->val('list with unique items'), + ], + ), + ), + ], + ), + ], + ), + ]); + + return $statements; + } + + public function getInputType(): TypeNode + { + return new IdentifierTypeNode('list'); + } + +} diff --git a/src/Runtime/Exception/MappingFailedException.php b/src/Runtime/Exception/MappingFailedException.php index 86a6ed6..2f37629 100644 --- a/src/Runtime/Exception/MappingFailedException.php +++ b/src/Runtime/Exception/MappingFailedException.php @@ -78,6 +78,21 @@ public static function incorrectValue( return new self($path, $reason, $previous); } + /** + * @param list $path + */ + public static function duplicateValue( + mixed $data, + array $path, + string $expectedValueDescription, + ?Throwable $previous = null + ): self + { + $describedValue = self::describeValue($data); + $reason = "Expected {$expectedValueDescription}, got {$describedValue} multiple times"; + return new self($path, $reason, $previous); + } + /** * @param list $path */ diff --git a/tests/Compiler/Validator/Array/AssertUniqueItemsTest.php b/tests/Compiler/Validator/Array/AssertUniqueItemsTest.php new file mode 100644 index 0000000..59edb56 --- /dev/null +++ b/tests/Compiler/Validator/Array/AssertUniqueItemsTest.php @@ -0,0 +1,45 @@ +compileValidator('UniqueItemsIntValidator', $mapperCompiler, $validatorCompiler); + + $validator->map([1, 2, 3]); + + self::assertException( + MappingFailedException::class, + 'Failed to map data at path /: Expected list with unique items, got 1 multiple times', + static fn() => $validator->map([1, 2, 1]), + ); + } + + public function testUniqueItemsStringValidator(): void + { + $mapperCompiler = new MapList(new MapString()); + $validatorCompiler = new AssertUniqueItems(); + $validator = $this->compileValidator('UniqueItemsStringValidator', $mapperCompiler, $validatorCompiler); + + $validator->map(['abc', 'def', 'fg']); + + self::assertException( + MappingFailedException::class, + 'Failed to map data at path /: Expected list with unique items, got "def" multiple times', + static fn() => $validator->map(['abc', 'def', 'def', 'fgq']), + ); + } + +} diff --git a/tests/Compiler/Validator/Array/Data/UniqueItemsIntValidatorMapper.php b/tests/Compiler/Validator/Array/Data/UniqueItemsIntValidatorMapper.php new file mode 100644 index 0000000..9d67fbf --- /dev/null +++ b/tests/Compiler/Validator/Array/Data/UniqueItemsIntValidatorMapper.php @@ -0,0 +1,56 @@ +> + */ +class UniqueItemsIntValidatorMapper implements Mapper +{ + public function __construct(private readonly MapperProvider $provider) + { + } + + /** + * @param list $path + * @return list + * @throws MappingFailedException + */ + public function map(mixed $data, array $path = []): array + { + if (!is_array($data) || !array_is_list($data)) { + throw MappingFailedException::incorrectType($data, $path, 'list'); + } + + $mapped = []; + + foreach ($data as $index => $item) { + if (!is_int($item)) { + throw MappingFailedException::incorrectType($item, [...$path, $index], 'int'); + } + + $mapped[] = $item; + } + + foreach ($mapped as $index2 => $item2) { + for ($innerIndex = $index2 + 1; $innerIndex < count($mapped); ++$innerIndex) { + if ($item2 === $mapped[$innerIndex]) { + throw MappingFailedException::duplicateValue($item2, $path, 'list with unique items'); + } + } + } + + return $mapped; + } +} diff --git a/tests/Compiler/Validator/Array/Data/UniqueItemsStringValidatorMapper.php b/tests/Compiler/Validator/Array/Data/UniqueItemsStringValidatorMapper.php new file mode 100644 index 0000000..afa1481 --- /dev/null +++ b/tests/Compiler/Validator/Array/Data/UniqueItemsStringValidatorMapper.php @@ -0,0 +1,56 @@ +> + */ +class UniqueItemsStringValidatorMapper implements Mapper +{ + public function __construct(private readonly MapperProvider $provider) + { + } + + /** + * @param list $path + * @return list + * @throws MappingFailedException + */ + public function map(mixed $data, array $path = []): array + { + if (!is_array($data) || !array_is_list($data)) { + throw MappingFailedException::incorrectType($data, $path, 'list'); + } + + $mapped = []; + + foreach ($data as $index => $item) { + if (!is_string($item)) { + throw MappingFailedException::incorrectType($item, [...$path, $index], 'string'); + } + + $mapped[] = $item; + } + + foreach ($mapped as $index2 => $item2) { + for ($innerIndex = $index2 + 1; $innerIndex < count($mapped); ++$innerIndex) { + if ($item2 === $mapped[$innerIndex]) { + throw MappingFailedException::duplicateValue($item2, $path, 'list with unique items'); + } + } + } + + return $mapped; + } +}