Skip to content

Commit

Permalink
feat: introduce Constructor attribute
Browse files Browse the repository at this point in the history
This attribute can be assigned to any method inside an object, to
automatically mark the method as a constructor for the class. This is
a more convenient way of registering constructors than using the
`MapperBuilder::registerConstructor` method, although it does not
replace it.

The method targeted by a `Constructor` attribute must be public, static
and return an instance of the class it is part of.

```php
final readonly class Email
{
    // When another constructor is registered for the class, the native
    // constructor is disabled. To enable it again, it is mandatory to
    // explicitly register it again.
    #[\CuyZ\Valinor\Mapper\Object\Constructor]
    public function __construct(public string $value) {}

    #[\CuyZ\Valinor\Mapper\Object\Constructor]
    public static function createFrom(
        string $userName, string $domainName
    ): self {
        return new self($userName . '@' . $domainName);
    }
}

(new \CuyZ\Valinor\MapperBuilder())
    ->mapper()
    ->map(Email::class, [
        'userName' => 'john.doe',
        'domainName' => 'example.com',
    ]); // [email protected]
```
  • Loading branch information
romm committed Mar 12, 2024
1 parent 2107ea1 commit d86295c
Show file tree
Hide file tree
Showing 13 changed files with 487 additions and 66 deletions.
147 changes: 91 additions & 56 deletions docs/pages/how-to/use-custom-object-constructors.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Using custom object constructors

An object may have custom ways of being created, in such cases these
constructors need to be registered to the mapper to be used. A constructor is a
callable that can be either:
constructors need to be registered to the mapper to be used. A constructor can
be either:

1. A named constructor, also known as a static factory method
2. The method of a service — for instance a repository
Expand All @@ -14,22 +14,65 @@ know when to use it. Any argument can be provided and will automatically be
mapped using the given source. These arguments can then be used to instantiate
the object in the desired way.

Registering any constructor will disable the native constructor — the
`__construct` method — of the targeted class. If for some reason it still needs
to be handled as well, the name of the class must be given to the
registration method.

If several constructors are registered, they must provide distinct signatures to
prevent collision during mapping — meaning that if two constructors require
several arguments with the exact same names, the mapping will fail.

!!! note

Registering a constructor for a class will prevent its native constructor
(the `__construct` method) to be handled by the mapper. If it needs to be
be enabled again, it has to be explicitly registered.

## The `Constructor` attribute

The quickest and easiest way to register a constructor is to use the
`Constructor` attribute, which will mark a method as a constructor that can be
used by the mapper.

The targeted method must be public, static and return an instance of the class
it is part of.

```php
final readonly class Email
{
// When another constructor is registered for the class, the native
// constructor is disabled. To enable it again, it is mandatory to
// explicitly register it again.
#[\CuyZ\Valinor\Mapper\Object\Constructor]
public function __construct(public string $value) {}

#[\CuyZ\Valinor\Mapper\Object\Constructor]
public static function createFrom(string $userName, string $domainName): self
{
return new self($userName . '@' . $domainName);
}
}

(new \CuyZ\Valinor\MapperBuilder())
->mapper()
->map(Email::class, [
'userName' => 'john.doe',
'domainName' => 'example.com',
]); // [email protected]
```

## Manually registering a constructor

There are cases where the `Constructor` attribute cannot be used, for instance
when the class is an external dependency that cannot be modified. In such cases,
the `registerConstructor` method can be used to register any callable as a
constructor.

```php
(new \CuyZ\Valinor\MapperBuilder())
->registerConstructor(
// Allow the native constructor to be used
// When another constructor is registered for the class, the native
// constructor is disabled. To enable it again, it is mandatory to
// explicitly register it again by giving the class name to this method.
Color::class,

// Register a named constructor (1)
// Register a named constructor
Color::fromHex(...),

/**
Expand Down Expand Up @@ -87,74 +130,66 @@ final class Color
}
```

1. …or for PHP < 8.1:

```php
[Color::class, 'fromHex'],
```

## Custom enum constructor

Registering a constructor for an enum works the same way as for a class, as
described above.

