feat: handle abstract constructor registration

It is now possible to register a static method constructor that can be
inherited by a child class. The constructor will then be used correctly
to map the child class.

```php
abstract class ClassWithStaticConstructor
{
    public string $value;

    final private function __construct(string $value)
    {
        $this->value = $value;
    }

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

final class ChildClass extends ClassWithStaticConstructor {}

(new MapperBuilder())
    // The constructor can be used for every child of the parent class
    ->registerConstructor(ClassWithStaticConstructor::from(...))
    ->mapper()
    ->map(ChildClass::class, 'foo');
```
This commit is contained in:
Romain Canon 2022-08-08 14:50:00 +02:00
parent 73b62241b6
commit c37ac1e259
13 changed files with 116 additions and 181 deletions

View File

@ -31,6 +31,6 @@ final class StaticMethodConstructor implements ObjectBuilderFactory
public function for(ClassDefinition $class): array
{
return [new MethodObjectBuilder($class, $this->methodName)];
return [new MethodObjectBuilder($class->name(), $this->methodName, $class->methods()->get($this->methodName)->parameters())];
}
}

View File

@ -18,6 +18,8 @@ final class FunctionDefinition
/** @var class-string|null */
private ?string $class;
private bool $isStatic;
private Parameters $parameters;
private Type $returnType;
@ -30,6 +32,7 @@ final class FunctionDefinition
string $signature,
?string $fileName,
?string $class,
bool $isStatic,
Parameters $parameters,
Type $returnType
) {
@ -37,6 +40,7 @@ final class FunctionDefinition
$this->signature = $signature;
$this->fileName = $fileName;
$this->class = $class;
$this->isStatic = $isStatic;
$this->parameters = $parameters;
$this->returnType = $returnType;
}
@ -64,6 +68,11 @@ final class FunctionDefinition
return $this->class;
}
public function isStatic(): bool
{
return $this->isStatic;
}
/**
* @phpstan-return Parameters
* @return Parameters&ParameterDefinition[]

View File

@ -34,6 +34,7 @@ final class FunctionDefinitionCompiler implements CacheCompiler
$fileName = var_export($value->fileName(), true);
$class = var_export($value->class(), true);
$isStatic = var_export($value->isStatic(), true);
$parameters = implode(', ', $parameters);
$returnType = $this->typeCompiler->compile($value->returnType());
@ -43,6 +44,7 @@ final class FunctionDefinitionCompiler implements CacheCompiler
'{$value->signature()}',
$fileName,
$class,
$isStatic,
new \CuyZ\Valinor\Definition\Parameters($parameters),
$returnType
)

View File

@ -40,6 +40,7 @@ final class ReflectionFunctionDefinitionRepository implements FunctionDefinition
$reflection->getParameters()
);
$class = $reflection->getClosureScopeClass();
$returnType = $typeResolver->resolveType($reflection);
return new FunctionDefinition(
@ -47,7 +48,8 @@ final class ReflectionFunctionDefinitionRepository implements FunctionDefinition
Reflection::signature($reflection),
$reflection->getFileName() ?: null,
// @PHP 8.0 nullsafe operator
$reflection->getClosureScopeClass() ? $reflection->getClosureScopeClass()->name : null,
$class ? $class->name : null,
$reflection->getClosureThis() === null,
new Parameters(...$parameters),
$returnType
);

View File

@ -1,22 +0,0 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Mapper\Object\Exception;
use CuyZ\Valinor\Definition\ClassDefinition;
use CuyZ\Valinor\Definition\MethodDefinition;
use LogicException;
/** @internal */
final class ConstructorMethodIsNotPublic extends LogicException
{
public function __construct(ClassDefinition $class, MethodDefinition $method)
{
$message = $method->name() === '__construct'
? "The constructor of the class `{$class->name()}` is not public."
: "The named constructor `{$method->signature()}` is not public.";
parent::__construct($message, 1630937169);
}
}

