Skip to content
Draft
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 docs/hydrator.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,42 @@ You can rename a property without a backwards compatibility break in your stored
data by keeping the old serialized name with `NormalizedName`.
:::

## Context

Both `extract` and `hydrate` accept a context array as last argument. It is
passed to every normalizer and on to nested objects, so it can change how
values are converted for a single call.

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

$data = $hydrator->extract($event, [DateTimeImmutableNormalizer::FORMAT => 'Y-m-d']);
```
With the `Context` attribute you can add context for a single property. It is
merged into the context that is passed to the normalizer of this property, and
on to nested objects if the property holds one. Values from the attribute win
over the context of the call.

```php
use Patchlevel\Hydrator\Attribute\Context;
use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer;

final class Profile
{
#[Context([DateTimeImmutableNormalizer::FORMAT => 'Y-m-d'])]
public DateTimeImmutable $birthday;

public DateTimeImmutable $createdAt;
}
```
The attribute can be used multiple times on the same property. The contexts
are merged in the given order.

:::note
The context only reaches normalizers. Properties without a normalizer, like
plain strings or integers, are copied as they are.
:::

## Ignore properties

Sometimes it is necessary to exclude properties. You can do that with the
Expand Down
18 changes: 17 additions & 1 deletion docs/normalizer.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,10 +109,26 @@ final class Profile
You can read about how the format is structured in the [php docs](https://www.php.net/manual/en/datetime.format.php).
:::

The format can also be changed through the [context](hydrator.md#context) with
the `DateTimeImmutableNormalizer::FORMAT` key. It takes precedence over the
format of the normalizer, for a single call or with the `Context` attribute for
a single property.

```php
use Patchlevel\Hydrator\Attribute\Context;
use Patchlevel\Hydrator\Normalizer\DateTimeImmutableNormalizer;

final class Profile
{
#[Context([DateTimeImmutableNormalizer::FORMAT => 'Y-m-d'])]
public DateTimeImmutable $birthday;
}
```
## DateTime

The `DateTimeNormalizer` works exactly like the `DateTimeImmutableNormalizer`,
only for `DateTime` objects. The default format is `DateTime::ATOM`.
only for `DateTime` objects. The default format is `DateTime::ATOM`, and it
reads the same context key, also available as `DateTimeNormalizer::FORMAT`.

```php
use Patchlevel\Hydrator\Normalizer\DateTimeNormalizer;
Expand Down
17 changes: 17 additions & 0 deletions src/Attribute/Context.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Attribute;

use Attribute;

#[Attribute(Attribute::TARGET_PROPERTY | Attribute::IS_REPEATABLE)]
final class Context
{
/** @param array<string, mixed> $context */
public function __construct(
public readonly array $context,
) {
}
}
10 changes: 8 additions & 2 deletions src/Extension/Cryptography/CryptographyMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@
: $info->fallback;

if ($propertyMetadata->normalizer) {
$fallback = $propertyMetadata->normalizer->normalize($fallback, $context);
$fallback = $propertyMetadata->normalizer->normalize(
$fallback,
$propertyMetadata->context === [] ? $context : [...$context, ...$propertyMetadata->context],

Check warning on line 71 in src/Extension/Cryptography/CryptographyMiddleware.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "ArrayItemRemoval": @@ @@ if ($propertyMetadata->normalizer) { $fallback = $propertyMetadata->normalizer->normalize( $fallback, - $propertyMetadata->context === [] ? $context : [...$context, ...$propertyMetadata->context], + $propertyMetadata->context === [] ? $context : [...$propertyMetadata->context], ); }
);
}

$data[$propertyMetadata->fieldName] = $fallback;
Expand Down Expand Up @@ -170,7 +173,10 @@
$subjectId = $property->getValue($data);

if ($property->normalizer) {
$subjectId = $property->normalizer->normalize($subjectId, $context);
$subjectId = $property->normalizer->normalize(
$subjectId,
$property->context === [] ? $context : [...$context, ...$property->context],

Check warning on line 178 in src/Extension/Cryptography/CryptographyMiddleware.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "ArrayItemRemoval": @@ @@ if ($property->normalizer) { $subjectId = $property->normalizer->normalize( $subjectId, - $property->context === [] ? $context : [...$context, ...$property->context], + $property->context === [] ? $context : [...$property->context], ); } }
);
}
}

Expand Down
14 changes: 14 additions & 0 deletions src/Metadata/AttributeMetadataFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Patchlevel\Hydrator\Metadata;

