Skip to content
Open
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
29 changes: 29 additions & 0 deletions docs/UPGRADE-3.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,35 @@ receive now contains the hydrator under the key `Hydrator::HYDRATOR`. Don't use
the key `hydrator` for your own context values. If you serialize, log or compare
the context, leave this key out.

### Object to populate

If you hydrate an existing object with `Hydrator::OBJECT_TO_POPULATE`, missing
fields no longer reset promoted properties to their default value, the object
keeps its current value. If you relied on the reset, pass the default value in
the data.

before:

```php
use Patchlevel\Hydrator\Hydrator;

$profile = new Profile('patchlevel', Role::Admin);

$hydrator->hydrate(Profile::class, ['name' => 'hydrator'], [Hydrator::OBJECT_TO_POPULATE => $profile]);

// $profile->role is Role::Member, the default value of the promoted property
```
after:

```php
use Patchlevel\Hydrator\Hydrator;

$profile = new Profile('patchlevel', Role::Admin);

$hydrator->hydrate(Profile::class, ['name' => 'hydrator'], [Hydrator::OBJECT_TO_POPULATE => $profile]);

// $profile->role is still Role::Admin
```
### Wrapping a hydrator

If you wrap the hydrator in your own `Hydrator` implementation, add it to the
Expand Down
4 changes: 4 additions & 0 deletions docs/hydrator.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ $profile = $hydrator->hydrate(
[Hydrator::OBJECT_TO_POPULATE => $profile],
);
```
Only the fields in the data are set. Properties whose fields are missing keep
their current value, promoted properties are not reset to their default value
either. Defaults are only used for new objects.

## Rename fields

By default, the property name is used to name the field in the extracted
Expand Down
12 changes: 12 additions & 0 deletions src/Extension/Generated/ClassPlan.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,18 @@ public function leaf(): bool
return true;
}

/** Whether missing fields of new objects fall back to the default of a promoted property. */
public function defaults(): bool
{
foreach ($this->properties as $property) {
if ($property->hasDefault()) {
return true;
}
}

return false;
}

/** @return array<int, NestedPlan> slot => nested class whose objects may be mapped in place */
public function nested(): array
{
Expand Down
36 changes: 28 additions & 8 deletions src/Extension/Generated/CodeEmitter.php
Original file line number Diff line number Diff line change
Expand Up @@ -78,10 +78,11 @@ public function emit(string $namespace, string $className): string
$this->properties[] = sprintf('public NestedMapper $x%d;', $slot);
}

[$hydrate, $hydrateHelpers] = $this->hydrateBody();
[$hydrate, $hydrateHelpers] = $this->hydrateBody(true);
[$populate, $populateHelpers] = $plan->defaults() ? $this->hydrateBody(false) : ['', []];
[$extract, $extractHelpers] = $this->extractBody();

foreach ([...$hydrateHelpers, ...$extractHelpers] as $helper) {
foreach ([...$hydrateHelpers, ...$populateHelpers, ...$extractHelpers] as $helper) {
$initialize[] = $helper;
}

Expand All @@ -95,6 +96,18 @@ public function emit(string $namespace, string $className): string
'class' => $plan->className(),
]);
$hydrate = 'return ($this->h)($object, $data, $context, $inline);';

if ($populate !== '') {
$this->properties[] = 'public Closure $p;';
$initialize[] = Templates::render(Templates::CLOSURE, [
'slot' => 'p',
'signature' => 'object $object, array $data, array $context, bool $inline',
'return' => 'object',
'body' => $populate,
'class' => $plan->className(),
]);
$populate = 'return ($this->p)($object, $data, $context, $inline);';
}
}

if ($plan->scopedExtract) {
Expand Down Expand Up @@ -122,6 +135,11 @@ public function emit(string $namespace, string $className): string
'properties' => implode("\n", $this->properties),
// nested objects are only mapped right away for calls of the hydrator which created the mapper
'hydrate' => self::withInline($hydrate, $plan->nested() === [] ? 'false' : Templates::OWNER),
// existing objects keep the values of missing fields, only classes with promoted defaults need own code
'populateCall' => $populate === '' ? '' : Templates::POPULATE_CALL,
'populate' => $populate === '' ? '' : Templates::render(Templates::POPULATE, [
'body' => self::withInline($populate, $plan->nested() === [] ? 'false' : Templates::OWNER),
]),
'extract' => self::withInline($extract, $plan->nested() === [] ? 'false' : Templates::OWNER),
// the nested entry points are only called by a parent mapper which already did these checks
'hydrateNested' => self::withInline($hydrate, 'true'),
Expand All @@ -131,7 +149,7 @@ public function emit(string $namespace, string $className): string
]);

// empty placeholders leave blank lines behind
$code = preg_replace(["/\n{3,}/", "/{\n\n/"], ["\n\n", "{\n"], $code);
$code = preg_replace(["/\n{3,}/", "/{\n\n/", "/\n\n([ \t]*})/"], ["\n\n", "{\n", "\n$1"], $code);
assert($code !== null);

return $code;
Expand Down Expand Up @@ -180,21 +198,23 @@ private function recursive(): string
/**
* Generates the hydrate code, properties which need another scope are assigned in a helper closure.
*
* @param bool $defaults whether missing fields fall back to the default of a promoted property, only for new objects
*
* @return array{string, list<string>} body and init code of the helper closures
*/
private function hydrateBody(): array
private function hydrateBody(bool $defaults): array
{
$main = [];
$scoped = [];

foreach ($this->plan->properties as $property) {
if ($property->hydrateScope !== null) {
$scoped[$property->hydrateScope][] = $this->assignment($property);
$scoped[$property->hydrateScope][] = $this->assignment($property, $defaults);

continue;
}

$main[] = $this->assignment($property);
$main[] = $this->assignment($property, $defaults);
}

$helpers = [];
Expand All @@ -215,9 +235,9 @@ private function hydrateBody(): array
return [implode("\n\n", $main), $helpers];
}

private function assignment(PropertyPlan $property): string
private function assignment(PropertyPlan $property, bool $defaults): string
{
$template = $property->hasDefault() ? Templates::ASSIGN_DEFAULT : Templates::ASSIGN;
$template = $defaults && $property->hasDefault() ? Templates::ASSIGN_DEFAULT : Templates::ASSIGN;
$code = [];

// the first alias in the data takes the place of the missing field
Expand Down
2 changes: 1 addition & 1 deletion src/Extension/Generated/MapperFiles.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ final class MapperFiles
public const NAMESPACE = 'Patchlevel\\Hydrator\\Generated';

/** Part of the name, bump it whenever the generated code changes. */
public const VERSION = 11;
public const VERSION = 12;

public function __construct(
public readonly string $cachePath,
Expand Down
16 changes: 16 additions & 0 deletions src/Extension/Generated/Templates.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ public function hydrate(array $data, array $context): object
if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
$object = $context[Hydrator::OBJECT_TO_POPULATE];
unset($context[Hydrator::OBJECT_TO_POPULATE]);
{{populateCall}}
} else {
$object = $this->reflection->newInstanceWithoutConstructor();
}
Expand Down Expand Up @@ -126,6 +127,8 @@ public function extractNested(object $object, array $context): array
{{extractNested}}
}

{{populate}}

protected function initialize(): void
{
{{initialize}}
Expand All @@ -137,6 +140,19 @@ protected function mayReachItself(): bool
}
}

PHP;

public const POPULATE_CALL = <<<'PHP'

return $this->populate($object, $data, $context);
PHP;

/** Existing objects keep the values of missing fields, the defaults of promoted properties are only set for new objects. */
public const POPULATE = <<<'PHP'
private function populate(object $object, array $data, array $context): object
{
{{body}}
}
PHP;

public const CLOSURE = <<<'PHP'
Expand Down
13 changes: 6 additions & 7 deletions src/Mapper/ReflectionMapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,28 +60,27 @@ public function __construct(
*/
public function hydrate(array $data, array $context): object
{
$metadata = $this->metadata;

if (isset($context[Hydrator::OBJECT_TO_POPULATE])) {
$object = $context[Hydrator::OBJECT_TO_POPULATE];
// only unset if it is there, unset() copies the context even if the key does not exist
unset($context[Hydrator::OBJECT_TO_POPULATE]);
} else {
$object = $metadata->newInstance();

return $this->hydrateProperties($object, $data, $context, $this->nested($context), true);
}

return $this->hydrateProperties($object, $data, $context, $this->nested($context));
return $this->hydrateProperties($this->metadata->newInstance(), $data, $context, $this->nested($context));
}

/**
* @param T $object
* @param array<string, mixed> $data
* @param array<string, mixed> $context
* @param array<string, array{class-string, bool}> $nested
* @param bool $populate the object already exists, it keeps the values of missing fields
*
* @return T
*/
private function hydrateProperties(object $object, array $data, array $context, array $nested): object
private function hydrateProperties(object $object, array $data, array $context, array $nested, bool $populate = false): object
{
$metadata = $this->metadata;
$constructorParameters = null;
Expand All @@ -91,7 +90,7 @@ private function hydrateProperties(object $object, array $data, array $context,
$alias = self::alias($propertyMetadata, $data);

if ($alias === null) {
if (!$propertyMetadata->reflection->isPromoted()) {
if ($populate || !$propertyMetadata->reflection->isPromoted()) {
continue;
}

Expand Down
69 changes: 69 additions & 0 deletions tests/Unit/Extension/Generated/GeneratedStackHydratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
use Patchlevel\Hydrator\Tests\Unit\Fixture\NormalizerInBaseClassDefinedDto;
use Patchlevel\Hydrator\Tests\Unit\Fixture\ParentDto;
use Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateChildDto;
use Patchlevel\Hydrator\Tests\Unit\Fixture\PrivateDefaultChildDto;
use Patchlevel\Hydrator\Tests\Unit\Fixture\PrivatePropertiesDto;
use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreated;
use Patchlevel\Hydrator\Tests\Unit\Fixture\ProfileCreatedWithInlineNormalizer;
Expand Down Expand Up @@ -126,6 +127,7 @@ final class GeneratedStackHydratorTest extends TestCase
ProfileCreatedWithInlineNormalizer::class,
PrivatePropertiesDto::class,
PrivateChildDto::class,
PrivateDefaultChildDto::class,
SensitiveDataProfileCreated::class,
NestedLifecycleDto::class,
AliasDto::class,
Expand Down Expand Up @@ -509,6 +511,73 @@ public function testExtractWithAlias(): void
);
}

public function testHydrateWithObjectToPopulateAndAlias(): void
{
$dto = new AliasDto(Email::fromString('old@patchlevel.de'), 'old');

$object = $this->hydrator->hydrate(
AliasDto::class,
['username' => 'new'],
[Hydrator::OBJECT_TO_POPULATE => $dto],
);

self::assertSame($dto, $object);
self::assertSame('new', $dto->name);
self::assertEquals(Email::fromString('old@patchlevel.de'), $dto->email);
}

public function testHydrateWithObjectToPopulateKeepsMissingFields(): void
{
$dto = new DefaultDto('old', Email::fromString('old@patchlevel.de'));
$dto->admin = false;

$object = $this->hydrator->hydrate(
DefaultDto::class,
['name' => 'new'],
[Hydrator::OBJECT_TO_POPULATE => $dto],
);

// missing fields keep the values of the object, also promoted properties with a default
self::assertSame($dto, $object);
self::assertSame('new', $dto->name);
self::assertEquals(Email::fromString('old@patchlevel.de'), $dto->email);
self::assertFalse($dto->admin);
}

public function testHydrateWithObjectToPopulateKeepsMissingPrivateFields(): void
{
$dto = new PrivatePropertiesDto(ProfileId::fromString('1'), 'old', 30);

$object = $this->hydrator->hydrate(
PrivatePropertiesDto::class,
['name' => 'new'],
[Hydrator::OBJECT_TO_POPULATE => $dto],
);

self::assertSame($dto, $object);
self::assertSame('1', $dto->profileId()->toString());
self::assertSame('new', $dto->name());
self::assertSame(30, $dto->age());
}

public function testHydrateWithObjectToPopulateKeepsMissingPrivateFieldsOfParent(): void
{
$dto = new PrivateDefaultChildDto('kept');

$object = $this->hydrator->hydrate(
PrivateDefaultChildDto::class,
['name' => 'new'],
[Hydrator::OBJECT_TO_POPULATE => $dto],
);

self::assertSame($dto, $object);
self::assertSame('new', $dto->name);
self::assertSame('kept', $dto->note());

// new objects still fall back to the default
self::assertSame('default', $this->hydrator->hydrate(PrivateDefaultChildDto::class, [])->note());
}

public function testHydrateWithTypeMismatch(): void
{
$this->expectException(TypeMismatch::class);
Expand Down
Loading
Loading