Skip to content

Commit 16fc45a

Browse files
olsavmicJanTvrdik
andauthored
Add support for mapping oneOf via #[Discriminator] attribute (#68)
Co-authored-by: Jan Tvrdík <[email protected]>
1 parent 8798979 commit 16fc45a

27 files changed

+1471
-0
lines changed

README.md

+90
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,96 @@ class Person
167167
}
168168
```
169169

170+
### Parsing polymorphic classes (subtypes with a common parent)
171+
172+
If you need to parse a hierarchy of classes, you can use the `#[Discriminator]` attribute.
173+
(The discriminator field does not need to be mapped to a property if `#[AllowExtraKeys]` is used.)
174+
175+
```php
176+
use ShipMonk\InputMapper\Compiler\Mapper\Object\Discriminator;
177+
178+
#[Discriminator(
179+
key: 'type', // key to use for mapping
180+
mapping: [
181+
'car' => Car::class,
182+
'truck' => Truck::class,
183+
]
184+
)]
185+
abstract class Vehicle {
186+
public function __construct(
187+
public readonly string $type,
188+
) {}
189+
}
190+
191+
class Car extends Vehicle {
192+
193+
public function __construct(
194+
string $type,
195+
public readonly string $color,
196+
) {
197+
parent::__construct($type);
198+
}
199+
200+
}
201+
202+
class Truck extends Vehicle {
203+
204+
public function __construct(
205+
string $type,
206+
public readonly string $color,
207+
) {
208+
parent::__construct($type);
209+
}
210+
211+
}
212+
```
213+
214+
or, with enum:
215+
216+
```php
217+
use ShipMonk\InputMapper\Compiler\Mapper\Object\Discriminator;
218+
219+
enum VehicleType: string {
220+
case Car = 'car';
221+
case Truck = 'truck';
222+
}
223+
224+
#[Discriminator(
225+
key: 'type', // key to use for mapping
226+
mapping: [
227+
VehicleType::Car->value => Car::class,
228+
VehicleType::Truck->value => Truck::class,
229+
]
230+
)]
231+
abstract class Vehicle {
232+
public function __construct(
233+
VehicleType $type,
234+
) {}
235+
}
236+
237+
class Car extends Vehicle {
238+
239+
public function __construct(
240+
VehicleType $type,
241+
public readonly string $color,
242+
) {
243+
parent::__construct($type);
244+
}
245+
246+
}
247+
248+
class Truck extends Vehicle {
249+
250+
public function __construct(
251+
VehicleType $type,
252+
public readonly string $color,
253+
) {
254+
parent::__construct($type);
255+
}
256+
257+
}
258+
```
259+
170260
### Using custom mappers
171261

172262
To map classes with your custom mapper, you need to implement `ShipMonk\InputMapper\Runtime\Mapper` interface and register it with `MapperProvider`:

src/Compiler/Exception/CannotCompileMapperException.php

+19
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use LogicException;
66
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
77
use ShipMonk\InputMapper\Compiler\Mapper\MapperCompiler;
8+
use ShipMonk\InputMapper\Compiler\Mapper\Object\MapDiscriminatedObject;
89
use ShipMonk\InputMapper\Compiler\Validator\ValidatorCompiler;
910
use Throwable;
1011

@@ -24,6 +25,24 @@ public static function withIncompatibleMapper(
2425
return new self("Cannot compile mapper {$mapperCompilerClass}, because {$reason}", 0, $previous);
2526
}
2627