use Patchlevel\Hydrator\Attribute\Context;
use Patchlevel\Hydrator\Attribute\Ignore;
use Patchlevel\Hydrator\Attribute\Lazy;
use Patchlevel\Hydrator\Attribute\NormalizedName;
Expand Down Expand Up @@ -131,6 +132,7 @@
$type,
$fieldName,
$this->getNormalizer($reflectionProperty, $type),
context: $this->getContext($reflectionProperty),
);
}

Expand Down Expand Up @@ -160,6 +162,18 @@
return $attributeReflectionList[0]->newInstance()->name();
}

/** @return array<string, mixed> */
private function getContext(ReflectionProperty $reflectionProperty): array
{
$context = [];

foreach ($reflectionProperty->getAttributes(Context::class) as $attributeReflection) {
$context = [...$context, ...$attributeReflection->newInstance()->context];

Check warning on line 171 in src/Metadata/AttributeMetadataFactory.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "ArrayItemRemoval": @@ @@ $context = []; foreach ($reflectionProperty->getAttributes(Context::class) as $attributeReflection) { - $context = [...$context, ...$attributeReflection->newInstance()->context]; + $context = [...$attributeReflection->newInstance()->context]; } return $context;
}

return $context;
}

private function hasIgnore(ReflectionProperty $reflectionProperty): bool
{
return $reflectionProperty->getAttributes(Ignore::class) !== [];
Expand Down
10 changes: 9 additions & 1 deletion src/Metadata/PropertyMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,24 @@
* fieldName: string,
* normalizer: Normalizer|null,
* extras: array<string, mixed>,
* context?: array<string, mixed>,
* }
*/
final class PropertyMetadata
{
public readonly string $propertyName;

/** @param array<string, mixed> $extras */
/**
* @param array<string, mixed> $extras
* @param array<string, mixed> $context merged into the context passed to the normalizer
*/
public function __construct(
public readonly ReflectionProperty $reflection,
public readonly Type $type,
public string $fieldName,
public Normalizer|null $normalizer = null,
public array $extras = [],
public array $context = [],
) {
$this->propertyName = $reflection->getName();
}
Expand All @@ -53,6 +58,7 @@ public function __serialize(): array
'fieldName' => $this->fieldName,
'normalizer' => $this->normalizer,
'extras' => $this->extras,
'context' => $this->context,
];
}