View File

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Mapper\Object\Exception;
use CuyZ\Valinor\Definition\MethodDefinition;
use RuntimeException;
/** @internal */
final class ConstructorMethodIsNotStatic extends RuntimeException
{
public function __construct(MethodDefinition $method)
{
parent::__construct(
"Invalid constructor method `{$method->signature()}`: it is neither the constructor nor a static constructor.",
1634044370
);
}
}

View File

@ -1,20 +0,0 @@
<?php
declare(strict_types=1);
namespace CuyZ\Valinor\Mapper\Object\Exception;
use CuyZ\Valinor\Definition\ClassDefinition;
use RuntimeException;
/** @internal */
final class MethodNotFound extends RuntimeException
{
public function __construct(ClassDefinition $class, string $methodName)
{
parent::__construct(
"Method `$methodName` was not found in class `{$class->name()}`.",
1634044209
);
}
}

View File

@ -9,9 +9,11 @@ use CuyZ\Valinor\Definition\FunctionsContainer;
use CuyZ\Valinor\Mapper\Object\Exception\CannotInstantiateObject;
use CuyZ\Valinor\Mapper\Object\Exception\InvalidClassConstructorType;
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\ClassType;
use CuyZ\Valinor\Type\Types\InterfaceType;
use function array_key_exists;
use function array_unshift;
@ -69,21 +71,30 @@ final class ConstructorObjectBuilderFactory implements ObjectBuilderFactory
if (! array_key_exists($key, $this->builders)) {
$builders = [];
$className = $class->name();
$methods = $class->methods();
foreach ($this->constructors as $constructor) {
$handledType = $constructor->definition()->returnType();
$definition = $constructor->definition();
$handledType = $definition->returnType();
$functionClass = $definition->class();
if (! $handledType instanceof ClassType) {
if (! $handledType instanceof ClassType && ! $handledType instanceof InterfaceType) {
throw new InvalidClassConstructorType($constructor->definition(), $handledType);
}
if ($handledType->matches($type)) {
if (! $handledType->matches($type)) {
continue;
}
if ($functionClass && $definition->isStatic()) {
$builders[] = new MethodObjectBuilder($className, $definition->name(), $definition->parameters());
} else {
$builders[] = new FunctionObjectBuilder($constructor);
}
}
if ((array_key_exists($class->name(), $this->nativeConstructors) || count($builders) === 0)
if ((array_key_exists($className, $this->nativeConstructors) || count($builders) === 0)
&& $methods->hasConstructor()
&& $methods->constructor()->isPublic()
) {

View File

@ -4,56 +4,40 @@ declare(strict_types=1);
namespace CuyZ\Valinor\Mapper\Object;
use CuyZ\Valinor\Definition\ClassDefinition;
use CuyZ\Valinor\Definition\MethodDefinition;
use CuyZ\Valinor\Mapper\Object\Exception\ConstructorMethodIsNotPublic;
use CuyZ\Valinor\Mapper\Object\Exception\ConstructorMethodIsNotStatic;
use CuyZ\Valinor\Mapper\Object\Exception\MethodNotFound;
use CuyZ\Valinor\Definition\Parameters;
use CuyZ\Valinor\Mapper\Tree\Message\UserlandError;
use Exception;
/** @internal */
final class MethodObjectBuilder implements ObjectBuilder
{
private ClassDefinition $class;
private string $className;
private MethodDefinition $method;
private string $methodName;
private Parameters $parameters;
private Arguments $arguments;
public function __construct(ClassDefinition $class, string $methodName)
public function __construct(string $className, string $methodName, Parameters $parameters)
{
$methods = $class->methods();
if (! $methods->has($methodName)) {
throw new MethodNotFound($class, $methodName);
}
$this->class = $class;
$this->method = $methods->get($methodName);
if (! $this->method->isPublic()) {
throw new ConstructorMethodIsNotPublic($this->class, $this->method);
}
if (! $this->method->isStatic()) {
throw new ConstructorMethodIsNotStatic($this->method);
}
$this->className = $className;
$this->methodName = $methodName;
$this->parameters = $parameters;
}
public function describeArguments(): Arguments
{
return $this->arguments ??= Arguments::fromParameters($this->method->parameters());
return $this->arguments ??= Arguments::fromParameters($this->parameters);
}
public function build(array $arguments): object
{
$className = $this->class->name();
$methodName = $this->method->name();
$arguments = new MethodArguments($this->method->parameters(), $arguments);
$methodName = $this->methodName;
$arguments = new MethodArguments($this->parameters, $arguments);
try {
return $className::$methodName(...$arguments); // @phpstan-ignore-line
return ($this->className)::$methodName(...$arguments); // @phpstan-ignore-line
} catch (Exception $exception) {
throw UserlandError::from($exception);
}
@ -61,6 +45,6 @@ final class MethodObjectBuilder implements ObjectBuilder
public function signature(): string
{
return $this->method->signature();
return "$this->className::$this->methodName()";
}
}

View File

@ -20,6 +20,7 @@ final class FakeFunctionDefinition
'foo:42-1337',
$fileName ?? 'foo/bar',
stdClass::class,
true,
new Parameters(
new ParameterDefinition(
'bar',

View File

@ -32,6 +32,7 @@ final class FunctionDefinitionCompilerTest extends TestCase
'foo:42-1337',
'foo/bar',
stdClass::class,
true,
new Parameters(
new ParameterDefinition(
'bar',
@ -53,6 +54,7 @@ final class FunctionDefinitionCompilerTest extends TestCase
self::assertSame('foo', $compiledFunction->name());
self::assertSame('foo:42-1337', $compiledFunction->signature());
self::assertSame('foo/bar', $compiledFunction->fileName());
self::assertSame(true, $compiledFunction->isStatic());
self::assertSame(stdClass::class, $compiledFunction->class());
self::assertTrue($compiledFunction->parameters()->has('bar'));
self::assertInstanceOf(NativeStringType::class, $compiledFunction->returnType());

View File

@ -312,6 +312,33 @@ final class ConstructorRegistrationMappingTest extends IntegrationTest
self::assertSame('baz', $resultB->baz);
}
public function test_inherited_static_constructor_is_used_to_map_child_class(): void
{
$class = get_class(new class () {
public SomeClassWithInheritedStaticConstructor $someChild;
public SomeOtherClassWithInheritedStaticConstructor $someOtherChild;
});
try {
$result = (new MapperBuilder())
// @PHP8.1 First-class callable syntax
->registerConstructor([SomeAbstractClassWithStaticConstructor::class, 'from'])
->mapper()
->map($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);
}
public function test_identical_registered_constructors_with_no_argument_throws_exception(): void
{
$this->expectException(ObjectBuildersCollision::class);
@ -550,3 +577,30 @@ function constructorB(int $argumentA, float $argumentB): stdClass
{
return new stdClass();
}
abstract class SomeAbstractClassWithStaticConstructor
{
public string $foo;
public int $bar;
final private function __construct(string $foo, int $bar)
{
$this->foo = $foo;
$this->bar = $bar;
}
// @PHP8.0 return static
public static function from(string $foo, int $bar): self
{
return new static($foo, $bar);
}
}
final class SomeClassWithInheritedStaticConstructor extends SomeAbstractClassWithStaticConstructor
{
}
final class SomeOtherClassWithInheritedStaticConstructor extends SomeAbstractClassWithStaticConstructor
{
}

View File

@ -4,118 +4,57 @@ declare(strict_types=1);
namespace CuyZ\Valinor\Tests\Unit\Mapper\Object;
use CuyZ\Valinor\Mapper\Object\Exception\ConstructorMethodIsNotPublic;
use CuyZ\Valinor\Mapper\Object\Exception\ConstructorMethodIsNotStatic;
use CuyZ\Valinor\Mapper\Object\Exception\MethodNotFound;
use CuyZ\Valinor\Definition\Parameters;
use CuyZ\Valinor\Mapper\Object\MethodObjectBuilder;
use CuyZ\Valinor\Mapper\Tree\Message\UserlandError;
use CuyZ\Valinor\Tests\Fake\Definition\FakeClassDefinition;
use PHPUnit\Framework\TestCase;
use ReflectionClass;
use RuntimeException;
use stdClass;
use function get_class;
final class MethodObjectBuilderTest extends TestCase
{
public function test_signature_is_method_signature(): void
{
$object = new class () {
$class = get_class(new class () {
public static function someMethod(): stdClass
{
return new stdClass();
}
};
});
$class = FakeClassDefinition::fromReflection(new ReflectionClass($object));
$objectBuilder = new MethodObjectBuilder($class, 'someMethod');
$objectBuilder = new MethodObjectBuilder($class, 'someMethod', new Parameters());
self::assertSame('Signature::someMethod', $objectBuilder->signature());
}
public function test_not_existing_method_throws_exception(): void
{
$this->expectException(MethodNotFound::class);
$this->expectExceptionCode(1634044209);
$this->expectExceptionMessage('Method `notExistingMethod` was not found in class `stdClass`.');
$class = FakeClassDefinition::fromReflection(new ReflectionClass(stdClass::class));
new MethodObjectBuilder($class, 'notExistingMethod');
}
public function test_invalid_constructor_method_throws_exception(): void
{
$this->expectException(ConstructorMethodIsNotStatic::class);
$this->expectExceptionCode(1634044370);
$this->expectExceptionMessage('Invalid constructor method `Signature::invalidConstructor`: it is neither the constructor nor a static constructor.');
$object = new class () {
public function invalidConstructor(): void
{
}
};
$class = FakeClassDefinition::fromReflection(new ReflectionClass($object));
new MethodObjectBuilder($class, 'invalidConstructor');
self::assertSame("$class::someMethod()", $objectBuilder->signature());
}
public function test_exception_thrown_by_method_is_caught_and_wrapped(): void
{
$class = new class () {
$class = get_class(new class () {
public static function someMethod(): stdClass
{
throw new RuntimeException('some exception', 1337);
}
};
});
$class = FakeClassDefinition::fromReflection(new ReflectionClass($class));
$objectBuilder = new MethodObjectBuilder($class, 'someMethod');
$objectBuilder = new MethodObjectBuilder($class, 'someMethod', new Parameters());
$this->expectException(UserlandError::class);
$objectBuilder->build([]);
}
public function test_constructor_builder_for_class_with_private_constructor_throws_exception(): void
{
$this->expectException(ConstructorMethodIsNotPublic::class);
$this->expectExceptionCode(1630937169);
$this->expectExceptionMessage('The constructor of the class `' . ObjectWithPrivateNativeConstructor::class . '` is not public.');
$class = FakeClassDefinition::fromReflection(new ReflectionClass(ObjectWithPrivateNativeConstructor::class));
new MethodObjectBuilder($class, '__construct');
}
public function test_constructor_builder_for_class_with_private_named_constructor_throws_exception(): void
{
$classWithPrivateNativeConstructor = new class () {
// @phpstan-ignore-next-line
private static function someConstructor(): void
{
}
};
$this->expectException(ConstructorMethodIsNotPublic::class);
$this->expectExceptionCode(1630937169);
$this->expectExceptionMessage('The named constructor `Signature::someConstructor` is not public.');
$class = FakeClassDefinition::fromReflection(new ReflectionClass($classWithPrivateNativeConstructor));
new MethodObjectBuilder($class, 'someConstructor');
}
public function test_arguments_instance_stays_the_same(): void
{
$class = new class () {
public static function someMethod(string $string): stdClass
$class = get_class(new class () {
public static function someMethod(): stdClass
{
$class = new stdClass();
$class->string = $string;
return $class;
return new stdClass();
}
};
$class = FakeClassDefinition::fromReflection(new ReflectionClass($class));
});
$objectBuilder = new MethodObjectBuilder($class, 'someMethod');
$objectBuilder = new MethodObjectBuilder($class, 'someMethod', new Parameters());
$argumentsA = $objectBuilder->describeArguments();
$argumentsB = $objectBuilder->describeArguments();
@ -123,10 +62,3 @@ final class MethodObjectBuilderTest extends TestCase
self::assertSame($argumentsA, $argumentsB);
}
}
final class ObjectWithPrivateNativeConstructor
{
private function __construct()
{
}
}