diff --git a/phpstan.neon.dist b/phpstan.neon.dist index a3f281e..c8d0db4 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -34,6 +34,8 @@ parameters: forbidCheckedExceptionInCallable: immediatelyCalledCallables: 'ShipMonkTests\InputMapper\InputMapperTestCase::assertException': 2 + allowedCheckedExceptionCallables: + 'ShipMonk\InputMapper\Runtime\CallbackMapper::__construct': 0 ignoreErrors: - @@ -53,3 +55,7 @@ parameters: message: "#^Method ShipMonkTests\\\\InputMapper\\\\Compiler\\\\Validator\\\\Array\\\\Data\\\\ListItemValidatorWithMultipleValidatorsMapper\\:\\:map\\(\\) should return list\\\\> but returns list\\\\.$#" count: 1 path: tests/Compiler/Validator/Array/Data/ListItemValidatorWithMultipleValidatorsMapper.php + - + message: "#^Throwing checked exception ShipMonk\\\\InputMapper\\\\Runtime\\\\Exception\\\\MappingFailedException in first-class-callable!$#" + count: 1 + path: tests/Compiler/Mapper/Object/Data/DelegateToIntCollectionMapper.php diff --git a/src/Compiler/Mapper/GenericMapperCompiler.php b/src/Compiler/Mapper/GenericMapperCompiler.php new file mode 100644 index 0000000..4d149e1 --- /dev/null +++ b/src/Compiler/Mapper/GenericMapperCompiler.php @@ -0,0 +1,15 @@ + + */ + public function getGenericParameters(): array; + +} diff --git a/src/Compiler/Mapper/Object/DelegateMapperCompiler.php b/src/Compiler/Mapper/Object/DelegateMapperCompiler.php index 1f32035..bd753e2 100644 --- a/src/Compiler/Mapper/Object/DelegateMapperCompiler.php +++ b/src/Compiler/Mapper/Object/DelegateMapperCompiler.php @@ -2,33 +2,40 @@ namespace ShipMonk\InputMapper\Compiler\Mapper\Object; +use Nette\Utils\Arrays; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\MethodCall; +use PhpParser\Node\VariadicPlaceholder; +use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use ShipMonk\InputMapper\Compiler\CompiledExpr; use ShipMonk\InputMapper\Compiler\Mapper\MapperCompiler; use ShipMonk\InputMapper\Compiler\Php\PhpCodeBuilder; +use ShipMonk\InputMapper\Runtime\CallbackMapper; +use function count; class DelegateMapperCompiler implements MapperCompiler { /** - * @param class-string $className + * @param list $innerMapperCompilers */ public function __construct( public readonly string $className, + public readonly array $innerMapperCompilers = [], ) { } public function compile(Expr $value, Expr $path, PhpCodeBuilder $builder): CompiledExpr { - $shortName = $builder->importClass($this->className); - $provider = $builder->propertyFetch($builder->var('this'), 'provider'); - $mapper = $builder->methodCall($provider, 'get', [$builder->classConstFetch($shortName, 'class')]); + $compilerMapper = $this->compileMapperExpr($builder); + $mapper = $compilerMapper->expr; + $statements = $compilerMapper->statements; $mapped = $builder->methodCall($mapper, 'map', [$value, $path]); - return new CompiledExpr($mapped); + return new CompiledExpr($mapped, $statements); } public function getInputType(): TypeNode @@ -38,7 +45,76 @@ public function getInputType(): TypeNode public function getOutputType(): TypeNode { - return new IdentifierTypeNode($this->className); + $outputType = new IdentifierTypeNode($this->className); + + if (count($this->innerMapperCompilers) === 0) { + return $outputType; + } + + return new GenericTypeNode($outputType, Arrays::map( + $this->innerMapperCompilers, + static function (MapperCompiler $innerMapperCompiler): TypeNode { + return $innerMapperCompiler->getOutputType(); + }, + )); + } + + /** + * @return list + */ + private function compileInnerMappers(PhpCodeBuilder $builder): array + { + $innerMappers = []; + + foreach ($this->innerMapperCompilers as $key => $innerMapperCompiler) { + $innerMappers[] = $this->compileInnerMapper($innerMapperCompiler, $key, $builder); + } + + return $innerMappers; + } + + private function compileInnerMapper(MapperCompiler $innerMapperCompiler, int $key, PhpCodeBuilder $builder): Expr + { + if ($innerMapperCompiler instanceof self && count($innerMapperCompiler->innerMapperCompilers) === 0) { + $provider = $builder->propertyFetch($builder->var('this'), 'provider'); + $innerClassExpr = $builder->classConstFetch($builder->importClass($innerMapperCompiler->className), 'class'); + return $builder->methodCall($provider, 'get', [$innerClassExpr]); + } + + $innerMapperMethodName = $builder->uniqMethodName("mapInner{$key}"); + $innerMapperMethod = $builder->mapperMethod($innerMapperMethodName, $innerMapperCompiler)->makePrivate()->getNode(); + $builder->addMethod($innerMapperMethod); + + $innerMapperMethodCallback = new MethodCall($builder->var('this'), $innerMapperMethodName, [new VariadicPlaceholder()]); + return $builder->new($builder->importClass(CallbackMapper::class), [$innerMapperMethodCallback]); + } + + private function compileMapperExpr(PhpCodeBuilder $builder): CompiledExpr + { + foreach ($builder->getGenericParameters() as $offset => $genericParameter) { + if ($this->className === $genericParameter->name) { + $innerMappers = $builder->propertyFetch($builder->var('this'), 'innerMappers'); + $innerMapper = $builder->arrayDimFetch($innerMappers, $builder->val($offset)); + return new CompiledExpr($innerMapper); + } + } + + $statements = []; + $classNameExpr = $builder->classConstFetch($builder->importClass($this->className), 'class'); + $provider = $builder->propertyFetch($builder->var('this'), 'provider'); + $innerMappers = $this->compileInnerMappers($builder); + + if (count($innerMappers) > 0) { + $innerMappersVarName = $builder->uniqVariableName('innerMappers'); + $statements[] = $builder->assign($builder->var($innerMappersVarName), $builder->val($innerMappers)); + $getArguments = [$classNameExpr, $builder->var($innerMappersVarName)]; + + } else { + $getArguments = [$classNameExpr]; + } + + $mapper = $builder->methodCall($provider, 'get', $getArguments); + return new CompiledExpr($mapper, $statements); } } diff --git a/src/Compiler/Mapper/Object/MapObject.php b/src/Compiler/Mapper/Object/MapObject.php index 980aec8..d7598d3 100644 --- a/src/Compiler/Mapper/Object/MapObject.php +++ b/src/Compiler/Mapper/Object/MapObject.php @@ -3,14 +3,18 @@ namespace ShipMonk\InputMapper\Compiler\Mapper\Object; use Attribute; +use Nette\Utils\Arrays; use PhpParser\Node\Expr; use PhpParser\Node\Stmt; +use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use ShipMonk\InputMapper\Compiler\CompiledExpr; +use ShipMonk\InputMapper\Compiler\Mapper\GenericMapperCompiler; use ShipMonk\InputMapper\Compiler\Mapper\MapperCompiler; use ShipMonk\InputMapper\Compiler\Mapper\UndefinedAwareMapperCompiler; use ShipMonk\InputMapper\Compiler\Php\PhpCodeBuilder; +use ShipMonk\InputMapper\Compiler\Type\GenericTypeParameter; use ShipMonk\InputMapper\Runtime\Exception\MappingFailedException; use function array_fill_keys; use function array_keys; @@ -22,17 +26,19 @@ * @template T of object */ #[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)] -class MapObject implements MapperCompiler +class MapObject implements GenericMapperCompiler { /** * @param class-string $className * @param array $constructorArgsMapperCompilers + * @param list $genericParameters */ public function __construct( public readonly string $className, public readonly array $constructorArgsMapperCompilers, public readonly bool $allowExtraKeys = false, + public readonly array $genericParameters = [], ) { } @@ -114,7 +120,26 @@ public function getInputType(): TypeNode public function getOutputType(): TypeNode { - return new IdentifierTypeNode($this->className); + $outputType = new IdentifierTypeNode($this->className); + + if (count($this->genericParameters) === 0) { + return $outputType; + } + + return new GenericTypeNode( + $outputType, + Arrays::map($this->genericParameters, static function (GenericTypeParameter $parameter): TypeNode { + return new IdentifierTypeNode($parameter->name); + }), + ); + } + + /** + * @return list + */ + public function getGenericParameters(): array + { + return $this->genericParameters; } /** diff --git a/src/Compiler/MapperFactory/DefaultMapperCompilerFactory.php b/src/Compiler/MapperFactory/DefaultMapperCompilerFactory.php index 280f8a7..3c42c93 100644 --- a/src/Compiler/MapperFactory/DefaultMapperCompilerFactory.php +++ b/src/Compiler/MapperFactory/DefaultMapperCompilerFactory.php @@ -57,12 +57,14 @@ use ShipMonk\InputMapper\Compiler\Validator\Int\AssertPositiveInt; use ShipMonk\InputMapper\Compiler\Validator\ValidatorCompiler; use ShipMonk\InputMapper\Runtime\Optional; +use function array_column; +use function array_fill_keys; use function class_exists; use function class_implements; use function class_parents; use function count; -use function enum_exists; use function interface_exists; +use function is_array; use function strcasecmp; use function strtolower; use function substr; @@ -71,6 +73,7 @@ class DefaultMapperCompilerFactory implements MapperCompilerFactory { final public const DELEGATE_OBJECT_MAPPING = 'delegateObjectMapping'; + final public const GENERIC_PARAMETERS = 'genericParameters'; /** * @param array): MapperCompiler> $mapperCompilerFactories @@ -102,13 +105,21 @@ public function create(TypeNode $type, array $options = []): MapperCompiler { if ($type instanceof IdentifierTypeNode) { if (!PhpDocTypeUtils::isKeyword($type)) { - if (!class_exists($type->name) && !interface_exists($type->name) && !enum_exists($type->name)) { + if (isset($options[self::DELEGATE_OBJECT_MAPPING]) && $options[self::DELEGATE_OBJECT_MAPPING] === true) { + if (!class_exists($type->name) && !interface_exists($type->name)) { + if (!isset($options[self::GENERIC_PARAMETERS]) || !is_array($options[self::GENERIC_PARAMETERS]) || !isset($options[self::GENERIC_PARAMETERS][$type->name])) { + throw CannotCreateMapperCompilerException::fromType($type, 'there is no class, interface or enum with this name'); + } + } + + return new DelegateMapperCompiler($type->name); + } + + if (!class_exists($type->name) && !interface_exists($type->name)) { throw CannotCreateMapperCompilerException::fromType($type, 'there is no class, interface or enum with this name'); } - return isset($options[self::DELEGATE_OBJECT_MAPPING]) && $options[self::DELEGATE_OBJECT_MAPPING] === true - ? new DelegateMapperCompiler($type->name) - : $this->createObjectMapperCompiler($type->name, $options); + return $this->createObjectMapperCompiler($type->name, $options); } return match (strtolower($type->name)) { @@ -122,10 +133,10 @@ 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()]), - 'positive-int' => new ValidatedMapperCompiler(new MapInt(), [new AssertPositiveInt()]), + 'negative-int' => new ValidatedMapperCompiler($this->createInner(new IdentifierTypeNode('int'), $options), [new AssertNegativeInt()]), + 'non-negative-int' => new ValidatedMapperCompiler($this->createInner(new IdentifierTypeNode('int'), $options), [new AssertNonNegativeInt()]), + 'non-positive-int' => new ValidatedMapperCompiler($this->createInner(new IdentifierTypeNode('int'), $options), [new AssertNonPositiveInt()]), + 'positive-int' => new ValidatedMapperCompiler($this->createInner(new IdentifierTypeNode('int'), $options), [new AssertPositiveInt()]), default => throw CannotCreateMapperCompilerException::fromType($type), }, }; @@ -143,7 +154,7 @@ public function create(TypeNode $type, array $options = []): MapperCompiler default => throw CannotCreateMapperCompilerException::fromType($type), }, 'int' => match (count($type->genericTypes)) { - 2 => new ValidatedMapperCompiler(new MapInt(), [ + 2 => new ValidatedMapperCompiler($this->createInner(new IdentifierTypeNode('int'), $options), [ new AssertIntRange( gte: $this->resolveIntegerBoundary($type, $type->genericTypes[0], 'min'), lte: $this->resolveIntegerBoundary($type, $type->genericTypes[1], 'max'), @@ -164,7 +175,7 @@ public function create(TypeNode $type, array $options = []): MapperCompiler 1 => new MapOptional($this->createInner($type->genericTypes[0], $options)), default => throw CannotCreateMapperCompilerException::fromType($type), }, - default => throw CannotCreateMapperCompilerException::fromType($type), + default => $this->createFromGenericType($type, $options), }, }; } @@ -218,13 +229,45 @@ protected function createInner(TypeNode $type, array $options): MapperCompiler return $this->create($type, $options); } + /** + * @param array $options + */ + protected function createFromGenericType(GenericTypeNode $type, array $options): MapperCompiler + { + if (!class_exists($type->type->name) && !interface_exists($type->type->name)) { + throw CannotCreateMapperCompilerException::fromType($type, 'there is no class or interface with this name'); + } + + $genericParameters = PhpDocTypeUtils::getGenericTypeDefinition($type->type)->parameters; + $innerMapperCompilers = []; + + foreach ($type->genericTypes as $index => $genericType) { + $genericParameter = $genericParameters[$index] ?? throw CannotCreateMapperCompilerException::fromType($type, "generic parameter at index {$index} does not exist"); + + if ($genericParameter->bound !== null && !PhpDocTypeUtils::isSubTypeOf($genericType, $genericParameter->bound)) { + throw CannotCreateMapperCompilerException::fromType($type, "type {$genericType} is not a subtype of {$genericParameter->bound}"); + } + + $innerMapperCompilers[] = $this->createInner($genericType, $options); + } + + return new DelegateMapperCompiler($type->type->name, $innerMapperCompilers); + } + /** * @param class-string $inputClassName * @param array $options */ protected function createObjectMapperCompiler(string $inputClassName, array $options): MapperCompiler { - $classLikeNames = [$inputClassName => true, ...class_parents($inputClassName), ...class_implements($inputClassName)]; + $classParents = class_parents($inputClassName); + $classImplements = class_implements($inputClassName); + + if ($classParents === false || $classImplements === false) { + throw new LogicException("Unable to get class parents or implements for '$inputClassName'."); + } + + $classLikeNames = [$inputClassName => true, ...$classParents, ...$classImplements]; foreach ($classLikeNames as $classLikeName => $_) { if (isset($this->mapperCompilerFactories[$classLikeName])) { @@ -245,20 +288,24 @@ protected function createObjectMappingByConstructorInvocation( array $options, ): MapperCompiler { + $inputType = new IdentifierTypeNode($inputClassName); $classReflection = new ReflectionClass($inputClassName); - $constructor = $classReflection->getConstructor(); if ($constructor === null) { - throw CannotCreateMapperCompilerException::fromType(new IdentifierTypeNode($inputClassName), 'class has no constructor'); + throw CannotCreateMapperCompilerException::fromType($inputType, 'class has no constructor'); } if (!$constructor->isPublic()) { - throw CannotCreateMapperCompilerException::fromType(new IdentifierTypeNode($inputClassName), 'class has a non-public constructor'); + throw CannotCreateMapperCompilerException::fromType($inputType, 'class has a non-public constructor'); } + $genericParameters = PhpDocTypeUtils::getGenericTypeDefinition($inputType)->parameters; + $genericParameterNames = array_column($genericParameters, 'name'); + $options[self::GENERIC_PARAMETERS] = array_fill_keys($genericParameterNames, true); + $constructorParameterMapperCompilers = []; - $constructorParameterTypes = $this->getConstructorParameterTypes($constructor); + $constructorParameterTypes = $this->getConstructorParameterTypes($constructor, $genericParameterNames); foreach ($constructor->getParameters() as $parameter) { $name = $parameter->getName(); @@ -267,13 +314,14 @@ protected function createObjectMappingByConstructorInvocation( } $allowExtraKeys = count($classReflection->getAttributes(AllowExtraKeys::class)) > 0; - return new MapObject($classReflection->getName(), $constructorParameterMapperCompilers, $allowExtraKeys); + return new MapObject($classReflection->getName(), $constructorParameterMapperCompilers, $allowExtraKeys, $genericParameters); } /** + * @param list $genericParameterNames * @return array */ - protected function getConstructorParameterTypes(ReflectionMethod $constructor): array + protected function getConstructorParameterTypes(ReflectionMethod $constructor, array $genericParameterNames): array { $class = $constructor->getDeclaringClass(); $parameterTypes = []; @@ -290,7 +338,7 @@ protected function getConstructorParameterTypes(ReflectionMethod $constructor): if ($constructorDocComment !== false) { foreach ($this->parsePhpDoc($constructorDocComment)->children as $node) { if ($node instanceof PhpDocTagNode && $node->value instanceof ParamTagValueNode) { - PhpDocTypeUtils::resolve($node->value->type, $class); + PhpDocTypeUtils::resolve($node->value->type, $class, $genericParameterNames); $parameterName = substr($node->value->parameterName, 1); $parameterTypes[$parameterName] = $node->value->type; } @@ -312,7 +360,7 @@ protected function getConstructorParameterTypes(ReflectionMethod $constructor): && $node->value instanceof VarTagValueNode && ($node->value->variableName === '' || substr($node->value->variableName, 1) === $parameterName) ) { - PhpDocTypeUtils::resolve($node->value->type, $class); + PhpDocTypeUtils::resolve($node->value->type, $class, $genericParameterNames); $parameterTypes[$parameterName] = $node->value->type; } } diff --git a/src/Compiler/Php/PhpCodeBuilder.php b/src/Compiler/Php/PhpCodeBuilder.php index 281f264..3dcd1dc 100644 --- a/src/Compiler/Php/PhpCodeBuilder.php +++ b/src/Compiler/Php/PhpCodeBuilder.php @@ -41,16 +41,21 @@ use PhpParser\Node\Stmt\Throw_; use PhpParser\Node\Stmt\Use_; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode; +use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\ObjectShapeItemNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use ShipMonk\InputMapper\Compiler\CompiledExpr; +use ShipMonk\InputMapper\Compiler\Mapper\GenericMapperCompiler; use ShipMonk\InputMapper\Compiler\Mapper\MapperCompiler; +use ShipMonk\InputMapper\Compiler\Type\GenericTypeParameter; use ShipMonk\InputMapper\Compiler\Type\PhpDocTypeUtils; use ShipMonk\InputMapper\Runtime\Exception\MappingFailedException; use ShipMonk\InputMapper\Runtime\Mapper; use ShipMonk\InputMapper\Runtime\MapperProvider; +use function array_column; +use function array_fill_keys; use function array_filter; use function array_pop; use function array_slice; @@ -62,13 +67,20 @@ use function is_array; use function is_object; use function ksort; +use function serialize; use function str_ends_with; use function strrpos; use function substr; +use function unserialize; class PhpCodeBuilder extends BuilderFactory { + /** + * @var list + */ + private array $genericParameters = []; + /** * @var array alias => class like FQN */ @@ -325,9 +337,6 @@ public function addMethod(ClassMethod $method): void $this->methods[$method->name->name] = $method; } - /** - * @param class-string $className - */ public function importClass(string $className): string { $lastBackslashOffset = strrpos($className, '\\'); @@ -420,13 +429,25 @@ public function mapperMethod(string $methodName, MapperCompiler $mapperCompiler) $this->importType($inputType); $this->importType($outputType); - $nativeInputType = PhpDocTypeUtils::toNativeType($inputType, $phpDocInputTypeUseful); - $nativeOutputType = PhpDocTypeUtils::toNativeType($outputType, $phpDocOutputTypeUseful); + $clonedGenericParameters = []; + + foreach ($this->genericParameters as $genericParameter) { + /** @var GenericTypeParameter $clonedGenericParameter */ + $clonedGenericParameter = unserialize(serialize($genericParameter)); + $clonedGenericParameters[] = $clonedGenericParameter; + + if ($clonedGenericParameter->bound !== null) { + $this->importType($clonedGenericParameter->bound); + } + } + + $nativeInputType = PhpDocTypeUtils::toNativeType($inputType, $clonedGenericParameters, $phpDocInputTypeUseful); + $nativeOutputType = PhpDocTypeUtils::toNativeType($outputType, $clonedGenericParameters, $phpDocOutputTypeUseful); $phpDoc = $this->phpDoc([ - $phpDocInputTypeUseful ? "@param {$inputType} \${$dataVarName}" : null, + $phpDocInputTypeUseful !== false ? "@param {$inputType} \${$dataVarName}" : null, "@param list \${$pathVarName}", - $phpDocOutputTypeUseful ? "@return {$outputType}" : null, + $phpDocOutputTypeUseful !== false ? "@return {$outputType}" : null, '@throws ' . $this->importClass(MappingFailedException::class), ]); @@ -439,16 +460,44 @@ public function mapperMethod(string $methodName, MapperCompiler $mapperCompiler) ->addStmt($this->return($mapper->expr)); } - public function mapperClass(string $shortClassName, MapperCompiler $mapperCompiler): Class_ + public function mapperClassConstructor(MapperCompiler $mapperCompiler): ClassMethod { - $providerType = $this->importClass(MapperProvider::class); - $providerParameter = $this->param('provider')->setType($providerType)->getNode(); + $mapperConstructorPhpDocLines = []; + $mapperConstructorBuilder = $this->method('__construct'); + + $providerParameter = $this->param('provider')->setType($this->importClass(MapperProvider::class))->getNode(); $providerParameter->flags = ClassNode::MODIFIER_PRIVATE | ClassNode::MODIFIER_READONLY; + $mapperConstructorBuilder->addParam($providerParameter); + + if ($mapperCompiler instanceof GenericMapperCompiler && count($mapperCompiler->getGenericParameters()) > 0) { + $innerMappersParameter = $this->param('innerMappers')->setType('array')->getNode(); + $innerMappersParameter->flags = ClassNode::MODIFIER_PRIVATE | ClassNode::MODIFIER_READONLY; + $mapperConstructorBuilder->addParam($innerMappersParameter); + + $innerMappersType = new ArrayShapeNode(Arrays::map( + $mapperCompiler->getGenericParameters(), + static function (GenericTypeParameter $genericParameter): ArrayShapeItemNode { + return new ArrayShapeItemNode( + keyName: null, + valueType: new GenericTypeNode(new IdentifierTypeNode(Mapper::class), [new IdentifierTypeNode($genericParameter->name)]), + optional: false, + ); + }, + )); + + $this->importType($innerMappersType); + $mapperConstructorPhpDocLines[] = "@param {$innerMappersType} \$innerMappers"; + } - $mapperConstructor = $this->method('__construct') + return $mapperConstructorBuilder ->makePublic() - ->addParam($providerParameter) + ->setDocComment($this->phpDoc($mapperConstructorPhpDocLines)) ->getNode(); + } + + public function mapperClass(string $shortClassName, MapperCompiler $mapperCompiler): Class_ + { + $mapperConstructor = $this->mapperClassConstructor($mapperCompiler); $mapMethod = $this->mapperMethod('map', $mapperCompiler) ->makePublic() @@ -464,11 +513,19 @@ public function mapperClass(string $shortClassName, MapperCompiler $mapperCompil [$outputType], ); - $phpDoc = $this->phpDoc([ + $phpDocLines = [ "Generated mapper by {@see $mapperCompilerType}. Do not edit directly.", '', - "@implements {$implementsType}", - ]); + ]; + + if ($mapperCompiler instanceof GenericMapperCompiler) { + foreach ($mapperCompiler->getGenericParameters() as $genericParameter) { + $phpDocLines[] = $genericParameter->toPhpDocLine(); + } + } + + $phpDocLines[] = "@implements {$implementsType}"; + $phpDoc = $this->phpDoc($phpDocLines); $constants = Arrays::map( $this->constants, @@ -497,6 +554,10 @@ public function mapperFile(string $mapperClassName, MapperCompiler $mapperCompil $namespaceName = $pos === false ? '' : substr($mapperClassName, 0, $pos); $shortClassName = $pos === false ? $mapperClassName : substr($mapperClassName, $pos + 1); + if ($mapperCompiler instanceof GenericMapperCompiler) { + $this->genericParameters = $mapperCompiler->getGenericParameters(); + } + $mapperClass = $this->mapperClass($shortClassName, $mapperCompiler) ->getNode(); @@ -529,11 +590,20 @@ public function file(string $namespaceName, array $statements): array ]; } + /** + * @return list + */ + public function getGenericParameters(): array + { + return $this->genericParameters; + } + /** * @return list */ public function getImports(string $namespace): array { + $genericParameterNames = array_fill_keys(array_column($this->genericParameters, 'name'), true); $classLikeImports = []; $functionImports = []; @@ -545,6 +615,9 @@ public function getImports(string $namespace): array } elseif ($fqn === "{$namespace}\\{$alias}") { continue; + + } elseif (isset($genericParameterNames[$alias])) { + continue; } $classLikeImports[$fqn] = $use->getNode(); diff --git a/src/Compiler/Type/GenericTypeDefinition.php b/src/Compiler/Type/GenericTypeDefinition.php new file mode 100644 index 0000000..dad05c0 --- /dev/null +++ b/src/Compiler/Type/GenericTypeDefinition.php @@ -0,0 +1,23 @@ +> $extends + * @param list $parameters + * @param array> $parameterOffsetMapping indexed by [parameterCount] + */ + public function __construct( + public readonly array $extends = [], + public readonly array $parameters = [], + public readonly array $parameterOffsetMapping = [], + ) + { + } + +} diff --git a/src/Compiler/Type/GenericTypeParameter.php b/src/Compiler/Type/GenericTypeParameter.php new file mode 100644 index 0000000..c6c05e9 --- /dev/null +++ b/src/Compiler/Type/GenericTypeParameter.php @@ -0,0 +1,33 @@ +variance) { + GenericTypeVariance::Invariant => 'template', + GenericTypeVariance::Covariant => 'template-covariant', + GenericTypeVariance::Contravariant => 'template-contravariant', + }; + + $bound = $this->bound !== null ? " of {$this->bound}" : ''; + $default = $this->default !== null ? " = {$this->default}" : ''; + return trim("@{$tagName} {$this->name}{$bound}{$default}"); + } + +} diff --git a/src/Compiler/Type/GenericTypeVariance.php b/src/Compiler/Type/GenericTypeVariance.php new file mode 100644 index 0000000..c43c996 --- /dev/null +++ b/src/Compiler/Type/GenericTypeVariance.php @@ -0,0 +1,12 @@ + true, ]; + /** + * @var array + */ + private static array $genericTypeDefinitions = []; + public static function isKeyword(IdentifierTypeNode $type): bool { return isset(self::KEYWORDS[$type->name]) @@ -133,13 +151,27 @@ public static function fromReflectionType(ReflectionType $reflectionType): TypeN return new IdentifierTypeNode('mixed'); } - public static function toNativeType(TypeNode $type, ?bool &$phpDocUseful): ComplexType|Identifier|Name + /** + * @param list $genericParameters + */ + public static function toNativeType( + TypeNode $type, + array $genericParameters, + ?bool &$phpDocUseful, + ): ComplexType|Identifier|Name { if ($phpDocUseful === null) { $phpDocUseful = false; } if ($type instanceof IdentifierTypeNode) { + foreach ($genericParameters as $genericParameter) { + if ($genericParameter->name === $type->name) { + $phpDocUseful = true; + return self::toNativeType($genericParameter->bound ?? new IdentifierTypeNode('mixed'), $genericParameters, $phpDocUseful); + } + } + if (!self::isKeyword($type)) { return new Name($type->name); } @@ -161,7 +193,7 @@ public static function toNativeType(TypeNode $type, ?bool &$phpDocUseful): Compl } if ($type instanceof NullableTypeNode) { - return NativeTypeUtils::createNullable(self::toNativeType($type->type, $phpDocUseful)); + return NativeTypeUtils::createNullable(self::toNativeType($type->type, $genericParameters, $phpDocUseful)); } if ($type instanceof ArrayTypeNode || $type instanceof ArrayShapeNode) { @@ -181,14 +213,14 @@ public static function toNativeType(TypeNode $type, ?bool &$phpDocUseful): Compl if ($type instanceof GenericTypeNode) { $phpDocUseful = true; - return self::toNativeType($type->type, $phpDocUseful); + return self::toNativeType($type->type, $genericParameters, $phpDocUseful); } if ($type instanceof UnionTypeNode) { $types = []; foreach ($type->types as $inner) { - $types[] = self::toNativeType($inner, $phpDocUseful); + $types[] = self::toNativeType($inner, $genericParameters, $phpDocUseful); } return NativeTypeUtils::createUnion(...$types); @@ -198,7 +230,7 @@ public static function toNativeType(TypeNode $type, ?bool &$phpDocUseful): Compl $types = []; foreach ($type->types as $inner) { - $types[] = self::toNativeType($inner, $phpDocUseful); + $types[] = self::toNativeType($inner, $genericParameters, $phpDocUseful); } return NativeTypeUtils::createIntersection(...$types); @@ -239,24 +271,27 @@ public static function makeNullable(TypeNode $type): TypeNode } /** - * @param ReflectionClass $context + * @param ReflectionClass $context + * @param list $genericParameterNames */ - public static function resolve(mixed $type, ReflectionClass $context): void + public static function resolve(mixed $type, ReflectionClass $context, array $genericParameterNames = []): void { if (is_array($type)) { foreach ($type as $item) { - self::resolve($item, $context); + self::resolve($item, $context, $genericParameterNames); } } elseif ($type instanceof IdentifierTypeNode) { if (!self::isKeyword($type) || $type->name === 'self' || $type->name === 'static' || $type->name === 'parent') { - $type->name = Reflection::expandClassName($type->name, $context); + if (!in_array($type->name, $genericParameterNames, true)) { + $type->name = Reflection::expandClassName($type->name, $context); + } } } elseif ($type instanceof ArrayShapeItemNode) { - self::resolve($type->valueType, $context); // intentionally not resolving key type + self::resolve($type->valueType, $context, $genericParameterNames); // intentionally not resolving key type } elseif (is_object($type)) { foreach (get_object_vars($type) as $item) { - self::resolve($item, $context); + self::resolve($item, $context, $genericParameterNames); } } } @@ -309,19 +344,19 @@ public static function intersect(TypeNode ...$types): TypeNode && $b instanceof GenericTypeNode && self::isSubTypeOf($b->type, $a->type) ) { - $typeDef = self::getGenericTypeDefinition($b); + $typeDef = self::getGenericTypeDefinition($b->type); $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'])) { + foreach ($typeDef->parameters as $parameterIndex => $parameterDef) { + if (!isset($typeDef->parameterOffsetMapping[$intersectedParameterCount])) { $intersectedParameterMapping[$parameterIndex] = $parameterIndex; - } elseif (isset($parameterDef['index'][$intersectedParameterCount])) { - $intersectedParameterIndex = $parameterDef['index'][$intersectedParameterCount]; + } elseif (isset($typeDef->parameterOffsetMapping[$intersectedParameterCount][$parameterIndex])) { + $intersectedParameterIndex = $typeDef->parameterOffsetMapping[$intersectedParameterCount][$parameterIndex]; $intersectedParameterMapping[$intersectedParameterIndex] = $parameterIndex; } } @@ -385,7 +420,7 @@ public static function isSubTypeOf(TypeNode $a, TypeNode $b): bool if ($b instanceof IdentifierTypeNode) { if (!self::isKeyword($b)) { return match (true) { - $a instanceof IdentifierTypeNode => is_a($a->name, $b->name, true), + $a instanceof IdentifierTypeNode => $a->name === $b->name || is_a($a->name, $b->name, true), $a instanceof GenericTypeNode => is_a($a->type->name, $b->name, true), default => false, }; @@ -636,9 +671,9 @@ private static function findDownCastPath(string $sourceTypeName, string $targetT return []; } - $targetTypeDef = self::getGenericTypeDefinition(new GenericTypeNode(new IdentifierTypeNode($targetTypeName), [])); + $targetTypeDef = self::getGenericTypeDefinition(new IdentifierTypeNode($targetTypeName)); - foreach ($targetTypeDef['extends'] ?? [] as $possibleTarget => $_) { + foreach ($targetTypeDef->extends ?? [] as $possibleTarget => $_) { $innerPath = self::findDownCastPath($sourceTypeName, $possibleTarget); if ($innerPath !== null) { @@ -659,17 +694,17 @@ private static function downCastOverPath(GenericTypeNode $type, array $path): Ge } $step = array_shift($path); - $targetTypeDef = self::getGenericTypeDefinition(new GenericTypeNode(new IdentifierTypeNode($step), [])); + $targetTypeDef = self::getGenericTypeDefinition(new IdentifierTypeNode($step)); - if (!isset($targetTypeDef['extends'][$type->type->name])) { + 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'); + $targetTypeParameters = Arrays::map($targetTypeDef->parameters ?? [], static function (GenericTypeParameter $parameter): TypeNode { + return $parameter->default ?? $parameter->bound ?? new IdentifierTypeNode('mixed'); }); - foreach ($targetTypeDef['extends'][$type->type->name] as $sourceIndex => $typeOrIndex) { + foreach ($targetTypeDef->extends[$type->type->name] as $sourceIndex => $typeOrIndex) { if (is_int($typeOrIndex)) { $targetTypeParameters[$typeOrIndex] = self::getGenericTypeParameter($type, $sourceIndex); } @@ -681,16 +716,15 @@ private static function downCastOverPath(GenericTypeNode $type, array $path): Ge 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 { + $typeDef = self::getGenericTypeDefinition($a->type); + return Arrays::every($typeDef->parameters ?? [], static function (GenericTypeParameter $parameter, int $idx) use ($a, $b): bool { $genericTypeA = self::getGenericTypeParameter($a, $idx); $genericTypeB = self::getGenericTypeParameter($b, $idx); - return match ($parameter['variance']) { - 'in' => self::isSubTypeOf($genericTypeB, $genericTypeA), - 'out' => self::isSubTypeOf($genericTypeA, $genericTypeB), - 'inout' => self::isSubTypeOf($genericTypeA, $genericTypeB) && self::isSubTypeOf($genericTypeB, $genericTypeA), - default => throw new LogicException("Invalid variance {$parameter['variance']}"), + return match ($parameter->variance) { + GenericTypeVariance::Contravariant => self::isSubTypeOf($genericTypeB, $genericTypeA), + GenericTypeVariance::Covariant => self::isSubTypeOf($genericTypeA, $genericTypeB), + GenericTypeVariance::Invariant => self::isSubTypeOf($genericTypeA, $genericTypeB) && self::isSubTypeOf($genericTypeB, $genericTypeA), }; }); } @@ -705,9 +739,9 @@ private static function isSubTypeOfGeneric(GenericTypeNode $a, GenericTypeNode $ */ public static function getGenericTypeSuperTypes(GenericTypeNode $type): array { - $typeDef = self::getGenericTypeDefinition($type); + $typeDef = self::getGenericTypeDefinition($type->type); - return Arrays::map($typeDef['extends'] ?? [], static function (array $mapping, string $superTypeName) use ($type): GenericTypeNode { + 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); })); @@ -716,89 +750,168 @@ public static function getGenericTypeSuperTypes(GenericTypeNode $type): array private static function getGenericTypeParameter(GenericTypeNode $type, int $index): TypeNode { - $typeDef = self::getGenericTypeDefinition($type); + $typeDef = self::getGenericTypeDefinition($type->type); - if (!isset($typeDef['parameters'])) { - throw new LogicException('Generic type has no parameters'); + if (!isset($typeDef->parameters[$index])) { + throw new LogicException("Generic type {$type->type} has no parameter at index {$index}"); } - $parameterDef = $typeDef['parameters'][$index]; + $parameterDef = $typeDef->parameters[$index]; $count = count($type->genericTypes); - if (isset($parameterDef['index'])) { - $index = $parameterDef['index'][$count] ?? -1; + if (isset($typeDef->parameterOffsetMapping[$count])) { + $index = $typeDef->parameterOffsetMapping[$count][$index] ?? -1; } - return $type->genericTypes[$index] ?? $parameterDef['bound'] ?? new IdentifierTypeNode('mixed'); + return $type->genericTypes[$index] ?? $parameterDef->default ?? $parameterDef->bound ?? new IdentifierTypeNode('mixed'); } - /** - * @return array{ - * extends?: array>, - * parameters?: list, variance: 'in' | 'out' | 'inout', bound?: TypeNode}>, - * } - */ - private static function getGenericTypeDefinition(GenericTypeNode $type): array + public static function getGenericTypeDefinition(IdentifierTypeNode $type): GenericTypeDefinition { - return match ($type->type->name) { - 'array' => [ - 'extends' => [ + return self::$genericTypeDefinitions[$type->name] ??= match ($type->name) { + 'array' => new GenericTypeDefinition( + extends: [ 'iterable' => [0, 1], ], - 'parameters' => [ - ['index' => [2 => 0], 'variance' => 'out', 'bound' => new UnionTypeNode([new IdentifierTypeNode('int'), new IdentifierTypeNode('string')])], - ['index' => [1 => 0, 2 => 1], 'variance' => 'out'], + parameters: [ + new GenericTypeParameter( + name: 'K', + variance: GenericTypeVariance::Covariant, + bound: new UnionTypeNode([new IdentifierTypeNode('int'), new IdentifierTypeNode('string')]), + ), + new GenericTypeParameter( + name: 'V', + variance: GenericTypeVariance::Covariant, + ), ], - ], + parameterOffsetMapping: [ + 1 => [null, 0], + ], + ), - 'list' => [ - 'extends' => [ + 'list' => new GenericTypeDefinition( + extends: [ 'array' => [new IdentifierTypeNode('int'), 0], ], - 'parameters' => [ - ['variance' => 'out'], + parameters: [ + new GenericTypeParameter( + name: 'T', + variance: GenericTypeVariance::Covariant, + ), ], - ], - - 'iterable' => [ - 'parameters' => [ - ['index' => [2 => 0], 'variance' => 'out'], - ['index' => [1 => 0, 2 => 1], 'variance' => 'out'], + ), + + 'iterable' => new GenericTypeDefinition( + parameters: [ + new GenericTypeParameter( + name: 'K', + variance: GenericTypeVariance::Covariant, + ), + new GenericTypeParameter( + name: 'V', + variance: GenericTypeVariance::Covariant, + ), + ], + parameterOffsetMapping: [ + 1 => [null, 0], ], - ], + ), - 'non-empty-list' => [ - 'extends' => [ + 'non-empty-list' => new GenericTypeDefinition( + extends: [ 'list' => [0], ], - 'parameters' => [ - ['variance' => 'out'], + parameters: [ + new GenericTypeParameter( + name: 'T', + variance: GenericTypeVariance::Covariant, + ), ], - ], - - Optional::class => [ - 'parameters' => [ - ['variance' => 'out'], + ), + + BackedEnum::class => new GenericTypeDefinition( + parameters: [ + new GenericTypeParameter( + name: 'T', + variance: GenericTypeVariance::Covariant, + bound: new UnionTypeNode([new IdentifierTypeNode('int'), new IdentifierTypeNode('string')]), + ), ], - ], + ), - OptionalSome::class => [ - 'extends' => [ - Optional::class => [0], - ], - 'parameters' => [ - ['variance' => 'out'], - ], - ], + default => self::isKeyword($type) ? new GenericTypeDefinition() : self::getGenericTypeDefinitionFromPhpDoc($type->name), + }; + } - OptionalNone::class => [ - 'extends' => [ - Optional::class => [new IdentifierTypeNode('never')], - ], - ], + private static function getGenericTypeDefinitionFromPhpDoc(string $className): GenericTypeDefinition + { + if (!class_exists($className) && !interface_exists($className)) { + return new GenericTypeDefinition(); + } - default => [], - }; + $classReflection = new ReflectionClass($className); + $classPhpDoc = $classReflection->getDocComment(); + + if ($classPhpDoc === false) { + return new GenericTypeDefinition(); + } + + $phpDocNode = self::parsePhpDoc($classPhpDoc); + $extends = []; + $genericParameters = []; + + foreach ($phpDocNode->children as $node) { + if ($node instanceof PhpDocTagNode && $node->value instanceof TemplateTagValueNode) { + $variance = match (true) { + str_ends_with($node->name, '-covariant') => GenericTypeVariance::Covariant, + str_ends_with($node->name, '-contravariant') => GenericTypeVariance::Contravariant, + default => GenericTypeVariance::Invariant, + }; + + $genericParameters[$node->value->name] = new GenericTypeParameter( + name: $node->value->name, + variance: $variance, + bound: $node->value->bound, + default: $node->value->default, + ); + } + } + + foreach ($genericParameters as $genericParameter) { + self::resolve($genericParameter->bound, $classReflection, array_keys($genericParameters)); + self::resolve($genericParameter->default, $classReflection, array_keys($genericParameters)); + } + + $genericParameterOffsets = array_flip(array_keys($genericParameters)); + + foreach ($phpDocNode->children as $node) { + if ($node instanceof PhpDocTagNode && ($node->value instanceof ImplementsTagValueNode || $node->value instanceof ExtendsTagValueNode)) { + self::resolve($node->value->type, $classReflection, array_keys($genericParameters)); + $extends[$node->value->type->type->name] = array_values( + Arrays::map($node->value->type->genericTypes, static function (TypeNode $type) use ($genericParameterOffsets): TypeNode|int { + return $type instanceof IdentifierTypeNode && isset($genericParameterOffsets[$type->name]) + ? $genericParameterOffsets[$type->name] + : $type; + }), + ); + } + } + + return new GenericTypeDefinition( + extends: $extends, + parameters: array_values($genericParameters), + ); + } + + private static function parsePhpDoc(string $phpDoc): PhpDocNode + { + $phpDocLexer = new Lexer(); + $phpDocTypeParser = new TypeParser(); + $phpDocConstExprParser = new ConstExprParser(unescapeStrings: true); + $phpDocParser = new PhpDocParser($phpDocTypeParser, $phpDocConstExprParser); + $phpDocTokens = $phpDocLexer->tokenize($phpDoc); + + return $phpDocParser->parse(new TokenIterator($phpDocTokens)); } private static function convertArrayShapeToGenericType(ArrayShapeNode $type): GenericTypeNode diff --git a/src/Runtime/CallbackMapper.php b/src/Runtime/CallbackMapper.php new file mode 100644 index 0000000..20a1dd4 --- /dev/null +++ b/src/Runtime/CallbackMapper.php @@ -0,0 +1,35 @@ + + */ +class CallbackMapper implements Mapper +{ + + /** + * @param Closure(mixed, list): T $callback + */ + public function __construct( + private readonly Closure $callback, + ) + { + } + + /** + * @param list $path + * @return T + * @throws MappingFailedException + */ + public function map(mixed $data, array $path = []): mixed + { + /** @throws MappingFailedException */ + return ($this->callback)($data, $path); + } + +} diff --git a/src/Runtime/MapperProvider.php b/src/Runtime/MapperProvider.php index 4662072..7de0ec3 100644 --- a/src/Runtime/MapperProvider.php +++ b/src/Runtime/MapperProvider.php @@ -9,18 +9,22 @@ use ShipMonk\InputMapper\Compiler\MapperFactory\MapperCompilerFactoryProvider; use ShipMonk\InputMapper\Compiler\Php\PhpCodeBuilder; use ShipMonk\InputMapper\Compiler\Php\PhpCodePrinter; +use function array_map; use function class_exists; use function class_implements; use function class_parents; +use function count; use function dirname; use function file_put_contents; use function flock; use function fopen; +use function implode; use function is_dir; use function is_file; use function md5; use function mkdir; use function rename; +use function spl_object_id; use function strlen; use function strrpos; use function substr; @@ -32,12 +36,12 @@ class MapperProvider { /** - * @var array> + * @var array> */ private array $mappers = []; /** - * @var array> + * @var array>, self): Mapper> */ private array $mapperFactories = []; @@ -51,20 +55,27 @@ public function __construct( /** * @template T of object - * @param class-string $inputClassName + * @param class-string $inputClassName + * @param list> $innerMappers * @return Mapper */ - public function get(string $inputClassName): Mapper + public function get(string $inputClassName, array $innerMappers = []): Mapper { + $key = $inputClassName; + + if (count($innerMappers) > 0) { + $key .= '+' . md5(implode('+', array_map(spl_object_id(...), $innerMappers))); + } + /** @var Mapper $mapper */ - $mapper = $this->mappers[$inputClassName] ??= $this->create($inputClassName); + $mapper = $this->mappers[$key] ??= $this->create($inputClassName, $innerMappers); return $mapper; } /** * @template T of object - * @param class-string $inputClassName - * @param callable(class-string, self): Mapper $mapperFactory + * @param class-string $inputClassName + * @param callable(class-string, list>, self): Mapper $mapperFactory */ public function registerFactory(string $inputClassName, callable $mapperFactory): void { @@ -77,18 +88,26 @@ public function registerFactory(string $inputClassName, callable $mapperFactory) /** * @template T of object - * @param class-string $inputClassName + * @param class-string $inputClassName + * @param list> $innerMappers * @return Mapper */ - private function create(string $inputClassName): Mapper + private function create(string $inputClassName, array $innerMappers): Mapper { - $classLikeNames = [$inputClassName => true, ...class_parents($inputClassName), ...class_implements($inputClassName)]; + $classParents = class_parents($inputClassName); + $classImplements = class_implements($inputClassName); + + if ($classParents === false || $classImplements === false) { + throw new LogicException("Unable to get class parents or implements for '$inputClassName'."); + } + + $classLikeNames = [$inputClassName => true, ...$classParents, ...$classImplements]; foreach ($classLikeNames as $classLikeName => $_) { if (isset($this->mapperFactories[$classLikeName])) { - /** @var callable(class-string, self): Mapper $factory */ + /** @var callable(class-string, list>, self): Mapper $factory */ $factory = $this->mapperFactories[$classLikeName]; - return $factory($inputClassName, $this); + return $factory($inputClassName, $innerMappers, $this); } } @@ -98,7 +117,7 @@ private function create(string $inputClassName): Mapper $this->load($inputClassName, $mapperClassName); } - return new $mapperClassName($this); + return new $mapperClassName($this, $innerMappers); } /** diff --git a/tests/Compiler/Mapper/MapperCompilerTestCase.php b/tests/Compiler/Mapper/MapperCompilerTestCase.php index 46fbbea..0e3734f 100644 --- a/tests/Compiler/Mapper/MapperCompilerTestCase.php +++ b/tests/Compiler/Mapper/MapperCompilerTestCase.php @@ -12,20 +12,24 @@ use function assert; use function class_exists; use function str_replace; +use function strrpos; use function strtr; +use function substr; use function ucfirst; abstract class MapperCompilerTestCase extends InputMapperTestCase { /** - * @param array> $mappers + * @param array $providedMapperCompilers + * @param list> $innerMappers * @return Mapper */ protected function compileMapper( string $name, MapperCompiler $mapperCompiler, - array $mappers = [], + array $providedMapperCompilers = [], + array $innerMappers = [], ): Mapper { $testCaseReflection = new ReflectionClass($this); @@ -48,14 +52,23 @@ protected function compileMapper( $mapperProvider = $this->createMock(MapperProvider::class); - foreach ($mappers as $inputClassName => $mapper) { - $mapperProvider->expects(self::any())->method('get')->with($inputClassName)->willReturn($mapper); - } + $mapperProvider->expects(self::any())->method('get')->willReturnCallback( + function (string $inputClassName, array $innerMappers = []) use ($name, $providedMapperCompilers): Mapper { + /** @var list> $innerMappers */ + return $this->compileMapper($name . '__' . $this->toShortClassName($inputClassName), $providedMapperCompilers[$inputClassName], [], $innerMappers); + }, + ); - $mapper = new $mapperClassName($mapperProvider); + $mapper = new $mapperClassName($mapperProvider, $innerMappers); assert($mapper instanceof Mapper); return $mapper; } + private function toShortClassName(string $className): string + { + $pos = strrpos($className, '\\'); + return $pos === false ? $className : substr($className, $pos + 1); + } + } diff --git a/tests/Compiler/Mapper/Object/Data/CollectionInnerIntMapper.php b/tests/Compiler/Mapper/Object/Data/CollectionInnerIntMapper.php new file mode 100644 index 0000000..ce7b155 --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/CollectionInnerIntMapper.php @@ -0,0 +1,34 @@ + + */ +class CollectionInnerIntMapper implements Mapper +{ + public function __construct(private readonly MapperProvider $provider) + { + } + + /** + * @param list $path + * @throws MappingFailedException + */ + public function map(mixed $data, array $path = []): int + { + if (!is_int($data)) { + throw MappingFailedException::incorrectType($data, $path, 'int'); + } + + return $data; + } +} diff --git a/tests/Compiler/Mapper/Object/Data/CollectionInnerStringMapper.php b/tests/Compiler/Mapper/Object/Data/CollectionInnerStringMapper.php new file mode 100644 index 0000000..9b11036 --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/CollectionInnerStringMapper.php @@ -0,0 +1,34 @@ + + */ +class CollectionInnerStringMapper implements Mapper +{ + public function __construct(private readonly MapperProvider $provider) + { + } + + /** + * @param list $path + * @throws MappingFailedException + */ + public function map(mixed $data, array $path = []): string + { + if (!is_string($data)) { + throw MappingFailedException::incorrectType($data, $path, 'string'); + } + + return $data; + } +} diff --git a/tests/Compiler/Mapper/Object/Data/CollectionInput.php b/tests/Compiler/Mapper/Object/Data/CollectionInput.php new file mode 100644 index 0000000..b91296a --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/CollectionInput.php @@ -0,0 +1,21 @@ + $items + */ + public function __construct( + public readonly array $items, + public readonly int $size, + ) + { + } + +} diff --git a/tests/Compiler/Mapper/Object/Data/CollectionMapper.php b/tests/Compiler/Mapper/Object/Data/CollectionMapper.php new file mode 100644 index 0000000..38a0d11 --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/CollectionMapper.php @@ -0,0 +1,96 @@ +> + */ +class CollectionMapper implements Mapper +{ + /** + * @param array{Mapper} $innerMappers + */ + public function __construct(private readonly MapperProvider $provider, private readonly array $innerMappers) + { + } + + /** + * @param list $path + * @return CollectionInput + * @throws MappingFailedException + */ + public function map(mixed $data, array $path = []): CollectionInput + { + if (!is_array($data)) { + throw MappingFailedException::incorrectType($data, $path, 'array'); + } + + if (!array_key_exists('items', $data)) { + throw MappingFailedException::missingKey($path, 'items'); + } + + if (!array_key_exists('size', $data)) { + throw MappingFailedException::missingKey($path, 'size'); + } + + $knownKeys = ['items' => true, 'size' => true]; + $extraKeys = array_diff_key($data, $knownKeys); + + if (count($extraKeys) > 0) { + throw MappingFailedException::extraKeys($path, array_keys($extraKeys)); + } + + return new CollectionInput( + $this->mapItems($data['items'], [...$path, 'items']), + $this->mapSize($data['size'], [...$path, 'size']), + ); + } + + /** + * @param list $path + * @return list + * @throws MappingFailedException + */ + private function mapItems(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[] = $this->innerMappers[0]->map($item, [...$path, $index]); + } + + return $mapped; + } + + /** + * @param list $path + * @throws MappingFailedException + */ + private function mapSize(mixed $data, array $path = []): int + { + if (!is_int($data)) { + throw MappingFailedException::incorrectType($data, $path, 'int'); + } + + return $data; + } +} diff --git a/tests/Compiler/Mapper/Object/Data/DelegateToEnumCollectionMapper.php b/tests/Compiler/Mapper/Object/Data/DelegateToEnumCollectionMapper.php new file mode 100644 index 0000000..5d96dca --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/DelegateToEnumCollectionMapper.php @@ -0,0 +1,31 @@ +> + */ +class DelegateToEnumCollectionMapper implements Mapper +{ + public function __construct(private readonly MapperProvider $provider) + { + } + + /** + * @param list $path + * @return CollectionInput + * @throws MappingFailedException + */ + public function map(mixed $data, array $path = []): CollectionInput + { + $innerMappers = [$this->provider->get(SuitEnum::class)]; + return $this->provider->get(CollectionInput::class, $innerMappers)->map($data, $path); + } +} diff --git a/tests/Compiler/Mapper/Object/Data/DelegateToEnumCollection__CollectionInputMapper.php b/tests/Compiler/Mapper/Object/Data/DelegateToEnumCollection__CollectionInputMapper.php new file mode 100644 index 0000000..891c592 --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/DelegateToEnumCollection__CollectionInputMapper.php @@ -0,0 +1,96 @@ +> + */ +class DelegateToEnumCollection__CollectionInputMapper implements Mapper +{ + /** + * @param array{Mapper} $innerMappers + */ + public function __construct(private readonly MapperProvider $provider, private readonly array $innerMappers) + { + } + + /** + * @param list $path + * @return CollectionInput + * @throws MappingFailedException + */ + public function map(mixed $data, array $path = []): CollectionInput + { + if (!is_array($data)) { + throw MappingFailedException::incorrectType($data, $path, 'array'); + } + + if (!array_key_exists('items', $data)) { + throw MappingFailedException::missingKey($path, 'items'); + } + + if (!array_key_exists('size', $data)) { + throw MappingFailedException::missingKey($path, 'size'); + } + + $knownKeys = ['items' => true, 'size' => true]; + $extraKeys = array_diff_key($data, $knownKeys); + + if (count($extraKeys) > 0) { + throw MappingFailedException::extraKeys($path, array_keys($extraKeys)); + } + + return new CollectionInput( + $this->mapItems($data['items'], [...$path, 'items']), + $this->mapSize($data['size'], [...$path, 'size']), + ); + } + + /** + * @param list $path + * @return list + * @throws MappingFailedException + */ + private function mapItems(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[] = $this->innerMappers[0]->map($item, [...$path, $index]); + } + + return $mapped; + } + + /** + * @param list $path + * @throws MappingFailedException + */ + private function mapSize(mixed $data, array $path = []): int + { + if (!is_int($data)) { + throw MappingFailedException::incorrectType($data, $path, 'int'); + } + + return $data; + } +} diff --git a/tests/Compiler/Mapper/Object/Data/DelegateToEnumCollection__SuitEnumMapper.php b/tests/Compiler/Mapper/Object/Data/DelegateToEnumCollection__SuitEnumMapper.php new file mode 100644 index 0000000..cab3209 --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/DelegateToEnumCollection__SuitEnumMapper.php @@ -0,0 +1,42 @@ + + */ +class DelegateToEnumCollection__SuitEnumMapper implements Mapper +{ + public function __construct(private readonly MapperProvider $provider) + { + } + + /** + * @param list $path + * @throws MappingFailedException + */ + public function map(mixed $data, array $path = []): SuitEnum + { + if (!is_string($data)) { + throw MappingFailedException::incorrectType($data, $path, 'string'); + } + + $enum = SuitEnum::tryFrom($data); + + if ($enum === null) { + throw MappingFailedException::incorrectValue($data, $path, 'one of ' . implode(', ', array_column(SuitEnum::cases(), 'value'))); + } + + return $enum; + } +} diff --git a/tests/Compiler/Mapper/Object/Data/DelegateToIntCollectionMapper.php b/tests/Compiler/Mapper/Object/Data/DelegateToIntCollectionMapper.php new file mode 100644 index 0000000..7c6d16f --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/DelegateToIntCollectionMapper.php @@ -0,0 +1,46 @@ +> + */ +class DelegateToIntCollectionMapper implements Mapper +{ + public function __construct(private readonly MapperProvider $provider) + { + } + + /** + * @param list $path + * @return CollectionInput + * @throws MappingFailedException + */ + public function map(mixed $data, array $path = []): CollectionInput + { + $innerMappers = [new CallbackMapper($this->mapInner0(...))]; + return $this->provider->get(CollectionInput::class, $innerMappers)->map($data, $path); + } + + /** + * @param list $path + * @throws MappingFailedException + */ + private function mapInner0(mixed $data, array $path = []): int + { + if (!is_int($data)) { + throw MappingFailedException::incorrectType($data, $path, 'int'); + } + + return $data; + } +} diff --git a/tests/Compiler/Mapper/Object/Data/DelegateToIntCollection__CollectionInputMapper.php b/tests/Compiler/Mapper/Object/Data/DelegateToIntCollection__CollectionInputMapper.php new file mode 100644 index 0000000..554030f --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/DelegateToIntCollection__CollectionInputMapper.php @@ -0,0 +1,96 @@ +> + */ +class DelegateToIntCollection__CollectionInputMapper implements Mapper +{ + /** + * @param array{Mapper} $innerMappers + */ + public function __construct(private readonly MapperProvider $provider, private readonly array $innerMappers) + { + } + + /** + * @param list $path + * @return CollectionInput + * @throws MappingFailedException + */ + public function map(mixed $data, array $path = []): CollectionInput + { + if (!is_array($data)) { + throw MappingFailedException::incorrectType($data, $path, 'array'); + } + + if (!array_key_exists('items', $data)) { + throw MappingFailedException::missingKey($path, 'items'); + } + + if (!array_key_exists('size', $data)) { + throw MappingFailedException::missingKey($path, 'size'); + } + + $knownKeys = ['items' => true, 'size' => true]; + $extraKeys = array_diff_key($data, $knownKeys); + + if (count($extraKeys) > 0) { + throw MappingFailedException::extraKeys($path, array_keys($extraKeys)); + } + + return new CollectionInput( + $this->mapItems($data['items'], [...$path, 'items']), + $this->mapSize($data['size'], [...$path, 'size']), + ); + } + + /** + * @param list $path + * @return list + * @throws MappingFailedException + */ + private function mapItems(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[] = $this->innerMappers[0]->map($item, [...$path, $index]); + } + + return $mapped; + } + + /** + * @param list $path + * @throws MappingFailedException + */ + private function mapSize(mixed $data, array $path = []): int + { + if (!is_int($data)) { + throw MappingFailedException::incorrectType($data, $path, 'int'); + } + + return $data; + } +} diff --git a/tests/Compiler/Mapper/Object/Data/DelegateToPersonMapper.php b/tests/Compiler/Mapper/Object/Data/DelegateToPersonMapper.php new file mode 100644 index 0000000..3c0dbe8 --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/DelegateToPersonMapper.php @@ -0,0 +1,29 @@ + + */ +class DelegateToPersonMapper implements Mapper +{ + public function __construct(private readonly MapperProvider $provider) + { + } + + /** + * @param list $path + * @throws MappingFailedException + */ + public function map(mixed $data, array $path = []): PersonInput + { + return $this->provider->get(PersonInput::class)->map($data, $path); + } +} diff --git a/tests/Compiler/Mapper/Object/Data/DelegateToPerson__PersonInputMapper.php b/tests/Compiler/Mapper/Object/Data/DelegateToPerson__PersonInputMapper.php new file mode 100644 index 0000000..2f70322 --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/DelegateToPerson__PersonInputMapper.php @@ -0,0 +1,100 @@ + + */ +class DelegateToPerson__PersonInputMapper implements Mapper +{ + public function __construct(private readonly MapperProvider $provider) + { + } + + /** + * @param list $path + * @throws MappingFailedException + */ + public function map(mixed $data, array $path = []): PersonInput + { + if (!is_array($data)) { + throw MappingFailedException::incorrectType($data, $path, 'array'); + } + + if (!array_key_exists('id', $data)) { + throw MappingFailedException::missingKey($path, 'id'); + } + + if (!array_key_exists('name', $data)) { + throw MappingFailedException::missingKey($path, 'name'); + } + + $knownKeys = ['id' => true, 'name' => true, 'age' => true]; + $extraKeys = array_diff_key($data, $knownKeys); + + if (count($extraKeys) > 0) { + throw MappingFailedException::extraKeys($path, array_keys($extraKeys)); + } + + return new PersonInput( + $this->mapId($data['id'], [...$path, 'id']), + $this->mapName($data['name'], [...$path, 'name']), + array_key_exists('age', $data) ? $this->mapAge($data['age'], [...$path, 'age']) : Optional::none($path, 'age'), + ); + } + + /** + * @param list $path + * @throws MappingFailedException + */ + private function mapId(mixed $data, array $path = []): int + { + if (!is_int($data)) { + throw MappingFailedException::incorrectType($data, $path, 'int'); + } + + return $data; + } + + /** + * @param list $path + * @throws MappingFailedException + */ + private function mapName(mixed $data, array $path = []): string + { + if (!is_string($data)) { + throw MappingFailedException::incorrectType($data, $path, 'string'); + } + + return $data; + } + + /** + * @param list $path + * @return Optional + * @throws MappingFailedException + */ + private function mapAge(mixed $data, array $path = []): Optional + { + if (!is_int($data)) { + throw MappingFailedException::incorrectType($data, $path, 'int'); + } + + return Optional::of($data); + } +} diff --git a/tests/Compiler/Mapper/Object/Data/EnumCollectionInput.php b/tests/Compiler/Mapper/Object/Data/EnumCollectionInput.php new file mode 100644 index 0000000..64900e0 --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/EnumCollectionInput.php @@ -0,0 +1,23 @@ + $items + */ + public function __construct( + public readonly array $items, + public readonly int $size, + ) + { + } + +} diff --git a/tests/Compiler/Mapper/Object/Data/Movie__PersonInputMapper.php b/tests/Compiler/Mapper/Object/Data/Movie__PersonInputMapper.php new file mode 100644 index 0000000..29feb0f --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/Movie__PersonInputMapper.php @@ -0,0 +1,100 @@ + + */ +class Movie__PersonInputMapper implements Mapper +{ + public function __construct(private readonly MapperProvider $provider) + { + } + + /** + * @param list $path + * @throws MappingFailedException + */ + public function map(mixed $data, array $path = []): PersonInput + { + if (!is_array($data)) { + throw MappingFailedException::incorrectType($data, $path, 'array'); + } + + if (!array_key_exists('id', $data)) { + throw MappingFailedException::missingKey($path, 'id'); + } + + if (!array_key_exists('name', $data)) { + throw MappingFailedException::missingKey($path, 'name'); + } + + $knownKeys = ['id' => true, 'name' => true, 'age' => true]; + $extraKeys = array_diff_key($data, $knownKeys); + + if (count($extraKeys) > 0) { + throw MappingFailedException::extraKeys($path, array_keys($extraKeys)); + } + + return new PersonInput( + $this->mapId($data['id'], [...$path, 'id']), + $this->mapName($data['name'], [...$path, 'name']), + array_key_exists('age', $data) ? $this->mapAge($data['age'], [...$path, 'age']) : Optional::none($path, 'age'), + ); + } + + /** + * @param list $path + * @throws MappingFailedException + */ + private function mapId(mixed $data, array $path = []): int + { + if (!is_int($data)) { + throw MappingFailedException::incorrectType($data, $path, 'int'); + } + + return $data; + } + + /** + * @param list $path + * @throws MappingFailedException + */ + private function mapName(mixed $data, array $path = []): string + { + if (!is_string($data)) { + throw MappingFailedException::incorrectType($data, $path, 'string'); + } + + return $data; + } + + /** + * @param list $path + * @return Optional + * @throws MappingFailedException + */ + private function mapAge(mixed $data, array $path = []): Optional + { + if (!is_int($data)) { + throw MappingFailedException::incorrectType($data, $path, 'int'); + } + + return Optional::of($data); + } +} diff --git a/tests/Compiler/Mapper/Object/DelegateMapperCompilerTest.php b/tests/Compiler/Mapper/Object/DelegateMapperCompilerTest.php new file mode 100644 index 0000000..9c9cedd --- /dev/null +++ b/tests/Compiler/Mapper/Object/DelegateMapperCompilerTest.php @@ -0,0 +1,97 @@ +compileMapper('DelegateToPerson', new DelegateMapperCompiler(PersonInput::class), [ + PersonInput::class => $this->createPersonMapperCompiler(), + ]); + + $personInputArray = [ + 'id' => 7, + 'name' => 'Lana Wachowski', + ]; + + $personObject = new PersonInput( + id: 7, + name: 'Lana Wachowski', + age: Optional::none([], 'age'), + ); + + self::assertEquals($personObject, $delegateMapper->map($personInputArray)); + } + + public function testCompileWithInnerMapper(): void + { + $collectionMapperCompiler = new MapObject( + className: CollectionInput::class, + constructorArgsMapperCompilers: [ + 'items' => new MapList(new DelegateMapperCompiler('T')), + 'size' => new MapInt(), + ], + genericParameters: [ + new GenericTypeParameter('T'), + ], + ); + + $intCollectionDelegateMapper = $this->compileMapper( + name: 'DelegateToIntCollection', + mapperCompiler: new DelegateMapperCompiler(CollectionInput::class, [ + new MapInt(), + ]), + providedMapperCompilers: [ + CollectionInput::class => $collectionMapperCompiler, + ], + ); + + self::assertEquals( + new CollectionInput([1, 2, 3], 3), + $intCollectionDelegateMapper->map(['items' => [1, 2, 3], 'size' => 3]), + ); + + $enumCollectionDelegateMapper = $this->compileMapper( + name: 'DelegateToEnumCollection', + mapperCompiler: new DelegateMapperCompiler(CollectionInput::class, [ + new DelegateMapperCompiler(SuitEnum::class), + ]), + providedMapperCompilers: [ + CollectionInput::class => $collectionMapperCompiler, + SuitEnum::class => new MapEnum(SuitEnum::class, new MapString()), + ], + ); + + self::assertEquals( + new CollectionInput([SuitEnum::Diamonds], 3), + $enumCollectionDelegateMapper->map(['items' => [SuitEnum::Diamonds->value], 'size' => 3]), + ); + } + + private function createPersonMapperCompiler(): MapperCompiler + { + return new MapObject(PersonInput::class, [ + 'id' => new MapInt(), + 'name' => new MapString(), + 'age' => new MapOptional(new MapInt()), + ]); + } + +} diff --git a/tests/Compiler/Mapper/Object/MapObjectTest.php b/tests/Compiler/Mapper/Object/MapObjectTest.php index d029cad..779ed45 100644 --- a/tests/Compiler/Mapper/Object/MapObjectTest.php +++ b/tests/Compiler/Mapper/Object/MapObjectTest.php @@ -9,9 +9,11 @@ use ShipMonk\InputMapper\Compiler\Mapper\Scalar\MapInt; use ShipMonk\InputMapper\Compiler\Mapper\Scalar\MapString; use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\MapOptional; +use ShipMonk\InputMapper\Compiler\Type\GenericTypeParameter; use ShipMonk\InputMapper\Runtime\Exception\MappingFailedException; use ShipMonk\InputMapper\Runtime\Optional; use ShipMonkTests\InputMapper\Compiler\Mapper\MapperCompilerTestCase; +use ShipMonkTests\InputMapper\Compiler\Mapper\Object\Data\CollectionInput; use ShipMonkTests\InputMapper\Compiler\Mapper\Object\Data\MovieInput; use ShipMonkTests\InputMapper\Compiler\Mapper\Object\Data\PersonInput; @@ -20,8 +22,8 @@ class MapObjectTest extends MapperCompilerTestCase public function testCompile(): void { - $personInputMapper = $this->compileMapper('Person', $this->createPersonInputMapperCompiler()); - $movieInputMapper = $this->compileMapper('Movie', $this->createMovieInputMapperCompiler(), [PersonInput::class => $personInputMapper]); + $personInputMapperCompiler = $this->createPersonInputMapperCompiler(); + $movieInputMapper = $this->compileMapper('Movie', $this->createMovieInputMapperCompiler(), [PersonInput::class => $personInputMapperCompiler]); $movieInputObject = new MovieInput( id: 1, @@ -106,6 +108,38 @@ public function testCompileWithAllowExtraProperties(): void ); } + public function testCompileGeneric(): void + { + $collectionMapperCompiler = new MapObject( + className: CollectionInput::class, + constructorArgsMapperCompilers: [ + 'items' => new MapList(new DelegateMapperCompiler('T')), + 'size' => new MapInt(), + ], + genericParameters: [ + new GenericTypeParameter('T'), + ], + ); + + $intCollectionMapper = $this->compileMapper('Collection', $collectionMapperCompiler, [], [ + $this->compileMapper('CollectionInnerInt', new MapInt()), + ]); + + $stringCollectionMapper = $this->compileMapper('Collection', $collectionMapperCompiler, [], [ + $this->compileMapper('CollectionInnerString', new MapString()), + ]); + + self::assertEquals( + new CollectionInput([1, 2, 3], 3), + $intCollectionMapper->map(['items' => [1, 2, 3], 'size' => 3]), + ); + + self::assertEquals( + new CollectionInput(['a', 'b', 'c'], 3), + $stringCollectionMapper->map(['items' => ['a', 'b', 'c'], 'size' => 3]), + ); + } + private function createMovieInputMapperCompiler(): MapperCompiler { return new MapObject(MovieInput::class, [ diff --git a/tests/Compiler/MapperFactory/Data/CarFilterInput.php b/tests/Compiler/MapperFactory/Data/CarFilterInput.php new file mode 100644 index 0000000..faa2e97 --- /dev/null +++ b/tests/Compiler/MapperFactory/Data/CarFilterInput.php @@ -0,0 +1,19 @@ + $id + * @param EqualsFilterInput $color + */ + public function __construct( + public readonly InFilterInput $id, + public readonly EqualsFilterInput $color, + ) + { + } + +} diff --git a/tests/Compiler/MapperFactory/Data/EnumFilterInput.php b/tests/Compiler/MapperFactory/Data/EnumFilterInput.php new file mode 100644 index 0000000..00c7987 --- /dev/null +++ b/tests/Compiler/MapperFactory/Data/EnumFilterInput.php @@ -0,0 +1,24 @@ + $in + * @param T $color + */ + public function __construct( + public readonly array $in, + public readonly BackedEnum $color, + ) + { + } + +} diff --git a/tests/Compiler/MapperFactory/Data/EqualsFilterInput.php b/tests/Compiler/MapperFactory/Data/EqualsFilterInput.php new file mode 100644 index 0000000..d87f736 --- /dev/null +++ b/tests/Compiler/MapperFactory/Data/EqualsFilterInput.php @@ -0,0 +1,20 @@ + $in + */ + public function __construct( + public readonly mixed $in, + ) + { + } + +} diff --git a/tests/Compiler/MapperFactory/DefaultMapperCompilerFactoryTest.php b/tests/Compiler/MapperFactory/DefaultMapperCompilerFactoryTest.php index 38d48b1..90841a1 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\Type\GenericTypeParameter; use ShipMonk\InputMapper\Compiler\Validator\Array\AssertListLength; use ShipMonk\InputMapper\Compiler\Validator\Int\AssertIntRange; use ShipMonk\InputMapper\Compiler\Validator\Int\AssertNegativeInt; @@ -40,9 +41,13 @@ use ShipMonk\InputMapper\Compiler\Validator\String\AssertStringLength; use ShipMonk\InputMapper\Compiler\Validator\String\AssertUrl; use ShipMonkTests\InputMapper\Compiler\MapperFactory\Data\BrandInput; +use ShipMonkTests\InputMapper\Compiler\MapperFactory\Data\CarFilterInput; use ShipMonkTests\InputMapper\Compiler\MapperFactory\Data\CarInput; use ShipMonkTests\InputMapper\Compiler\MapperFactory\Data\CarInputWithVarTags; use ShipMonkTests\InputMapper\Compiler\MapperFactory\Data\ColorEnum; +use ShipMonkTests\InputMapper\Compiler\MapperFactory\Data\EnumFilterInput; +use ShipMonkTests\InputMapper\Compiler\MapperFactory\Data\EqualsFilterInput; +use ShipMonkTests\InputMapper\Compiler\MapperFactory\Data\InFilterInput; use ShipMonkTests\InputMapper\Compiler\MapperFactory\Data\InputWithDate; use ShipMonkTests\InputMapper\Compiler\MapperFactory\Data\InputWithIncompatibleMapperCompiler; use ShipMonkTests\InputMapper\Compiler\MapperFactory\Data\InputWithoutConstructor; @@ -125,6 +130,22 @@ public static function provideCreateOkData(): iterable ), ]; + yield 'CarFilterInput' => [ + CarFilterInput::class, + [], + new MapObject( + className: CarFilterInput::class, + constructorArgsMapperCompilers: [ + 'id' => new DelegateMapperCompiler(InFilterInput::class, [ + new MapInt(), + ]), + 'color' => new DelegateMapperCompiler(EqualsFilterInput::class, [ + new DelegateMapperCompiler(ColorEnum::class), + ]), + ], + ), + ]; + yield 'ColorEnum' => [ ColorEnum::class, [], @@ -143,6 +164,20 @@ public static function provideCreateOkData(): iterable new MapDateTimeImmutable(), ]; + yield 'EqualsFilterInput' => [ + EqualsFilterInput::class, + [], + new MapObject( + className: EqualsFilterInput::class, + constructorArgsMapperCompilers: [ + 'equals' => new DelegateMapperCompiler('T'), + ], + genericParameters: [ + new GenericTypeParameter('T'), + ], + ), + ]; + yield 'InputWithDate' => [ InputWithDate::class, [], @@ -399,11 +434,6 @@ public static function provideCreateErrorData(): iterable [], ]; - yield 'List' => [ - 'List', - [], - ]; - yield 'callable(): void' => [ 'callable(): void', [], @@ -415,6 +445,12 @@ public static function provideCreateErrorData(): iterable [], 'Cannot create mapper for type int, because integer boundary foo is not supported', ]; + + yield 'EnumFilterInput' => [ + EnumFilterInput::class . '', + [], + 'Cannot create mapper for type ShipMonkTests\\InputMapper\\Compiler\\MapperFactory\\Data\\EnumFilterInput, because type int is not a subtype of BackedEnum', + ]; } public function testCreateWithCustomFactory(): void diff --git a/tests/Compiler/Type/GenericTypeParameterTest.php b/tests/Compiler/Type/GenericTypeParameterTest.php new file mode 100644 index 0000000..b457dda --- /dev/null +++ b/tests/Compiler/Type/GenericTypeParameterTest.php @@ -0,0 +1,86 @@ +toPhpDocLine()); + } + + /** + * @return iterable + */ + public static function provideToPhpDocLineData(): iterable + { + yield [ + new GenericTypeParameter('T'), + '@template T', + ]; + + yield [ + new GenericTypeParameter('T', variance: GenericTypeVariance::Covariant), + '@template-covariant T', + ]; + + yield [ + new GenericTypeParameter('T', variance: GenericTypeVariance::Contravariant), + '@template-contravariant T', + ]; + + yield [ + new GenericTypeParameter('T', bound: new IdentifierTypeNode('int')), + '@template T of int', + ]; + + yield [ + new GenericTypeParameter('T', default: new IdentifierTypeNode('int')), + '@template T = int', + ]; + + yield [ + new GenericTypeParameter('T', variance: GenericTypeVariance::Covariant, bound: new IdentifierTypeNode('int')), + '@template-covariant T of int', + ]; + + yield [ + new GenericTypeParameter('T', variance: GenericTypeVariance::Contravariant, default: new IdentifierTypeNode('int')), + '@template-contravariant T = int', + ]; + + yield [ + new GenericTypeParameter('T', variance: GenericTypeVariance::Covariant, bound: new IdentifierTypeNode('int'), default: new IdentifierTypeNode('int')), + '@template-covariant T of int = int', + ]; + + yield [ + new GenericTypeParameter('T', variance: GenericTypeVariance::Contravariant, bound: new IdentifierTypeNode('int'), default: new IdentifierTypeNode('int')), + '@template-contravariant T of int = int', + ]; + + yield [ + new GenericTypeParameter('T', variance: GenericTypeVariance::Invariant, bound: new IdentifierTypeNode('int'), default: new IdentifierTypeNode('int')), + '@template T of int = int', + ]; + + yield [ + new GenericTypeParameter('T', variance: GenericTypeVariance::Invariant, bound: new IdentifierTypeNode('int')), + '@template T of int', + ]; + + yield [ + new GenericTypeParameter('T', variance: GenericTypeVariance::Invariant, default: new IdentifierTypeNode('int')), + '@template T = int', + ]; + } + +} diff --git a/tests/Compiler/Type/PhpDocTypeUtilsTest.php b/tests/Compiler/Type/PhpDocTypeUtilsTest.php index 6958bb1..271fc23 100644 --- a/tests/Compiler/Type/PhpDocTypeUtilsTest.php +++ b/tests/Compiler/Type/PhpDocTypeUtilsTest.php @@ -28,6 +28,7 @@ use ReflectionClass; use ReflectionFunction; use ReflectionParameter; +use ShipMonk\InputMapper\Compiler\Type\GenericTypeParameter; use ShipMonk\InputMapper\Compiler\Type\PhpDocTypeUtils; use ShipMonkTests\InputMapper\InputMapperTestCase; use Traversable; @@ -140,80 +141,94 @@ public function testFromReflectionType(): void ); } + /** + * @param list $genericParameters + */ #[DataProvider('provideToNativeTypeData')] public function testToNativeType( TypeNode $type, + array $genericParameters, ComplexType|Identifier|Name $expectedNative, - bool $expectedIsPhpDocUseful + bool $expectedIsPhpDocUseful, ): void { - $nativeType = PhpDocTypeUtils::toNativeType($type, $phpDocUseful); + $nativeType = PhpDocTypeUtils::toNativeType($type, $genericParameters, $phpDocUseful); self::assertEquals($expectedNative, $nativeType); self::assertSame($expectedIsPhpDocUseful, $phpDocUseful); } /** - * @return iterable + * @return iterable, ComplexType|Identifier|Name, bool}> */ public static function provideToNativeTypeData(): iterable { yield 'int' => [ new IdentifierTypeNode('int'), + [], new Identifier('int'), false, ]; yield 'list' => [ new IdentifierTypeNode('list'), + [], new Identifier('array'), true, ]; yield 'positive-int' => [ new IdentifierTypeNode('positive-int'), + [], new Identifier('int'), true, ]; yield 'negative-int' => [ new IdentifierTypeNode('negative-int'), + [], new Identifier('int'), true, ]; yield 'non-positive-int' => [ new IdentifierTypeNode('non-positive-int'), + [], new Identifier('int'), true, ]; yield 'non-negative-int' => [ new IdentifierTypeNode('non-negative-int'), + [], new Identifier('int'), true, ]; yield 'DateTimeImmutable' => [ new IdentifierTypeNode(DateTimeImmutable::class), + [], new Name(DateTimeImmutable::class), false, ]; yield '?int' => [ new NullableTypeNode(new IdentifierTypeNode('int')), + [], new NullableType(new Identifier('int')), false, ]; yield '?(int|float)' => [ new NullableTypeNode(new UnionTypeNode([new IdentifierTypeNode('int'), new IdentifierTypeNode('float')])), + [], new UnionType([new Identifier('int'), new Identifier('float'), new Identifier('null')]), false, ]; yield 'int|float' => [ new UnionTypeNode([new IdentifierTypeNode('int'), new IdentifierTypeNode('float')]), + [], new UnionType([new Identifier('int'), new Identifier('float')]), false, ]; @@ -224,12 +239,14 @@ public static function provideToNativeTypeData(): iterable new IdentifierTypeNode('float'), new IdentifierTypeNode('null'), ]), + [], new UnionType([new Identifier('int'), new Identifier('float'), new Identifier('null')]), false, ]; yield 'int|list' => [ new UnionTypeNode([new IdentifierTypeNode('int'), new IdentifierTypeNode('list')]), + [], new UnionType([new Identifier('int'), new Identifier('array')]), true, ]; @@ -239,18 +256,21 @@ public static function provideToNativeTypeData(): iterable new ArrayShapeItemNode(keyName: null, optional: false, valueType: new IdentifierTypeNode('int')), new ArrayShapeItemNode(keyName: null, optional: false, valueType: new IdentifierTypeNode('string')), ]), + [], new Identifier('array'), true, ]; yield 'int[]' => [ new ArrayTypeNode(new IdentifierTypeNode('int')), + [], new Identifier('array'), true, ]; yield 'array' => [ new GenericTypeNode(new IdentifierTypeNode('array'), [new IdentifierTypeNode('int')]), + [], new Identifier('array'), true, ]; @@ -260,12 +280,14 @@ public static function provideToNativeTypeData(): iterable new IdentifierTypeNode('int'), new IdentifierTypeNode('string'), ]), + [], new Identifier('array'), true, ]; yield 'iterable' => [ new GenericTypeNode(new IdentifierTypeNode('iterable'), [new IdentifierTypeNode('int')]), + [], new Identifier('iterable'), true, ]; @@ -275,9 +297,31 @@ public static function provideToNativeTypeData(): iterable new IdentifierTypeNode('int'), new IdentifierTypeNode('string'), ]), + [], + new Identifier('iterable'), + true, + ]; + + yield 'T' => [ + new IdentifierTypeNode('T'), + [new GenericTypeParameter('T')], + new Identifier('mixed'), + true, + ]; + + yield 'T of iterable' => [ + new IdentifierTypeNode('T'), + [new GenericTypeParameter('T', bound: new IdentifierTypeNode('iterable'))], new Identifier('iterable'), true, ]; + + yield 'list' => [ + new GenericTypeNode(new IdentifierTypeNode('list'), [new IdentifierTypeNode('T')]), + [new GenericTypeParameter('T')], + new Identifier('array'), + true, + ]; } #[DataProvider('provideIsNullableData')] @@ -414,32 +458,70 @@ public static function provideMakeNullableData(): iterable ]; } - public function testResolve(): void + /** + * @param ReflectionClass $context + * @param list $genericParameterNames + */ + #[DataProvider('provideResolveData')] + public function testResolve( + TypeNode $type, + ReflectionClass $context, + array $genericParameterNames, + string $expectedResolvedType, + ): void { - $context = new ReflectionClass($this); - $identifier = new IdentifierTypeNode('TestCase'); - $typeA = $identifier; - - PhpDocTypeUtils::resolve($typeA, $context); - self::assertSame(TestCase::class, $identifier->name); - - $identifier = new IdentifierTypeNode('TestCase'); - $typeB = new UnionTypeNode([ - $identifier, - new IdentifierTypeNode('string'), - ]); - - PhpDocTypeUtils::resolve($typeB, $context); - self::assertSame(TestCase::class, $identifier->name); - - $identifier = new IdentifierTypeNode('self'); - $typeC = new UnionTypeNode([ - $identifier, - new IdentifierTypeNode('string'), - ]); - - PhpDocTypeUtils::resolve($typeC, $context); - self::assertSame(self::class, $identifier->name); + PhpDocTypeUtils::resolve($type, $context, $genericParameterNames); + self::assertSame($expectedResolvedType, (string) $type); + } + + /** + * @return iterable, list, string}> + */ + public static function provideResolveData(): iterable + { + yield 'TestCase' => [ + new IdentifierTypeNode('TestCase'), + new ReflectionClass(self::class), + [], + TestCase::class, + ]; + + yield 'TestCase|string' => [ + new UnionTypeNode([ + new IdentifierTypeNode('TestCase'), + new IdentifierTypeNode('string'), + ]), + new ReflectionClass(self::class), + [], + '(PHPUnit\\Framework\\TestCase | string)', + ]; + + yield 'self|string' => [ + new UnionTypeNode([ + new IdentifierTypeNode('self'), + new IdentifierTypeNode('string'), + ]), + new ReflectionClass(self::class), + [], + '(ShipMonkTests\\InputMapper\\Compiler\\Type\\PhpDocTypeUtilsTest | string)', + ]; + + yield 'T' => [ + new IdentifierTypeNode('T'), + new ReflectionClass(self::class), + ['T'], + 'T', + ]; + + yield 'T|string' => [ + new UnionTypeNode([ + new IdentifierTypeNode('T'), + new IdentifierTypeNode('string'), + ]), + new ReflectionClass(self::class), + ['T'], + '(T | string)', + ]; } /** @@ -1253,6 +1335,46 @@ private static function provideIsSubTypeOfDataInner(): iterable ], ]; + yield 'BackedEnum' => [ + 'true' => [ + 'BackedEnum', + 'BackedEnum', + ], + + 'false' => [ + 'int', + 'UnitEnum', + 'ShipMonk\InputMapper\Compiler\Type\GenericTypeVariance', + ], + ]; + + yield 'BackedEnum' => [ + 'true' => [ + 'BackedEnum', + ], + + 'false' => [ + 'int', + 'UnitEnum', + 'BackedEnum', + 'BackedEnum', + ], + ]; + + yield 'BackedEnum' => [ + 'true' => [ + 'BackedEnum', + 'BackedEnum', + 'BackedEnum', + 'BackedEnum', + ], + + 'false' => [ + 'int', + 'UnitEnum', + ], + ]; + yield 'Countable & Traversable' => [ 'true' => [ 'Countable & Traversable', @@ -1362,6 +1484,31 @@ private static function provideIsSubTypeOfDataInner(): iterable 'stdClass', ], ]; + + yield 'T' => [ + 'true' => [ + 'T', + ], + + 'false' => [ + 'X', + 'int', + 'stdClass', + ], + ]; + + yield 'UnitEnum' => [ + 'true' => [ + 'UnitEnum', + 'BackedEnum', + 'BackedEnum', + ], + + 'false' => [ + 'int', + 'stdClass', + ], + ]; } #[DataProvider('provideInferGenericParameterData')] diff --git a/tests/Runtime/CallbackMapperTest.php b/tests/Runtime/CallbackMapperTest.php new file mode 100644 index 0000000..3d5fab2 --- /dev/null +++ b/tests/Runtime/CallbackMapperTest.php @@ -0,0 +1,33 @@ + (int) $data); + self::assertSame(123, $mapper->map('123')); + } + + public function testMapThrowsException(): void + { + $mapper = new CallbackMapper(static function (mixed $data, array $path): never { + // @phpstan-ignore-next-line intentionally throwing checked exception + throw MappingFailedException::incorrectValue('123', [], 'int'); + }); + + self::assertException( + MappingFailedException::class, + 'Failed to map data at path /: Expected int, got "123"', + static fn () => $mapper->map('123'), + ); + } + +} diff --git a/tests/Runtime/MapperProviderTest.php b/tests/Runtime/MapperProviderTest.php index ba1c9bd..ab009f9 100644 --- a/tests/Runtime/MapperProviderTest.php +++ b/tests/Runtime/MapperProviderTest.php @@ -34,7 +34,7 @@ public function testGetCustomMapperForEmptyInput(): void $mapperProvider = $this->createMapperProvider(); $mapperProvider->registerFactory( EmptyInput::class, - static function (string $inputClassName, MapperProvider $provider) use ($myCustomMapper, $mapperProvider): DummyMapper { + static function (string $inputClassName, array $innerMappers, MapperProvider $provider) use ($myCustomMapper, $mapperProvider): DummyMapper { self::assertSame(EmptyInput::class, $inputClassName); self::assertSame($mapperProvider, $provider); return $myCustomMapper; @@ -52,7 +52,7 @@ public function testGetCustomMapperForInterfaceImplementationInput(): void $mapperProvider = $this->createMapperProvider(); $mapperProvider->registerFactory( InputInterface::class, - static function (string $inputClassName, MapperProvider $provider) use ($myCustomMapper, $mapperProvider): DummyMapper { + static function (string $inputClassName, array $innerMappers, MapperProvider $provider) use ($myCustomMapper, $mapperProvider): DummyMapper { self::assertSame(InterfaceImplementationInput::class, $inputClassName); self::assertSame($mapperProvider, $provider); return $myCustomMapper;