feat: introduce attribute DynamicConstructor

In some situations the type handled by a constructor is only known at
runtime, in which case the constructor needs to know what class must be
used to instantiate the object.

For instance, an interface may declare a static constructor that is then
implemented by several child classes. One solution would be to register
the constructor for each child class, which leads to a lot of
boilerplate code and would require a new registration each time a new
child is created. Another way is to use the attribute
`\CuyZ\Valinor\Mapper\Object\DynamicConstructor`.

When a constructor uses this attribute, its first parameter must be a
string and will be filled with the name of the actual class that the
mapper needs to build when the constructor is called. Other arguments
may be added and will be mapped normally, depending on the source given
to the mapper.

```php
interface InterfaceWithStaticConstructor
{
    public static function from(string $value): self;
}

final class ClassWithInheritedStaticConstructor implements InterfaceWithStaticConstructor
{
    private function __construct(private SomeValueObject $value) {}

    public static function from(string $value): self
    {
        return new self(new SomeValueObject($value));
    }
}

(new \CuyZ\Valinor\MapperBuilder())
    ->registerConstructor(
        #[\CuyZ\Valinor\Attribute\DynamicConstructor]
        function (string $className, string $value): InterfaceWithStaticConstructor {
            return $className::from($value);
        }
    )
    ->mapper()
    ->map(ClassWithInheritedStaticConstructor::class, 'foo');
```
This commit is contained in:
Romain Canon 2022-08-29 14:51:28 +02:00
parent 4bc50e3e42
commit e437d9405c
10 changed files with 418 additions and 58 deletions

View File

