Skip to content

Commit

Permalink
Add support for mapping oneOf via #[Discriminator] attribute (#68)
Browse files Browse the repository at this point in the history
Co-authored-by: Jan Tvrdík <[email protected]>
  • Loading branch information
olsavmic and JanTvrdik authored Sep 9, 2024
1 parent 8798979 commit 16fc45a
Show file tree
Hide file tree
Showing 27 changed files with 1,471 additions and 0 deletions.
90 changes: 90 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,96 @@ class Person
}
```

### Parsing polymorphic classes (subtypes with a common parent)

If you need to parse a hierarchy of classes, you can use the `#[Discriminator]` attribute.
(The discriminator field does not need to be mapped to a property if `#[AllowExtraKeys]` is used.)

```php
use ShipMonk\InputMapper\Compiler\Mapper\Object\Discriminator;

#[Discriminator(
key: 'type', // key to use for mapping
mapping: [
'car' => Car::class,
'truck' => Truck::class,
]
)]
abstract class Vehicle {
public function __construct(
public readonly string $type,
) {}
}

class Car extends Vehicle {

public function __construct(
string $type,
public readonly string $color,
) {
parent::__construct($type);
}

}

class Truck extends Vehicle {

public function __construct(
string $type,
public readonly string $color,
) {
parent::__construct($type);
}

}
```

or, with enum:

```php
use ShipMonk\InputMapper\Compiler\Mapper\Object\Discriminator;

enum VehicleType: string {
case Car = 'car';
case Truck = 'truck';
}

#[Discriminator(
key: 'type', // key to use for mapping
mapping: [
VehicleType::Car->value => Car::class,
VehicleType::Truck->value => Truck::class,
]
)]
abstract class Vehicle {
public function __construct(
VehicleType $type,
) {}
}

class Car extends Vehicle {

public function __construct(
VehicleType $type,
public readonly string $color,
) {
parent::__construct($type);
}

}

class Truck extends Vehicle {

public function __construct(
VehicleType $type,
public readonly string $color,
) {
parent::__construct($type);
}

}
```

### Using custom mappers

To map classes with your custom mapper, you need to implement `ShipMonk\InputMapper\Runtime\Mapper` interface and register it with `MapperProvider`:
Expand Down
19 changes: 19 additions & 0 deletions src/Compiler/Exception/CannotCompileMapperException.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use LogicException;
use PHPStan\PhpDocParser\Ast\Type\TypeNode;
use ShipMonk\InputMapper\Compiler\Mapper\MapperCompiler;
use ShipMonk\InputMapper\Compiler\Mapper\Object\MapDiscriminatedObject;
use ShipMonk\InputMapper\Compiler\Validator\ValidatorCompiler;
use Throwable;

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

/**
* @template T of object
* @param MapDiscriminatedObject<T> $mapperCompiler
*/
public static function withIncompatibleSubtypeMapper(
MapDiscriminatedObject $mapperCompiler,
MapperCompiler $subtypeMapperCompiler,
?Throwable $previous = null
): self
{
$mapperOutputType = $mapperCompiler->getOutputType();
$subtypeMapperCompilerClass = $subtypeMapperCompiler::class;
$subtypeMapperOutputType = $subtypeMapperCompiler->getOutputType();

$reason = "its output type '{$subtypeMapperOutputType}' is not subtype of '{$mapperOutputType}'";
return new self("Cannot compile mapper {$subtypeMapperCompilerClass} as subtype (#[Discriminator]) mapper, because {$reason}", 0, $previous);
}

