diff --git a/CHANGELOG.md b/CHANGELOG.md index 59aeab13..ec30e031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,12 @@ # CHANGELOG -## 1.2.1 + +## 1.3.0 * test: add tests for mapping to objects with existing value +* fix: fix: Setter does not get called if the property is also in the + constructor +* feat: option to remove missing members from the target object ## 1.2.0 diff --git a/config/tests.php b/config/tests.php index df4175d4..05cad79c 100644 --- a/config/tests.php +++ b/config/tests.php @@ -23,6 +23,8 @@ use Rekalogika\Mapper\Tests\Fixtures\PropertyMapper\PropertyMapperWithExtraArguments; use Rekalogika\Mapper\Tests\Fixtures\PropertyMapper\PropertyMapperWithoutClassAttribute; use Rekalogika\Mapper\Tests\Fixtures\RememberingMapper\RememberingMapper; +use Rekalogika\Mapper\Tests\Fixtures\Remove\MemberDtoToMemberMapper; +use Rekalogika\Mapper\Tests\Fixtures\Remove\MemberRepository; use Rekalogika\Mapper\Tests\Fixtures\TransformerOverride\OverrideTransformer; use Rekalogika\Mapper\Transformer\Implementation\ScalarToScalarTransformer; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; @@ -52,6 +54,12 @@ $services->set(PropertyMapperWithExtraArguments::class); $services->set(MoneyObjectMapper::class); $services->set(PersonToPersonDtoMapper::class); + $services->set(MemberRepository::class); + $services + ->set(MemberDtoToMemberMapper::class) + ->args([ + '$memberRepository' => service(MemberRepository::class), + ]); $services->set(MoneyToMoneyDtoTransformer::class) ->tag('rekalogika.mapper.transformer'); diff --git a/src/Attribute/AllowDelete.php b/src/Attribute/AllowDelete.php new file mode 100644 index 00000000..1588ab50 --- /dev/null +++ b/src/Attribute/AllowDelete.php @@ -0,0 +1,19 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Attribute; + +#[\Attribute(\Attribute::TARGET_PROPERTY | \Attribute::TARGET_METHOD)] +final readonly class AllowDelete +{ +} diff --git a/src/Transformer/Implementation/ObjectToObjectTransformer.php b/src/Transformer/Implementation/ObjectToObjectTransformer.php index 7689db80..8ddd60f1 100644 --- a/src/Transformer/Implementation/ObjectToObjectTransformer.php +++ b/src/Transformer/Implementation/ObjectToObjectTransformer.php @@ -14,6 +14,7 @@ namespace Rekalogika\Mapper\Transformer\Implementation; use Psr\Container\ContainerInterface; +use Rekalogika\Mapper\Attribute\AllowDelete; use Rekalogika\Mapper\Context\Context; use Rekalogika\Mapper\Context\MapperOptions; use Rekalogika\Mapper\Exception\InvalidArgumentException; @@ -547,6 +548,12 @@ private function transformValue( $guessedSourceType = TypeGuesser::guessTypeFromVariable($sourcePropertyValue); $sourceType = $propertyMapping->getCompatibleSourceType($guessedSourceType); + // add AllowDelete to context if target allows deletion + + if ($propertyMapping->targetAllowsDelete()) { + $context = $context->with(new AllowDelete()); + } + // transform the value /** @var mixed */ diff --git a/src/Transformer/Implementation/TraversableToArrayAccessTransformer.php b/src/Transformer/Implementation/TraversableToArrayAccessTransformer.php index 4a5ec536..c1149e5a 100644 --- a/src/Transformer/Implementation/TraversableToArrayAccessTransformer.php +++ b/src/Transformer/Implementation/TraversableToArrayAccessTransformer.php @@ -16,6 +16,7 @@ use Doctrine\Common\Collections\ArrayCollection; use Doctrine\Common\Collections\Collection; use Doctrine\Common\Collections\ReadableCollection; +use Rekalogika\Mapper\Attribute\AllowDelete; use Rekalogika\Mapper\CollectionInterface; use Rekalogika\Mapper\Context\Context; use Rekalogika\Mapper\Exception\InvalidArgumentException; @@ -99,6 +100,7 @@ public function transform( context: $context, ); } + return $this->eagerTransform( source: $source, target: $target, @@ -147,6 +149,14 @@ private function eagerTransform( target: $target, ); + // determine if target allows deletion + + $allowDelete = $context(AllowDelete::class) !== null; + + if ($allowDelete) { + $context = $context->without(AllowDelete::class); + } + // Transform the source $transformed = $this->transformTraversableSource( @@ -156,12 +166,46 @@ private function eagerTransform( context: $context, ); + if ($allowDelete) { + $values = []; + } else { + $values = null; + } + foreach ($transformed as $key => $value) { if ($key === null) { $target[] = $value; } else { $target[$key] = $value; } + + if (is_array($values)) { + $values[] = $value; + } + } + + // if target allows delete, remove values in the target that are not in + // the values array + + if (is_array($values) && is_iterable($target)) { + /** + * @psalm-suppress RedundantConditionGivenDocblockType + */ + $isList = is_array($target) && array_is_list($target); + + foreach ($target as $key => $value) { + if (!in_array($value, $values, true)) { + unset($target[$key]); + } + } + + // renumber array if it is a list + + /** @psalm-suppress RedundantConditionGivenDocblockType */ + if (is_array($target) && $isList) { + /** @psalm-suppress RedundantFunctionCall */ + $target = array_values($target); + } } return $target; diff --git a/src/Transformer/Model/AdderRemoverProxy.php b/src/Transformer/Model/AdderRemoverProxy.php index 24a37574..1d0faeb9 100644 --- a/src/Transformer/Model/AdderRemoverProxy.php +++ b/src/Transformer/Model/AdderRemoverProxy.php @@ -19,25 +19,65 @@ * @template TKey of array-key * @template TValue * @implements \ArrayAccess + * @implements \IteratorAggregate * @internal */ -final readonly class AdderRemoverProxy implements \ArrayAccess +final readonly class AdderRemoverProxy implements + \ArrayAccess, + \IteratorAggregate, + \Countable { public function __construct( private object $hostObject, + private ?string $getterMethodName, private ?string $adderMethodName, private ?string $removerMethodName, ) { } + /** + * @return \ArrayAccess|array + */ + private function getCollection(): mixed + { + if ($this->getterMethodName === null) { + throw new LogicException('Getter method is not available'); + } + + /** @psalm-suppress MixedMethodCall */ + $result = $this->hostObject->{$this->getterMethodName}(); + + if (!is_array($result) && !$result instanceof \ArrayAccess) { + throw new LogicException('Value is not an array or ArrayAccess'); + } + + /** @var \ArrayAccess|array $result */ + + return $result; + } + + public function getIterator(): \Traversable + { + $value = $this->getCollection(); + + if ($value instanceof \Traversable) { + return $value; + } elseif (is_array($value)) { + return new \ArrayIterator($value); + } + + throw new LogicException('Value is not traversable or array'); + } + public function offsetExists(mixed $offset): bool { - throw new LogicException('Not implemented'); + return isset($this->getCollection()[$offset]); } + /** @psalm-suppress MixedInferredReturnType */ public function offsetGet(mixed $offset): mixed { - throw new LogicException('Not implemented'); + return $this->getCollection()[$offset]; } public function offsetSet(mixed $offset, mixed $value): void @@ -56,7 +96,20 @@ public function offsetUnset(mixed $offset): void throw new LogicException('Remover method is not available'); } + $value = $this->getCollection()[$offset]; + /** @psalm-suppress MixedMethodCall */ - $this->hostObject->{$this->removerMethodName}($offset); + $this->hostObject->{$this->removerMethodName}($value); + } + + public function count(): int + { + $value = $this->getCollection(); + + if ($value instanceof \Countable) { + return $value->count(); + } + + throw new LogicException('Value is not countable'); } } diff --git a/src/Transformer/ObjectToObjectMetadata/Implementation/ObjectToObjectMetadataFactory.php b/src/Transformer/ObjectToObjectMetadata/Implementation/ObjectToObjectMetadataFactory.php index 207e2fc4..7b0a99b0 100644 --- a/src/Transformer/ObjectToObjectMetadata/Implementation/ObjectToObjectMetadataFactory.php +++ b/src/Transformer/ObjectToObjectMetadata/Implementation/ObjectToObjectMetadataFactory.php @@ -13,6 +13,7 @@ namespace Rekalogika\Mapper\Transformer\ObjectToObjectMetadata\Implementation; +use Rekalogika\Mapper\Attribute\AllowDelete; use Rekalogika\Mapper\Attribute\InheritanceMap; use Rekalogika\Mapper\CustomMapper\PropertyMapperResolverInterface; use Rekalogika\Mapper\Proxy\Exception\ProxyNotSupportedException; @@ -142,11 +143,17 @@ public function createObjectToObjectMetadata( foreach ($propertiesToMap as $targetProperty) { $sourceProperty = $targetProperty; - // determine if a property mapper is defined for the property - $serviceMethodSpecification = $this->propertyMapperResolver ->getPropertyMapper($sourceClass, $targetClass, $targetProperty); + // get reflection for target property + + try { + $targetPropertyReflection = $targetReflection->getProperty($targetProperty); + } catch (\ReflectionException) { + $targetPropertyReflection = null; + } + // get read & write info for source and target properties $sourceReadInfo = $this->propertyReadInfoExtractor @@ -158,6 +165,14 @@ public function createObjectToObjectMetadata( $targetSetterWriteInfo = $this ->getSetterWriteInfo($targetClass, $targetProperty); + // determine if target allows delete + + if ($targetPropertyReflection === null) { + $targetAllowsDelete = false; + } else { + $targetAllowsDelete = count($targetPropertyReflection->getAttributes(AllowDelete::class)) > 0; + } + // process source read mode if ($sourceReadInfo === null) { @@ -239,6 +254,9 @@ public function createObjectToObjectMetadata( // process target setter write mode + $targetRemoverWriteName = null; + $targetRemoverWriteVisibility = Visibility::None; + if ($targetSetterWriteInfo === null) { $targetSetterWriteMode = WriteMode::None; $targetSetterWriteName = null; @@ -246,12 +264,19 @@ public function createObjectToObjectMetadata( } elseif ($targetSetterWriteInfo->getType() === PropertyWriteInfo::TYPE_ADDER_AND_REMOVER) { $targetSetterWriteMode = WriteMode::AdderRemover; $targetSetterWriteName = $targetSetterWriteInfo->getAdderInfo()->getName(); + $targetRemoverWriteName = $targetSetterWriteInfo->getRemoverInfo()->getName(); $targetSetterWriteVisibility = match ($targetSetterWriteInfo->getAdderInfo()->getVisibility()) { PropertyWriteInfo::VISIBILITY_PUBLIC => Visibility::Public, PropertyWriteInfo::VISIBILITY_PROTECTED => Visibility::Protected, PropertyWriteInfo::VISIBILITY_PRIVATE => Visibility::Private, default => Visibility::None, }; + $targetRemoverWriteVisibility = match ($targetSetterWriteInfo->getRemoverInfo()->getVisibility()) { + PropertyWriteInfo::VISIBILITY_PUBLIC => Visibility::Public, + PropertyWriteInfo::VISIBILITY_PROTECTED => Visibility::Protected, + PropertyWriteInfo::VISIBILITY_PRIVATE => Visibility::Private, + default => Visibility::None, + }; } else { $targetSetterWriteMode = match ($targetSetterWriteInfo->getType()) { PropertyWriteInfo::TYPE_METHOD => WriteMode::Method, @@ -361,13 +386,16 @@ public function createObjectToObjectMetadata( targetReadVisibility: $targetReadVisibility, targetSetterWriteMode: $targetSetterWriteMode, targetSetterWriteName: $targetSetterWriteName, + targetRemoverWriteName: $targetRemoverWriteName, targetSetterWriteVisibility: $targetSetterWriteVisibility, + targetRemoverWriteVisibility: $targetRemoverWriteVisibility, targetConstructorWriteMode: $targetConstructorWriteMode, targetConstructorWriteName: $targetConstructorWriteName, targetScalarType: $targetPropertyScalarType, propertyMapper: $serviceMethodSpecification, sourceLazy: $sourceLazy, targetCanAcceptNull: $targetCanAcceptNull, + targetAllowsDelete: $targetAllowsDelete, ); $propertyMappings[] = $propertyMapping; diff --git a/src/Transformer/ObjectToObjectMetadata/PropertyMapping.php b/src/Transformer/ObjectToObjectMetadata/PropertyMapping.php index 2e498e3a..65ff6fe2 100644 --- a/src/Transformer/ObjectToObjectMetadata/PropertyMapping.php +++ b/src/Transformer/ObjectToObjectMetadata/PropertyMapping.php @@ -51,13 +51,16 @@ public function __construct( private Visibility $targetReadVisibility, private WriteMode $targetSetterWriteMode, private ?string $targetSetterWriteName, + private ?string $targetRemoverWriteName, private Visibility $targetSetterWriteVisibility, + private Visibility $targetRemoverWriteVisibility, private WriteMode $targetConstructorWriteMode, private ?string $targetConstructorWriteName, private ?string $targetScalarType, private ?ServiceMethodSpecification $propertyMapper, private bool $sourceLazy, private bool $targetCanAcceptNull, + private bool $targetAllowsDelete, ) { $this->sourceTypes = array_values($sourceTypes); $this->targetTypes = array_values($targetTypes); @@ -180,4 +183,19 @@ public function targetCanAcceptNull(): bool { return $this->targetCanAcceptNull; } + + public function targetAllowsDelete(): bool + { + return $this->targetAllowsDelete; + } + + public function getTargetRemoverWriteName(): ?string + { + return $this->targetRemoverWriteName; + } + + public function getTargetRemoverWriteVisibility(): Visibility + { + return $this->targetRemoverWriteVisibility; + } } diff --git a/src/Transformer/Util/ReaderWriter.php b/src/Transformer/Util/ReaderWriter.php index 7b03470c..503d9963 100644 --- a/src/Transformer/Util/ReaderWriter.php +++ b/src/Transformer/Util/ReaderWriter.php @@ -100,10 +100,18 @@ public function readTargetProperty( $propertyMapping->getTargetSetterWriteMode() === WriteMode::AdderRemover && $propertyMapping->getTargetSetterWriteVisibility() === Visibility::Public ) { + if ($propertyMapping->getTargetRemoverWriteVisibility() === Visibility::Public + ) { + $removerMethodName = $propertyMapping->getTargetRemoverWriteName(); + } else { + $removerMethodName = null; + } + return new AdderRemoverProxy( - $target, - $propertyMapping->getTargetSetterWriteName(), - null + hostObject: $target, + getterMethodName: $propertyMapping->getTargetReadName(), + adderMethodName: $propertyMapping->getTargetSetterWriteName(), + removerMethodName: $removerMethodName, ); } diff --git a/tests/Fixtures/Remove/Member.php b/tests/Fixtures/Remove/Member.php new file mode 100644 index 00000000..5f31044e --- /dev/null +++ b/tests/Fixtures/Remove/Member.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Fixtures\Remove; + +class Member +{ + public function __construct( + private string $id, + ) { + } + + public function getId(): string + { + return $this->id; + } +} diff --git a/tests/Fixtures/Remove/MemberDto.php b/tests/Fixtures/Remove/MemberDto.php new file mode 100644 index 00000000..8f71c585 --- /dev/null +++ b/tests/Fixtures/Remove/MemberDto.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Fixtures\Remove; + +class MemberDto +{ + public function __construct( + public string $id, + ) { + } +} diff --git a/tests/Fixtures/Remove/MemberDtoToMemberMapper.php b/tests/Fixtures/Remove/MemberDtoToMemberMapper.php new file mode 100644 index 00000000..17a4b18c --- /dev/null +++ b/tests/Fixtures/Remove/MemberDtoToMemberMapper.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Fixtures\Remove; + +use Rekalogika\Mapper\Attribute\AsObjectMapper; + +class MemberDtoToMemberMapper +{ + public function __construct( + private MemberRepository $memberRepository, + ) { + } + + #[AsObjectMapper] + public function mapMemberDtoToMember(MemberDto $memberDto): Member + { + $id = $memberDto->id; + + return $this->memberRepository->get($id); + } +} diff --git a/tests/Fixtures/Remove/MemberRepository.php b/tests/Fixtures/Remove/MemberRepository.php new file mode 100644 index 00000000..8d85a7ab --- /dev/null +++ b/tests/Fixtures/Remove/MemberRepository.php @@ -0,0 +1,35 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Fixtures\Remove; + +class MemberRepository +{ + /** + * @var array + */ + private array $members = []; + + public function add(string $id): Member + { + $member = new Member($id); + $this->members[$id] = $member; + + return $member; + } + + public function get(string $id): Member + { + return $this->members[$id] ?? throw new \InvalidArgumentException('Member not found'); + } +} diff --git a/tests/Fixtures/Remove/ObjectWithAdderRemover.php b/tests/Fixtures/Remove/ObjectWithAdderRemover.php new file mode 100644 index 00000000..f438f763 --- /dev/null +++ b/tests/Fixtures/Remove/ObjectWithAdderRemover.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Fixtures\Remove; + +use Rekalogika\Mapper\Attribute\AllowDelete; + +class ObjectWithAdderRemover +{ + /** + * @var array + */ + #[AllowDelete] + private array $members = []; + + /** + * @return array + */ + public function getMembers(): array + { + return $this->members; + } + + public function addMember(Member $member): void + { + if (!in_array($member, $this->members, true)) { + $this->members[] = $member; + } + } + + public function removeMember(Member $member): void + { + $key = array_search($member, $this->members, true); + + if (false !== $key) { + unset($this->members[$key]); + } + } +} diff --git a/tests/Fixtures/Remove/ObjectWithArray.php b/tests/Fixtures/Remove/ObjectWithArray.php new file mode 100644 index 00000000..aba7ffa5 --- /dev/null +++ b/tests/Fixtures/Remove/ObjectWithArray.php @@ -0,0 +1,25 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Fixtures\Remove; + +use Rekalogika\Mapper\Attribute\AllowDelete; + +class ObjectWithArray +{ + /** + * @var array + */ + #[AllowDelete] + public array $members = []; +} diff --git a/tests/Fixtures/Remove/ObjectWithArrayDto.php b/tests/Fixtures/Remove/ObjectWithArrayDto.php new file mode 100644 index 00000000..a481dec8 --- /dev/null +++ b/tests/Fixtures/Remove/ObjectWithArrayDto.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Fixtures\Remove; + +class ObjectWithArrayDto +{ + /** + * @var array + */ + public array $members = []; +} diff --git a/tests/Fixtures/Remove/ObjectWithArrayWithoutAllowDeleteAttribute.php b/tests/Fixtures/Remove/ObjectWithArrayWithoutAllowDeleteAttribute.php new file mode 100644 index 00000000..41b9b831 --- /dev/null +++ b/tests/Fixtures/Remove/ObjectWithArrayWithoutAllowDeleteAttribute.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Fixtures\Remove; + +class ObjectWithArrayWithoutAllowDeleteAttribute +{ + /** + * @var array + */ + public array $members = []; +} diff --git a/tests/IntegrationTest/RemoveTest.php b/tests/IntegrationTest/RemoveTest.php new file mode 100644 index 00000000..cc6199e2 --- /dev/null +++ b/tests/IntegrationTest/RemoveTest.php @@ -0,0 +1,124 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\IntegrationTest; + +use Rekalogika\Mapper\Tests\Common\FrameworkTestCase; +use Rekalogika\Mapper\Tests\Fixtures\Remove\MemberDto; +use Rekalogika\Mapper\Tests\Fixtures\Remove\MemberRepository; +use Rekalogika\Mapper\Tests\Fixtures\Remove\ObjectWithAdderRemover; +use Rekalogika\Mapper\Tests\Fixtures\Remove\ObjectWithArray; +use Rekalogika\Mapper\Tests\Fixtures\Remove\ObjectWithArrayDto; +use Rekalogika\Mapper\Tests\Fixtures\Remove\ObjectWithArrayWithoutAllowDeleteAttribute; + +/** @psalm-suppress MissingConstructor */ +class RemoveTest extends FrameworkTestCase +{ + private MemberRepository $repository; + + public function setUp(): void + { + parent::setUp(); + + $this->repository = $this->get(MemberRepository::class); + $this->repository->add('1'); + $this->repository->add('2'); + $this->repository->add('3'); + } + + public function testAdd(): void + { + $objectWithArrayDto = new ObjectWithArrayDto(); + $objectWithArrayDto->members[] = new MemberDto('1'); + $objectWithArrayDto->members[] = new MemberDto('2'); + $objectWithArrayDto->members[] = new MemberDto('3'); + + $objectWithArray = $this->mapper->map($objectWithArrayDto, ObjectWithArray::class); + + $this->assertCount(3, $objectWithArray->members); + $this->assertSame('1', $objectWithArray->members[0]->getId()); + $this->assertSame('2', $objectWithArray->members[1]->getId()); + $this->assertSame('3', $objectWithArray->members[2]->getId()); + $this->assertSame($this->repository->get('1'), $objectWithArray->members[0]); + $this->assertSame($this->repository->get('2'), $objectWithArray->members[1]); + $this->assertSame($this->repository->get('3'), $objectWithArray->members[2]); + } + + public function testRemoveFromArray(): void + { + $objectWithArrayDto = new ObjectWithArrayDto(); + $objectWithArrayDto->members[] = new MemberDto('1'); + $objectWithArrayDto->members[] = new MemberDto('2'); + // 3 is missing, and this should remove 3 from the target object + + $objectWithArray = new ObjectWithArray(); + $objectWithArray->members[] = $this->repository->get('1'); + $objectWithArray->members[] = $this->repository->get('2'); + $objectWithArray->members[] = $this->repository->get('3'); + $this->assertCount(3, $objectWithArray->members); + + $this->mapper->map($objectWithArrayDto, $objectWithArray); + + $this->assertCount(2, $objectWithArray->members); + $this->assertSame('1', $objectWithArray->members[0]->getId()); + $this->assertSame('2', $objectWithArray->members[1]->getId()); + $this->assertSame($this->repository->get('1'), $objectWithArray->members[0]); + $this->assertSame($this->repository->get('2'), $objectWithArray->members[1]); + } + + public function testNoRemovalWithoutAllowDeleteAttribute(): void + { + $objectWithArrayDto = new ObjectWithArrayDto(); + $objectWithArrayDto->members[] = new MemberDto('1'); + $objectWithArrayDto->members[] = new MemberDto('2'); + // 3 is missing, and this should remove 3 from the target object + + $objectWithArray = new ObjectWithArrayWithoutAllowDeleteAttribute(); + $objectWithArray->members[] = $this->repository->get('1'); + $objectWithArray->members[] = $this->repository->get('2'); + $objectWithArray->members[] = $this->repository->get('3'); + $this->assertCount(3, $objectWithArray->members); + + $this->mapper->map($objectWithArrayDto, $objectWithArray); + + $this->assertCount(3, $objectWithArray->members); + $this->assertSame('1', $objectWithArray->members[0]->getId()); + $this->assertSame('2', $objectWithArray->members[1]->getId()); + $this->assertSame('3', $objectWithArray->members[2]->getId()); + $this->assertSame($this->repository->get('1'), $objectWithArray->members[0]); + $this->assertSame($this->repository->get('2'), $objectWithArray->members[1]); + $this->assertSame($this->repository->get('3'), $objectWithArray->members[2]); + } + + public function testRemoveUsingRemover(): void + { + $objectWithArrayDto = new ObjectWithArrayDto(); + $objectWithArrayDto->members[] = new MemberDto('1'); + $objectWithArrayDto->members[] = new MemberDto('2'); + // 3 is missing, and this should remove 3 from the target object + + $objectWithArray = new ObjectWithAdderRemover(); + $objectWithArray->addMember($this->repository->get('1')); + $objectWithArray->addMember($this->repository->get('2')); + $objectWithArray->addMember($this->repository->get('3')); + $this->assertCount(3, $objectWithArray->getMembers()); + + $this->mapper->map($objectWithArrayDto, $objectWithArray); + + $this->assertCount(2, $objectWithArray->getMembers()); + $this->assertSame('1', $objectWithArray->getMembers()[0]->getId()); + $this->assertSame('2', $objectWithArray->getMembers()[1]->getId()); + $this->assertSame($this->repository->get('1'), $objectWithArray->getMembers()[0]); + $this->assertSame($this->repository->get('2'), $objectWithArray->getMembers()[1]); + } +}