Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for mapping oneOf via #[Discriminator] attribute #68

Merged
merged 16 commits into from
Sep 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading