Skip to content

Commit ea63758

Browse files
committed
add support for mapping to user defined generic input classes
1 parent c7f289f commit ea63758

35 files changed

+1557
-155
lines changed

phpstan.neon.dist

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ parameters:
3434
forbidCheckedExceptionInCallable:
3535
immediatelyCalledCallables:
3636
'ShipMonkTests\InputMapper\InputMapperTestCase::assertException': 2
37+
allowedCheckedExceptionCallables:
38+
'ShipMonk\InputMapper\Runtime\CallbackMapper::__construct': 0
3739

3840
ignoreErrors:
3941
-
@@ -53,3 +55,7 @@ parameters:
5355
message: "#^Method ShipMonkTests\\\\InputMapper\\\\Compiler\\\\Validator\\\\Array\\\\Data\\\\ListItemValidatorWithMultipleValidatorsMapper\\:\\:map\\(\\) should return list\\<int\\<1, max\\>\\> but returns list\\<int\\>\\.$#"
5456
count: 1
5557
path: tests/Compiler/Validator/Array/Data/ListItemValidatorWithMultipleValidatorsMapper.php
58+
-
59+
message: "#^Throwing checked exception ShipMonk\\\\InputMapper\\\\Runtime\\\\Exception\\\\MappingFailedException in first-class-callable!$#"
60+
count: 1
61+
path: tests/Compiler/Mapper/Object/Data/DelegateToIntCollectionMapper.php
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ShipMonk\InputMapper\Compiler\Mapper;
4+
5+
use ShipMonk\InputMapper\Compiler\Type\GenericTypeParameter;
6+
7+
interface GenericMapperCompiler extends MapperCompiler
8+
{
9+
10+
/**
11+
* @return list<GenericTypeParameter>
12+
*/
13+
public function getGenericParameters(): array;
14+
15+
}

src/Compiler/Mapper/Object/DelegateMapperCompiler.php

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,40 @@
22

33
namespace ShipMonk\InputMapper\Compiler\Mapper\Object;
44

5+
use Nette\Utils\Arrays;
56
use PhpParser\Node\Expr;
7+
use PhpParser\Node\Expr\MethodCall;
8+
use PhpParser\Node\VariadicPlaceholder;
9+
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
610
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
711
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
812
use ShipMonk\InputMapper\Compiler\CompiledExpr;
913
use ShipMonk\InputMapper\Compiler\Mapper\MapperCompiler;
1014
use ShipMonk\InputMapper\Compiler\Php\PhpCodeBuilder;
15+
use ShipMonk\InputMapper\Runtime\CallbackMapper;
16+
use function count;
1117

