diff --git a/compose.yaml b/compose.yaml index 065db0c..ad3df96 100644 --- a/compose.yaml +++ b/compose.yaml @@ -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: diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 6b182b6..974a4bb 100644 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -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') @@ -69,6 +69,9 @@ private function addConverterSection(ArrayNodeDefinition $rootNode): void ->scalarNode('default') ->defaultValue(null) ->end() + ->booleanNode('skip_null') + ->defaultFalse() + ->end() ->end() ->end() ->end() diff --git a/src/DependencyInjection/NeustaConverterExtension.php b/src/DependencyInjection/NeustaConverterExtension.php index f87db0c..28947df 100644 --- a/src/DependencyInjection/NeustaConverterExtension.php +++ b/src/DependencyInjection/NeustaConverterExtension.php @@ -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([ @@ -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, ]); } diff --git a/src/Populator/PropertyMappingPopulator.php b/src/Populator/PropertyMappingPopulator.php index 7b3959c..4d4c384 100644 --- a/src/Populator/PropertyMappingPopulator.php +++ b/src/Populator/PropertyMappingPopulator.php @@ -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(); @@ -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); } diff --git a/tests/Converter/GenericExtendedConverterIntegrationTest.php b/tests/Converter/GenericExtendedConverterIntegrationTest.php new file mode 100644 index 0000000..2a29838 --- /dev/null +++ b/tests/Converter/GenericExtendedConverterIntegrationTest.php @@ -0,0 +1,36 @@ + $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('default@test.de', $target->getMail()); + self::assertSame(39, $target->getAge()); + } +} diff --git a/tests/DependencyInjection/NeustaConverterExtensionTest.php b/tests/DependencyInjection/NeustaConverterExtensionTest.php index ab7ad57..fdf833a 100644 --- a/tests/DependencyInjection/NeustaConverterExtensionTest.php +++ b/tests/DependencyInjection/NeustaConverterExtensionTest.php @@ -57,6 +57,7 @@ public function test_with_mapped_properties(): void 'email' => [ 'source' => 'mail', ], + 'fullName?' => null, ], ], ], @@ -70,6 +71,7 @@ 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 @@ -77,18 +79,28 @@ public function test_with_mapped_properties(): void $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 diff --git a/tests/Fixtures/Config/person.yaml b/tests/Fixtures/Config/person.yaml index 59da524..a9e2747 100644 --- a/tests/Fixtures/Config/person.yaml +++ b/tests/Fixtures/Config/person.yaml @@ -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: ~ diff --git a/tests/Fixtures/Model/Source/User.php b/tests/Fixtures/Model/Source/User.php index 10b6a30..1b75a17 100644 --- a/tests/Fixtures/Model/Source/User.php +++ b/tests/Fixtures/Model/Source/User.php @@ -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 */ private array $favouriteMovies; @@ -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; diff --git a/tests/Fixtures/Model/Target/Factory/PersonWithDefaultsFactory.php b/tests/Fixtures/Model/Target/Factory/PersonWithDefaultsFactory.php new file mode 100644 index 0000000..eb0d0fd --- /dev/null +++ b/tests/Fixtures/Model/Target/Factory/PersonWithDefaultsFactory.php @@ -0,0 +1,22 @@ + + */ +class PersonWithDefaultsFactory implements TargetFactory +{ + public function create(?object $ctx = null): Person + { + return (new Person()) + ->setMail('default@test.de') + ->setAge(39); + } +} diff --git a/tests/Fixtures/Model/Target/Person.php b/tests/Fixtures/Model/Target/Person.php index e7b8c1f..d6ae6d7 100644 --- a/tests/Fixtures/Model/Target/Person.php +++ b/tests/Fixtures/Model/Target/Person.php @@ -18,6 +18,8 @@ class Person private ?PersonAddress $address = null; + private ?string $placeOfResidence = null; + /** @var array */ private array $favouriteMovies; @@ -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; @@ -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; diff --git a/tests/Populator/PropertyMappingPopulatorTest.php b/tests/Populator/PropertyMappingPopulatorTest.php index 1a60a7d..57af128 100644 --- a/tests/Populator/PropertyMappingPopulatorTest.php +++ b/tests/Populator/PropertyMappingPopulatorTest.php @@ -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; @@ -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()); } }