diff --git a/README.md b/README.md index 6cc988c..ffcfeb9 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,8 @@ composer require shipmonk/input-mapper Input Mapper comes with built-in mappers for the following types: * `array`, `bool`, `float`, `int`, `mixed`, `string`, `list` -* `positive-int`, `negative-int`, `int` -* `array`, `array`, `list` +* `positive-int`, `negative-int`, `int`, `non-empty-list` +* `array`, `array`, `list`, `non-empty-list` * `array{K1: V1, ...}` * `?T`, `Optional` * `DateTimeInterface`, `DateTimeImmutable` @@ -53,6 +53,7 @@ Input Mapper comes with some built-in validators: * `AssertUrl` * list validators: * `AssertListItem` + * `AssertListLength` * date time validators: * `AssertDateTimeRange` @@ -73,15 +74,15 @@ class Person { public function __construct( public readonly string $name, - + public readonly int $age, - + /** @var Optional */ public readonly Optional $email, - + /** @var list */ public readonly array $hobbies, - + /** @var Optional> */ public readonly Optional $friends, ) {} @@ -138,7 +139,7 @@ class Person { public function __construct( public readonly string $name, - + #[AssertIntRange(gte: 18, lte: 99)] public readonly int $age, ) {} diff --git a/src/Compiler/MapperFactory/DefaultMapperCompilerFactory.php b/src/Compiler/MapperFactory/DefaultMapperCompilerFactory.php index a3f3a08..280f8a7 100644 --- a/src/Compiler/MapperFactory/DefaultMapperCompilerFactory.php +++ b/src/Compiler/MapperFactory/DefaultMapperCompilerFactory.php @@ -49,6 +49,7 @@ use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\MapOptional; use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\ValidatedMapperCompiler; use ShipMonk\InputMapper\Compiler\Type\PhpDocTypeUtils; +use ShipMonk\InputMapper\Compiler\Validator\Array\AssertListLength; use ShipMonk\InputMapper\Compiler\Validator\Int\AssertIntRange; use ShipMonk\InputMapper\Compiler\Validator\Int\AssertNegativeInt; use ShipMonk\InputMapper\Compiler\Validator\Int\AssertNonNegativeInt; @@ -120,6 +121,7 @@ public function create(TypeNode $type, array $options = []): MapperCompiler default => match ($type->name) { 'list' => new MapList(new MapMixed()), + 'non-empty-list' => new ValidatedMapperCompiler(new MapList(new MapMixed()), [new AssertListLength(min: 1)]), 'negative-int' => new ValidatedMapperCompiler(new MapInt(), [new AssertNegativeInt()]), 'non-negative-int' => new ValidatedMapperCompiler(new MapInt(), [new AssertNonNegativeInt()]), 'non-positive-int' => new ValidatedMapperCompiler(new MapInt(), [new AssertNonPositiveInt()]), @@ -154,6 +156,10 @@ public function create(TypeNode $type, array $options = []): MapperCompiler 1 => new MapList($this->createInner($type->genericTypes[0], $options)), default => throw CannotCreateMapperCompilerException::fromType($type), }, + 'non-empty-list' => match (count($type->genericTypes)) { + 1 => new ValidatedMapperCompiler(new MapList($this->createInner($type->genericTypes[0], $options)), [new AssertListLength(min: 1)]), + default => throw CannotCreateMapperCompilerException::fromType($type), + }, Optional::class => match (count($type->genericTypes)) { 1 => new MapOptional($this->createInner($type->genericTypes[0], $options)), default => throw CannotCreateMapperCompilerException::fromType($type), diff --git a/src/Compiler/Type/NativeTypeUtils.php b/src/Compiler/Type/NativeTypeUtils.php index f5b4598..2e34389 100644 --- a/src/Compiler/Type/NativeTypeUtils.php +++ b/src/Compiler/Type/NativeTypeUtils.php @@ -9,9 +9,12 @@ use PhpParser\Node\Name; use PhpParser\Node\NullableType; use PhpParser\Node\UnionType; +use function array_splice; use function count; use function get_debug_type; +use function is_a; use function sprintf; +use function strcasecmp; class NativeTypeUtils { @@ -83,6 +86,20 @@ public static function createIntersection(ComplexType|Identifier|Name ...$member throw new LogicException(sprintf('Unexpected intersection member type: %s', get_debug_type($member))); } + for ($i = 0; $i < count($types); $i++) { + for ($j = $i + 1; $j < count($types); $j++) { + if (self::isSubTypeOf($types[$i], $types[$j])) { + array_splice($types, $j--, 1); + continue; + } + + if (self::isSubTypeOf($types[$j], $types[$i])) { + array_splice($types, $i--, 1); + continue 2; + } + } + } + return match (count($types)) { 0 => new Identifier('mixed'), 1 => $types[0], @@ -90,4 +107,17 @@ public static function createIntersection(ComplexType|Identifier|Name ...$member }; } + public static function isSubTypeOf(Identifier|Name $a, Identifier|Name $b): bool + { + if ($a instanceof Identifier && $b instanceof Identifier) { + return strcasecmp($a->name, $b->name) === 0; + } + + if ($a instanceof Name && $b instanceof Name) { + return is_a($a->toString(), $b->toString(), true); + } + + return false; + } + } diff --git a/src/Compiler/Type/PhpDocTypeUtils.php b/src/Compiler/Type/PhpDocTypeUtils.php index 79dabc6..95f4adf 100644 --- a/src/Compiler/Type/PhpDocTypeUtils.php +++ b/src/Compiler/Type/PhpDocTypeUtils.php @@ -38,7 +38,9 @@ use ShipMonk\InputMapper\Runtime\OptionalSome; use Traversable; use function array_map; +use function array_shift; use function array_splice; +use function array_values; use function constant; use function count; use function get_object_vars; @@ -148,7 +150,8 @@ public static function toNativeType(TypeNode $type, ?bool &$phpDocUseful): Compl $phpDocUseful = true; return match ($type->name) { - 'list' => new Identifier('array'), + 'list', + 'non-empty-list' => new Identifier('array'), 'positive-int', 'negative-int', 'non-positive-int', @@ -284,13 +287,57 @@ public static function union(TypeNode ...$types): TypeNode public static function intersect(TypeNode ...$types): TypeNode { for ($i = 0; $i < count($types); $i++) { - for ($j = $i + 1; $j < count($types); $j++) { - if (self::isSubTypeOf($types[$i], $types[$j])) { - array_splice($types, $j--, 1); + for ($j = 0; $j < count($types); $j++) { + if ($i === $j) { continue; } - if (self::isSubTypeOf($types[$j], $types[$i])) { + $a = $types[$i]; + $b = $types[$j]; + + if (self::isSubTypeOf($b, $a)) { + array_splice($types, $i--, 1); + continue 2; + } + + if ($b instanceof IdentifierTypeNode) { + $b = new GenericTypeNode($b, []); // @phpstan-ignore-line intentionally converting to generic type + } + + if ( + $a instanceof GenericTypeNode + && $b instanceof GenericTypeNode + && self::isSubTypeOf($b->type, $a->type) + ) { + $typeDef = self::getGenericTypeDefinition($b); + $downCastedType = self::downCast($a, $b->type->name); + + $intersectedParameters = []; + $intersectedParameterMapping = []; + $intersectedParameterCount = max(count($b->genericTypes), count($downCastedType->genericTypes)); + + foreach ($typeDef['parameters'] ?? [] as $parameterIndex => $parameterDef) { + if (!isset($parameterDef['index'])) { + $intersectedParameterMapping[$parameterIndex] = $parameterIndex; + + } elseif (isset($parameterDef['index'][$intersectedParameterCount])) { + $intersectedParameterIndex = $parameterDef['index'][$intersectedParameterCount]; + $intersectedParameterMapping[$intersectedParameterIndex] = $parameterIndex; + } + } + + for ($k = 0; $k < $intersectedParameterCount; $k++) { + if (!isset($intersectedParameterMapping[$k])) { + throw new LogicException('Invalid generic type definition'); + } + + $intersectedParameters[$k] = self::intersect( + self::getGenericTypeParameter($downCastedType, $intersectedParameterMapping[$k]), + self::getGenericTypeParameter($b, $intersectedParameterMapping[$k]), + ); + } + + $types[$j] = new GenericTypeNode($b->type, $intersectedParameters); array_splice($types, $i--, 1); continue 2; } @@ -348,8 +395,8 @@ public static function isSubTypeOf(TypeNode $a, TypeNode $b): bool 'array' => match (true) { $a instanceof ArrayTypeNode => true, $a instanceof ArrayShapeNode => true, - $a instanceof IdentifierTypeNode => in_array($a->name, ['array', 'list'], true), - $a instanceof GenericTypeNode => in_array($a->type->name, ['array', 'list'], true), + $a instanceof IdentifierTypeNode => in_array($a->name, ['array', 'list', 'non-empty-list'], true), + $a instanceof GenericTypeNode => in_array($a->type->name, ['array', 'list', 'non-empty-list'], true), default => false, }, @@ -397,8 +444,8 @@ public static function isSubTypeOf(TypeNode $a, TypeNode $b): bool 'list' => match (true) { $a instanceof ArrayShapeNode => Arrays::every($a->items, static fn(ArrayShapeItemNode $item, int $idx) => self::getArrayShapeKey($item) === (string) $idx), - $a instanceof IdentifierTypeNode => $a->name === 'list', - $a instanceof GenericTypeNode => $a->type->name === 'list', + $a instanceof IdentifierTypeNode => $a->name === 'list' || $a->name === 'non-empty-list', + $a instanceof GenericTypeNode => $a->type->name === 'list' || $a->type->name === 'non-empty-list', default => false, }, @@ -406,6 +453,14 @@ public static function isSubTypeOf(TypeNode $a, TypeNode $b): bool 'never' => $a instanceof IdentifierTypeNode && $a->name === 'never', + 'non-empty-list' => match (true) { + $a instanceof ArrayShapeNode => Arrays::every($a->items, static fn(ArrayShapeItemNode $item, int $idx) => self::getArrayShapeKey($item) === (string) $idx) + && Arrays::some($a->items, static fn(ArrayShapeItemNode $item, int $idx) => !$item->optional), + $a instanceof IdentifierTypeNode => $a->name === 'non-empty-list', + $a instanceof GenericTypeNode => $a->type->name === 'non-empty-list', + default => false, + }, + 'null' => match (true) { $a instanceof IdentifierTypeNode => $a->name === 'null', $a instanceof ConstTypeNode => match (true) { @@ -544,13 +599,11 @@ public static function inferGenericParameter(TypeNode $type, string $typeName, i } if ($type instanceof GenericTypeNode) { - $typeDef = self::getGenericTypeDefinition($type); - if (strcasecmp($type->type->name, $typeName) === 0) { - return $type->genericTypes[$parameter] ?? $typeDef['parameters'][$parameter]['bound'] ?? new IdentifierTypeNode('mixed'); + return self::getGenericTypeParameter($type, $parameter); } - $superTypes = isset($typeDef['superTypes']) ? $typeDef['superTypes']($type->genericTypes) : []; + $superTypes = array_values(self::getGenericTypeSuperTypes($type)); if (count($superTypes) > 0) { return self::union(...Arrays::map( @@ -563,14 +616,75 @@ public static function inferGenericParameter(TypeNode $type, string $typeName, i throw new LogicException("Unable to infer generic parameter, {$type} is not subtype of {$typeName}"); } - private static function isSubTypeOfGeneric(GenericTypeNode $a, GenericTypeNode $b): bool + private static function downCast(GenericTypeNode $type, string $targetTypeName): GenericTypeNode + { + $path = self::findDownCastPath($type->type->name, $targetTypeName); + + if ($path === null) { + throw new LogicException("Unable to downcast {$type->type->name} to {$targetTypeName}"); + } + + return self::downCastOverPath($type, $path); + } + + /** + * @return list|null + */ + private static function findDownCastPath(string $sourceTypeName, string $targetTypeName): ?array + { + if ($sourceTypeName === $targetTypeName) { + return []; + } + + $targetTypeDef = self::getGenericTypeDefinition(new GenericTypeNode(new IdentifierTypeNode($targetTypeName), [])); + + foreach ($targetTypeDef['extends'] ?? [] as $possibleTarget => $_) { + $innerPath = self::findDownCastPath($sourceTypeName, $possibleTarget); + + if ($innerPath !== null) { + return [...$innerPath, $targetTypeName]; + } + } + + return null; + } + + /** + * @param list $path + */ + private static function downCastOverPath(GenericTypeNode $type, array $path): GenericTypeNode { - $typeDef = self::getGenericTypeDefinition($a); + if (count($path) === 0) { + return $type; + } + + $step = array_shift($path); + $targetTypeDef = self::getGenericTypeDefinition(new GenericTypeNode(new IdentifierTypeNode($step), [])); + + if (!isset($targetTypeDef['extends'][$type->type->name])) { + throw new LogicException('Invalid downcast path'); + } + + $targetTypeParameters = Arrays::map($targetTypeDef['parameters'] ?? [], static function (array $parameter): TypeNode { + return $parameter['bound'] ?? new IdentifierTypeNode('mixed'); + }); + + foreach ($targetTypeDef['extends'][$type->type->name] as $sourceIndex => $typeOrIndex) { + if (is_int($typeOrIndex)) { + $targetTypeParameters[$typeOrIndex] = self::getGenericTypeParameter($type, $sourceIndex); + } + } + + return self::downCastOverPath(new GenericTypeNode(new IdentifierTypeNode($step), $targetTypeParameters), $path); + } + private static function isSubTypeOfGeneric(GenericTypeNode $a, GenericTypeNode $b): bool + { if (strcasecmp($a->type->name, $b->type->name) === 0) { + $typeDef = self::getGenericTypeDefinition($a); return Arrays::every($typeDef['parameters'] ?? [], static function (array $parameter, int $idx) use ($a, $b): bool { - $genericTypeA = $a->genericTypes[$idx] ?? $parameter['bound'] ?? new IdentifierTypeNode('mixed'); - $genericTypeB = $b->genericTypes[$idx] ?? $parameter['bound'] ?? new IdentifierTypeNode('mixed'); + $genericTypeA = self::getGenericTypeParameter($a, $idx); + $genericTypeB = self::getGenericTypeParameter($b, $idx); return match ($parameter['variance']) { 'in' => self::isSubTypeOf($genericTypeB, $genericTypeA), @@ -581,40 +695,81 @@ private static function isSubTypeOfGeneric(GenericTypeNode $a, GenericTypeNode $ }); } - $superTypes = isset($typeDef['superTypes']) ? $typeDef['superTypes']($a->genericTypes) : []; - return Arrays::some($superTypes, static function (TypeNode $superType) use ($b): bool { + return Arrays::some(self::getGenericTypeSuperTypes($a), static function (GenericTypeNode $superType) use ($b): bool { return self::isSubTypeOf($superType, $b); }); } + /** + * @return array + */ + public static function getGenericTypeSuperTypes(GenericTypeNode $type): array + { + $typeDef = self::getGenericTypeDefinition($type); + + return Arrays::map($typeDef['extends'] ?? [], static function (array $mapping, string $superTypeName) use ($type): GenericTypeNode { + return new GenericTypeNode(new IdentifierTypeNode($superTypeName), Arrays::map($mapping, static function (TypeNode | int $typeOrIndex) use ($type): TypeNode { + return $typeOrIndex instanceof TypeNode ? $typeOrIndex : self::getGenericTypeParameter($type, $typeOrIndex); + })); + }); + } + + private static function getGenericTypeParameter(GenericTypeNode $type, int $index): TypeNode + { + $typeDef = self::getGenericTypeDefinition($type); + + if (!isset($typeDef['parameters'])) { + throw new LogicException('Generic type has no parameters'); + } + + $parameterDef = $typeDef['parameters'][$index]; + $count = count($type->genericTypes); + + if (isset($parameterDef['index'])) { + $index = $parameterDef['index'][$count] ?? -1; + } + + return $type->genericTypes[$index] ?? $parameterDef['bound'] ?? new IdentifierTypeNode('mixed'); + } + /** * @return array{ - * superTypes?: callable(array): list, - * parameters?: list, + * extends?: array>, + * parameters?: list, variance: 'in' | 'out' | 'inout', bound?: TypeNode}>, * } */ private static function getGenericTypeDefinition(GenericTypeNode $type): array { return match ($type->type->name) { 'array' => [ - 'superTypes' => static fn (array $types): array => [ - new GenericTypeNode(new IdentifierTypeNode('iterable'), [ - $types[0] ?? new IdentifierTypeNode('mixed'), - $types[1] ?? new IdentifierTypeNode('mixed'), - ]), + 'extends' => [ + 'iterable' => [0, 1], ], 'parameters' => [ - ['variance' => 'out', 'bound' => new UnionTypeNode([new IdentifierTypeNode('int'), new IdentifierTypeNode('string')])], - ['variance' => 'out'], + ['index' => [2 => 0], 'variance' => 'out', 'bound' => new UnionTypeNode([new IdentifierTypeNode('int'), new IdentifierTypeNode('string')])], + ['index' => [1 => 0, 2 => 1], 'variance' => 'out'], ], ], 'list' => [ - 'superTypes' => static fn (array $types): array => [ - new GenericTypeNode(new IdentifierTypeNode('array'), [ - new IdentifierTypeNode('int'), - $types[0] ?? new IdentifierTypeNode('mixed'), - ]), + 'extends' => [ + 'array' => [new IdentifierTypeNode('int'), 0], + ], + 'parameters' => [ + ['variance' => 'out'], + ], + ], + + 'iterable' => [ + 'parameters' => [ + ['index' => [2 => 0], 'variance' => 'out'], + ['index' => [1 => 0, 2 => 1], 'variance' => 'out'], + ], + ], + + 'non-empty-list' => [ + 'extends' => [ + 'list' => [0], ], 'parameters' => [ ['variance' => 'out'], @@ -628,10 +783,8 @@ private static function getGenericTypeDefinition(GenericTypeNode $type): array ], OptionalSome::class => [ - 'superTypes' => static fn (array $types): array => [ - new GenericTypeNode(new IdentifierTypeNode(Optional::class), [ - $types[0] ?? new IdentifierTypeNode('mixed'), - ]), + 'extends' => [ + Optional::class => [0], ], 'parameters' => [ ['variance' => 'out'], @@ -639,8 +792,8 @@ private static function getGenericTypeDefinition(GenericTypeNode $type): array ], OptionalNone::class => [ - 'superTypes' => static fn (): array => [ - new GenericTypeNode(new IdentifierTypeNode(Optional::class), [new IdentifierTypeNode('never')]), + 'extends' => [ + Optional::class => [new IdentifierTypeNode('never')], ], ], @@ -771,20 +924,6 @@ private static function normalizeType(TypeNode $type): TypeNode } if ($type instanceof GenericTypeNode) { - if (strtolower($type->type->name) === 'array' && count($type->genericTypes) === 1) { - return new GenericTypeNode(new IdentifierTypeNode('array'), [ - new UnionTypeNode([new IdentifierTypeNode('int'), new IdentifierTypeNode('string')]), - self::normalizeType($type->genericTypes[0]), - ]); - } - - if (strtolower($type->type->name) === 'iterable' && count($type->genericTypes) === 1) { - return new GenericTypeNode(new IdentifierTypeNode('iterable'), [ - new IdentifierTypeNode('mixed'), - self::normalizeType($type->genericTypes[0]), - ]); - } - if ( strtolower($type->type->name) === 'int' && count($type->genericTypes) === 2 diff --git a/src/Compiler/Validator/Array/AssertListLength.php b/src/Compiler/Validator/Array/AssertListLength.php new file mode 100644 index 0000000..fe08a4f --- /dev/null +++ b/src/Compiler/Validator/Array/AssertListLength.php @@ -0,0 +1,102 @@ +min = $exact ?? $min; + $this->max = $exact ?? $max; + } + + /** + * @return list + */ + public function compile(Expr $value, TypeNode $type, Expr $path, PhpCodeBuilder $builder): array + { + $statements = []; + $length = $builder->funcCall($builder->importFunction('count'), [$value]); + + if ($this->min !== null && $this->max !== null && $this->min === $this->max) { + $statements[] = $builder->if($builder->notSame($length, $builder->val($this->min)), [ + $builder->throw( + $builder->staticCall( + $builder->importClass(MappingFailedException::class), + 'incorrectValue', + [$value, $path, $builder->val("list with exactly {$this->min} items")], + ), + ), + ]); + + } else { + if ($this->min !== null) { + $statements[] = $builder->if($builder->lt($length, $builder->val($this->min)), [ + $builder->throw( + $builder->staticCall( + $builder->importClass(MappingFailedException::class), + 'incorrectValue', + [$value, $path, $builder->val("list with at least {$this->min} items")], + ), + ), + ]); + } + + if ($this->max !== null) { + $statements[] = $builder->if($builder->gt($length, $builder->val($this->max)), [ + $builder->throw( + $builder->staticCall( + $builder->importClass(MappingFailedException::class), + 'incorrectValue', + [$value, $path, $builder->val("list with at most {$this->max} items")], + ), + ), + ]); + } + } + + return $statements; + } + + public function getInputType(): TypeNode + { + return new IdentifierTypeNode('list'); + } + + public function getNarrowedInputType(): TypeNode + { + $itemType = new IdentifierTypeNode('mixed'); + + if ($this->min !== null && $this->min > 0) { + return new GenericTypeNode(new IdentifierTypeNode('non-empty-list'), [$itemType]); + } + + return new GenericTypeNode(new IdentifierTypeNode('list'), [$itemType]); + } + +} diff --git a/tests/Compiler/MapperFactory/Data/BrandInput.php b/tests/Compiler/MapperFactory/Data/BrandInput.php index 8924e76..d6d3f7c 100644 --- a/tests/Compiler/MapperFactory/Data/BrandInput.php +++ b/tests/Compiler/MapperFactory/Data/BrandInput.php @@ -10,10 +10,12 @@ class BrandInput /** * @param int<1900, 2100> $foundedIn + * @param non-empty-list $founders */ public function __construct( public readonly string $name, public readonly int $foundedIn, + public readonly array $founders, ) { } diff --git a/tests/Compiler/MapperFactory/DefaultMapperCompilerFactoryTest.php b/tests/Compiler/MapperFactory/DefaultMapperCompilerFactoryTest.php index eab4389..38d48b1 100644 --- a/tests/Compiler/MapperFactory/DefaultMapperCompilerFactoryTest.php +++ b/tests/Compiler/MapperFactory/DefaultMapperCompilerFactoryTest.php @@ -31,6 +31,7 @@ use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\MapOptional; use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\ValidatedMapperCompiler; use ShipMonk\InputMapper\Compiler\MapperFactory\DefaultMapperCompilerFactory; +use ShipMonk\InputMapper\Compiler\Validator\Array\AssertListLength; use ShipMonk\InputMapper\Compiler\Validator\Int\AssertIntRange; use ShipMonk\InputMapper\Compiler\Validator\Int\AssertNegativeInt; use ShipMonk\InputMapper\Compiler\Validator\Int\AssertNonNegativeInt; @@ -115,6 +116,10 @@ public static function provideCreateOkData(): iterable new MapInt(), [new AssertIntRange(gte: 1_900, lte: 2_100)], ), + 'founders' => new ValidatedMapperCompiler( + new MapList(new MapString()), + [new AssertListLength(min: 1)], + ), ], allowExtraKeys: true, ), @@ -265,6 +270,22 @@ public static function provideCreateOkData(): iterable ]), ]; + yield 'non-empty-list' => [ + 'non-empty-list', + [], + new ValidatedMapperCompiler(new MapList(new MapMixed()), [ + new AssertListLength(min: 1), + ]), + ]; + + yield 'non-empty-list' => [ + 'non-empty-list', + [], + new ValidatedMapperCompiler(new MapList(new MapInt()), [ + new AssertListLength(min: 1), + ]), + ]; + yield 'non-positive-int' => [ 'non-positive-int', [], diff --git a/tests/Compiler/Type/PhpDocTypeUtilsTest.php b/tests/Compiler/Type/PhpDocTypeUtilsTest.php index d025143..6958bb1 100644 --- a/tests/Compiler/Type/PhpDocTypeUtilsTest.php +++ b/tests/Compiler/Type/PhpDocTypeUtilsTest.php @@ -32,6 +32,7 @@ use ShipMonkTests\InputMapper\InputMapperTestCase; use Traversable; use function array_map; +use function array_reverse; class PhpDocTypeUtilsTest extends InputMapperTestCase { @@ -73,6 +74,7 @@ public static function provideIsKeywordData(): iterable yield ['resource', true]; yield ['unknown', false]; yield ['positive-int', true]; + yield ['non-empty-list', true]; yield [DateTimeImmutable::class, false]; } @@ -501,7 +503,7 @@ public static function provideUnionData(): iterable * @param list $types */ #[DataProvider('provideIntersectData')] - public function testIntersect(array $types, string $expected): void + public function testIntersect(array $types, string $expected, ?string $expectedReversed = null): void { $typesNodes = []; @@ -510,11 +512,13 @@ public function testIntersect(array $types, string $expected): void } $expectedTypeNode = $this->parseType($expected); - self::assertEquals($expectedTypeNode, PhpDocTypeUtils::intersect(...$typesNodes)); + $expectedTypeNodeReversed = $expectedReversed !== null ? $this->parseType($expectedReversed) : $expectedTypeNode; + self::assertEquals($expectedTypeNode->__toString(), PhpDocTypeUtils::intersect(...$typesNodes)->__toString()); + self::assertEquals($expectedTypeNodeReversed->__toString(), PhpDocTypeUtils::intersect(...array_reverse($typesNodes))->__toString()); } /** - * @return iterable, 1: string}> + * @return iterable, 1: string, 2?: string}> */ public static function provideIntersectData(): iterable { @@ -538,20 +542,57 @@ public static function provideIntersectData(): iterable 'int', ]; - yield 'number & int' => [ - ['number', 'int'], - 'int', - ]; - yield 'Countable & Traversable & mixed' => [ ['Countable', 'Traversable', 'mixed'], 'Countable & Traversable', + 'Traversable & Countable', ]; yield 'Countable & Traversable & never' => [ ['Countable', 'Traversable', 'never'], 'never', ]; + + yield 'array & array' => [ + ['array', 'array'], + 'array', + 'array', + ]; + + yield 'array & list' => [ + ['array', 'list'], + 'list', + ]; + + yield 'non-empty-list & list' => [ + ['non-empty-list', 'list'], + 'non-empty-list', + ]; + + yield 'array & non-empty-list' => [ + ['array', 'non-empty-list'], + 'non-empty-list', + ]; + + yield 'array & non-empty-list' => [ + ['array', 'non-empty-list'], + 'non-empty-list', + ]; + + yield 'array & iterable' => [ + ['array', 'iterable'], + 'array', + ]; + + yield 'array & iterable' => [ + ['array', 'iterable'], + 'array', + ]; + + yield 'array & iterable' => [ + ['array', 'iterable'], + 'array', + ]; } #[DataProvider('provideIsSubTypeOfData')] @@ -600,6 +641,8 @@ private static function provideIsSubTypeOfDataInner(): iterable 'list', 'array', 'array', + 'non-empty-list', + 'non-empty-list', ], 'false' => [ @@ -613,6 +656,7 @@ private static function provideIsSubTypeOfDataInner(): iterable 'array', 'array', 'list', + 'non-empty-list', 'array{int}', 'int[]', ], @@ -623,6 +667,7 @@ private static function provideIsSubTypeOfDataInner(): iterable 'int', 'iterable', 'iterable', + 'non-empty-list', ], ]; @@ -946,6 +991,8 @@ private static function provideIsSubTypeOfDataInner(): iterable 'true' => [ 'list', 'list', + 'non-empty-list', + 'non-empty-list', 'array{int, string}', 'array{0: int, 1: string}', ], @@ -1046,6 +1093,30 @@ private static function provideIsSubTypeOfDataInner(): iterable ], ]; + yield 'non-empty-list' => [ + 'true' => [ + 'non-empty-list', + 'non-empty-list', + ], + + 'false' => [ + 'list', + 'list', + ], + ]; + + yield 'non-empty-list' => [ + 'true' => [ + 'non-empty-list', + ], + + 'false' => [ + 'list', + 'list', + 'non-empty-list', + ], + ]; + yield 'number' => [ 'true' => [ 'int', diff --git a/tests/Compiler/Validator/Array/AssertListLengthTest.php b/tests/Compiler/Validator/Array/AssertListLengthTest.php new file mode 100644 index 0000000..95defad --- /dev/null +++ b/tests/Compiler/Validator/Array/AssertListLengthTest.php @@ -0,0 +1,105 @@ +compileValidator('NoopListLengthValidator', $mapperCompiler, $validatorCompiler); + + $validator->map(['abc']); + self::assertTrue(true); // @phpstan-ignore-line always true + } + + public function testListLengthValidatorWithMin(): void + { + $mapperCompiler = new MapList(new MapString()); + $validatorCompiler = new AssertListLength(min: 2); + $validator = $this->compileValidator('ListLengthValidatorWithMin', $mapperCompiler, $validatorCompiler); + + $validator->map(['a', 'b']); + $validator->map(['a', 'b', 'c']); + + self::assertException( + MappingFailedException::class, + 'Failed to map data at path /: Expected list with at least 2 items, got array', + static fn() => $validator->map(['a']), + ); + } + + public function testListLengthValidatorWithMax(): void + { + $mapperCompiler = new MapList(new MapMixed()); + $validatorCompiler = new AssertListLength(max: 5); + $validator = $this->compileValidator('ListLengthValidatorWithMax', $mapperCompiler, $validatorCompiler); + + $validator->map(['a', 'b']); + $validator->map(['a', 'b', 'c', 'd', 'e']); + + self::assertException( + MappingFailedException::class, + 'Failed to map data at path /: Expected list with at most 5 items, got array', + static fn() => $validator->map(['a', 'b', 'c', 'd', 'e', 'f']), + ); + } + + public function testListLengthValidatorWithMinAndMax(): void + { + $mapperCompiler = new MapList(new MapMixed()); + $validatorCompiler = new AssertListLength(min: 1, max: 5); + $validator = $this->compileValidator('ListLengthValidatorWithMinAndMax', $mapperCompiler, $validatorCompiler); + + $validator->map(['a']); + $validator->map(['a', 'b', 'c']); + $validator->map(['a', 'b', 'c', 'd', 'e']); + + self::assertException( + MappingFailedException::class, + 'Failed to map data at path /: Expected list with at least 1 items, got array', + static fn() => $validator->map([]), + ); + + self::assertException( + MappingFailedException::class, + 'Failed to map data at path /: Expected list with at most 5 items, got array', + static fn() => $validator->map(['a', 'b', 'c', 'd', 'e', 'f']), + ); + } + + public function testListLengthValidatorWithExact(): void + { + $mapperCompiler = new MapList(new MapMixed()); + $validatorCompiler = new AssertListLength(exact: 5); + $validator = $this->compileValidator('ListLengthValidatorWithExact', $mapperCompiler, $validatorCompiler); + + $validator->map(['a', 'b', 'c', 'd', 'e']); + + self::assertException( + MappingFailedException::class, + 'Failed to map data at path /: Expected list with exactly 5 items, got array', + static fn() => $validator->map([]), + ); + } + + public function testInvalidCombinationOfExactWithMinMax(): void + { + self::assertException( + LogicException::class, + 'Cannot use "exact" and "min" or "max" at the same time', + static fn() => new AssertListLength(exact: 5, min: 1), + ); + } + +} diff --git a/tests/Compiler/Validator/Array/Data/ListLengthValidatorWithExactMapper.php b/tests/Compiler/Validator/Array/Data/ListLengthValidatorWithExactMapper.php new file mode 100644 index 0000000..43224b5 --- /dev/null +++ b/tests/Compiler/Validator/Array/Data/ListLengthValidatorWithExactMapper.php @@ -0,0 +1,47 @@ +> + */ +class ListLengthValidatorWithExactMapper implements Mapper +{ + public function __construct(private readonly MapperProvider $provider) + { + } + + /** + * @param list $path + * @return non-empty-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) { + $mapped[] = $item; + } + + if (count($mapped) !== 5) { + throw MappingFailedException::incorrectValue($mapped, $path, 'list with exactly 5 items'); + } + + return $mapped; + } +} diff --git a/tests/Compiler/Validator/Array/Data/ListLengthValidatorWithMaxMapper.php b/tests/Compiler/Validator/Array/Data/ListLengthValidatorWithMaxMapper.php new file mode 100644 index 0000000..50317b8 --- /dev/null +++ b/tests/Compiler/Validator/Array/Data/ListLengthValidatorWithMaxMapper.php @@ -0,0 +1,47 @@ +> + */ +class ListLengthValidatorWithMaxMapper 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) { + $mapped[] = $item; + } + + if (count($mapped) > 5) { + throw MappingFailedException::incorrectValue($mapped, $path, 'list with at most 5 items'); + } + + return $mapped; + } +} diff --git a/tests/Compiler/Validator/Array/Data/ListLengthValidatorWithMinAndMaxMapper.php b/tests/Compiler/Validator/Array/Data/ListLengthValidatorWithMinAndMaxMapper.php new file mode 100644 index 0000000..eb633d3 --- /dev/null +++ b/tests/Compiler/Validator/Array/Data/ListLengthValidatorWithMinAndMaxMapper.php @@ -0,0 +1,51 @@ +> + */ +class ListLengthValidatorWithMinAndMaxMapper implements Mapper +{ + public function __construct(private readonly MapperProvider $provider) + { + } + + /** + * @param list $path + * @return non-empty-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) { + $mapped[] = $item; + } + + if (count($mapped) < 1) { + throw MappingFailedException::incorrectValue($mapped, $path, 'list with at least 1 items'); + } + + if (count($mapped) > 5) { + throw MappingFailedException::incorrectValue($mapped, $path, 'list with at most 5 items'); + } + + return $mapped; + } +} diff --git a/tests/Compiler/Validator/Array/Data/ListLengthValidatorWithMinMapper.php b/tests/Compiler/Validator/Array/Data/ListLengthValidatorWithMinMapper.php new file mode 100644 index 0000000..52b2a72 --- /dev/null +++ b/tests/Compiler/Validator/Array/Data/ListLengthValidatorWithMinMapper.php @@ -0,0 +1,52 @@ +> + */ +class ListLengthValidatorWithMinMapper implements Mapper +{ + public function __construct(private readonly MapperProvider $provider) + { + } + + /** + * @param list $path + * @return non-empty-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; + } + + if (count($mapped) < 2) { + throw MappingFailedException::incorrectValue($mapped, $path, 'list with at least 2 items'); + } + + return $mapped; + } +} diff --git a/tests/Compiler/Validator/Array/Data/NoopListLengthValidatorMapper.php b/tests/Compiler/Validator/Array/Data/NoopListLengthValidatorMapper.php new file mode 100644 index 0000000..4846927 --- /dev/null +++ b/tests/Compiler/Validator/Array/Data/NoopListLengthValidatorMapper.php @@ -0,0 +1,43 @@ +> + */ +class NoopListLengthValidatorMapper 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) { + $mapped[] = $item; + } + + return $mapped; + } +}