Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow skipping null values #69

Merged
merged 10 commits into from
Apr 2, 2024
2 changes: 1 addition & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ services:
timeout: 10s

php:
image: pimcore/pimcore:php8.2-latest
image: pimcore/pimcore:php8.2-debug-latest
volumes:
- ./:/var/www/html/
environment:
Expand Down
7 changes: 5 additions & 2 deletions src/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,11 @@ private function addConverterSection(ArrayNodeDefinition $rootNode): void
->arrayPrototype()
->beforeNormalization()
->ifNull()
->then(fn () => ['source' => null, 'default' => null])
->then(fn () => ['source' => null, 'default' => null, 'skip_null' => false])
->end()
->beforeNormalization()
->ifString()
->then(fn (string $v) => ['source' => $v, 'default' => null])
->then(fn (string $v) => ['source' => $v, 'default' => null, 'skip_null' => false])
->end()
->children()
->scalarNode('source')
Expand All @@ -69,6 +69,9 @@ private function addConverterSection(ArrayNodeDefinition $rootNode): void
->scalarNode('default')
->defaultValue(null)
->end()
->booleanNode('skip_null')
->defaultFalse()
->end()
->end()
->end()
->end()
Expand Down
6 changes: 6 additions & 0 deletions src/DependencyInjection/NeustaConverterExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ public function loadInternal(array $mergedConfig, ContainerBuilder $container):
private function registerConverterConfiguration(string $id, array $config, ContainerBuilder $container): void
{
foreach ($config['properties'] ?? [] as $targetProperty => $sourceConfig) {
$skipNull = false;
if (str_ends_with($targetProperty, '?')) {
$skipNull = true;
$targetProperty = substr($targetProperty, 0, -1);
}
$config['populators'][] = $propertyPopulatorId = "{$id}.populator.{$targetProperty}";
$container->register($propertyPopulatorId, PropertyMappingPopulator::class)
->setArguments([
Expand All @@ -50,6 +55,7 @@ private function registerConverterConfiguration(string $id, array $config, Conta
'$defaultValue' => $sourceConfig['default'] ?? null,
'$mapper' => null,
'$accessor' => new Reference('property_accessor'),
'$skipNull' => $sourceConfig['skip_null'] || $skipNull,
]);
}

Expand Down
5 changes: 4 additions & 1 deletion src/Populator/PropertyMappingPopulator.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public function __construct(
private mixed $defaultValue = null,
?\Closure $mapper = null,
?PropertyAccessorInterface $accessor = null,
private bool $skipNull = false,
) {
$this->mapper = $mapper ?? static fn ($v) => $v;
$this->accessor = $accessor ?? PropertyAccess::createPropertyAccessor();
Expand All @@ -44,7 +45,9 @@ public function populate(object $target, object $source, ?object $ctx = null): v
try {
$value = $this->accessor->getValue($source, $this->sourceProperty) ?? $this->defaultValue;

$this->accessor->setValue($target, $this->targetProperty, ($this->mapper)($value, $ctx));
if (!$this->skipNull || (null !== $value)) {
$this->accessor->setValue($target, $this->targetProperty, ($this->mapper)($value, $ctx));
}
} catch (\Throwable $exception) {
throw new PopulationException($this->sourceProperty, $this->targetProperty, $exception);
}
Expand Down
36 changes: 36 additions & 0 deletions tests/Converter/GenericExtendedConverterIntegrationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

declare(strict_types=1);

namespace Neusta\ConverterBundle\Tests\Converter;

use Neusta\ConverterBundle\Converter;
use Neusta\ConverterBundle\Converter\Context\GenericContext;
use Neusta\ConverterBundle\Tests\ConfigurableKernelTestCase;
use Neusta\ConverterBundle\Tests\Fixtures\Model\Source\User;
use Neusta\ConverterBundle\Tests\Fixtures\Model\Target\Person;
use Neusta\ConverterBundle\Tests\Support\Attribute\ConfigureContainer;

#[ConfigureContainer(__DIR__ . '/../Fixtures/Config/person.yaml')]
class GenericExtendedConverterIntegrationTest extends ConfigurableKernelTestCase
{
public function test_convert_with_skip_null(): void
{
/** @var Converter<User, Person, GenericContext> $converter */
$converter = self::getContainer()->get('test.person.converter.extended');

// Test Fixture
$source = (new User())
->setFullName(null)
->setAgeInYears(null)
->setEmail(null);

// Test Execution
$target = $converter->convert($source);

// Test Assertion
self::assertSame('Hans Herrmann', $target->getFullName());
self::assertSame('[email protected]', $target->getMail());
self::assertSame(39, $target->getAge());
}
}
12 changes: 12 additions & 0 deletions tests/DependencyInjection/NeustaConverterExtensionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ public function test_with_mapped_properties(): void
'email' => [
'source' => 'mail',
],
'fullName?' => null,
],
],
],
Expand All @@ -70,25 +71,36 @@ public function test_with_mapped_properties(): void
new Reference('foobar.populator.name'),
new Reference('foobar.populator.ageInYears'),
new Reference('foobar.populator.email'),
new Reference('foobar.populator.fullName'),
]);

