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..bbad799 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,71 @@ 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 + { + return Arrays::map($this->innerMapperCompilers, function (MapperCompiler $innerMapperCompiler, int $key) use ($builder): Expr { + return $this->compileInnerMapper($innerMapperCompiler, $key, $builder); + }); + } + + 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) { + $mapper = $builder->methodCall($provider, 'get', [$classNameExpr]); + + } else { + $innerMappersVarName = $builder->uniqVariableName('innerMappers'); + $statements[] = $builder->assign($builder->var($innerMappersVarName), $builder->val($innerMappers)); + $mapper = $builder->methodCall($provider, 'getGeneric', [$classNameExpr, $builder->var($innerMappersVarName)]); + } + + 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 7bb50dd..4eb0405 100644 --- a/src/Compiler/MapperFactory/DefaultMapperCompilerFactory.php +++ b/src/Compiler/MapperFactory/DefaultMapperCompilerFactory.php @@ -6,11 +6,13 @@ use DateTimeImmutable; use DateTimeInterface; use LogicException; +use Nette\Utils\Arrays; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\VarTagValueNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; @@ -48,6 +50,8 @@ use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\MapNullable; use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\MapOptional; use ShipMonk\InputMapper\Compiler\Mapper\Wrapper\ValidatedMapperCompiler; +use ShipMonk\InputMapper\Compiler\Type\GenericTypeParameter; +use ShipMonk\InputMapper\Compiler\Type\GenericTypeVariance; use ShipMonk\InputMapper\Compiler\Type\PhpDocTypeUtils; use ShipMonk\InputMapper\Compiler\Validator\Array\AssertListLength; use ShipMonk\InputMapper\Compiler\Validator\Int\AssertIntRange; @@ -57,12 +61,17 @@ 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 array_keys; +use function array_values; 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 str_ends_with; use function strcasecmp; use function strtolower; use function substr; @@ -71,6 +80,7 @@ class DefaultMapperCompilerFactory implements MapperCompilerFactory { final public const DELEGATE_OBJECT_MAPPING = 'delegateObjectMapping'; + final public const GENERIC_PARAMETERS = 'genericParameters'; /** * @param array): MapperCompiler> $mapperCompilerFactories @@ -102,7 +112,7 @@ 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 (!class_exists($type->name) && !interface_exists($type->name) && !enum_exists($type->name) && !isset($options[self::GENERIC_PARAMETERS][$type->name])) { throw CannotCreateMapperCompilerException::fromType($type, 'there is no class, interface or enum with this name'); } @@ -164,7 +174,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,6 +228,27 @@ protected function createInner(TypeNode $type, array $options): MapperCompiler return $this->create($type, $options); } + /** + * @param array $options + */ + protected function createFromGenericType(GenericTypeNode $type, array $options): MapperCompiler + { + $genericParameters = $this->getGenericParameters($type->type->name); + + return new DelegateMapperCompiler( + $type->type->name, + Arrays::map(array_values($type->genericTypes), function (TypeNode $genericType, int $index) use ($type, $options, $genericParameters): MapperCompiler { + $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}"); + } + + return $this->createInner($genericType, $options); + }), + ); + } + /** * @param class-string $inputClassName * @param array $options @@ -257,8 +288,12 @@ protected function createObjectMappingByConstructorInvocation( throw CannotCreateMapperCompilerException::fromType(new IdentifierTypeNode($inputClassName), 'class has a non-public constructor'); } + $genericParameters = $this->getGenericParameters($inputClassName); + $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 +302,54 @@ 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 class-string $inputClassName + * @return list + */ + protected function getGenericParameters(string $inputClassName): array + { + $classReflection = new ReflectionClass($inputClassName); + $classPhpDoc = $classReflection->getDocComment(); + + if ($classPhpDoc === false) { + return []; + } + + $genericParameters = []; + + foreach ($this->parsePhpDoc($classPhpDoc)->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) { + PhpDocTypeUtils::resolve($genericParameter->bound, $classReflection, array_keys($genericParameters)); + PhpDocTypeUtils::resolve($genericParameter->default, $classReflection, array_keys($genericParameters)); + } + + return array_values($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 +366,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 +388,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..b85943b 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 */ @@ -420,8 +432,19 @@ 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) { + $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, @@ -439,16 +462,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 +515,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 +556,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 +592,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 +617,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/PhpDocTypeUtils.php b/src/Compiler/Type/PhpDocTypeUtils.php index f7223b9..cd36320 100644 --- a/src/Compiler/Type/PhpDocTypeUtils.php +++ b/src/Compiler/Type/PhpDocTypeUtils.php @@ -402,7 +402,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, }; diff --git a/src/Runtime/MapperProvider.php b/src/Runtime/MapperProvider.php index 4662072..8e9c46c 100644 --- a/src/Runtime/MapperProvider.php +++ b/src/Runtime/MapperProvider.php @@ -9,6 +9,7 @@ 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; @@ -16,6 +17,7 @@ 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; @@ -32,12 +34,12 @@ class MapperProvider { /** - * @var array> + * @var array> */ private array $mappers = []; /** - * @var array> + * @var array, callable(never, self): Mapper> */ private array $mapperFactories = []; @@ -52,19 +54,41 @@ public function __construct( /** * @template T of object * @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[$key] ??= $this->create($inputClassName, $innerMappers); + return $mapper; + } + + /** + * @template T of object + * @param class-string $inputClassName + * @param list $innerMappers + * @return Mapper + */ + public function getGeneric(string $inputClassName, array $innerMappers): Mapper + { + $key = $inputClassName . '+' . 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 callable(class-string, list, self): Mapper $mapperFactory */ public function registerFactory(string $inputClassName, callable $mapperFactory): void { @@ -78,9 +102,10 @@ public function registerFactory(string $inputClassName, callable $mapperFactory) /** * @template T of object * @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)]; @@ -88,7 +113,7 @@ private function create(string $inputClassName): Mapper if (isset($this->mapperFactories[$classLikeName])) { /** @var callable(class-string, self): Mapper $factory */ $factory = $this->mapperFactories[$classLikeName]; - return $factory($inputClassName, $this); + return $factory($inputClassName, $innerMappers, $this); } } @@ -98,7 +123,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..3fc7852 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,28 @@ 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) use ($name, $providedMapperCompilers): Mapper { + return $this->compileMapper($name . '__' . $this->toShortClassName($inputClassName), $providedMapperCompilers[$inputClassName]); + }, + ); + + $mapperProvider->expects(self::any())->method('getGeneric')->willReturnCallback( + function (string $inputClassName, array $innerMappers) use ($name, $providedMapperCompilers): Mapper { + 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..4ec63af --- /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->getGeneric(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..bacaf3a --- /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->getGeneric(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..08aef2f --- /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..247e1dc 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,14 +41,19 @@ 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; use ShipMonkTests\InputMapper\Compiler\MapperFactory\Data\InputWithPrivateConstructor; use ShipMonkTests\InputMapper\InputMapperTestCase; +use function sprintf; class DefaultMapperCompilerFactoryTest extends InputMapperTestCase { @@ -125,6 +131,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 +165,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 +435,6 @@ public static function provideCreateErrorData(): iterable [], ]; - yield 'List' => [ - 'List', - [], - ]; - yield 'callable(): void' => [ 'callable(): void', [], @@ -415,6 +446,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/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;