diff --git a/README.md b/README.md index 5c4667e..b5e2bf0 100644 --- a/README.md +++ b/README.md @@ -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`: diff --git a/src/Compiler/Exception/CannotCompileMapperException.php b/src/Compiler/Exception/CannotCompileMapperException.php index a91b6cb..0192f93 100644 --- a/src/Compiler/Exception/CannotCompileMapperException.php +++ b/src/Compiler/Exception/CannotCompileMapperException.php @@ -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; @@ -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 $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, diff --git a/src/Compiler/Mapper/Object/Discriminator.php b/src/Compiler/Mapper/Object/Discriminator.php new file mode 100644 index 0000000..f0ab0b1 --- /dev/null +++ b/src/Compiler/Mapper/Object/Discriminator.php @@ -0,0 +1,24 @@ + $mapping Mapping of discriminator values to class names + */ + public function __construct( + public readonly string $key, + public readonly array $mapping + ) + { + } + +} diff --git a/src/Compiler/Mapper/Object/MapDiscriminatedObject.php b/src/Compiler/Mapper/Object/MapDiscriminatedObject.php new file mode 100644 index 0000000..4e319ec --- /dev/null +++ b/src/Compiler/Mapper/Object/MapDiscriminatedObject.php @@ -0,0 +1,155 @@ + $className + * @param array $subtypeCompilers + * @param list $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 + */ + public function getGenericParameters(): array + { + return $this->genericParameters; + } + +} diff --git a/src/Compiler/MapperFactory/DefaultMapperCompilerFactory.php b/src/Compiler/MapperFactory/DefaultMapperCompilerFactory.php index 14be3c7..2402c4c 100644 --- a/src/Compiler/MapperFactory/DefaultMapperCompilerFactory.php +++ b/src/Compiler/MapperFactory/DefaultMapperCompilerFactory.php @@ -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; @@ -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; @@ -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); } @@ -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 $genericParameterNames * @return array diff --git a/src/Compiler/Php/PhpCodeBuilder.php b/src/Compiler/Php/PhpCodeBuilder.php index bfd8cc7..2e0f84f 100644 --- a/src/Compiler/Php/PhpCodeBuilder.php +++ b/src/Compiler/Php/PhpCodeBuilder.php @@ -24,8 +24,11 @@ use PhpParser\Node\Expr\BinaryOp\SmallerOrEqual; use PhpParser\Node\Expr\BooleanNot; use PhpParser\Node\Expr\Instanceof_; +use PhpParser\Node\Expr\Match_; use PhpParser\Node\Expr\PreInc; use PhpParser\Node\Expr\Ternary; +use PhpParser\Node\Expr\Throw_ as ThrowExpr_; +use PhpParser\Node\MatchArm; use PhpParser\Node\Name; use PhpParser\Node\Stmt; use PhpParser\Node\Stmt\Class_ as ClassNode; @@ -216,6 +219,22 @@ public function if(Expr $if, array $then, ?array $else = null): If_ return new If_($if, ['stmts' => $then, 'elseifs' => $elseIfClauses, 'else' => $elseClause]); } + /** + * @param list $arms + */ + public function match(Expr $cond, array $arms = []): Match_ + { + return new Match_($cond, $arms); + } + + public function matchArm(?Expr $cond, Expr $body): MatchArm + { + return new MatchArm( + $cond !== null ? [$cond] : null, + $body, + ); + } + /** * @param list $statements */ @@ -260,6 +279,11 @@ public function throw(Expr $expr): Throw_ return new Throw_($expr); } + public function throwExpr(Expr $expr): ThrowExpr_ + { + return new ThrowExpr_($expr); + } + public function assign(Expr $var, Expr $expr): Expression { return new Expression(new Assign($var, $expr)); diff --git a/tests/Compiler/Mapper/Object/Data/HierarchicalChildOneInput.php b/tests/Compiler/Mapper/Object/Data/HierarchicalChildOneInput.php new file mode 100644 index 0000000..ff570f3 --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/HierarchicalChildOneInput.php @@ -0,0 +1,24 @@ + $age + */ + public function __construct( + int $id, + string $name, + Optional $age, + string $type, + public readonly string $childOneField, + ) + { + parent::__construct($id, $name, $age, $type); + } + +} diff --git a/tests/Compiler/Mapper/Object/Data/HierarchicalChildTwoInput.php b/tests/Compiler/Mapper/Object/Data/HierarchicalChildTwoInput.php new file mode 100644 index 0000000..b2f9129 --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/HierarchicalChildTwoInput.php @@ -0,0 +1,24 @@ + $age + */ + public function __construct( + int $id, + string $name, + Optional $age, + string $type, + public readonly int $childTwoField, + ) + { + parent::__construct($id, $name, $age, $type); + } + +} diff --git a/tests/Compiler/Mapper/Object/Data/HierarchicalParentInput.php b/tests/Compiler/Mapper/Object/Data/HierarchicalParentInput.php new file mode 100644 index 0000000..b939619 --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/HierarchicalParentInput.php @@ -0,0 +1,30 @@ + HierarchicalChildOneInput::class, + 'childTwo' => HierarchicalChildTwoInput::class, + ], +)] +abstract class HierarchicalParentInput +{ + + /** + * @param Optional $age + */ + public function __construct( + public readonly int $id, + public readonly string $name, + public readonly Optional $age, + public readonly string $type, + ) + { + } + +} diff --git a/tests/Compiler/Mapper/Object/Data/HierarchicalParentInputMapper.php b/tests/Compiler/Mapper/Object/Data/HierarchicalParentInputMapper.php new file mode 100644 index 0000000..523bb05 --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/HierarchicalParentInputMapper.php @@ -0,0 +1,62 @@ + + */ +class HierarchicalParentInputMapper implements Mapper +{ + public function __construct(private readonly MapperProvider $provider) + { + } + + /** + * @param list $path + * @throws MappingFailedException + */ + public function map(mixed $data, array $path = []): HierarchicalParentInput + { + if (!is_array($data)) { + throw MappingFailedException::incorrectType($data, $path, 'array'); + } + + if (!array_key_exists('type', $data)) { + throw MappingFailedException::missingKey($path, 'type'); + } + + return match ($data['type']) { + 'childOne' => $this->mapChildOne($data, $path), + 'childTwo' => $this->mapChildTwo($data, $path), + default => throw MappingFailedException::incorrectValue($data['type'], [...$path, 'type'], 'one of ' . implode(', ', ['childOne', 'childTwo'])), + }; + } + + /** + * @param list $path + * @throws MappingFailedException + */ + private function mapChildOne(mixed $data, array $path = []): HierarchicalChildOneInput + { + return $this->provider->get(HierarchicalChildOneInput::class)->map($data, $path); + } + + /** + * @param list $path + * @throws MappingFailedException + */ + private function mapChildTwo(mixed $data, array $path = []): HierarchicalChildTwoInput + { + return $this->provider->get(HierarchicalChildTwoInput::class)->map($data, $path); + } +} diff --git a/tests/Compiler/Mapper/Object/Data/HierarchicalParentInput__HierarchicalChildOneInputMapper.php b/tests/Compiler/Mapper/Object/Data/HierarchicalParentInput__HierarchicalChildOneInputMapper.php new file mode 100644 index 0000000..b41e9e6 --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/HierarchicalParentInput__HierarchicalChildOneInputMapper.php @@ -0,0 +1,137 @@ + + */ +class HierarchicalParentInput__HierarchicalChildOneInputMapper implements Mapper +{ + public function __construct(private readonly MapperProvider $provider) + { + } + + /** + * @param list $path + * @throws MappingFailedException + */ + public function map(mixed $data, array $path = []): HierarchicalChildOneInput + { + 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'); + } + + if (!array_key_exists('type', $data)) { + throw MappingFailedException::missingKey($path, 'type'); + } + + if (!array_key_exists('childOneField', $data)) { + throw MappingFailedException::missingKey($path, 'childOneField'); + } + + $knownKeys = ['id' => true, 'name' => true, 'age' => true, 'type' => true, 'childOneField' => true]; + $extraKeys = array_diff_key($data, $knownKeys); + + if (count($extraKeys) > 0) { + throw MappingFailedException::extraKeys($path, array_keys($extraKeys)); + } + + return new HierarchicalChildOneInput( + $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'), + $this->mapType($data['type'], [...$path, 'type']), + $this->mapChildOneField($data['childOneField'], [...$path, 'childOneField']), + ); + } + + /** + * @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 OptionalSome + * @throws MappingFailedException + */ + private function mapAge(mixed $data, array $path = []): OptionalSome + { + if (!is_int($data)) { + throw MappingFailedException::incorrectType($data, $path, 'int'); + } + + return Optional::of($data); + } + + /** + * @param list $path + * @throws MappingFailedException + */ + private function mapType(mixed $data, array $path = []): string + { + if (!is_string($data)) { + throw MappingFailedException::incorrectType($data, $path, 'string'); + } + + return $data; + } + + /** + * @param list $path + * @throws MappingFailedException + */ + private function mapChildOneField(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/HierarchicalParentInput__HierarchicalChildTwoInputMapper.php b/tests/Compiler/Mapper/Object/Data/HierarchicalParentInput__HierarchicalChildTwoInputMapper.php new file mode 100644 index 0000000..78fa6ef --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/HierarchicalParentInput__HierarchicalChildTwoInputMapper.php @@ -0,0 +1,137 @@ + + */ +class HierarchicalParentInput__HierarchicalChildTwoInputMapper implements Mapper +{ + public function __construct(private readonly MapperProvider $provider) + { + } + + /** + * @param list $path + * @throws MappingFailedException + */ + public function map(mixed $data, array $path = []): HierarchicalChildTwoInput + { + 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'); + } + + if (!array_key_exists('type', $data)) { + throw MappingFailedException::missingKey($path, 'type'); + } + + if (!array_key_exists('childTwoField', $data)) { + throw MappingFailedException::missingKey($path, 'childTwoField'); + } + + $knownKeys = ['id' => true, 'name' => true, 'age' => true, 'type' => true, 'childTwoField' => true]; + $extraKeys = array_diff_key($data, $knownKeys); + + if (count($extraKeys) > 0) { + throw MappingFailedException::extraKeys($path, array_keys($extraKeys)); + } + + return new HierarchicalChildTwoInput( + $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'), + $this->mapType($data['type'], [...$path, 'type']), + $this->mapChildTwoField($data['childTwoField'], [...$path, 'childTwoField']), + ); + } + + /** + * @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 OptionalSome + * @throws MappingFailedException + */ + private function mapAge(mixed $data, array $path = []): OptionalSome + { + if (!is_int($data)) { + throw MappingFailedException::incorrectType($data, $path, 'int'); + } + + return Optional::of($data); + } + + /** + * @param list $path + * @throws MappingFailedException + */ + private function mapType(mixed $data, array $path = []): string + { + if (!is_string($data)) { + throw MappingFailedException::incorrectType($data, $path, 'string'); + } + + return $data; + } + + /** + * @param list $path + * @throws MappingFailedException + */ + private function mapChildTwoField(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/HierarchicalWithEnumChildInput.php b/tests/Compiler/Mapper/Object/Data/HierarchicalWithEnumChildInput.php new file mode 100644 index 0000000..664a701 --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/HierarchicalWithEnumChildInput.php @@ -0,0 +1,16 @@ + HierarchicalWithEnumChildInput::class, + ], +)] +abstract class HierarchicalWithEnumParentInput +{ + + public function __construct( + public readonly int $id, + public readonly HierarchicalWithEnumType $type, + ) + { + } + +} diff --git a/tests/Compiler/Mapper/Object/Data/HierarchicalWithEnumParentInputMapper.php b/tests/Compiler/Mapper/Object/Data/HierarchicalWithEnumParentInputMapper.php new file mode 100644 index 0000000..d9041c4 --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/HierarchicalWithEnumParentInputMapper.php @@ -0,0 +1,52 @@ + + */ +class HierarchicalWithEnumParentInputMapper implements Mapper +{ + public function __construct(private readonly MapperProvider $provider) + { + } + + /** + * @param list $path + * @throws MappingFailedException + */ + public function map(mixed $data, array $path = []): HierarchicalWithEnumParentInput + { + if (!is_array($data)) { + throw MappingFailedException::incorrectType($data, $path, 'array'); + } + + if (!array_key_exists('type', $data)) { + throw MappingFailedException::missingKey($path, 'type'); + } + + return match ($data['type']) { + 'childOne' => $this->mapChildOne($data, $path), + default => throw MappingFailedException::incorrectValue($data['type'], [...$path, 'type'], 'one of ' . implode(', ', ['childOne'])), + }; + } + + /** + * @param list $path + * @throws MappingFailedException + */ + private function mapChildOne(mixed $data, array $path = []): HierarchicalWithEnumChildInput + { + return $this->provider->get(HierarchicalWithEnumChildInput::class)->map($data, $path); + } +} diff --git a/tests/Compiler/Mapper/Object/Data/HierarchicalWithEnumParentInput__HierarchicalWithEnumChildInputMapper.php b/tests/Compiler/Mapper/Object/Data/HierarchicalWithEnumParentInput__HierarchicalWithEnumChildInputMapper.php new file mode 100644 index 0000000..e8893dc --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/HierarchicalWithEnumParentInput__HierarchicalWithEnumChildInputMapper.php @@ -0,0 +1,92 @@ + + */ +class HierarchicalWithEnumParentInput__HierarchicalWithEnumChildInputMapper implements Mapper +{ + public function __construct(private readonly MapperProvider $provider) + { + } + + /** + * @param list $path + * @throws MappingFailedException + */ + public function map(mixed $data, array $path = []): HierarchicalWithEnumChildInput + { + 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('type', $data)) { + throw MappingFailedException::missingKey($path, 'type'); + } + + $knownKeys = ['id' => true, 'type' => true]; + $extraKeys = array_diff_key($data, $knownKeys); + + if (count($extraKeys) > 0) { + throw MappingFailedException::extraKeys($path, array_keys($extraKeys)); + } + + return new HierarchicalWithEnumChildInput( + $this->mapId($data['id'], [...$path, 'id']), + $this->mapType($data['type'], [...$path, 'type']), + ); + } + + /** + * @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 mapType(mixed $data, array $path = []): HierarchicalWithEnumType + { + if (!is_string($data)) { + throw MappingFailedException::incorrectType($data, $path, 'string'); + } + + $enum = HierarchicalWithEnumType::tryFrom($data); + + if ($enum === null) { + throw MappingFailedException::incorrectValue($data, $path, 'one of ' . implode(', ', array_column(HierarchicalWithEnumType::cases(), 'value'))); + } + + return $enum; + } +} diff --git a/tests/Compiler/Mapper/Object/Data/HierarchicalWithEnumType.php b/tests/Compiler/Mapper/Object/Data/HierarchicalWithEnumType.php new file mode 100644 index 0000000..e5d0b40 --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/HierarchicalWithEnumType.php @@ -0,0 +1,10 @@ + + */ +class HierarchicalWithNoTypeFieldInputMapper implements Mapper +{ + public function __construct(private readonly MapperProvider $provider) + { + } + + /** + * @param list $path + * @throws MappingFailedException + */ + public function map(mixed $data, array $path = []): HierarchicalWithNoTypeFieldParentInput + { + if (!is_array($data)) { + throw MappingFailedException::incorrectType($data, $path, 'array'); + } + + if (!array_key_exists('$type', $data)) { + throw MappingFailedException::missingKey($path, '$type'); + } + + return match ($data['$type']) { + 'childOne' => $this->mapChildOne($data, $path), + default => throw MappingFailedException::incorrectValue($data['$type'], [...$path, '$type'], 'one of ' . implode(', ', ['childOne'])), + }; + } + + /** + * @param list $path + * @throws MappingFailedException + */ + private function mapChildOne(mixed $data, array $path = []): HierarchicalWithNoTypeFieldChildInput + { + return $this->provider->get(HierarchicalWithNoTypeFieldChildInput::class)->map($data, $path); + } +} diff --git a/tests/Compiler/Mapper/Object/Data/HierarchicalWithNoTypeFieldInput__HierarchicalWithNoTypeFieldChildInputMapper.php b/tests/Compiler/Mapper/Object/Data/HierarchicalWithNoTypeFieldInput__HierarchicalWithNoTypeFieldChildInputMapper.php new file mode 100644 index 0000000..0bd99d2 --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/HierarchicalWithNoTypeFieldInput__HierarchicalWithNoTypeFieldChildInputMapper.php @@ -0,0 +1,74 @@ + + */ +class HierarchicalWithNoTypeFieldInput__HierarchicalWithNoTypeFieldChildInputMapper implements Mapper +{ + public function __construct(private readonly MapperProvider $provider) + { + } + + /** + * @param list $path + * @throws MappingFailedException + */ + public function map(mixed $data, array $path = []): HierarchicalWithNoTypeFieldChildInput + { + 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('childOneField', $data)) { + throw MappingFailedException::missingKey($path, 'childOneField'); + } + + return new HierarchicalWithNoTypeFieldChildInput( + $this->mapId($data['id'], [...$path, 'id']), + $this->mapChildOneField($data['childOneField'], [...$path, 'childOneField']), + ); + } + + /** + * @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 mapChildOneField(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/HierarchicalWithNoTypeFieldParentInput.php b/tests/Compiler/Mapper/Object/Data/HierarchicalWithNoTypeFieldParentInput.php new file mode 100644 index 0000000..674bf02 --- /dev/null +++ b/tests/Compiler/Mapper/Object/Data/HierarchicalWithNoTypeFieldParentInput.php @@ -0,0 +1,22 @@ + HierarchicalWithNoTypeFieldChildInput::class, + ], +)] +abstract class HierarchicalWithNoTypeFieldParentInput +{ + + public function __construct( + public readonly int $id, + ) + { + } + +} diff --git a/tests/Compiler/Mapper/Object/MapDiscriminatedObjectTest.php b/tests/Compiler/Mapper/Object/MapDiscriminatedObjectTest.php new file mode 100644 index 0000000..d3e48ac --- /dev/null +++ b/tests/Compiler/Mapper/Object/MapDiscriminatedObjectTest.php @@ -0,0 +1,267 @@ +compileMapper('HierarchicalParentInput', $this->createParentInputMapperCompiler(), [ + HierarchicalChildOneInput::class => $this->createHierarchicalChildOneInputMapperCompiler(), + HierarchicalChildTwoInput::class => $this->createHierarchicalChildTwoInputMapperCompiler(), + ]); + + $childOneInputObject = new HierarchicalChildOneInput( + id: 1, + name: 'John Doe', + age: Optional::of(30), + type: 'childOne', + childOneField: 'childOneField', + ); + + $childOneInputArray = [ + 'id' => 1, + 'name' => 'John Doe', + 'type' => 'childOne', + 'age' => 30, + 'childOneField' => 'childOneField', + ]; + + self::assertEquals($childOneInputObject, $parentInputMapper->map($childOneInputArray)); + + self::assertException( + MappingFailedException::class, + 'Failed to map data at path /: Expected array, got null', + static fn() => $parentInputMapper->map(null), + ); + + self::assertException( + MappingFailedException::class, + 'Failed to map data at path /: Expected array, got 123', + static fn() => $parentInputMapper->map(123), + ); + + self::assertException( + MappingFailedException::class, + 'Failed to map data at path /: Unrecognized key "extra"', + static fn() => $parentInputMapper->map($childOneInputArray + ['extra' => 1]), + ); + + self::assertException( + MappingFailedException::class, + 'Failed to map data at path /type: Expected one of childOne, childTwo, got null', + static fn() => $parentInputMapper->map([...$childOneInputArray, 'type' => null]), + ); + + self::assertException( + MappingFailedException::class, + 'Failed to map data at path /type: Expected one of childOne, childTwo, got "c"', + static fn() => $parentInputMapper->map([...$childOneInputArray, 'type' => 'c']), + ); + + $childOneInputWithoutType = $childOneInputArray; + unset($childOneInputWithoutType['type']); + + self::assertException( + MappingFailedException::class, + 'Failed to map data at path /: Missing required key "type"', + static fn() => $parentInputMapper->map($childOneInputWithoutType), + ); + + $childTwoInputObject = new HierarchicalChildTwoInput( + id: 1, + name: 'John Doe', + age: Optional::of(30), + type: 'childTwo', + childTwoField: 5, + ); + + $childTwoInputArray = [ + 'id' => 1, + 'name' => 'John Doe', + 'type' => 'childTwo', + 'age' => 30, + 'childTwoField' => 5, + ]; + + self::assertEquals($childTwoInputObject, $parentInputMapper->map($childTwoInputArray)); + } + + public function testCompileWithEnumAsType(): void + { + $parentInputMapper = $this->compileMapper('HierarchicalWithEnumParentInput', $this->createParentInputWithEnumMapperCompiler(), [ + HierarchicalWithEnumChildInput::class => $this->createHierarchicalChildWithEnumMapperCompiler(), + ]); + + $childOneInputObject = new HierarchicalWithEnumChildInput( + id: 1, + type: HierarchicalWithEnumType::ChildOne, + ); + + $childOneInputArray = [ + 'id' => 1, + 'type' => 'childOne', + ]; + + self::assertEquals($childOneInputObject, $parentInputMapper->map($childOneInputArray)); + + self::assertException( + MappingFailedException::class, + 'Failed to map data at path /type: Expected one of childOne, got null', + static fn() => $parentInputMapper->map([...$childOneInputArray, 'type' => null]), + ); + + self::assertException( + MappingFailedException::class, + 'Failed to map data at path /type: Expected one of childOne, got "c"', + static fn() => $parentInputMapper->map([...$childOneInputArray, 'type' => 'c']), + ); + } + + public function testCompileWithNoTypeFieldMapping(): void + { + $parentInputMapper = $this->compileMapper('HierarchicalWithNoTypeFieldInput', $this->createParentInputWithNoTypeFieldMapperCompiler(), [ + HierarchicalWithNoTypeFieldChildInput::class => $this->createHierarchicalChildWithNoTypeFieldMapperCompiler(), + ]); + + $childOneInputObject = new HierarchicalWithNoTypeFieldChildInput( + id: 1, + childOneField: 'abc', + ); + + $childOneInputArray = [ + 'id' => 1, + '$type' => 'childOne', + 'childOneField' => 'abc', + ]; + + self::assertEquals($childOneInputObject, $parentInputMapper->map($childOneInputArray)); + + self::assertException( + MappingFailedException::class, + 'Failed to map data at path /$type: Expected one of childOne, got null', + static fn() => $parentInputMapper->map([...$childOneInputArray, '$type' => null]), + ); + + self::assertException( + MappingFailedException::class, + 'Failed to map data at path /$type: Expected one of childOne, got "c"', + static fn() => $parentInputMapper->map([...$childOneInputArray, '$type' => 'c']), + ); + } + + public function testCompileWithSubtypesFromDifferentHierarchies(): void + { + $mapperCompiler = new MapDiscriminatedObject( + HierarchicalParentInput::class, + 'type', + [ + 'childOne' => new DelegateMapperCompiler(HierarchicalChildOneInput::class), + 'childTwo' => new DelegateMapperCompiler(MovieInput::class), + ], + ); + + self::assertException( + CannotCompileMapperException::class, + 'Cannot compile mapper ShipMonk\InputMapper\Compiler\Mapper\Object\DelegateMapperCompiler as subtype (#[Discriminator]) mapper, because its output type \'ShipMonkTests\InputMapper\Compiler\Mapper\Object\Data\MovieInput\' is not subtype of \'ShipMonkTests\InputMapper\Compiler\Mapper\Object\Data\HierarchicalParentInput\'', + fn(): Mapper => $this->compileMapper('InvalidHierarchyMapper', $mapperCompiler), + ); + } + + private function createParentInputMapperCompiler(): MapperCompiler + { + return new MapDiscriminatedObject( + HierarchicalParentInput::class, + 'type', + [ + 'childOne' => new DelegateMapperCompiler(HierarchicalChildOneInput::class), + 'childTwo' => new DelegateMapperCompiler(HierarchicalChildTwoInput::class), + ], + ); + } + + private function createParentInputWithEnumMapperCompiler(): MapperCompiler + { + return new MapDiscriminatedObject( + HierarchicalWithEnumParentInput::class, + 'type', + [ + 'childOne' => new DelegateMapperCompiler(HierarchicalWithEnumChildInput::class), + ], + ); + } + + public function createHierarchicalChildOneInputMapperCompiler(): MapperCompiler + { + return new MapObject(HierarchicalChildOneInput::class, [ + 'id' => new MapInt(), + 'name' => new MapString(), + 'age' => new MapOptional(new MapInt()), + 'type' => new MapString(), + 'childOneField' => new MapString(), + ]); + } + + public function createHierarchicalChildTwoInputMapperCompiler(): MapperCompiler + { + return new MapObject(HierarchicalChildTwoInput::class, [ + 'id' => new MapInt(), + 'name' => new MapString(), + 'age' => new MapOptional(new MapInt()), + 'type' => new MapString(), + 'childTwoField' => new MapInt(), + ]); + } + + public function createHierarchicalChildWithEnumMapperCompiler(): MapperCompiler + { + return new MapObject(HierarchicalWithEnumChildInput::class, [ + 'id' => new MapInt(), + 'type' => new MapEnum(HierarchicalWithEnumType::class, new MapString()), + ]); + } + + private function createParentInputWithNoTypeFieldMapperCompiler(): MapperCompiler + { + return new MapDiscriminatedObject( + HierarchicalWithNoTypeFieldParentInput::class, + '$type', + [ + 'childOne' => new DelegateMapperCompiler(HierarchicalWithNoTypeFieldChildInput::class), + ], + ); + } + + public function createHierarchicalChildWithNoTypeFieldMapperCompiler(): MapperCompiler + { + return new MapObject(HierarchicalWithNoTypeFieldChildInput::class, [ + 'id' => new MapInt(), + 'childOneField' => new MapString(), + ], allowExtraKeys: true); + } + +} diff --git a/tests/Compiler/MapperFactory/Data/AnimalCatInput.php b/tests/Compiler/MapperFactory/Data/AnimalCatInput.php new file mode 100644 index 0000000..2d55307 --- /dev/null +++ b/tests/Compiler/MapperFactory/Data/AnimalCatInput.php @@ -0,0 +1,16 @@ + AnimalCatInput::class, + 'dog' => AnimalDogInput::class, + ], +)] +abstract class AnimalInput +{ + + public function __construct( + public readonly int $id, + public readonly AnimalType $type, + ) + { + } + +} diff --git a/tests/Compiler/MapperFactory/Data/AnimalType.php b/tests/Compiler/MapperFactory/Data/AnimalType.php new file mode 100644 index 0000000..015eaba --- /dev/null +++ b/tests/Compiler/MapperFactory/Data/AnimalType.php @@ -0,0 +1,13 @@ + [ + AnimalInput::class, + [], + new MapDiscriminatedObject( + className: AnimalInput::class, + discriminatorKeyName: 'type', + subtypeCompilers: [ + AnimalType::Cat->value => new DelegateMapperCompiler(AnimalCatInput::class), + AnimalType::Dog->value => new DelegateMapperCompiler(AnimalDogInput::class), + ], + ), + ]; + yield 'ColorEnum' => [ ColorEnum::class, [],