1218
class DelegateMapperCompiler implements MapperCompiler
1319
{
1420

1521
/**
16-
* @param class-string $className
22+
* @param list<MapperCompiler> $innerMapperCompilers
1723
*/
1824
public function __construct(
1925
public readonly string $className,
26+
public readonly array $innerMapperCompilers = [],
2027
)
2128
{
2229
}
2330

2431
public function compile(Expr $value, Expr $path, PhpCodeBuilder $builder): CompiledExpr
2532
{
26-
$shortName = $builder->importClass($this->className);
27-
$provider = $builder->propertyFetch($builder->var('this'), 'provider');
28-
$mapper = $builder->methodCall($provider, 'get', [$builder->classConstFetch($shortName, 'class')]);
33+
$compilerMapper = $this->compileMapperExpr($builder);
34+
$mapper = $compilerMapper->expr;
35+
$statements = $compilerMapper->statements;
2936
$mapped = $builder->methodCall($mapper, 'map', [$value, $path]);
3037

31-
return new CompiledExpr($mapped);
38+
return new CompiledExpr($mapped, $statements);
3239
}
3340

3441
public function getInputType(): TypeNode
@@ -38,7 +45,76 @@ public function getInputType(): TypeNode
3845

3946
public function getOutputType(): TypeNode
4047
{
41-
return new IdentifierTypeNode($this->className);
48+
$outputType = new IdentifierTypeNode($this->className);
49+
50+
if (count($this->innerMapperCompilers) === 0) {
51+
return $outputType;
52+
}
53+
54+
return new GenericTypeNode($outputType, Arrays::map(
55+
$this->innerMapperCompilers,
56+
static function (MapperCompiler $innerMapperCompiler): TypeNode {
57+
return $innerMapperCompiler->getOutputType();
58+
},
59+
));
60+
}
61+
62+
/**
63+
* @return list<Expr>
64+
*/
65+
private function compileInnerMappers(PhpCodeBuilder $builder): array
66+
{
67+
$innerMappers = [];
68+
69+
foreach ($this->innerMapperCompilers as $key => $innerMapperCompiler) {
70+
$innerMappers[] = $this->compileInnerMapper($innerMapperCompiler, $key, $builder);
71+
}
72+
73+
return $innerMappers;
74+
}
75+
76+
private function compileInnerMapper(MapperCompiler $innerMapperCompiler, int $key, PhpCodeBuilder $builder): Expr
77+
{
78+
if ($innerMapperCompiler instanceof self && count($innerMapperCompiler->innerMapperCompilers) === 0) {
79+
$provider = $builder->propertyFetch($builder->var('this'), 'provider');
80+
$innerClassExpr = $builder->classConstFetch($builder->importClass($innerMapperCompiler->className), 'class');
81+
return $builder->methodCall($provider, 'get', [$innerClassExpr]);
82+
}
83+
84+
$innerMapperMethodName = $builder->uniqMethodName("mapInner{$key}");
85+
$innerMapperMethod = $builder->mapperMethod($innerMapperMethodName, $innerMapperCompiler)->makePrivate()->getNode();
86+
$builder->addMethod($innerMapperMethod);
87+
88+
$innerMapperMethodCallback = new MethodCall($builder->var('this'), $innerMapperMethodName, [new VariadicPlaceholder()]);
89+
return $builder->new($builder->importClass(CallbackMapper::class), [$innerMapperMethodCallback]);
90+
}
91+
92+
private function compileMapperExpr(PhpCodeBuilder $builder): CompiledExpr
93+
{
94+
foreach ($builder->getGenericParameters() as $offset => $genericParameter) {
95+
if ($this->className === $genericParameter->name) {
96+
$innerMappers = $builder->propertyFetch($builder->var('this'), 'innerMappers');
97+
$innerMapper = $builder->arrayDimFetch($innerMappers, $builder->val($offset));
98+
return new CompiledExpr($innerMapper);
99+
}
100+
}
101+
102+
$statements = [];
103+
$classNameExpr = $builder->classConstFetch($builder->importClass($this->className), 'class');
104+
$provider = $builder->propertyFetch($builder->var('this'), 'provider');
105+
$innerMappers = $this->compileInnerMappers($builder);
106+
107+
if (count($innerMappers) > 0) {
108+
$innerMappersVarName = $builder->uniqVariableName('innerMappers');
109+
$statements[] = $builder->assign($builder->var($innerMappersVarName), $builder->val($innerMappers));
110+
$getArguments = [$classNameExpr, $builder->var($innerMappersVarName)];
111+
112+
} else {
113+
$getArguments = [$classNameExpr];
114+
}
115+
116+
$mapper = $builder->methodCall($provider, 'get', $getArguments);
117+
return new CompiledExpr($mapper, $statements);
42118
}
43119

44120
}

src/Compiler/Mapper/Object/MapObject.php

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,18 @@
33
namespace ShipMonk\InputMapper\Compiler\Mapper\Object;
44