```php
(new \CuyZ\Valinor\MapperBuilder())
->registerConstructor(
// Allow the native constructor to be used
SomeEnum::class,

// Register a named constructor
SomeEnum::fromMatrix(...)
)
->mapper()
->map(SomeEnum::class, [
'type' => 'FOO',
'number' => 2,
]);

enum SomeEnum: string
enum Color: string
{
case CASE_A = 'FOO_VALUE_1';
case CASE_B = 'FOO_VALUE_2';
case CASE_C = 'BAR_VALUE_1';
case CASE_D = 'BAR_VALUE_2';

/**
* @param 'FOO'|'BAR' $type
* @param int<1, 2> $number
*/
public static function fromMatrix(string $type, int $number): self
case LIGHT_RED = 'LIGHT_RED';
case LIGHT_GREEN = 'LIGHT_GREEN';
case LIGHT_BLUE = 'LIGHT_BLUE';
case DARK_RED = 'DARK_RED';
case DARK_GREEN = 'DARK_GREEN';
case DARK_BLUE = 'DARK_BLUE';

#[\CuyZ\Valinor\Mapper\Object\Constructor]
public static function fromMatrix(string $type, string $color): Color
{
return self::from("{$type}_VALUE_{$number}");
return self::from($type . '_' . $color);
}
}

(new \CuyZ\Valinor\MapperBuilder())
->mapper()
->map(Color::class , [
'type' => 'DARK',
'color' => 'RED'
]); // Color::DARK_RED
```

!!! note

An enum constructor can be for a specific pattern:
An enum constructor can target a specific pattern:

```php
enum SomeEnum
enum Color: string
{
case FOO;
case BAR;
case BAZ;
case LIGHT_RED = 'LIGHT_RED';
case LIGHT_GREEN = 'LIGHT_GREEN';
case LIGHT_BLUE = 'LIGHT_BLUE';
case DARK_RED = 'DARK_RED';
case DARK_GREEN = 'DARK_GREEN';
case DARK_BLUE = 'DARK_BLUE';

/**
* This constructor will be called only when pattern `SomeEnum::DARK_*`
* is requested during mapping.
*
* @return Color::DARK_*
*/
#[\CuyZ\Valinor\Mapper\Object\Constructor]
public static function darkFrom(string $value): Color
{
return self::from('DARK_' . $value);
}
}

(new \CuyZ\Valinor\MapperBuilder())
->registerConstructor(
/**
* This constructor will be called only when pattern `SomeEnum::BA*`
* is requested during mapping.
*
* @return SomeEnum::BA*
*/
fn (string $value): SomeEnum => /* Some custom domain logic */
)
->mapper()
->map(SomeEnum::class . '::BA*', 'some custom value');
->map(Color::class . '::DARK_*', 'RED'); // Color::DARK_RED
```

## Dynamic constructors
Expand Down
2 changes: 1 addition & 1 deletion docs/pages/serialization/extending-normalizer.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ A transformer can be a callable (function, closure or a class implementing the

!!! note
You can find common examples of transformers in the [next
chapter](common-examples.md).
chapter](common-transformers-examples.md).

## Callable transformers