Expand All @@ -65,5 +71,7 @@ public function __unserialize(array $data): void
$this->fieldName = $data['fieldName'];
$this->normalizer = $data['normalizer'];
$this->extras = $data['extras'];
// metadata cached by an older version has no context
$this->context = $data['context'] ?? [];
}
}
15 changes: 13 additions & 2 deletions src/Middleware/TransformMiddleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,14 @@
if ($propertyMetadata->normalizer) {
try {
/** @psalm-suppress MixedAssignment */
$value = $propertyMetadata->normalizer->denormalize($data[$propertyMetadata->fieldName], $context);
$value = $propertyMetadata->normalizer->denormalize(
$data[$propertyMetadata->fieldName],
match (true) {

Check warning on line 66 in src/Middleware/TransformMiddleware.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "MatchArmRemoval": @@ @@ $data[$propertyMetadata->fieldName], match (true) { $propertyMetadata->context === [] => $context, - $context === [] => $propertyMetadata->context, default => [...$context, ...$propertyMetadata->context], }, );

Check warning on line 66 in src/Middleware/TransformMiddleware.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "MatchArmRemoval": @@ @@ $value = $propertyMetadata->normalizer->denormalize( $data[$propertyMetadata->fieldName], match (true) { - $propertyMetadata->context === [] => $context, $context === [] => $propertyMetadata->context, default => [...$context, ...$propertyMetadata->context], },
$propertyMetadata->context === [] => $context,
$context === [] => $propertyMetadata->context,

Check warning on line 68 in src/Middleware/TransformMiddleware.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "Identical": @@ @@ $data[$propertyMetadata->fieldName], match (true) { $propertyMetadata->context === [] => $context, - $context === [] => $propertyMetadata->context, + $context !== [] => $propertyMetadata->context, default => [...$context, ...$propertyMetadata->context], }, );
default => [...$context, ...$propertyMetadata->context],

Check warning on line 69 in src/Middleware/TransformMiddleware.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "ArrayItemRemoval": @@ @@ match (true) { $propertyMetadata->context === [] => $context, $context === [] => $propertyMetadata->context, - default => [...$context, ...$propertyMetadata->context], + default => [...$propertyMetadata->context], }, ); } catch (Throwable $e) {
},
);
} catch (Throwable $e) {
throw new DenormalizationFailure(
$metadata->className,
Expand Down Expand Up @@ -106,7 +113,7 @@

$this->callStack[$objectId] = $object::class;

try {

Check warning on line 116 in src/Middleware/TransformMiddleware.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "UnwrapFinally": @@ @@ } $this->callStack[$objectId] = $object::class; - - try { - $data = []; - - foreach ($metadata->properties as $propertyMetadata) { - if ($propertyMetadata->normalizer) { - try { - /** @psalm-suppress MixedAssignment */ - $data[$propertyMetadata->fieldName] = $propertyMetadata->normalizer->normalize( - $propertyMetadata->getValue($object), - match (true) { - $propertyMetadata->context === [] => $context, - $context === [] => $propertyMetadata->context, - default => [...$context, ...$propertyMetadata->context], - }, - ); - } catch (CircularReference $e) { - throw $e; - } catch (Throwable $e) { - throw new NormalizationFailure( - $object::class, - $propertyMetadata->propertyName, - $propertyMetadata->normalizer::class, - $e, - ); - } - } else { - $data[$propertyMetadata->fieldName] = $propertyMetadata->getValue($object); + $data = []; + foreach ($metadata->properties as $propertyMetadata) { + if ($propertyMetadata->normalizer) { + try { + /** @psalm-suppress MixedAssignment */ + $data[$propertyMetadata->fieldName] = $propertyMetadata->normalizer->normalize( + $propertyMetadata->getValue($object), + match (true) { + $propertyMetadata->context === [] => $context, + $context === [] => $propertyMetadata->context, + default => [...$context, ...$propertyMetadata->context], + }, + ); + } catch (CircularReference $e) { + throw $e; + } catch (Throwable $e) { + throw new NormalizationFailure( + $object::class, + $propertyMetadata->propertyName, + $propertyMetadata->normalizer::class, + $e, + ); } + } else { + $data[$propertyMetadata->fieldName] = $propertyMetadata->getValue($object); } - } finally { - unset($this->callStack[$objectId]); } + unset($this->callStack[$objectId]); return $data; }
$data = [];

foreach ($metadata->properties as $propertyMetadata) {
Expand All @@ -115,7 +122,11 @@
/** @psalm-suppress MixedAssignment */
$data[$propertyMetadata->fieldName] = $propertyMetadata->normalizer->normalize(
$propertyMetadata->getValue($object),
$context,
match (true) {

Check warning on line 125 in src/Middleware/TransformMiddleware.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "MatchArmRemoval": @@ @@ $data[$propertyMetadata->fieldName] = $propertyMetadata->normalizer->normalize( $propertyMetadata->getValue($object), match (true) { - $propertyMetadata->context === [] => $context, $context === [] => $propertyMetadata->context, default => [...$context, ...$propertyMetadata->context], },

Check warning on line 125 in src/Middleware/TransformMiddleware.php

View workflow job for this annotation

GitHub Actions / Mutation tests on diff (locked, 8.5, ubuntu-latest)

Escaped Mutant for Mutator "MatchArmRemoval": @@ @@ $propertyMetadata->getValue($object), match (true) { $propertyMetadata->context === [] => $context, - $context === [] => $propertyMetadata->context, default => [...$context, ...$propertyMetadata->context], }, );
$propertyMetadata->context === [] => $context,
$context === [] => $propertyMetadata->context,
default => [...$context, ...$propertyMetadata->context],
},
);
} catch (CircularReference $e) {
throw $e;
Expand Down
15 changes: 13 additions & 2 deletions src/Normalizer/DateTimeImmutableNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
#[Attribute(Attribute::TARGET_PROPERTY)]
final readonly class DateTimeImmutableNormalizer implements Normalizer
{
/** Context key to override the format, e.g. with the Context attribute. */
public const FORMAT = 'datetime_format';

public function __construct(
private string $format = DateTimeImmutable::ATOM,
) {
Expand All @@ -28,7 +31,7 @@ public function normalize(mixed $value, array $context): string|null
throw InvalidArgument::withWrongType('DateTimeImmutable|null', $value);
}

return $value->format($this->format);
return $value->format($this->format($context));
}

/** @param array<string, mixed> $context */
Expand All @@ -42,12 +45,20 @@ public function denormalize(mixed $value, array $context): DateTimeImmutable|nul
throw InvalidArgument::withWrongType('string|null', $value);
}

$date = DateTimeImmutable::createFromFormat($this->format, $value);
$date = DateTimeImmutable::createFromFormat($this->format($context), $value);

if ($date === false) {
throw new InvalidArgument();
}

return $date;
}

/** @param array<string, mixed> $context */
private function format(array $context): string
{
$format = $context[self::FORMAT] ?? null;

return is_string($format) ? $format : $this->format;
}
}
15 changes: 13 additions & 2 deletions src/Normalizer/DateTimeNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
#[Attribute(Attribute::TARGET_PROPERTY)]
final readonly class DateTimeNormalizer implements Normalizer
{
/** Context key to override the format, e.g. with the Context attribute. */
public const FORMAT = 'datetime_format';

public function __construct(
private string $format = DateTime::ATOM,
) {
Expand All @@ -28,7 +31,7 @@ public function normalize(mixed $value, array $context): string|null
throw InvalidArgument::withWrongType('DateTime|null', $value);
}

return $value->format($this->format);
return $value->format($this->format($context));
}

/** @param array<string, mixed> $context */
Expand All @@ -42,12 +45,20 @@ public function denormalize(mixed $value, array $context): DateTime|null
throw InvalidArgument::withWrongType('string|null', $value);
}

$date = DateTime::createFromFormat($this->format, $value);
$date = DateTime::createFromFormat($this->format($context), $value);

if ($date === false) {
throw new InvalidArgument();
}

return $date;
}

