Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,42 @@ final class AnotherDto
> [!WARNING]
> Circular references are not supported and will result in an exception.

#### Inline

The `InlineNormalizer` allows you to define normalization and denormalization logic directly via closures.
This is useful for simple value objects or when you don't want to create a separate normalizer class.

```php
use Patchlevel\Hydrator\Normalizer\InlineNormalizer;

#[InlineNormalizer(
normalize: static function (self $object): string {
return $object->toString();
},
denormalize: static function (string $value): self {
return new self($value);
},
)]
final class Email
{
public function __construct(
private string $value
) {}

public function toString(): string
{
return $this->value;
}
}
```

> [!NOTE]
> Closures in attributes can only be used since PHP 8.5, therefore this normalizer can only be used with PHP 8.5.

> [!TIP]
> If you want to handle `null` values within your closures, you can set the `passNull` option to `true`.
> By default, `null` values are not passed to the closures and are returned as `null` directly.

### Custom Normalizer

Since we only offer normalizers for PHP native things,
Expand Down
53 changes: 53 additions & 0 deletions src/Normalizer/InlineNormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Normalizer;

use Attribute;
use Closure;
use TypeError;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)]
final class InlineNormalizer implements Normalizer
{
/**
* @param Closure(O): N $normalize
* @param Closure(N): O $denormalize
*
* @template O of mixed
* @template N of mixed
*/
public function __construct(
private readonly Closure $normalize,
private readonly Closure $denormalize,
private readonly bool $passNull = false,
) {
}

public function normalize(mixed $value): mixed
{
if (!$this->passNull && $value === null) {
return null;
}

try {
return ($this->normalize)($value);
} catch (TypeError) {
throw new InvalidType();
}
}

public function denormalize(mixed $value): mixed
{
if (!$this->passNull && $value === null) {
return null;
}

try {
return ($this->denormalize)($value);
} catch (TypeError) {
throw new InvalidType();
}
}
}
14 changes: 14 additions & 0 deletions tests/Unit/Fixture/ProfileCreatedWithInlineNormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Tests\Unit\Fixture;

final class ProfileCreatedWithInlineNormalizer
{
public function __construct(
public ProfileId $profileId,
public ValueObject $valueObject,
) {
}
}
33 changes: 33 additions & 0 deletions tests/Unit/Fixture/ValueObject.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Tests\Unit\Fixture;

use Patchlevel\Hydrator\Normalizer\InlineNormalizer;

#[InlineNormalizer(
normalize: static function (self $object): string {
return $object->toString();
},
denormalize: static function (string $value): self {
return self::fromString($value);
},
)]
final class ValueObject
{
private function __construct(
private readonly string $value,
) {
}

public function toString(): string
{
return $this->value;
}

public static function fromString(string $value): self
{
return new self($value);
}
}
34 changes: 33 additions & 1 deletion tests/Unit/MetadataHydratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,14 @@
use Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto;
use Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto;
use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated;
use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer;
use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithNormalizer;
use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWrapper;
use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileId;
use Patchlevel\Hydrator\Tests\Unit\Fixture\Skill;
use Patchlevel\Hydrator\Tests\Unit\Fixture\Status;
use Patchlevel\Hydrator\Tests\Unit\Fixture\StatusWithNormalizer;
use Patchlevel\Hydrator\Tests\Unit\Fixture\ValueObject;
use Patchlevel\Hydrator\Tests\Unit\Fixture\WrongNormalizer;
use Patchlevel\Hydrator\TypeMismatch;
use PHPUnit\Framework\Attributes\RequiresPhp;
Expand Down Expand Up @@ -114,7 +116,7 @@ public function testExtractCircularReference(): void
$this->hydrator->extract($dto1);
}

public function testExtractWithInferNormalizer2(): void
public function testExtractWithInferNormalizer(): void
{
$result = $this->hydrator->extract(
new InferNormalizerWithNullableDto(
Expand Down Expand Up @@ -149,6 +151,20 @@ public function testExtractWithInferNormalizerFailed(): void
);
}

#[RequiresPhp('>=8.5')]
public function testExtractWithInlineNormalizer(): void
{
$event = new ProfileCreatedWithInlineNormalizer(
ProfileId::fromString('1'),
ValueObject::fromString('foo'),
);

self::assertEquals(
['profileId' => '1', 'valueObject' => 'foo'],
$this->hydrator->extract($event),
);
}

public function testExtractWithHooks(): void
{
$data = $this->hydrator->extract(new DtoWithHooks());
Expand Down Expand Up @@ -238,6 +254,22 @@ public function testHydrateWithTypeMismatch(): void
);
}

#[RequiresPhp('>=8.5')]
public function testHydrateWithInlineNormalizer(): void
{
$expected = new ProfileCreatedWithInlineNormalizer(
ProfileId::fromString('1'),
ValueObject::fromString('foo'),
);

$event = $this->hydrator->hydrate(
ProfileCreatedWithInlineNormalizer::class,
['profileId' => '1', 'valueObject' => 'foo'],
);

self::assertEquals($expected, $event);
}

public function testDenormalizationFailure(): void
{
$this->expectException(DenormalizationFailure::class);
Expand Down
98 changes: 98 additions & 0 deletions tests/Unit/Normalizer/InlineNormalizerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Tests\Unit\Normalizer;

use Patchlevel\Hydrator\Normalizer\InlineNormalizer;
use Patchlevel\Hydrator\Normalizer\InvalidType;
use PHPUnit\Framework\TestCase;

final class InlineNormalizerTest extends TestCase
{
public function testNormalizeWithValue(): void
{
$normalizer = new InlineNormalizer(
static fn (int $value): string => (string)$value,
static fn (string $value): int => (int)$value,
);

$this->assertEquals('123', $normalizer->normalize(123));
}

public function testDenormalizeWithValue(): void
{
$normalizer = new InlineNormalizer(
static fn (int $value): string => (string)$value,
static fn (string $value): int => (int)$value,
);

$this->assertEquals(123, $normalizer->denormalize('123'));
}

public function testNormalizeWithNull(): void
{
$normalizer = new InlineNormalizer(
static fn (mixed $value) => 'not null',
static fn (mixed $value) => 'not null',
);

$this->assertNull($normalizer->normalize(null));
}

public function testDenormalizeWithNull(): void
{
$normalizer = new InlineNormalizer(
static fn (mixed $value) => 'not null',
static fn (mixed $value) => 'not null',
);

$this->assertNull($normalizer->denormalize(null));
}

public function testNormalizePassNull(): void
{
$normalizer = new InlineNormalizer(
static fn (mixed $value) => $value === null ? 'is null' : 'is not null',
static fn (mixed $value) => $value,
true,
);

$this->assertEquals('is null', $normalizer->normalize(null));
}

public function testDenormalizePassNull(): void
{
$normalizer = new InlineNormalizer(
static fn (mixed $value) => $value,
static fn (mixed $value) => $value === null ? 'is null' : 'is not null',
true,
);

$this->assertEquals('is null', $normalizer->denormalize(null));
}

public function testNormalizeInvalidType(): void
{
$this->expectException(InvalidType::class);

$normalizer = new InlineNormalizer(
static fn (string $value) => $value,
static fn (mixed $value) => $value,
);

$normalizer->normalize(123);
}

public function testDenormalizeInvalidType(): void
{
$this->expectException(InvalidType::class);

$normalizer = new InlineNormalizer(
static fn (mixed $value) => $value,
static fn (string $value) => $value,
);

$normalizer->denormalize(123);
}
}
Loading