diff --git a/src/Mapper/FieldAccessor/Closure.php b/src/Mapper/FieldAccessor/Closure.php index 820cd85..380ce67 100644 --- a/src/Mapper/FieldAccessor/Closure.php +++ b/src/Mapper/FieldAccessor/Closure.php @@ -4,6 +4,7 @@ namespace Retailcrm\AutoMapperBundle\Mapper\FieldAccessor; +use Retailcrm\AutoMapperBundle\Mapper\Exception\SkipFieldMappingException; use Retailcrm\AutoMapperBundle\Mapper\FieldDeciderAwareInterface; use Retailcrm\AutoMapperBundle\Mapper\FieldDeciderInterface; @@ -14,11 +15,16 @@ */ class Closure implements FieldAccessorInterface, FieldDeciderAwareInterface { - private ?FieldDeciderInterface $fieldDecider; + private ?FieldDeciderInterface $fieldDecider = null; + private ?string $destinationMember = null; + private ?string $routedSourceField = null; + private bool $effectiveSkipNonExists = false; - public function __construct(private \Closure $closure) - { - $this->closure = $closure; + public function __construct( + private \Closure $closure, + private ?string $sourceField = null, + private ?bool $skipNonExists = null, + ) { } public function getValue(mixed $source): mixed @@ -26,6 +32,10 @@ public function getValue(mixed $source): mixed $closure = $this->closure; $fieldDecider = $this->fieldDecider; + if ($this->shouldSkipNonExistingField($source)) { + throw new SkipFieldMappingException(); + } + return $fieldDecider ? $closure($source, $fieldDecider) : $closure($source); } @@ -33,4 +43,38 @@ public function setFieldDecider(?FieldDeciderInterface $fieldDecider): void { $this->fieldDecider = $fieldDecider; } + + public function setDestinationMember(string $destinationMember): void + { + $this->destinationMember = $destinationMember; + } + + public function setRoutedSourceField(?string $routedSourceField): void + { + $this->routedSourceField = $routedSourceField; + } + + public function setEffectiveSkipNonExists(bool $skipNonExists): void + { + $this->effectiveSkipNonExists = $skipNonExists; + } + + public function getSkipNonExistsOverride(): ?bool + { + return $this->skipNonExists; + } + + private function shouldSkipNonExistingField(mixed $source): bool + { + if (!$this->effectiveSkipNonExists || !is_object($source) || null === $this->fieldDecider) { + return false; + } + + $sourceField = $this->sourceField ?? $this->routedSourceField ?? $this->destinationMember; + if (null === $sourceField) { + return false; + } + + return !$this->fieldDecider->shouldMapField($source, $sourceField); + } } diff --git a/src/Mapper/Map/AbstractMap.php b/src/Mapper/Map/AbstractMap.php index f18bc41..51d85eb 100644 --- a/src/Mapper/Map/AbstractMap.php +++ b/src/Mapper/Map/AbstractMap.php @@ -5,6 +5,7 @@ namespace Retailcrm\AutoMapperBundle\Mapper\Map; use Retailcrm\AutoMapperBundle\Mapper\AfterMapper\AfterMapperInterface; +use Retailcrm\AutoMapperBundle\Mapper\FieldAccessor\Closure; use Retailcrm\AutoMapperBundle\Mapper\FieldAccessor\FieldAccessorInterface; use Retailcrm\AutoMapperBundle\Mapper\FieldAccessor\Simple; use Retailcrm\AutoMapperBundle\Mapper\FieldDeciderAwareInterface; @@ -27,6 +28,7 @@ abstract class AbstractMap implements MapInterface, FieldDeciderAwareInterface protected bool $overwriteIfSet = true; protected bool $skipNull = false; protected bool $skipNonExists = false; + protected bool $closureSkipNonExists = false; /** @var AfterMapperInterface[] */ protected array $afterMappers = []; protected ?FieldDeciderInterface $fieldDecider = null; @@ -58,6 +60,10 @@ public function route(string $destinationMember, string $sourceMember): self */ public function forMember(string $destinationMember, FieldAccessorInterface $fieldMapper): self { + if ($fieldMapper instanceof Closure) { + $this->configureClosure($destinationMember, $fieldMapper); + } + if ($fieldMapper instanceof FieldDeciderAwareInterface) { $fieldMapper->setFieldDecider($this->fieldDecider); } @@ -95,6 +101,7 @@ public function getSkipNull(): bool public function setSkipNonExists(bool $value): self { $this->skipNonExists = $value; + $this->configureClosures(); return $this; } @@ -104,6 +111,14 @@ public function getSkipNonExists(): bool return $this->skipNonExists; } + public function setClosureSkipNonExists(bool $value): self + { + $this->closureSkipNonExists = $value; + $this->configureClosures(); + + return $this; + } + /** * Sets whether to overwrite the destination value if it is already set. */ @@ -177,6 +192,24 @@ protected function createSimple(string $name): FieldAccessorInterface ); } + private function configureClosures(): void + { + foreach ($this->fieldAccessors as $destinationMember => $fieldAccessor) { + if ($fieldAccessor instanceof Closure) { + $this->configureClosure($destinationMember, $fieldAccessor); + } + } + } + + private function configureClosure(string $destinationMember, Closure $closure): void + { + $closure->setDestinationMember($destinationMember); + $closure->setRoutedSourceField($this->fieldRoutes[$destinationMember] ?? null); + $closure->setEffectiveSkipNonExists( + $this->skipNonExists && ($closure->getSkipNonExistsOverride() ?? $this->closureSkipNonExists) + ); + } + private function getCorrectPropertyPath(string $name): string { return 'array' === $this->getSourceType() ? '[' . $name . ']' : $name; diff --git a/src/Mapper/Map/DefaultMap.php b/src/Mapper/Map/DefaultMap.php index f979bd5..2d2593c 100644 --- a/src/Mapper/Map/DefaultMap.php +++ b/src/Mapper/Map/DefaultMap.php @@ -31,6 +31,7 @@ public function __construct( public function route(string $destinationMember, string $sourceMember): self { $this->fieldAccessors[$destinationMember] = new Simple($this->getCorrectPropertyPath($sourceMember)); + $this->fieldRoutes[$destinationMember] = $sourceMember; return $this; } diff --git a/tests/Mapper/MapperTest.php b/tests/Mapper/MapperTest.php index feacd76..b6bfc7a 100644 --- a/tests/Mapper/MapperTest.php +++ b/tests/Mapper/MapperTest.php @@ -499,4 +499,206 @@ public function getDestinationType(): string $this->assertEquals('Foo bar', $destination->description); } + + public function testClosureSkipNonExistsIsDisabledByDefault(): void + { + $source = new SourcePost(); + $source->description = null; + $destination = new DestinationPost(); + $destination->description = 'Foo bar'; + $mapper = new Mapper(); + $mapper->registerMap(new class extends AbstractMap { + public function __construct() + { + $this->setSkipNonExists(true); + $this->setFieldDecider(new class implements FieldDeciderInterface { + public function shouldMapField(object $source, string $field): bool + { + return 'description' !== $field; + } + }); + + $this->forMember('description', new Closure( + static fn (SourcePost $source): ?string => $source->description + )); + } + + public function getSourceType(): string + { + return SourcePost::class; + } + + public function getDestinationType(): string + { + return DestinationPost::class; + } + }); + + $mapper->map($source, $destination); + + $this->assertNull($destination->description); + } + + public function testClosureSkipNonExistsUsesDestinationMemberByDefault(): void + { + $source = new SourcePost(); + $source->description = null; + $destination = new DestinationPost(); + $destination->description = 'Foo bar'; + $mapper = new Mapper(); + $mapper->registerMap(new class extends AbstractMap { + public function __construct() + { + $this->setSkipNonExists(true); + $this->setClosureSkipNonExists(true); + $this->setFieldDecider(new class implements FieldDeciderInterface { + public function shouldMapField(object $source, string $field): bool + { + return 'description' !== $field; + } + }); + + $this->forMember('description', new Closure( + static fn () => throw new \RuntimeException('Closure should not be called') + )); + } + + public function getSourceType(): string + { + return SourcePost::class; + } + + public function getDestinationType(): string + { + return DestinationPost::class; + } + }); + + $mapper->map($source, $destination); + + $this->assertEquals('Foo bar', $destination->description); + } + + public function testClosureSkipNonExistsUsesRouteSourceField(): void + { + $source = new SourcePost(); + $source->name = null; + $destination = new DestinationPost(); + $destination->title = 'Foo bar'; + $mapper = new Mapper(); + $mapper->registerMap(new class extends AbstractMap { + public function __construct() + { + $this->setSkipNonExists(true); + $this->setClosureSkipNonExists(true); + $this->setFieldDecider(new class implements FieldDeciderInterface { + public function shouldMapField(object $source, string $field): bool + { + return 'name' !== $field; + } + }); + + $this->route('title', 'name'); + $this->forMember('title', new Closure( + static fn () => throw new \RuntimeException('Closure should not be called') + )); + } + + public function getSourceType(): string + { + return SourcePost::class; + } + + public function getDestinationType(): string + { + return DestinationPost::class; + } + }); + + $mapper->map($source, $destination); + + $this->assertEquals('Foo bar', $destination->title); + } + + public function testClosureSkipNonExistsCanUseExplicitSourceField(): void + { + $source = new SourcePost(); + $source->name = null; + $destination = new DestinationPost(); + $destination->title = 'Foo bar'; + $mapper = new Mapper(); + $mapper->registerMap(new class extends AbstractMap { + public function __construct() + { + $this->setSkipNonExists(true); + $this->setClosureSkipNonExists(true); + $this->setFieldDecider(new class implements FieldDeciderInterface { + public function shouldMapField(object $source, string $field): bool + { + return 'name' !== $field; + } + }); + + $this->forMember('title', new Closure( + static fn () => throw new \RuntimeException('Closure should not be called'), + sourceField: 'name' + )); + } + + public function getSourceType(): string + { + return SourcePost::class; + } + + public function getDestinationType(): string + { + return DestinationPost::class; + } + }); + + $mapper->map($source, $destination); + + $this->assertEquals('Foo bar', $destination->title); + } + + public function testClosureSkipNonExistsCanBeDisabledForMember(): void + { + $source = new SourcePost(); + $source->description = null; + $destination = new DestinationPost(); + $destination->description = 'Foo bar'; + $mapper = new Mapper(); + $mapper->registerMap(new class extends AbstractMap { + public function __construct() + { + $this->setSkipNonExists(true); + $this->setClosureSkipNonExists(true); + $this->setFieldDecider(new class implements FieldDeciderInterface { + public function shouldMapField(object $source, string $field): bool + { + return 'description' !== $field; + } + }); + + $this->forMember('description', new Closure( + static fn (): string => 'called', + skipNonExists: false + )); + } + + public function getSourceType(): string + { + return SourcePost::class; + } + + public function getDestinationType(): string + { + return DestinationPost::class; + } + }); + + $mapper->map($source, $destination); + + $this->assertEquals('called', $destination->description); + } }