Skip to content

Commit

Permalink
Merge pull request #49 from patchlevel/infer-normalizer
Browse files Browse the repository at this point in the history
Infer Normalizer
  • Loading branch information
DavidBadura authored Jul 3, 2024
2 parents 3c2e8c0 + 04ac73c commit 824959c
Show file tree
Hide file tree
Showing 11 changed files with 277 additions and 12 deletions.
5 changes: 3 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,10 @@ vendor: composer.lock
cs-check: vendor ## run phpcs
vendor/bin/phpcs

.PHONY: phpcs-fix
.PHONY: cs
cs: vendor ## run phpcs fixer
vendor/bin/phpcbf
vendor/bin/phpcbf || true
vendor/bin/phpcs

.PHONY: phpstan
phpstan: vendor ## run phpstan static code analyser
Expand Down
50 changes: 50 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,56 @@ final class DTO
}
```

### Define normalizer on class level

You can also set the attribute on the value object on class level. For that the normalizer needs to allow to be set on
class level.

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

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)]
class NameNormalizer implements Normalizer
{
// ... same as before
}
```

Then set the attribute on the value object.


```php
#[NameNormalizer]
final class Name
{
// ... same as before
}
```

After that the DTO can then look like this.

```php
final class DTO
{
public Name $name
}
```

### Infer Normalizer

We also integrated a process where the normalizer gets inferred by type. This means you don't need to define the
normalizer in for the properties or on class level. Right now this is only possible for Normalizer defined by our
library. There are exceptions though, the `ObjectNormalizer` and the `ArrayNormalizer`.

These Normalizer can be inferred:

* `DateTimeImmutableNormalizer`
* `DateTimeNormalizer`
* `DateTimeZoneNormalizer`
* `EnumNormalizer`


### Normalized Name

By default, the property name is used to name the field in the normalized result.
Expand Down
77 changes: 69 additions & 8 deletions src/Metadata/AttributeMetadataFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,29 @@

namespace Patchlevel\Hydrator\Metadata;

use BackedEnum;
use DateTime;
use DateTimeImmutable;
use DateTimeZone;
use Patchlevel\Hydrator\Attribute\DataSubjectId;
use Patchlevel\Hydrator\Attribute\Ignore;
use Patchlevel\Hydrator\Attribute\NormalizedName;
use Patchlevel\Hydrator\Attribute\PersonalData;
use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer;
use Patchlevel\Hydrator\Normalizer\DateTimeNormalizer;
use Patchlevel\Hydrator\Normalizer\DateTimeZoneNormalizer;
use Patchlevel\Hydrator\Normalizer\EnumNormalizer;
use Patchlevel\Hydrator\Normalizer\Normalizer;
use Patchlevel\Hydrator\Normalizer\ReflectionTypeAwareNormalizer;
use ReflectionAttribute;
use ReflectionClass;
use ReflectionNamedType;
use ReflectionProperty;

use function array_key_exists;
use function array_values;
use function class_exists;
use function is_a;

final class AttributeMetadataFactory implements MetadataFactory
{
Expand Down Expand Up @@ -132,31 +143,53 @@ private function getFieldName(ReflectionProperty $reflectionProperty): string
return $reflectionProperty->getName();
}

$attribute = $attributeReflectionList[0]->newInstance();

return $attribute->name();
return $attributeReflectionList[0]->newInstance()->name();
}

private function getNormalizer(ReflectionProperty $reflectionProperty): Normalizer|null
{
$attributeReflectionList = $reflectionProperty->getAttributes(
Normalizer::class,
ReflectionAttribute::IS_INSTANCEOF,
);
$attributeReflectionList = $this->getAttributeReflectionList($reflectionProperty);
$reflectionPropertyType = $reflectionProperty->getType();

if ($attributeReflectionList === []) {
if ($reflectionPropertyType instanceof ReflectionNamedType) {
return $this->inferNormalizer($reflectionPropertyType);
}

return null;
}

$normalizer = $attributeReflectionList[0]->newInstance();

if ($normalizer instanceof ReflectionTypeAwareNormalizer) {
$normalizer->handleReflectionType($reflectionProperty->getType());
$normalizer->handleReflectionType($reflectionPropertyType);
}

return $normalizer;
}

private function inferNormalizer(ReflectionNamedType $type): Normalizer|null
{
$className = $type->getName();

$normalizer = match ($className) {
DateTimeImmutable::class => new DateTimeImmutableNormalizer(),
DateTime::class => new DateTimeNormalizer(),
DateTimeZone::class => new DateTimeZoneNormalizer(),
default => null,
};

if ($normalizer) {
return $normalizer;
}

if (is_a($className, BackedEnum::class, true)) {
return new EnumNormalizer($className);
}

return null;
}

private function hasIgnore(ReflectionProperty $reflectionProperty): bool
{
return $reflectionProperty->getAttributes(Ignore::class) !== [];
Expand Down Expand Up @@ -263,4 +296,32 @@ private function validate(ClassMetadata $metadata): void
throw new MissingDataSubjectId($metadata->className());
}
}

/** @return array<ReflectionAttribute<Normalizer>> */
private function getAttributeReflectionList(ReflectionProperty $reflectionProperty): array
{
$attributeReflectionList = $reflectionProperty->getAttributes(
Normalizer::class,
ReflectionAttribute::IS_INSTANCEOF,
);

if ($attributeReflectionList !== []) {
return $attributeReflectionList;
}

$type = $reflectionProperty->getType();

if (!$type instanceof ReflectionNamedType) {
return [];
}

if (!class_exists($type->getName())) {
return [];
}

return (new ReflectionClass($type->getName()))->getAttributes(
Normalizer::class,
ReflectionAttribute::IS_INSTANCEOF,
);
}
}
2 changes: 1 addition & 1 deletion src/Normalizer/EnumNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
use function is_int;
use function is_string;