55
use Attribute;
6+
use Nette\Utils\Arrays;
67
use PhpParser\Node\Expr;
78
use PhpParser\Node\Stmt;
9+
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
810
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
911
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
1012
use ShipMonk\InputMapper\Compiler\CompiledExpr;
13+
use ShipMonk\InputMapper\Compiler\Mapper\GenericMapperCompiler;
1114
use ShipMonk\InputMapper\Compiler\Mapper\MapperCompiler;
1215
use ShipMonk\InputMapper\Compiler\Mapper\UndefinedAwareMapperCompiler;
1316
use ShipMonk\InputMapper\Compiler\Php\PhpCodeBuilder;
17+
use ShipMonk\InputMapper\Compiler\Type\GenericTypeParameter;
1418
use ShipMonk\InputMapper\Runtime\Exception\MappingFailedException;
1519
use function array_fill_keys;
1620
use function array_keys;
@@ -22,17 +26,19 @@
2226
* @template T of object
2327
*/
2428
#[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)]
25-
class MapObject implements MapperCompiler
29+
class MapObject implements GenericMapperCompiler
2630
{
2731

2832
/**
2933
* @param class-string<T> $className
3034
* @param array<string, MapperCompiler> $constructorArgsMapperCompilers
35+
* @param list<GenericTypeParameter> $genericParameters
3136
*/
3237
public function __construct(
3338
public readonly string $className,
3439
public readonly array $constructorArgsMapperCompilers,
3540
public readonly bool $allowExtraKeys = false,
41+
public readonly array $genericParameters = [],
3642
)
3743
{
3844
}
@@ -114,7 +120,26 @@ public function getInputType(): TypeNode
114120

115121
public function getOutputType(): TypeNode
116122
{
117-
return new IdentifierTypeNode($this->className);
123+
$outputType = new IdentifierTypeNode($this->className);
124+
125+
if (count($this->genericParameters) === 0) {
126+
return $outputType;
127+
}
128+
129+
return new GenericTypeNode(
130+
$outputType,
131+
Arrays::map($this->genericParameters, static function (GenericTypeParameter $parameter): TypeNode {
132+
return new IdentifierTypeNode($parameter->name);
133+
}),
134+
);
135+
}
136+
137+
/**
138+
* @return list<GenericTypeParameter>
139+
*/
140+
public function getGenericParameters(): array
141+
{
142+
return $this->genericParameters;
118143
}
119144

120145
/**

src/Compiler/MapperFactory/DefaultMapperCompilerFactory.php

Lines changed: 63 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -57,12 +57,14 @@
5757
use ShipMonk\InputMapper\Compiler\Validator\Int\AssertPositiveInt;
5858
use ShipMonk\InputMapper\Compiler\Validator\ValidatorCompiler;
5959
use ShipMonk\InputMapper\Runtime\Optional;
60+
use function array_column;
61+
use function array_fill_keys;
6062
use function class_exists;
6163
use function class_implements;
6264
use function class_parents;
6365
use function count;
64-
use function enum_exists;
6566
use function interface_exists;
67+
use function is_array;
6668
use function strcasecmp;
6769
use function strtolower;
6870
use function substr;
@@ -71,6 +73,7 @@ class DefaultMapperCompilerFactory implements MapperCompilerFactory
7173
{
7274

7375
final public const DELEGATE_OBJECT_MAPPING = 'delegateObjectMapping';
76+
final public const GENERIC_PARAMETERS = 'genericParameters';
7477

7578
/**
7679
* @param array<class-string, callable(class-string, array<string, mixed>): MapperCompiler> $mapperCompilerFactories
@@ -102,13 +105,21 @@ public function create(TypeNode $type, array $options = []): MapperCompiler
102105
{
103106
if ($type instanceof IdentifierTypeNode) {
104107
if (!PhpDocTypeUtils::isKeyword($type)) {
105-
if (!class_exists($type->name) && !interface_exists($type->name) && !enum_exists($type->name)) {
108+
if (isset($options[self::DELEGATE_OBJECT_MAPPING]) && $options[self::DELEGATE_OBJECT_MAPPING] === true) {
109+
if (!class_exists($type->name) && !interface_exists($type->name)) {
110+
if (!isset($options[self::GENERIC_PARAMETERS]) || !is_array($options[self::GENERIC_PARAMETERS]) || !isset($options[self::GENERIC_PARAMETERS][$type->name])) {
111+
throw CannotCreateMapperCompilerException::fromType($type, 'there is no class, interface or enum with this name');
112+
}
113+
}
114+
115+
return new DelegateMapperCompiler($type->name);
116+
}
117+
118+
if (!class_exists($type->name) && !interface_exists($type->name)) {
106119
throw CannotCreateMapperCompilerException::fromType($type, 'there is no class, interface or enum with this name');
107120
}
108121

109-
return isset($options[self::DELEGATE_OBJECT_MAPPING]) && $options[self::DELEGATE_OBJECT_MAPPING] === true
110-
? new DelegateMapperCompiler($type->name)
111-
: $this->createObjectMapperCompiler($type->name, $options);
122+
return $this->createObjectMapperCompiler($type->name, $options);
112123
}
113124

114125
return match (strtolower($type->name)) {
@@ -164,7 +175,7 @@ public function create(TypeNode $type, array $options = []): MapperCompiler
164175
1 => new MapOptional($this->createInner($type->genericTypes[0], $options)),
165176
default => throw CannotCreateMapperCompilerException::fromType($type),
166177
},
167-
default => throw CannotCreateMapperCompilerException::fromType($type),
178+
default => $this->createFromGenericType($type, $options),
168179
},
169180
};
170181
}
@@ -218,13 +229,45 @@ protected function createInner(TypeNode $type, array $options): MapperCompiler
218229
return $this->create($type, $options);
219230
}
220231

232+
/**
233+
* @param array<string, mixed> $options
234+
*/
235+
protected function createFromGenericType(GenericTypeNode $type, array $options): MapperCompiler
236+
{
237+
if (!class_exists($type->type->name) && !interface_exists($type->type->name)) {
238+
throw CannotCreateMapperCompilerException::fromType($type, 'there is no class or interface with this name');
239+
}
240+
241+
$genericParameters = PhpDocTypeUtils::getGenericTypeDefinition($type->type)->parameters;
242+
$innerMapperCompilers = [];
243+
244+
foreach ($type->genericTypes as $index => $genericType) {
245+
$genericParameter = $genericParameters[$index] ?? throw CannotCreateMapperCompilerException::fromType($type, "generic parameter at index {$index} does not exist");
246+
247+
if ($genericParameter->bound !== null && !PhpDocTypeUtils::isSubTypeOf($genericType, $genericParameter->bound)) {
248+
throw CannotCreateMapperCompilerException::fromType($type, "type {$genericType} is not a subtype of {$genericParameter->bound}");
249+
}
250+
251+
$innerMapperCompilers[] = $this->createInner($genericType, $options);
252+
}
253+
254+
return new DelegateMapperCompiler($type->type->name, $innerMapperCompilers);
255+
}
256+
221257
/**
222258
* @param class-string $inputClassName
223259
* @param array<string, mixed> $options
224260
*/
225261
protected function createObjectMapperCompiler(string $inputClassName, array $options): MapperCompiler
226262
{
227-
$classLikeNames = [$inputClassName => true, ...class_parents($inputClassName), ...class_implements($inputClassName)];
263+
$classParents = class_parents($inputClassName);
264+
$classImplements = class_implements($inputClassName);
265+
266+
if ($classParents === false || $classImplements === false) {
267+
throw new LogicException("Unable to get class parents or implements for '$inputClassName'.");
268+
}
269+
270+
$classLikeNames = [$inputClassName => true, ...$classParents, ...$classImplements];
228271

229272
foreach ($classLikeNames as $classLikeName => $_) {
230273
if (isset($this->mapperCompilerFactories[$classLikeName])) {
@@ -245,20 +288,24 @@ protected function createObjectMappingByConstructorInvocation(
245288
array $options,
246289
): MapperCompiler
247290
{
291+
$inputType = new IdentifierTypeNode($inputClassName);
248292
$classReflection = new ReflectionClass($inputClassName);
249-
250293
$constructor = $classReflection->getConstructor();
251294

252295
if ($constructor === null) {
253-
throw CannotCreateMapperCompilerException::fromType(new IdentifierTypeNode($inputClassName), 'class has no constructor');
296+
throw CannotCreateMapperCompilerException::fromType($inputType, 'class has no constructor');
254297
}
255298

256299
if (!$constructor->isPublic()) {
257-
throw CannotCreateMapperCompilerException::fromType(new IdentifierTypeNode($inputClassName), 'class has a non-public constructor');
300+
throw CannotCreateMapperCompilerException::fromType($inputType, 'class has a non-public constructor');
258301
}
259302

303+
$genericParameters = PhpDocTypeUtils::getGenericTypeDefinition($inputType)->parameters;
304+
$genericParameterNames = array_column($genericParameters, 'name');
305+
$options[self::GENERIC_PARAMETERS] = array_fill_keys($genericParameterNames, true);
306+
260307
$constructorParameterMapperCompilers = [];
261-
$constructorParameterTypes = $this->getConstructorParameterTypes($constructor);
308+
$constructorParameterTypes = $this->getConstructorParameterTypes($constructor, $genericParameterNames);
262309

263310
foreach ($constructor->getParameters() as $parameter) {
264311
$name = $parameter->getName();
@@ -267,13 +314,14 @@ protected function createObjectMappingByConstructorInvocation(
267314
}
268315

269316
$allowExtraKeys = count($classReflection->getAttributes(AllowExtraKeys::class)) > 0;
270-
return new MapObject($classReflection->getName(), $constructorParameterMapperCompilers, $allowExtraKeys);
317+
return new MapObject($classReflection->getName(), $constructorParameterMapperCompilers, $allowExtraKeys, $genericParameters);
271318
}
272319

273320
/**
321+
* @param list<string> $genericParameterNames
274322
* @return array<string, TypeNode>
275323
*/
276-
protected function getConstructorParameterTypes(ReflectionMethod $constructor): array
324+
protected function getConstructorParameterTypes(ReflectionMethod $constructor, array $genericParameterNames): array
277325
{
278326
$class = $constructor->getDeclaringClass();
279327
$parameterTypes = [];
@@ -290,7 +338,7 @@ protected function getConstructorParameterTypes(ReflectionMethod $constructor):
290338
if ($constructorDocComment !== false) {
291339
foreach ($this->parsePhpDoc($constructorDocComment)->children as $node) {
292340
if ($node instanceof PhpDocTagNode && $node->value instanceof ParamTagValueNode) {
293-
PhpDocTypeUtils::resolve($node->value->type, $class);
341+
PhpDocTypeUtils::resolve($node->value->type, $class, $genericParameterNames);
294342
$parameterName = substr($node->value->parameterName, 1);
295343
$parameterTypes[$parameterName] = $node->value->type;
296344
}
@@ -312,7 +360,7 @@ protected function getConstructorParameterTypes(ReflectionMethod $constructor):
312360
&& $node->value instanceof VarTagValueNode
313361
&& ($node->value->variableName === '' || substr($node->value->variableName, 1) === $parameterName)
314362
) {
315-
PhpDocTypeUtils::resolve($node->value->type, $class);
363+
PhpDocTypeUtils::resolve($node->value->type, $class, $genericParameterNames);
316364
$parameterTypes[$parameterName] = $node->value->type;
317365
}
318366
}

0 commit comments

Comments
 (0)