Skip to content

Commit

Permalink
Add AssertUniqueItems validator
Browse files Browse the repository at this point in the history
  • Loading branch information
olsavmic committed May 31, 2024
1 parent 2c95d69 commit a3e703e
Show file tree
Hide file tree
Showing 6 changed files with 280 additions and 3 deletions.
37 changes: 34 additions & 3 deletions src/Compiler/Php/PhpCodeBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -193,7 +196,7 @@ public function ternary(Expr $cond, Expr $ifTrue, Expr $else): Ternary
}

/**
* @param list<Stmt> $then
* @param list<Stmt> $then
* @param list<Stmt>|null $else
*/
public function if(Expr $if, array $then, ?array $else = null): If_
Expand Down Expand Up @@ -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<Stmt> $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<int|string, scalar|array<mixed>|Expr|Arg|null> $args
*/
Expand All @@ -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);
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 '';
Expand Down
74 changes: 74 additions & 0 deletions src/Compiler/Validator/Array/AssertUniqueItems.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php declare(strict_types = 1);

namespace ShipMonk\InputMapper\Compiler\Validator\Array;

use Attribute;
use PhpParser\Node\Expr;
use PhpParser\Node\Stmt;
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use ShipMonk\InputMapper\Compiler\Php\PhpCodeBuilder;
use ShipMonk\InputMapper\Compiler\Validator\ValidatorCompiler;
use ShipMonk\InputMapper\Runtime\Exception\MappingFailedException;

#[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)]
class AssertUniqueItems implements ValidatorCompiler
{

/**
* @return list<Stmt>
*/
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');
}

}
15 changes: 15 additions & 0 deletions src/Runtime/Exception/MappingFailedException.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,21 @@ public static function incorrectValue(
return new self($path, $reason, $previous);
}

/**
* @param list<string|int> $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<string|int> $path
*/
Expand Down
45 changes: 45 additions & 0 deletions tests/Compiler/Validator/Array/AssertUniqueItemsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php declare(strict_types = 1);

namespace ShipMonkTests\InputMapper\Compiler\Validator\Array;

use ShipMonk\InputMapper\Compiler\Mapper\Array\MapList;
use ShipMonk\InputMapper\Compiler\Mapper\Scalar\MapInt;
use ShipMonk\InputMapper\Compiler\Mapper\Scalar\MapString;
use ShipMonk\InputMapper\Compiler\Validator\Array\AssertUniqueItems;
use ShipMonk\InputMapper\Runtime\Exception\MappingFailedException;
use ShipMonkTests\InputMapper\Compiler\Validator\ValidatorCompilerTestCase;

class AssertUniqueItemsTest extends ValidatorCompilerTestCase
{

public function testUniqueItemsIntValidator(): void
{
$mapperCompiler = new MapList(new MapInt());
$validatorCompiler = new AssertUniqueItems();
$validator = $this->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']),
);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php declare (strict_types=1);

namespace ShipMonkTests\InputMapper\Compiler\Validator\Array\Data;

use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\ValidatedMapperCompiler;
use ShipMonk\InputMapper\Runtime\Exception\MappingFailedException;
use ShipMonk\InputMapper\Runtime\Mapper;
use ShipMonk\InputMapper\Runtime\MapperProvider;
use function array_is_list;
use function count;
use function is_array;
use function is_int;

/**
* Generated mapper by {@see ValidatedMapperCompiler}. Do not edit directly.
*
* @implements Mapper<list<int>>
*/
class UniqueItemsIntValidatorMapper implements Mapper
{
public function __construct(private readonly MapperProvider $provider)
{
}

/**
* @param list<string|int> $path
* @return list<int>
* @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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php declare (strict_types=1);

namespace ShipMonkTests\InputMapper\Compiler\Validator\Array\Data;

use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\ValidatedMapperCompiler;
use ShipMonk\InputMapper\Runtime\Exception\MappingFailedException;
use ShipMonk\InputMapper\Runtime\Mapper;
use ShipMonk\InputMapper\Runtime\MapperProvider;
use function array_is_list;
use function count;
use function is_array;
use function is_string;

/**
* Generated mapper by {@see ValidatedMapperCompiler}. Do not edit directly.
*
* @implements Mapper<list<string>>
*/
class UniqueItemsStringValidatorMapper implements Mapper
{
public function __construct(private readonly MapperProvider $provider)
{
}

/**
* @param list<string|int> $path
* @return list<string>
* @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;
}
}

0 comments on commit a3e703e

Please sign in to comment.