public static function withIncompatibleValidator(
ValidatorCompiler $validatorCompiler,
MapperCompiler $mapperCompiler,
Expand Down
24 changes: 24 additions & 0 deletions src/Compiler/Mapper/Object/Discriminator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
<?php declare(strict_types = 1);

namespace ShipMonk\InputMapper\Compiler\Mapper\Object;

use Attribute;

/**
* Provides a way to map a polymorphic classes with common base class, according to the discriminator key.
*/
#[Attribute(Attribute::TARGET_CLASS)]
class Discriminator
{

/**
* @param array<string, class-string> $mapping Mapping of discriminator values to class names
*/
public function __construct(
public readonly string $key,
public readonly array $mapping
)
{
}

}
155 changes: 155 additions & 0 deletions src/Compiler/Mapper/Object/MapDiscriminatedObject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
<?php declare(strict_types = 1);

namespace ShipMonk\InputMapper\Compiler\Mapper\Object;

use Attribute;
use Nette\Utils\Arrays;
use PhpParser\Node\Expr;
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\Exception\CannotCompileMapperException;
use ShipMonk\InputMapper\Compiler\Mapper\GenericMapperCompiler;
use ShipMonk\InputMapper\Compiler\Mapper\MapperCompiler;
use ShipMonk\InputMapper\Compiler\Php\PhpCodeBuilder;
use ShipMonk\InputMapper\Compiler\Type\GenericTypeParameter;
use ShipMonk\InputMapper\Compiler\Type\PhpDocTypeUtils;
use ShipMonk\InputMapper\Runtime\Exception\MappingFailedException;
use function array_keys;
use function count;
use function ucfirst;

/**
* @template T of object
*/
#[Attribute(Attribute::TARGET_PARAMETER | Attribute::TARGET_PROPERTY)]
class MapDiscriminatedObject implements GenericMapperCompiler
{

/**
* @param class-string<T> $className
* @param array<string, MapperCompiler> $subtypeCompilers
* @param list<GenericTypeParameter> $genericParameters
*/
public function __construct(
public readonly string $className,
public readonly string $discriminatorKeyName,
public readonly array $subtypeCompilers,
public readonly array $genericParameters = [],
)
{
}

public function compile(Expr $value, Expr $path, PhpCodeBuilder $builder): CompiledExpr
{
foreach ($this->subtypeCompilers as $subtypeCompiler) {
if (!PhpDocTypeUtils::isSubTypeOf($subtypeCompiler->getOutputType(), $this->getOutputType())) {
throw CannotCompileMapperException::withIncompatibleSubtypeMapper($this, $subtypeCompiler);
}
}

$statements = [
$builder->if($builder->not($builder->funcCall($builder->importFunction('is_array'), [$value])), [
$builder->throw(
$builder->staticCall(
$builder->importClass(MappingFailedException::class),
'incorrectType',
[$value, $path, $builder->val('array')],
),
),
]),
];

$discriminatorKeyAsValue = $builder->val($this->discriminatorKeyName);

$isDiscriminatorPresent = $builder->funcCall($builder->importFunction('array_key_exists'), [$discriminatorKeyAsValue, $value]);
$isDiscriminatorMissing = $builder->not($isDiscriminatorPresent);

$statements[] = $builder->if($isDiscriminatorMissing, [
$builder->throw(
$builder->staticCall(
$builder->importClass(MappingFailedException::class),
'missingKey',
[$path, $discriminatorKeyAsValue],
),
),
]);

$discriminatorRawValue = $builder->arrayDimFetch($value, $discriminatorKeyAsValue);
$discriminatorPath = $builder->arrayImmutableAppend($path, $discriminatorKeyAsValue);

$validMappingKeys = array_keys($this->subtypeCompilers);

$expectedDescription = $builder->concat(
'one of ',
$builder->funcCall($builder->importFunction('implode'), [
', ',
$builder->val($validMappingKeys),
]),
);

$subtypeMatchArms = [];

foreach ($this->subtypeCompilers as $key => $subtypeCompiler) {
$subtypeMapperMethodName = $builder->uniqMethodName('map' . ucfirst($key));
$subtypeMapperMethod = $builder->mapperMethod($subtypeMapperMethodName, $subtypeCompiler)->makePrivate()->getNode();

$builder->addMethod($subtypeMapperMethod);
$subtypeMapperMethodCall = $builder->methodCall($builder->var('this'), $subtypeMapperMethodName, [$value, $path]);

$subtypeMatchArms[] = $builder->matchArm(
$builder->val($key),
$subtypeMapperMethodCall,
);
}

$subtypeMatchArms[] = $builder->matchArm(
null,
$builder->throwExpr(
$builder->staticCall(
$builder->importClass(MappingFailedException::class),
'incorrectValue',
[$discriminatorRawValue, $discriminatorPath, $expectedDescription],
),
),
);

$matchedSubtype = $builder->match($discriminatorRawValue, $subtypeMatchArms);

return new CompiledExpr(
$matchedSubtype,
$statements,
);
}

public function getInputType(): TypeNode
{
return new IdentifierTypeNode('mixed');
}

public function getOutputType(): TypeNode
{
$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<GenericTypeParameter>
*/
public function getGenericParameters(): array
{
return $this->genericParameters;
}

}
33 changes: 33 additions & 0 deletions src/Compiler/MapperFactory/DefaultMapperCompilerFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@
use ShipMonk\InputMapper\Compiler\Mapper\Mixed\MapMixed;
use ShipMonk\InputMapper\Compiler\Mapper\Object\AllowExtraKeys;
use ShipMonk\InputMapper\Compiler\Mapper\Object\DelegateMapperCompiler;
use ShipMonk\InputMapper\Compiler\Mapper\Object\Discriminator;
use ShipMonk\InputMapper\Compiler\Mapper\Object\MapDateTimeImmutable;
use ShipMonk\InputMapper\Compiler\Mapper\Object\MapDiscriminatedObject;
use ShipMonk\InputMapper\Compiler\Mapper\Object\MapEnum;
use ShipMonk\InputMapper\Compiler\Mapper\Object\MapObject;
use ShipMonk\InputMapper\Compiler\Mapper\Object\SourceKey;
Expand All @@ -63,6 +65,7 @@
use ShipMonk\InputMapper\Runtime\Optional;
use function array_column;
use function array_fill_keys;
use function array_map;
use function class_exists;
use function class_implements;
use function class_parents;
Expand Down Expand Up @@ -281,6 +284,12 @@ protected function createObjectMapperCompiler(string $inputClassName, array $opt
}
}

$classReflection = new ReflectionClass($inputClassName);

foreach ($classReflection->getAttributes(Discriminator::class) as $discriminatorAttribute) {
return $this->createDiscriminatorObjectMapping($inputClassName, $discriminatorAttribute->newInstance());
}

return $this->createObjectMappingByConstructorInvocation($inputClassName, $options);
}

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

/**
* @param class-string $inputClassName
*/
public function createDiscriminatorObjectMapping(
string $inputClassName,
Discriminator $discriminatorAttribute,
): MapperCompiler
{
$inputType = new IdentifierTypeNode($inputClassName);
$genericParameters = PhpDocTypeUtils::getGenericTypeDefinition($inputType)->parameters;

$subtypeMappers = array_map(
static fn (string $subtypeClassName): MapperCompiler => new DelegateMapperCompiler($subtypeClassName),
$discriminatorAttribute->mapping,
);

return new MapDiscriminatedObject(
$inputClassName,
$discriminatorAttribute->key,
$subtypeMappers,
$genericParameters,
);
}

/**
* @param list<string> $genericParameterNames
* @return array<string, TypeNode>
Expand Down
Loading

0 comments on commit 16fc45a

Please sign in to comment.