#[Attribute(Attribute::TARGET_PROPERTY)]
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)]
final class EnumNormalizer implements Normalizer, ReflectionTypeAwareNormalizer
{
/** @param class-string<BackedEnum>|null $enum */
Expand Down
2 changes: 1 addition & 1 deletion src/Normalizer/ObjectNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

use function is_array;

#[Attribute(Attribute::TARGET_PROPERTY)]
#[Attribute(Attribute::TARGET_PROPERTY | Attribute::TARGET_CLASS)]
final class ObjectNormalizer implements Normalizer, ReflectionTypeAwareNormalizer, HydratorAwareNormalizer
{
private Hydrator|null $hydrator = null;
Expand Down
14 changes: 14 additions & 0 deletions tests/Unit/Fixture/InferNormalizerBrokenDto.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 InferNormalizerBrokenDto
{
/** @param array<string> $array */
public function __construct(
public ProfileCreated $profileCreated,
) {
}
}
22 changes: 22 additions & 0 deletions tests/Unit/Fixture/InferNormalizerDto.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Tests\Unit\Fixture;

use DateTime;
use DateTimeImmutable;
use DateTimeZone;

final class InferNormalizerDto
{
/** @param array<string> $array */
public function __construct(
public Status $status,
public DateTimeImmutable $dateTimeImmutable,
public DateTime $dateTime,
public DateTimeZone $dateTimeZone,
public array $array,
) {
}
}
16 changes: 16 additions & 0 deletions tests/Unit/Fixture/NormalizerInBaseClassDefinedDto.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Tests\Unit\Fixture;

final class NormalizerInBaseClassDefinedDto
{
/** @param array<string> $array */
public function __construct(
public StatusWithNormalizer $status,
public ProfileCreatedWithNormalizer $profileCreated,
public array $array,
) {
}
}
19 changes: 19 additions & 0 deletions tests/Unit/Fixture/ProfileCreatedWithNormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Tests\Unit\Fixture;

use Patchlevel\Hydrator\Normalizer\ObjectNormalizer;

#[ObjectNormalizer]
final class ProfileCreatedWithNormalizer
{
public function __construct(
#[ProfileIdNormalizer]
public ProfileId $profileId,
#[EmailNormalizer]
public Email $email,
) {
}
}
15 changes: 15 additions & 0 deletions tests/Unit/Fixture/StatusWithNormalizer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Tests\Unit\Fixture;

use Patchlevel\Hydrator\Normalizer\EnumNormalizer;

#[EnumNormalizer]
enum StatusWithNormalizer: string
{
case Draft = 'draft';
case Pending = 'pending';
case Closed = 'closed';
}
67 changes: 67 additions & 0 deletions tests/Unit/MetadataHydratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

namespace Patchlevel\Hydrator\Tests\Unit;

use DateTime;
use DateTimeImmutable;
use DateTimeZone;
use Patchlevel\Hydrator\CircularReference;
use Patchlevel\Hydrator\Cryptography\PayloadCryptographer;
use Patchlevel\Hydrator\DenormalizationFailure;
Expand All @@ -15,10 +18,16 @@
use Patchlevel\Hydrator\Tests\Unit\Fixture\Circle3Dto;
use Patchlevel\Hydrator\Tests\Unit\Fixture\DefaultDto;
use Patchlevel\Hydrator\Tests\Unit\Fixture\Email;
use Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerBrokenDto;
use Patchlevel\Hydrator\Tests\Unit\Fixture\InferNormalizerDto;
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\ProfileCreatedWithNormalizer;
use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWrapper;
use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileId;
use Patchlevel\Hydrator\Tests\Unit\Fixture\Status;
use Patchlevel\Hydrator\Tests\Unit\Fixture\StatusWithNormalizer;
use Patchlevel\Hydrator\Tests\Unit\Fixture\WrongNormalizer;
use Patchlevel\Hydrator\TypeMismatch;
use PHPUnit\Framework\TestCase;
Expand Down Expand Up @@ -231,4 +240,62 @@ public function testEncrypt(): void

self::assertSame($encryptedPayload, $return);
}

public function testHydrateWithNormalizerInBaseClass(): void
{
$expected = new NormalizerInBaseClassDefinedDto(
StatusWithNormalizer::Draft,
new ProfileCreatedWithNormalizer(
ProfileId::fromString('1'),
Email::fromString('[email protected]'),
),
['foo'],
);

$event = $this->hydrator->hydrate(
NormalizerInBaseClassDefinedDto::class,
[
'status' => 'draft',
'profileCreated' => ['profileId' => '1', 'email' => '[email protected]'],
'array' => ['foo'],
],
);

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

public function testHydrateWithInferNormalizer(): void
{
$expected = new InferNormalizerDto(
Status::Draft,
new DateTimeImmutable('2015-02-13 22:34:32+01:00'),
new DateTime('2015-02-13 22:34:32+01:00'),
new DateTimeZone('EDT'),
['foo'],
);

$event = $this->hydrator->hydrate(
InferNormalizerDto::class,
[
'status' => 'draft',
'dateTimeImmutable' => '2015-02-13T22:34:32+01:00',
'dateTime' => '2015-02-13T22:34:32+01:00',
'dateTimeZone' => 'EDT',
'array' => ['foo'],
],
);

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

public function testHydrateWithInferNormalizerFailed(): void
{
$this->expectException(TypeMismatch::class);
$event = $this->hydrator->hydrate(
InferNormalizerBrokenDto::class,
[
'profileCreated' => ['profileId' => '1', 'email' => '[email protected]'],
],
);
}
}

0 comments on commit 824959c

Please sign in to comment.