28+
/**
29+
* @template T of object
30+
* @param MapDiscriminatedObject<T> $mapperCompiler
31+
*/
32+
public static function withIncompatibleSubtypeMapper(
33+
MapDiscriminatedObject $mapperCompiler,
34+
MapperCompiler $subtypeMapperCompiler,
35+
?Throwable $previous = null
36+
): self
37+
{
38+
$mapperOutputType = $mapperCompiler->getOutputType();
39+
$subtypeMapperCompilerClass = $subtypeMapperCompiler::class;
40+
$subtypeMapperOutputType = $subtypeMapperCompiler->getOutputType();
41+
42+
$reason = "its output type '{$subtypeMapperOutputType}' is not subtype of '{$mapperOutputType}'";
43+
return new self("Cannot compile mapper {$subtypeMapperCompilerClass} as subtype (#[Discriminator]) mapper, because {$reason}", 0, $previous);
44+
}
45+
2746
public static function withIncompatibleValidator(
2847
ValidatorCompiler $validatorCompiler,
2948
MapperCompiler $mapperCompiler,
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ShipMonk\InputMapper\Compiler\Mapper\Object;
4+
5+
use Attribute;
6+
7+
/**
8+
* Provides a way to map a polymorphic classes with common base class, according to the discriminator key.
9+
*/
10+
#[Attribute(Attribute::TARGET_CLASS)]
11+
class Discriminator
12+
{
13+
14+
/**
15+
* @param array<string, class-string> $mapping Mapping of discriminator values to class names
16+
*/
17+
public function __construct(
18+
public readonly string $key,
19+
public readonly array $mapping
20+
)
21+
{
22+
}
23+
24+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace ShipMonk\InputMapper\Compiler\Mapper\Object;
4+
5+
use Attribute;
6+
use Nette\Utils\Arrays;
7+
use PhpParser\Node\Expr;
8+
use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode;
9+
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
10+
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
11+
use ShipMonk\InputMapper\Compiler\CompiledExpr;
12+
use ShipMonk\InputMapper\Compiler\Exception\CannotCompileMapperException;
13+
use ShipMonk\InputMapper\Compiler\Mapper\GenericMapperCompiler;
14+
use ShipMonk\InputMapper\Compiler\Mapper\MapperCompiler;
15+
use ShipMonk\InputMapper\Compiler\Php\PhpCodeBuilder;
16+
use ShipMonk\InputMapper\Compiler\Type\GenericTypeParameter;
17+
use ShipMonk\InputMapper\Compiler\Type\PhpDocTypeUtils;
18+
use ShipMonk\InputMapper\Runtime\Exception\MappingFailedException;
19+
use function array_keys;
20+
use function count;
21+
use function ucfirst;
22+
23+
/**
24+
* @template T of object
25+
*/
26+
#[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)]
27+
class MapDiscriminatedObject implements GenericMapperCompiler
28+
{
29+
30+
/**
31+
* @param class-string<T> $className
32+
* @param array<string, MapperCompiler> $subtypeCompilers
33+
* @param list<GenericTypeParameter> $genericParameters
34+
*/
35+
public function __construct(
36+
public readonly string $className,
37+
public readonly string $discriminatorKeyName,
38+
public readonly array $subtypeCompilers,
39+
public readonly array $genericParameters = [],
40+
)
41+
{
42+
}
43+
44+
public function compile(Expr $value, Expr $path, PhpCodeBuilder $builder): CompiledExpr
45+
{
46+
foreach ($this->subtypeCompilers as $subtypeCompiler) {
47+
if (!PhpDocTypeUtils::isSubTypeOf($subtypeCompiler->getOutputType(), $this->getOutputType())) {
48+
throw CannotCompileMapperException::withIncompatibleSubtypeMapper($this, $subtypeCompiler);
49+
}
50+
}
51+
52+
$statements = [
53+
$builder->if($builder->not($builder->funcCall($builder->importFunction('is_array'), [$value])), [
54+
$builder->throw(
55+
$builder->staticCall(
56+
$builder->importClass(MappingFailedException::class),
57+
'incorrectType',
58+
[$value, $path, $builder->val('array')],
59+
),
60+
),
61+
]),
62+
];
63+
64+
$discriminatorKeyAsValue = $builder->val($this->discriminatorKeyName);
65+
66+
$isDiscriminatorPresent = $builder->funcCall($builder->importFunction('array_key_exists'), [$discriminatorKeyAsValue, $value]);
67+
$isDiscriminatorMissing = $builder->not($isDiscriminatorPresent);
68+
69+
$statements[] = $builder->if($isDiscriminatorMissing, [
70+
$builder->throw(
71+
$builder->staticCall(
72+
$builder->importClass(MappingFailedException::class),
73+
'missingKey',
74+
[$path, $discriminatorKeyAsValue],
75+
),
76+
),
77+
]);
78+
79+
$discriminatorRawValue = $builder->arrayDimFetch($value, $discriminatorKeyAsValue);
80+
$discriminatorPath = $builder->arrayImmutableAppend($path, $discriminatorKeyAsValue);
81+
82+
$validMappingKeys = array_keys($this->subtypeCompilers);
83+
84+
$expectedDescription = $builder->concat(
85+
'one of ',
86+
$builder->funcCall($builder->importFunction('implode'), [
87+
', ',
88+
$builder->val($validMappingKeys),
89+
]),
90+
);
91+
92+
$subtypeMatchArms = [];
93+
94+
foreach ($this->subtypeCompilers as $key => $subtypeCompiler) {
95+
$subtypeMapperMethodName = $builder->uniqMethodName('map' . ucfirst($key));
96+
$subtypeMapperMethod = $builder->mapperMethod($subtypeMapperMethodName, $subtypeCompiler)->makePrivate()->getNode();
97+
98+
$builder->addMethod($subtypeMapperMethod);
99+
$subtypeMapperMethodCall = $builder->methodCall($builder->var('this'), $subtypeMapperMethodName, [$value, $path]);
100+
101+
$subtypeMatchArms[] = $builder->matchArm(
102+
$builder->val($key),
103+
$subtypeMapperMethodCall,
104+
);
105+
}
106+
107+
$subtypeMatchArms[] = $builder->matchArm(
108+
null,
109+
$builder->throwExpr(
110+
$builder->staticCall(
111+
$builder->importClass(MappingFailedException::class),
112+
'incorrectValue',
113+
[$discriminatorRawValue, $discriminatorPath, $expectedDescription],
114+
),
115+
),
116+
);
117+
118+
$matchedSubtype = $builder->match($discriminatorRawValue, $subtypeMatchArms);
119+
120+
return new CompiledExpr(
121+
$matchedSubtype,
122+
$statements,
123+
);
124+
}
125+
126+
public function getInputType(): TypeNode
127+
{
128+
return new IdentifierTypeNode('mixed');
129+
}
130+
131+
public function getOutputType(): TypeNode
132+
{
133+
$outputType = new IdentifierTypeNode($this->className);
134+
135+
if (count($this->genericParameters) === 0) {
136+
return $outputType;
137+
}
138+
139+
return new GenericTypeNode(
140+
$outputType,
141+
Arrays::map($this->genericParameters, static function (GenericTypeParameter $parameter): TypeNode {
142+
return new IdentifierTypeNode($parameter->name);
143+
}),
144+
);
145+
}
146+
147+
/**
148+
* @return list<GenericTypeParameter>
149+
*/
150+
public function getGenericParameters(): array
151+
{
152+
return $this->genericParameters;
153+
}
154+
155+
}

src/Compiler/MapperFactory/DefaultMapperCompilerFactory.php

+33
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@
3737
use ShipMonk\InputMapper\Compiler\Mapper\Mixed\MapMixed;
3838
use ShipMonk\InputMapper\Compiler\Mapper\Object\AllowExtraKeys;
3939
use ShipMonk\InputMapper\Compiler\Mapper\Object\DelegateMapperCompiler;
40+
use ShipMonk\InputMapper\Compiler\Mapper\Object\Discriminator;
4041
use ShipMonk\InputMapper\Compiler\Mapper\Object\MapDateTimeImmutable;
42+
use ShipMonk\InputMapper\Compiler\Mapper\Object\MapDiscriminatedObject;
4143
use ShipMonk\InputMapper\Compiler\Mapper\Object\MapEnum;
4244
use ShipMonk\InputMapper\Compiler\Mapper\Object\MapObject;
4345
use ShipMonk\InputMapper\Compiler\Mapper\Object\SourceKey;
@@ -63,6 +65,7 @@
6365
use ShipMonk\InputMapper\Runtime\Optional;
6466
use function array_column;
6567
use function array_fill_keys;
68+
use function array_map;
6669
use function class_exists;
6770
use function class_implements;
6871
use function class_parents;
@@ -281,6 +284,12 @@ protected function createObjectMapperCompiler(string $inputClassName, array $opt
281284
}
282285
}
283286

287+
$classReflection = new ReflectionClass($inputClassName);
288+
289+
foreach ($classReflection->getAttributes(Discriminator::class) as $discriminatorAttribute) {
290+
return $this->createDiscriminatorObjectMapping($inputClassName, $discriminatorAttribute->newInstance());
291+
}
292+
284293
return $this->createObjectMappingByConstructorInvocation($inputClassName, $options);
285294
}
286295

@@ -327,6 +336,30 @@ protected function createObjectMappingByConstructorInvocation(
327336
return new MapObject($classReflection->getName(), $constructorParameterMapperCompilers, $allowExtraKeys, $genericParameters);
328337
}
329338

339+
/**
340+
* @param class-string $inputClassName
341+
*/
342+
public function createDiscriminatorObjectMapping(
343+
string $inputClassName,
344+
Discriminator $discriminatorAttribute,
345+
): MapperCompiler
346+
{
347+
$inputType = new IdentifierTypeNode($inputClassName);
348+
$genericParameters = PhpDocTypeUtils::getGenericTypeDefinition($inputType)->parameters;
349+
350+
$subtypeMappers = array_map(
351+
static fn (string $subtypeClassName): MapperCompiler => new DelegateMapperCompiler($subtypeClassName),
352+
$discriminatorAttribute->mapping,
353+
);
354+
355+
return new MapDiscriminatedObject(
356+
$inputClassName,
357+
$discriminatorAttribute->key,
358+
$subtypeMappers,
359+
$genericParameters,
360+
);
361+
}
362+
330363
/**
331364
* @param list<string> $genericParameterNames
332365
* @return array<string, TypeNode>

0 commit comments

Comments
 (0)