Skip to content

Commit

Permalink
feat: option to remove missing members from the target object (#60)
Browse files Browse the repository at this point in the history
* feat: option to remove missing members from the target object

* the feature

* if target is a list, renumber keys after deletion

* add test using remover

* add getter method to AdderRemoverProxy

* add remover metadata & fix remover
  • Loading branch information
priyadi authored May 1, 2024
1 parent 30df0be commit ba9130c
Show file tree
Hide file tree
Showing 18 changed files with 557 additions and 10 deletions.
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
8 changes: 8 additions & 0 deletions config/tests.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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');
Expand Down
19 changes: 19 additions & 0 deletions src/Attribute/AllowDelete.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

/*
* This file is part of rekalogika/mapper package.
*
* (c) Priyadi Iman Nurcahyo <https://rekalogika.dev>
*
* 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
{
}
7 changes: 7 additions & 0 deletions src/Transformer/Implementation/ObjectToObjectTransformer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 */
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -99,6 +100,7 @@ public function transform(
context: $context,
);
}

return $this->eagerTransform(
source: $source,
target: $target,
Expand Down Expand Up @@ -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(
Expand All @@ -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;
Expand Down
61 changes: 57 additions & 4 deletions src/Transformer/Model/AdderRemoverProxy.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,65 @@
* @template TKey of array-key
* @template TValue
* @implements \ArrayAccess<TKey,TValue>
* @implements \IteratorAggregate<TKey,TValue>
* @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<TKey,TValue>|array<TKey,TValue>
*/
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<TKey,TValue>|array<TKey,TValue> $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
Expand All @@ -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');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -239,19 +254,29 @@ public function createObjectToObjectMetadata(

// process target setter write mode

$targetRemoverWriteName = null;
$targetRemoverWriteVisibility = Visibility::None;

if ($targetSetterWriteInfo === null) {
$targetSetterWriteMode = WriteMode::None;
$targetSetterWriteName = null;
$targetSetterWriteVisibility = Visibility::None;
} 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,
Expand Down Expand Up @@ -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;
Expand Down
18 changes: 18 additions & 0 deletions src/Transformer/ObjectToObjectMetadata/PropertyMapping.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
}
14 changes: 11 additions & 3 deletions src/Transformer/Util/ReaderWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}

Expand Down
Loading

0 comments on commit ba9130c

Please sign in to comment.