/** @param array<string, mixed> $context */
private function format(array $context): string
{
$format = $context[self::FORMAT] ?? null;

return is_string($format) ? $format : $this->format;
}
}
41 changes: 41 additions & 0 deletions tests/Unit/Extension/Cryptography/CryptographyMiddlewareTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
use Patchlevel\Hydrator\Middleware\TransformMiddleware;
use Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreated;
use Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataProfileCreatedFallbackCallback;
use Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture\SensitiveDataWithContextDto;
use Patchlevel\Hydrator\Tests\Unit\Fixture\Email;
use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated;
use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileId;
Expand Down Expand Up @@ -223,6 +224,46 @@ public function testDecryptWithFallbackCallback(): void
self::assertEquals(new Email('foo@example.com'), $result->email);
}

public function testFallbackUsesPropertyContext(): void
{
$cryptographer = $this->createMock(Cryptographer::class);
$cryptographer->method('supports')->willReturn(true);
$cryptographer->method('decrypt')->willThrowException(DecryptionFailed::forMethod('aes-256-gcm'));

$middleware = new CryptographyMiddleware($cryptographer);

$result = $middleware->hydrate(
$this->metadata(SensitiveDataWithContextDto::class),
['id' => 'foo', 'email' => 'encrypted'],
[],
new Stack([new TransformMiddleware()]),
);

self::assertInstanceOf(SensitiveDataWithContextDto::class, $result);
self::assertSame('p-fallback-s', $result->email);
}

public function testSubjectIdUsesPropertyContext(): void
{
$cryptographer = $this->createMock(Cryptographer::class);
$cryptographer
->expects($this->once())
->method('encrypt')
->with('id-foo', 'p-info@patchlevel.de')
->willReturn('encrypted');

$middleware = new CryptographyMiddleware($cryptographer);

$result = $middleware->extract(
$this->metadata(SensitiveDataWithContextDto::class),
new SensitiveDataWithContextDto('foo', 'info@patchlevel.de'),
[],
new Stack([new TransformMiddleware()]),
);

self::assertSame(['id' => 'id-foo', 'email' => 'encrypted'], $result);
}

public function testDecrypt(): void
{
$cryptographer = $this->createMock(Cryptographer::class);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Patchlevel\Hydrator\Tests\Unit\Extension\Cryptography\Fixture;

use Patchlevel\Hydrator\Attribute\Context;
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\DataSubjectId;
use Patchlevel\Hydrator\Extension\Cryptography\Attribute\SensitiveData;
use Patchlevel\Hydrator\Tests\Unit\Fixture\ContextAwareNormalizer;

final class SensitiveDataWithContextDto
{
public function __construct(
#[ContextAwareNormalizer]
#[Context(['prefix' => 'id-'])]
#[DataSubjectId]
public string $id,
#[ContextAwareNormalizer]
#[Context(['prefix' => 'p-', 'suffix' => '-s'])]
#[SensitiveData(fallback: 'fallback')]
public string $email,
) {
}
}
Loading
Loading