Skip to content

Commit

Permalink
feat: introduce AsTransformer attribute
Browse files Browse the repository at this point in the history
This attribute can be used to automatically register a transformer
attribute.

```php
namespace My\App;

#[\CuyZ\Valinor\Normalizer\AsTransformer]
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class DateTimeFormat
{
    public function __construct(private string $format) {}

    public function normalize(\DateTimeInterface $date): string
    {
        return $date->format($this->format);
    }
}

final readonly class Event
{
    public function __construct(
        public string $eventName,
        #[\My\App\DateTimeFormat('Y/m/d')]
        public \DateTimeInterface $date,
    ) {}
}

(new \CuyZ\Valinor\MapperBuilder())
    ->normalizer(\CuyZ\Valinor\Normalizer\Format::array())
    ->normalize(new \My\App\Event(
        eventName: 'Release of legendary album',
        date: new \DateTimeImmutable('1971-11-08'),
    ));

// [
//     'eventName' => 'Release of legendary album',
//     'date' => '1971/11/08',
// ]
  • Loading branch information
romm committed Mar 15, 2024
1 parent 274cfd5 commit 13b6d01
Show file tree
Hide file tree
Showing 6 changed files with 207 additions and 42 deletions.
10 changes: 5 additions & 5 deletions docs/pages/serialization/common-transformers-examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ property, as shown in the example below:
```php
namespace My\App;

#[\CuyZ\Valinor\Normalizer\AsTransformer]
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class DateTimeFormat
{
Expand All @@ -81,7 +82,6 @@ final readonly class Event
}