Expand Down
1 change: 1 addition & 0 deletions src/Definition/MethodDefinition.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public function __construct(
public readonly string $name,
/** @var non-empty-string */
public readonly string $signature,
public readonly Attributes $attributes,
public readonly Parameters $parameters,
public readonly bool $isStatic,
public readonly bool $isPublic,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,21 @@ final class MethodDefinitionCompiler
{
private TypeCompiler $typeCompiler;

private AttributesCompiler $attributesCompiler;

private ParameterDefinitionCompiler $parameterCompiler;

public function __construct(TypeCompiler $typeCompiler, AttributesCompiler $attributesCompiler)
{
$this->typeCompiler = $typeCompiler;
$this->attributesCompiler = $attributesCompiler;
$this->parameterCompiler = new ParameterDefinitionCompiler($typeCompiler, $attributesCompiler);
}

public function compile(MethodDefinition $method): string
{
$attributes = $this->attributesCompiler->compile($method->attributes);

$parameters = array_map(
fn (ParameterDefinition $parameter) => $this->parameterCompiler->compile($parameter),
iterator_to_array($method->parameters)
Expand All @@ -38,6 +43,7 @@ public function compile(MethodDefinition $method): string
new \CuyZ\Valinor\Definition\MethodDefinition(
'{$method->name}',
'{$method->signature}',
$attributes,
new \CuyZ\Valinor\Definition\Parameters($parameters),
$isStatic,
$isPublic,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@

namespace CuyZ\Valinor\Definition\Repository\Reflection;

use CuyZ\Valinor\Definition\Attributes;
use CuyZ\Valinor\Definition\MethodDefinition;
use CuyZ\Valinor\Definition\Parameters;
use CuyZ\Valinor\Definition\Repository\AttributesRepository;
use CuyZ\Valinor\Utility\Reflection\Reflection;
use ReflectionAttribute;
use ReflectionMethod;
use ReflectionParameter;

Expand All @@ -16,10 +18,13 @@
/** @internal */
final class ReflectionMethodDefinitionBuilder
{
private AttributesRepository $attributesRepository;

private ReflectionParameterDefinitionBuilder $parameterBuilder;

public function __construct(AttributesRepository $attributesRepository)
{
$this->attributesRepository = $attributesRepository;
$this->parameterBuilder = new ReflectionParameterDefinitionBuilder($attributesRepository);
}

Expand All @@ -28,6 +33,11 @@ public function for(ReflectionMethod $reflection, ReflectionTypeResolver $typeRe
/** @var non-empty-string $name */
$name = $reflection->name;

$attributes = array_map(
fn (ReflectionAttribute $attribute) => $this->attributesRepository->for($attribute),
Reflection::attributes($reflection)
);

$parameters = array_map(
fn (ReflectionParameter $parameter) => $this->parameterBuilder->for($parameter, $typeResolver),
$reflection->getParameters()
Expand All @@ -38,6 +48,7 @@ public function for(ReflectionMethod $reflection, ReflectionTypeResolver $typeRe
return new MethodDefinition(
$name,
Reflection::signature($reflection),
new Attributes(...$attributes),
new Parameters(...$parameters),
$reflection->isStatic(),
$reflection->isPublic(),
Expand Down
45 changes: 45 additions & 0 deletions src/Mapper/Object/Constructor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Mapper\Object;

use Attribute;

/**
* This attribute allows a static method inside a class to be marked as a
* constructor, that can be used by the mapper to instantiate the object. The
* method must be public, static and return an instance of the class it is part
* of.
*
* This attribute is a convenient replacement to the usage of the constructor
* registration method: @see \CuyZ\Valinor\MapperBuilder::registerConstructor()
*
* ```php
* final readonly class Email
* {
* // When another constructor is registered for the class, the native
* // constructor is disabled. To enable it again, it is mandatory to
* // explicitly register it again.
* #[\CuyZ\Valinor\Mapper\Object\Constructor]
* public function __construct(public string $value) {}
*
* #[\CuyZ\Valinor\Mapper\Object\Constructor]
* public static function createFrom(string $user, string $domainName): self
* {
* return new self($user . '@' . $domainName);
* }
* }
*
* (new \CuyZ\Valinor\MapperBuilder())
* ->mapper()
* ->map(Email::class, [
* 'userName' => 'john.doe',
* 'domainName' => 'example.com',
* ]); // [email protected]
* ```
*
* @api
*/
#[Attribute(Attribute::TARGET_METHOD)]
final class Constructor {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Mapper\Object\Exception;

use CuyZ\Valinor\Definition\MethodDefinition;
use CuyZ\Valinor\Type\Types\UnresolvableType;
use LogicException;

/** @internal */
final class InvalidConstructorMethodWithAttributeReturnType extends LogicException
{
/**
* @param class-string $expectedClassName
*/
public function __construct(string $expectedClassName, MethodDefinition $method)
{
if ($method->returnType instanceof UnresolvableType) {
$message = $method->returnType->message();
} else {
$message = "Invalid return type `{$method->returnType->toString()}` for constructor `{$method->signature}`, it must be `$expectedClassName`.";
}

parent::__construct($message, 1708104783);
}
}
Loading

0 comments on commit d86295c

Please sign in to comment.