// name property populator
$this->assertContainerBuilderHasService('foobar.populator.name', PropertyMappingPopulator::class);
$this->assertContainerBuilderHasServiceDefinitionWithArgument('foobar.populator.name', '$accessor', new Reference('property_accessor'));
$this->assertContainerBuilderHasServiceDefinitionWithArgument('foobar.populator.name', '$targetProperty', 'name');
$this->assertContainerBuilderHasServiceDefinitionWithArgument('foobar.populator.name', '$sourceProperty', 'name');
$this->assertContainerBuilderHasServiceDefinitionWithArgument('foobar.populator.name', '$skipNull', false);

// ageInYears property populator
$this->assertContainerBuilderHasService('foobar.populator.ageInYears', PropertyMappingPopulator::class);
$this->assertContainerBuilderHasServiceDefinitionWithArgument('foobar.populator.ageInYears', '$accessor', new Reference('property_accessor'));
$this->assertContainerBuilderHasServiceDefinitionWithArgument('foobar.populator.ageInYears', '$targetProperty', 'ageInYears');
$this->assertContainerBuilderHasServiceDefinitionWithArgument('foobar.populator.ageInYears', '$sourceProperty', 'age');
$this->assertContainerBuilderHasServiceDefinitionWithArgument('foobar.populator.ageInYears', '$skipNull', false);

// email property populator
$this->assertContainerBuilderHasService('foobar.populator.email', PropertyMappingPopulator::class);
$this->assertContainerBuilderHasServiceDefinitionWithArgument('foobar.populator.email', '$accessor', new Reference('property_accessor'));
$this->assertContainerBuilderHasServiceDefinitionWithArgument('foobar.populator.email', '$targetProperty', 'email');
$this->assertContainerBuilderHasServiceDefinitionWithArgument('foobar.populator.email', '$sourceProperty', 'mail');
$this->assertContainerBuilderHasServiceDefinitionWithArgument('foobar.populator.email', '$skipNull', false);

// fullName property populator
$this->assertContainerBuilderHasService('foobar.populator.fullName', PropertyMappingPopulator::class);
$this->assertContainerBuilderHasServiceDefinitionWithArgument('foobar.populator.fullName', '$accessor', new Reference('property_accessor'));
$this->assertContainerBuilderHasServiceDefinitionWithArgument('foobar.populator.fullName', '$targetProperty', 'fullName');
$this->assertContainerBuilderHasServiceDefinitionWithArgument('foobar.populator.fullName', '$sourceProperty', 'fullName');
$this->assertContainerBuilderHasServiceDefinitionWithArgument('foobar.populator.fullName', '$skipNull', true);
}

public function test_with_mapped_context(): void
Expand Down
12 changes: 12 additions & 0 deletions tests/Fixtures/Config/person.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,18 @@ neusta_converter:
# group: ~ # same property name
# locale: language # different property names

test.person.converter.extended:
target_factory: Neusta\ConverterBundle\Tests\Fixtures\Model\Target\Factory\PersonWithDefaultsFactory
properties:
fullName:
source: fullName
default: 'Hans Herrmann'
mail:
source: email
skip_null: true
age?: ageInYears

services:
Neusta\ConverterBundle\Tests\Fixtures\Model\Target\Factory\PersonWithDefaultsFactory: ~
Neusta\ConverterBundle\Tests\Fixtures\Model\Target\Factory\PersonFactory: ~
Neusta\ConverterBundle\Tests\Fixtures\Populator\PersonNamePopulator: ~
20 changes: 11 additions & 9 deletions tests/Fixtures/Model/Source/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ class User
private string $firstname;
private string $lastname;
private ?string $fullName;
private int $ageInYears;
private string $email;
private Address $address;
private ?int $ageInYears;
private ?string $email;
private ?Address $address;

/** @var array<string> */
private array $favouriteMovies;
Expand Down Expand Up @@ -75,34 +75,36 @@ public function setFullName(?string $fullName): self
return $this;
}

public function getAgeInYears(): int
public function getAgeInYears(): ?int
{
return $this->ageInYears;
}

public function setAgeInYears($ageInYears): self
public function setAgeInYears(?int $ageInYears): self
{
$this->ageInYears = $ageInYears;

return $this;
}

public function getEmail(): string
public function getEmail(): ?string
{
return $this->email;
}

public function setEmail(string $email): void
public function setEmail(?string $email): self
{
$this->email = $email;

return $this;
}

public function getAddress(): Address
public function getAddress(): ?Address
{
return $this->address;
}

