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
52 changes: 48 additions & 4 deletions src/Mapper/FieldAccessor/Closure.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -14,23 +15,66 @@
*/
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
{
$closure = $this->closure;
$fieldDecider = $this->fieldDecider;

if ($this->shouldSkipNonExistingField($source)) {
throw new SkipFieldMappingException();
}

return $fieldDecider ? $closure($source, $fieldDecider) : $closure($source);
}

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);
}
}
33 changes: 33 additions & 0 deletions src/Mapper/Map/AbstractMap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -95,6 +101,7 @@ public function getSkipNull(): bool
public function setSkipNonExists(bool $value): self
{
$this->skipNonExists = $value;
$this->configureClosures();

return $this;
}
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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;
Expand Down
1 change: 1 addition & 0 deletions src/Mapper/Map/DefaultMap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
202 changes: 202 additions & 0 deletions tests/Mapper/MapperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Loading