(new \CuyZ\Valinor\MapperBuilder())
->registerTransformer(\My\App\DateTimeFormat::class)
->normalizer(\CuyZ\Valinor\Normalizer\Format::array())
->normalize(
new \My\App\Event(
Expand Down Expand Up @@ -170,6 +170,7 @@ objects, as shown in the example below:
```php
namespace My\App;

#[\CuyZ\Valinor\Normalizer\AsTransformer]
#[\Attribute(\Attribute::TARGET_CLASS)]
final class SnakeCaseProperties
{
Expand All @@ -193,7 +194,7 @@ final class SnakeCaseProperties
}
}

#[SnakeCaseProperties]
#[\My\App\SnakeCaseProperties]
final readonly class Country
{
public function __construct(
Expand All @@ -203,7 +204,6 @@ final readonly class Country
}

(new \CuyZ\Valinor\MapperBuilder())
->registerTransformer(\My\App\SnakeCaseProperties::class)
->normalizer(\CuyZ\Valinor\Normalizer\Format::array())
->normalize(
new \My\App\User(
Expand Down Expand Up @@ -243,6 +243,7 @@ value with a custom object that is afterward removed by a global transformer.
```php
namespace My\App;

#[\CuyZ\Valinor\Normalizer\AsTransformer]
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class Ignore
{
Expand All @@ -267,7 +268,6 @@ final readonly class User
}

(new \CuyZ\Valinor\MapperBuilder())
->registerTransformer(\My\App\Ignore::class)
->registerTransformer(
fn (object $value, callable $next) => array_filter(
$next(),
Expand Down Expand Up @@ -297,6 +297,7 @@ renamed during normalization
```php
namespace My\App;

#[\CuyZ\Valinor\Normalizer\AsTransformer]
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class Rename
{
Expand All @@ -319,7 +320,6 @@ final readonly class Address
}

(new \CuyZ\Valinor\MapperBuilder())
->registerTransformer(\My\App\Rename::class)
->normalizer(\CuyZ\Valinor\Normalizer\Format::array())
->normalize(
new Address(
Expand Down
66 changes: 39 additions & 27 deletions docs/pages/serialization/extending-normalizer.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,29 +82,8 @@ Callable transformers allow targeting any value during normalization, whereas
attribute transformers allow targeting a specific class or property for a more
granular control.

To be detected by the normalizer, an attribute must be registered first by
giving its class name to the `registerTransformer` method.

!!! tip
It is possible to register attributes that share a common interface by
giving the interface name to the method.

```php
namespace My\App;

interface SomeAttributeInterface {}

#[\Attribute]
final class SomeAttribute implements \My\App\SomeAttributeInterface {}

#[\Attribute]
final class SomeOtherAttribute implements \My\App\SomeAttributeInterface {}

(new \CuyZ\Valinor\MapperBuilder())
// Registers both `SomeAttribute` and `SomeOtherAttribute` attributes
->registerTransformer(\My\App\SomeAttributeInterface::class)
```
To be detected by the normalizer, an attribute class must be registered first by
adding the `AsTransformer` attribute to it.

Attributes must declare a method named `normalize` that follows the same rules
as callable transformers: a mandatory first parameter and an optional second
Expand All @@ -113,6 +92,7 @@ as callable transformers: a mandatory first parameter and an optional second
```php
namespace My\App;

#[\CuyZ\Valinor\Normalizer\AsTransformer]
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class Uppercase
{
Expand All @@ -126,15 +106,14 @@ final readonly class City
{
public function __construct(
public string $zipCode,
#[Uppercase]
#[\My\App\Uppercase]
public string $name,
#[Uppercase]
#[\My\App\Uppercase]
public string $country,
) {}
}

(new \CuyZ\Valinor\MapperBuilder())
->registerTransformer(Uppercase::class)
->normalizer(\CuyZ\Valinor\Normalizer\Format::array())
->normalize(
new \My\App\City(
Expand All @@ -157,6 +136,7 @@ method named `normalizeKey`.
```php
namespace My\App;

#[\CuyZ\Valinor\Normalizer\AsTransformer]
#[\Attribute(\Attribute::TARGET_PROPERTY)]
final class PrefixedWith
{
Expand All @@ -181,7 +161,6 @@ final readonly class Address
}

(new \CuyZ\Valinor\MapperBuilder())
->registerTransformer(PrefixedWith::class)
->normalizer(\CuyZ\Valinor\Normalizer\Format::array())
->normalize(
new \My\App\Address(
Expand All @@ -197,3 +176,36 @@ final readonly class Address
// 'address_city' => 'London',
// ]
```

---

When there is no control over the transformer attribute class, it is possible to
register it using the `registerTransformer` method.

```php
(new \CuyZ\Valinor\MapperBuilder())
->registerTransformer(\Some\External\TransformerAttribute::class)
->normalizer(\CuyZ\Valinor\Normalizer\Format::array())
->normalize(…);
```

It is also possible to register attributes that share a common interface by
giving the interface name to the registration method.

```php
namespace My\App;

interface SomeAttributeInterface {}

#[\Attribute]
final class SomeAttribute implements \My\App\SomeAttributeInterface {}

#[\Attribute]
final class SomeOtherAttribute implements \My\App\SomeAttributeInterface {}

(new \CuyZ\Valinor\MapperBuilder())
// Registers both `SomeAttribute` and `SomeOtherAttribute` attributes
->registerTransformer(\My\App\SomeAttributeInterface::class)
->normalizer(\CuyZ\Valinor\Normalizer\Format::array())
->normalize(…);
```
11 changes: 6 additions & 5 deletions src/MapperBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -477,8 +477,9 @@ public function filterExceptions(callable $filter): self
* transformer will be called. Default priority is 0.
*
* An attribute on a property or a class can act as a transformer if:
* 1. It is callable (they define an `__invoke` method)
* 2. It is registered using `registerTransformer()`
* 1. It defines a `normalize` or `normalizeKey` method.
* 2. It is registered using either the `registerTransformer()` method or
* the following attribute: @see \CuyZ\Valinor\Normalizer\AsTransformer
*
* Example:
*
Expand All @@ -505,9 +506,9 @@ public function filterExceptions(callable $filter): self
* priority: -100 // Negative priority: transformer is called early
* )
*
* // Transformer attributes must be registered before they are used by
* // the normalizer.
* ->registerTransformer(SomeAttribute::class)
* // External transformer attributes must be registered before they are
* // used by the normalizer.
* ->registerTransformer(\Some\External\TransformerAttribute::class)
*
* ->normalizer()
* ->normalize('Hello world'); // HELLO WORLD?!
Expand Down
55 changes: 55 additions & 0 deletions src/Normalizer/AsTransformer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Normalizer;

use Attribute;

/**
* This attribute can be used to automatically register a transformer attribute.
*
* When there is no control over the transformer attribute class, the following
* method can be used: @see \CuyZ\Valinor\MapperBuilder::registerTransformer
*
* ```php
* namespace My\App;
*
* #[\CuyZ\Valinor\Normalizer\AsTransformer]
* #[\Attribute(\Attribute::TARGET_PROPERTY)]
* final class DateTimeFormat
* {
* public function __construct(private string $format) {}
*
* public function normalize(\DateTimeInterface $date): string
* {
* return $date->format($this->format);
* }
* }
*
* final readonly class Event
* {
* public function __construct(
* public string $eventName,
* #[\My\App\DateTimeFormat('Y/m/d')]
* public \DateTimeInterface $date,
* ) {}
* }
*
* (new \CuyZ\Valinor\MapperBuilder())
* ->normalizer(\CuyZ\Valinor\Normalizer\Format::array())
* ->normalize(new \My\App\Event(
* eventName: 'Release of legendary album',
* date: new \DateTimeImmutable('1971-11-08'),
* ));
*
* // [
* // 'eventName' => 'Release of legendary album',
* // 'date' => '1971/11/08',
* // ]
* ```
*
* @api
*/
#[Attribute(Attribute::TARGET_CLASS)]
final class AsTransformer {}
15 changes: 10 additions & 5 deletions src/Normalizer/Transformer/RecursiveTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
use CuyZ\Valinor\Definition\AttributeDefinition;
use CuyZ\Valinor\Definition\Attributes;
use CuyZ\Valinor\Definition\Repository\ClassDefinitionRepository;
use CuyZ\Valinor\Normalizer\AsTransformer;
use CuyZ\Valinor\Normalizer\Exception\CircularReferenceFoundDuringNormalization;
use CuyZ\Valinor\Normalizer\Exception\TypeUnhandledByNormalizer;
use CuyZ\Valinor\Type\Types\NativeClassType;
Expand Down Expand Up @@ -64,17 +65,17 @@ private function doTransform(mixed $value, WeakMap $references, array $attribute
$references[$value] = true;
}

if ($this->transformers === [] && $this->transformerAttributes === []) {
return $this->defaultTransformer($value, $references);
}

if ($this->transformerAttributes !== [] && is_object($value)) {
if (is_object($value)) {
$classAttributes = $this->classDefinitionRepository->for(NativeClassType::for($value::class))->attributes;
$classAttributes = $this->filterAttributes($classAttributes);

$attributes = [...$attributes, ...$classAttributes];
}

if ($this->transformers === [] && $attributes === []) {
return $this->defaultTransformer($value, $references);
}

return $this->valueTransformers->transform(
$value,
$attributes,
Expand Down Expand Up @@ -155,6 +156,10 @@ private function defaultTransformer(mixed $value, WeakMap $references): mixed
private function filterAttributes(Attributes $attributes): Attributes
{
return $attributes->filter(function (AttributeDefinition $attribute) {
if ($attribute->class->attributes->has(AsTransformer::class)) {
return true;
}

foreach ($this->transformerAttributes as $transformerAttribute) {
if (is_a($attribute->class->type->className(), $transformerAttribute, true)) {
return true;
Expand Down
92 changes: 92 additions & 0 deletions tests/Integration/Normalizer/AsTransformerAttributeTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
<?php

declare(strict_types=1);

namespace CuyZ\Valinor\Tests\Integration\Normalizer;

use Attribute;
use CuyZ\Valinor\Normalizer\AsTransformer;
use CuyZ\Valinor\Normalizer\Format;
use CuyZ\Valinor\Tests\Integration\IntegrationTestCase;

use function strtoupper;

final class AsTransformerAttributeTest extends IntegrationTestCase
{
public function test_property_transformer_attribute_registered_with_attribute_is_used(): void
{
$class = new class ('foo') {
public function __construct(
#[TransformerAttributeForString]
public string $value,
) {}
};

$result = $this->mapperBuilder()
->normalizer(Format::array())
->normalize($class);

self::assertSame(['value' => 'FOO'], $result);
}

public function test_class_transformer_attribute_registered_with_attribute_is_used(): void
{
$class = new #[TransformerAttributeForObject] class ('foo') {
public function __construct(
public string $value,
) {}
};

$result = $this->mapperBuilder()
->normalizer(Format::array())
->normalize($class);

self::assertSame(['value' => 'foo', 'added' => 'foo'], $result);
}

public function test_property_key_transformer_attribute_registered_with_attribute_is_used(): void
{
$class = new class ('foo') {
public function __construct(
#[TransformerAttributeForStringKey]
public string $value,
) {}
};

$result = $this->mapperBuilder()
->normalizer(Format::array())
->normalize($class);

self::assertSame(['VALUE' => 'foo'], $result);
}
}

#[AsTransformer, Attribute(Attribute::TARGET_PROPERTY)]
final class TransformerAttributeForString
{
public function normalize(string $value): string
{
return strtoupper($value);
}
}

#[AsTransformer, Attribute(Attribute::TARGET_CLASS)]
final class TransformerAttributeForObject
{
/**
* @return array<mixed>
*/
public function normalize(object $object, callable $next): array
{
return $next() + ['added' => 'foo'];
}
}

#[AsTransformer, Attribute(Attribute::TARGET_PROPERTY)]
final class TransformerAttributeForStringKey
{
public function normalizeKey(string $value): string
{
return strtoupper($value);
}
}

0 comments on commit 13b6d01

Please sign in to comment.