public function setAddress(Address $address): self
public function setAddress(?Address $address): self
{
$this->address = $address;

Expand Down
22 changes: 22 additions & 0 deletions tests/Fixtures/Model/Target/Factory/PersonWithDefaultsFactory.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace Neusta\ConverterBundle\Tests\Fixtures\Model\Target\Factory;

use Neusta\ConverterBundle\Converter\Context\GenericContext;
use Neusta\ConverterBundle\TargetFactory;
use Neusta\ConverterBundle\Tests\Fixtures\Model\Target\Person;

/**
* @implements TargetFactory<Person, GenericContext>
*/
class PersonWithDefaultsFactory implements TargetFactory
{
public function create(?object $ctx = null): Person
{
return (new Person())
->setMail('[email protected]')
->setAge(39);
}
}
26 changes: 26 additions & 0 deletions tests/Fixtures/Model/Target/Person.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class Person

private ?PersonAddress $address = null;

private ?string $placeOfResidence = null;

/** @var array<string> */
private array $favouriteMovies;

Expand Down Expand Up @@ -63,6 +65,18 @@ public function setAge(?int $age): self
return $this;
}

public function getMail(): ?string
{
return $this->mail;
}

public function setMail(?string $mail): self
{
$this->mail = $mail;

return $this;
}

public function getAddress(): ?PersonAddress
{
return $this->address;
Expand All @@ -75,6 +89,18 @@ public function setAddress(?PersonAddress $address): self
return $this;
}

public function getPlaceOfResidence(): ?string
{
return $this->placeOfResidence;
}

public function setPlaceOfResidence(?string $placeOfResidence): self
{
$this->placeOfResidence = $placeOfResidence;

return $this;
}

public function getFavouriteMovies(): array
{
return $this->favouriteMovies;
Expand Down
77 changes: 67 additions & 10 deletions tests/Populator/PropertyMappingPopulatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace Neusta\ConverterBundle\Tests\Populator;

use Neusta\ConverterBundle\Populator\PropertyMappingPopulator;
use Neusta\ConverterBundle\Tests\Fixtures\Model\Source\Address;
use Neusta\ConverterBundle\Tests\Fixtures\Model\Source\User;
use Neusta\ConverterBundle\Tests\Fixtures\Model\Target\Person;
use PHPUnit\Framework\TestCase;
Expand All @@ -16,23 +17,79 @@ class PropertyMappingPopulatorTest extends TestCase

public function test_populate(): void
{
$populator = new PropertyMappingPopulator('age', 'ageInYears');
$user = (new User())->setAgeInYears(37);
$person = new Person();
$populator = new PropertyMappingPopulator(
targetProperty: 'age',
sourceProperty: 'ageInYears',
);
$source = (new User())->setAgeInYears(37);
$target = new Person();

$populator->populate($person, $user);
$populator->populate($target, $source);

self::assertEquals(37, $person->getAge());
self::assertEquals(37, $target->getAge());
}

public function test_populate_default_value(): void
{
$populator = new PropertyMappingPopulator('fullName', 'fullName', 'default');
$user = (new User())->setFullName(null);
$person = new Person();
$populator = new PropertyMappingPopulator(
targetProperty: 'fullName',
sourceProperty: 'fullName',
defaultValue: 'default',
);
$source = (new User())->setFullName(null);
$target = new Person();

$populator->populate($person, $user);
$populator->populate($target, $source);

self::assertSame('default', $person->getFullName());
self::assertSame('default', $target->getFullName());
}

public function test_populate_skip_null(): void
{
$populator = new PropertyMappingPopulator(
targetProperty: 'fullName',
sourceProperty: 'fullName',
skipNull: true,
);
$source = (new User())->setFullName(null);
$target = new Person();
$target->setFullName('old Name');

$populator->populate($target, $source);

self::assertSame('old Name', $target->getFullName());
}

public function test_populate_with_sub_fields(): void
{
$populator = new PropertyMappingPopulator(
targetProperty: 'placeOfResidence',
sourceProperty: 'address.city',
);
$source = (new User())->setAddress((new Address())->setCity('Bremen'));
$target = new Person();

$populator->populate($target, $source);

self::assertSame('Bremen', $target->getPlaceOfResidence());
}

/**
* @requires function \Symfony\Component\PropertyAccess\PropertyPath::isNullSafe
*/
public function test_populate_skip_null_with_sub_fields_and_null_safety(): void
{
$populator = new PropertyMappingPopulator(
targetProperty: 'placeOfResidence',
sourceProperty: 'address?.city',
skipNull: true,
);
$source = (new User())->setAddress(null);
$target = new Person();
$target->setPlaceOfResidence('Old City');

$populator->populate($target, $source);

self::assertSame('Old City', $target->getPlaceOfResidence());
}
}