@ -114,6 +114,50 @@ final class Color
[Color::class, 'fromHex'],
```
### Dynamic constructors
In some situations the type handled by a constructor is only known at runtime,
in which case the constructor needs to know what class must be used to
instantiate the object.
For instance, an interface may declare a static constructor that is then
implemented by several child classes. One solution would be to register the
constructor for each child class, which leads to a lot of boilerplate code and
would require a new registration each time a new child is created. Another way
is to use the attribute `\CuyZ\Valinor\Mapper\Object\DynamicConstructor`.
When a constructor uses this attribute, its first parameter must be a string and
will be filled with the name of the actual class that the mapper needs to build
when the constructor is called. Other arguments may be added and will be mapped
normally, depending on the source given to the mapper.
```php
interface InterfaceWithStaticConstructor
{
public static function from(string $value): self;
}
final class ClassWithInheritedStaticConstructor implements InterfaceWithStaticConstructor
{
private function __construct(private SomeValueObject $value) {}
public static function from(string $value): self
{
return new self(new SomeValueObject($value));
}
}
(new \CuyZ\Valinor\MapperBuilder())
->registerConstructor(
#[\CuyZ\Valinor\Attribute\DynamicConstructor]
function (string $className, string $value): InterfaceWithStaticConstructor {
return $className::from($value);
}
)
->mapper()
->map(ClassWithInheritedStaticConstructor::class, 'foo');
```
## Properties
If no constructor is registered, properties will determine which values are

View File

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Mapper\Object;
use Attribute;
use CuyZ\Valinor\MapperBuilder;
/**
* This attribute allows the registration of dynamic constructors used when
* mapping implementations of interfaces or abstract classes.
*
* A constructor given to {@see MapperBuilder::registerConstructor()} with this
* attribute will be called with the first parameter filled with the name of the
* class the mapper needs to build.
*
* Note that the first parameter of the constructor has to be a string otherwise
* an exception will be thrown on mapping.
*
* ```php
* interface SomeInterfaceWithStaticConstructor
* {
* public static function from(string $value): self;
* }
*
* final class SomeClassWithInheritedStaticConstructor implements SomeInterfaceWithStaticConstructor
* {
* private function __construct(private SomeValueObject $value) {}
*
* public static function from(string $value): self
* {
* return new self(new SomeValueObject($value));
* }
* }
*
* (new \CuyZ\Valinor\MapperBuilder())
* ->registerConstructor(
* #[\CuyZ\Valinor\Attribute\DynamicConstructor]
* function (string $className, string $value): SomeInterfaceWithStaticConstructor {
* return $className::from($value);
* }
* )
* ->mapper()
* ->map(SomeClassWithInheritedStaticConstructor::class, 'foo');
* ```
*
* @api
*/
#[Attribute(Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD)]
final class DynamicConstructor
{
}

View File

@ -6,16 +6,16 @@ namespace CuyZ\Valinor\Mapper\Object\Exception;
use CuyZ\Valinor\Definition\FunctionDefinition;
use CuyZ\Valinor\Type\Type;
use RuntimeException;
use LogicException;
/** @internal */
final class InvalidClassConstructorType extends RuntimeException
final class InvalidConstructorClassTypeParameter extends LogicException
{
public function __construct(FunctionDefinition $function, Type $type)
{
parent::__construct(
"Invalid type `{$type->toString()}` handled by constructor `{$function->signature()}`. It must be a valid class name.",
1659446121
"Invalid type `{$type->toString()}` for the first parameter of the constructor `{$function->signature()}`, it should be of type `class-string`.",
1661517000
);
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Mapper\Object\Exception;
use CuyZ\Valinor\Definition\FunctionDefinition;
use LogicException;
/** @internal */
final class InvalidConstructorReturnType extends LogicException
{
public function __construct(FunctionDefinition $function)
{
parent::__construct(
"Invalid return type `{$function->returnType()->toString()}` for constructor `{$function->signature()}`, it must be a valid class name.",
1659446121
);
}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Mapper\Object\Exception;
use CuyZ\Valinor\Definition\FunctionDefinition;
use LogicException;
/** @internal */
final class MissingConstructorClassTypeParameter extends LogicException
{
public function __construct(FunctionDefinition $function)
{
parent::__construct(
"Missing first parameter of type `class-string` for the constructor `{$function->signature()}`.",
1661516853
);
}
}

View File

@ -5,18 +5,23 @@ declare(strict_types=1);
namespace CuyZ\Valinor\Mapper\Object\Factory;
use CuyZ\Valinor\Definition\ClassDefinition;
use CuyZ\Valinor\Definition\FunctionObject;
use CuyZ\Valinor\Definition\FunctionsContainer;
use CuyZ\Valinor\Mapper\Object\DynamicConstructor;
use CuyZ\Valinor\Mapper\Object\Exception\CannotInstantiateObject;
use CuyZ\Valinor\Mapper\Object\Exception\InvalidClassConstructorType;
use CuyZ\Valinor\Mapper\Object\Exception\InvalidConstructorClassTypeParameter;
use CuyZ\Valinor\Mapper\Object\Exception\InvalidConstructorReturnType;
use CuyZ\Valinor\Mapper\Object\Exception\MissingConstructorClassTypeParameter;
use CuyZ\Valinor\Mapper\Object\FunctionObjectBuilder;
use CuyZ\Valinor\Mapper\Object\MethodObjectBuilder;
use CuyZ\Valinor\Mapper\Object\NativeConstructorObjectBuilder;
use CuyZ\Valinor\Mapper\Object\ObjectBuilder;
use CuyZ\Valinor\Type\Types\ClassStringType;
use CuyZ\Valinor\Type\Types\ClassType;
use CuyZ\Valinor\Type\Types\InterfaceType;
use CuyZ\Valinor\Type\Types\NativeStringType;
use function array_key_exists;
use function array_unshift;
use function count;
/** @internal */
@ -29,9 +34,6 @@ final class ConstructorObjectBuilderFactory implements ObjectBuilderFactory
private FunctionsContainer $constructors;
/** @var array<string, ObjectBuilder[]> */
private array $builders = [];
/**
* @param array<class-string, null> $nativeConstructors
*/
@ -47,7 +49,7 @@ final class ConstructorObjectBuilderFactory implements ObjectBuilderFactory
public function for(ClassDefinition $class): array
{
$builders = $this->listBuilders($class);
$builders = $this->builders($class);
if (count($builders) === 0) {
if ($class->methods()->hasConstructor()) {
@ -63,47 +65,78 @@ final class ConstructorObjectBuilderFactory implements ObjectBuilderFactory
/**
* @return list<ObjectBuilder>
*/
private function listBuilders(ClassDefinition $class): array
private function builders(ClassDefinition $class): array
{
$type = $class->type();
$key = $type->toString();
if (! array_key_exists($key, $this->builders)) {
$builders = [];
$className = $class->name();
$classType = $class->type();
$methods = $class->methods();
foreach ($this->constructors as $constructor) {
$definition = $constructor->definition();
$handledType = $definition->returnType();
$functionClass = $definition->class();
$builders = [];
if (! $handledType instanceof ClassType && ! $handledType instanceof InterfaceType) {
throw new InvalidClassConstructorType($constructor->definition(), $handledType);
}
if (! $handledType->matches($type)) {
foreach ($this->constructors as $function) {
if (! $this->constructorMatches($function, $classType)) {
continue;
}
$definition = $function->definition();
$functionClass = $definition->class();
if ($functionClass && $definition->isStatic()) {
$builders[] = new MethodObjectBuilder($className, $definition->name(), $definition->parameters());
} else {
$builders[] = new FunctionObjectBuilder($constructor);
$builders[] = new FunctionObjectBuilder($function, $classType);
}
}
if ((array_key_exists($className, $this->nativeConstructors) || count($builders) === 0)
&& $methods->hasConstructor()
&& $methods->constructor()->isPublic()
) {
array_unshift($builders, new NativeConstructorObjectBuilder($class));
if (! array_key_exists($className, $this->nativeConstructors) && count($builders) > 0) {
return $builders;
}
$this->builders[$key] = $builders;
if ($methods->hasConstructor() && $methods->constructor()->isPublic()) {
$builders[] = new NativeConstructorObjectBuilder($class);
}
return $this->builders[$key];
return $builders;
}
private function constructorMatches(FunctionObject $function, ClassType $classType): bool
{
$definition = $function->definition();
$parameters = $definition->parameters();
$returnType = $definition->returnType();
if (! $returnType instanceof ClassType && ! $returnType instanceof InterfaceType) {
throw new InvalidConstructorReturnType($definition);
}
if (! $classType->matches($returnType)) {
return false;
}
if (! $definition->attributes()->has(DynamicConstructor::class)) {
return true;
}
if (count($parameters) === 0) {
throw new MissingConstructorClassTypeParameter($definition);
}
$parameterType = $parameters->at(0)->type();
if ($parameterType instanceof NativeStringType) {
$parameterType = ClassStringType::get();
}
if (! $parameterType instanceof ClassStringType) {
throw new InvalidConstructorClassTypeParameter($definition, $parameterType);
}
$subType = $parameterType->subType();
if ($subType) {
return $classType->matches($subType);
}
return true;
}
}

View File

@ -9,7 +9,7 @@ use CuyZ\Valinor\Definition\FunctionsContainer;
use CuyZ\Valinor\Mapper\Object\DateTimeObjectBuilder;
use CuyZ\Valinor\Mapper\Object\FunctionObjectBuilder;
use CuyZ\Valinor\Mapper\Object\ObjectBuilder;
use CuyZ\Valinor\Type\Type;
use CuyZ\Valinor\Type\Types\ClassType;
use DateTimeInterface;
use function count;
@ -39,15 +39,16 @@ final class DateTimeObjectBuilderFactory implements ObjectBuilderFactory
return $this->delegate->for($class);
}
return $this->builders($class->type(), $className);
return $this->builders($class->type());
}
/**
* @param class-string<DateTimeInterface> $className
* @return list<ObjectBuilder>
*/
private function builders(Type $type, string $className): array
private function builders(ClassType $type): array
{
/** @var class-string<DateTimeInterface> $className */
$className = $type->className();
$key = $type->toString();
if (! isset($this->builders[$key])) {
@ -66,7 +67,7 @@ final class DateTimeObjectBuilderFactory implements ObjectBuilderFactory
$overridesDefault = true;
}
$this->builders[$key][] = new FunctionObjectBuilder($function);
$this->builders[$key][] = new FunctionObjectBuilder($function, $type);
}
if (! $overridesDefault) {

View File

@ -5,29 +5,59 @@ declare(strict_types=1);
namespace CuyZ\Valinor\Mapper\Object;
use CuyZ\Valinor\Definition\FunctionObject;
use CuyZ\Valinor\Definition\ParameterDefinition;
use CuyZ\Valinor\Mapper\Tree\Message\UserlandError;
use CuyZ\Valinor\Type\Types\ClassType;
use Exception;
use function array_map;
use function array_shift;
/** @internal */
final class FunctionObjectBuilder implements ObjectBuilder
{
private FunctionObject $function;
private string $className;
private Arguments $arguments;
public function __construct(FunctionObject $function)
private bool $isDynamicConstructor;
public function __construct(FunctionObject $function, ClassType $type)
{
$definition = $function->definition();
$arguments = array_map(
fn (ParameterDefinition $parameter) => Argument::fromParameter($parameter),
array_values(iterator_to_array($definition->parameters())) // @PHP8.1 array unpacking
);
$this->isDynamicConstructor = $definition->attributes()->has(DynamicConstructor::class);
if ($this->isDynamicConstructor) {
array_shift($arguments);
}
$this->function = $function;
$this->className = $type->className();
$this->arguments = new Arguments(...$arguments);
}
public function describeArguments(): Arguments
{
return $this->arguments ??= Arguments::fromParameters($this->function->definition()->parameters());
return $this->arguments;
}
public function build(array $arguments): object
{
$arguments = new MethodArguments($this->function->definition()->parameters(), $arguments);
$parameters = $this->function->definition()->parameters();
if ($this->isDynamicConstructor) {
$arguments[$parameters->at(0)->name()] = $this->className;
}
$arguments = new MethodArguments($parameters, $arguments);
try {
return ($this->function->callback())(...$arguments);

View File

@ -5,8 +5,11 @@ declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Integration\Mapping;
use CuyZ\Valinor\Mapper\MappingError;
use CuyZ\Valinor\Mapper\Object\DynamicConstructor;
use CuyZ\Valinor\Mapper\Object\Exception\CannotInstantiateObject;
use CuyZ\Valinor\Mapper\Object\Exception\InvalidClassConstructorType;
use CuyZ\Valinor\Mapper\Object\Exception\InvalidConstructorClassTypeParameter;
use CuyZ\Valinor\Mapper\Object\Exception\InvalidConstructorReturnType;
use CuyZ\Valinor\Mapper\Object\Exception\MissingConstructorClassTypeParameter;
use CuyZ\Valinor\Mapper\Object\Exception\ObjectBuildersCollision;
use CuyZ\Valinor\MapperBuilder;
use CuyZ\Valinor\Tests\Fake\Mapper\Tree\Message\FakeErrorMessage;
@ -114,6 +117,109 @@ final class ConstructorRegistrationMappingTest extends IntegrationTest
self::assertSame('foo', $result->foo);
}
/**
* @requires PHP >= 8
*/
public function test_registered_constructor_with_injected_class_name_is_used_for_abstract_class(): void
{
try {
$result = (new MapperBuilder())
->registerConstructor(
/**
* @param class-string<SomeAbstractClassWithStaticConstructor> $className
*/
#[DynamicConstructor]
fn (string $className, string $foo, int $bar): SomeAbstractClassWithStaticConstructor => $className::from($foo, $bar)
)
->mapper()
->map(SomeClassWithBothInheritedStaticConstructors::class, [
'someChild' => ['foo' => 'foo', 'bar' => 42],
'someOtherChild' => ['foo' => 'fiz', 'bar' => 1337],
]);
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertSame('foo', $result->someChild->foo);
self::assertSame(42, $result->someChild->bar);
self::assertSame('fiz', $result->someOtherChild->foo);
self::assertSame(1337, $result->someOtherChild->bar);
}
/**
* @requires PHP >= 8
*/
public function test_registered_constructor_with_injected_class_name_is_used_for_interface(): void
{
try {
$result = (new MapperBuilder())
->registerConstructor(
/**
* @param class-string<SomeInterfaceWithStaticConstructor> $className
*/
#[DynamicConstructor]
fn (string $className, string $foo, int $bar): SomeInterfaceWithStaticConstructor => $className::from($foo, $bar)
)
->mapper()
->map(SomeClassWithBothInheritedStaticConstructors::class, [
'someChild' => ['foo' => 'foo', 'bar' => 42],
'someOtherChild' => ['foo' => 'fiz', 'bar' => 1337],
]);
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertSame('foo', $result->someChild->foo);
self::assertSame(42, $result->someChild->bar);
self::assertSame('fiz', $result->someOtherChild->foo);
self::assertSame(1337, $result->someOtherChild->bar);
}
/**
* @requires PHP >= 8
*/
public function test_registered_constructor_with_injected_class_name_without_class_string_type_is_used(): void
{
try {
$object = new stdClass();
$result = (new MapperBuilder())
->registerConstructor(
#[DynamicConstructor]
fn (string $className, string $foo): stdClass => $object
)
->mapper()
->map(stdClass::class, 'foo');
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertSame($object, $result);
}
/**
* @requires PHP >= 8
*/
public function test_registered_constructor_with_injected_class_name_with_previously_other_registered_constructor_is_used(): void
{
try {
$object = new stdClass();
$result = (new MapperBuilder())
->registerConstructor(fn (): DateTimeInterface => new DateTime())
->registerConstructor(
#[DynamicConstructor]
fn (string $className, string $foo): stdClass => $object
)
->mapper()
->map(stdClass::class, 'foo');
} catch (MappingError $error) {
$this->mappingFail($error);
}
self::assertSame($object, $result);
}
public function test_native_constructor_is_not_called_if_not_registered_but_other_constructors_are_registered(): void
{
try {
@ -415,11 +521,11 @@ final class ConstructorRegistrationMappingTest extends IntegrationTest
->map(SomeClassWithPrivateNativeConstructor::class, []);
}
public function test_invalid_constructor_type_throws_exception(): void
public function test_invalid_constructor_return_type_throws_exception(): void
{
$this->expectException(InvalidClassConstructorType::class);
$this->expectException(InvalidConstructorReturnType::class);
$this->expectExceptionCode(1659446121);
$this->expectExceptionMessageMatches('/Invalid type `string` handled by constructor `.*`\. It must be a valid class name\./');
$this->expectExceptionMessageMatches('/Invalid return type `string` for constructor `.*`\, it must be a valid class name\./');
(new MapperBuilder())
->registerConstructor(fn (): string => 'foo')
@ -427,6 +533,42 @@ final class ConstructorRegistrationMappingTest extends IntegrationTest
->map(stdClass::class, []);
}
/**
* @requires PHP >= 8
*/
public function test_missing_constructor_class_type_parameter_throws_exception(): void
{
$this->expectException(MissingConstructorClassTypeParameter::class);
$this->expectExceptionCode(1661516853);
$this->expectExceptionMessageMatches('/Missing first parameter of type `class-string` for the constructor `.*`\./');
(new MapperBuilder())
->registerConstructor(
#[DynamicConstructor]
fn (): stdClass => new stdClass()
)
->mapper()
->map(stdClass::class, []);
}
/**
* @requires PHP >= 8
*/
public function test_invalid_constructor_class_type_parameter_throws_exception(): void
{
$this->expectException(InvalidConstructorClassTypeParameter::class);
$this->expectExceptionCode(1661517000);
$this->expectExceptionMessageMatches('/Invalid type `int` for the first parameter of the constructor `.*`, it should be of type `class-string`\./');
(new MapperBuilder())
->registerConstructor(
#[DynamicConstructor]
fn (int $invalidParameterType): stdClass => new stdClass()
)
->mapper()
->map(stdClass::class, []);
}
public function test_registered_datetime_constructor_is_used(): void
{
$default = new DateTime('@1356097062');
@ -578,7 +720,13 @@ function constructorB(int $argumentA, float $argumentB): stdClass
return new stdClass();
}
abstract class SomeAbstractClassWithStaticConstructor
interface SomeInterfaceWithStaticConstructor
{
// @PHP8.0 return static
public static function from(string $foo, int $bar): self;
}
abstract class SomeAbstractClassWithStaticConstructor implements SomeInterfaceWithStaticConstructor
{
public string $foo;
@ -604,3 +752,10 @@ final class SomeClassWithInheritedStaticConstructor extends SomeAbstractClassWit
final class SomeOtherClassWithInheritedStaticConstructor extends SomeAbstractClassWithStaticConstructor
{
}
final class SomeClassWithBothInheritedStaticConstructors
{
public SomeClassWithInheritedStaticConstructor $someChild;
public SomeOtherClassWithInheritedStaticConstructor $someOtherChild;
}

View File

@ -7,6 +7,7 @@ namespace CuyZ\Valinor\Tests\Unit\Mapper\Object;
use CuyZ\Valinor\Definition\FunctionObject;
use CuyZ\Valinor\Mapper\Object\FunctionObjectBuilder;
use CuyZ\Valinor\Tests\Fake\Definition\FakeFunctionDefinition;
use CuyZ\Valinor\Type\Types\ClassType;
use PHPUnit\Framework\TestCase;
use stdClass;
@ -14,7 +15,10 @@ final class FunctionObjectBuilderTest extends TestCase
{
public function test_arguments_instance_stays_the_same(): void
{
$objectBuilder = new FunctionObjectBuilder(new FunctionObject(FakeFunctionDefinition::new(), fn () => new stdClass()));
$objectBuilder = new FunctionObjectBuilder(
new FunctionObject(FakeFunctionDefinition::new(), fn () => new stdClass()),
new ClassType(stdClass::class)
);
$argumentsA = $objectBuilder->describeArguments();
$argumentsB = $objectBuilder->describeArguments();