From c30d661568e1f18ef4bdd45299a41650bca1514b Mon Sep 17 00:00:00 2001 From: Priyadi Iman Nurcahyo <1102197+priyadi@users.noreply.github.com> Date: Sun, 28 Apr 2024 00:23:47 +0700 Subject: [PATCH] feat: add `IterableMapperInterface` for mapping iterables (#53) --- CHANGELOG.md | 4 ++ config/services.php | 5 +- src/Implementation/Mapper.php | 45 +++++++++++++++++- src/IterableMapperInterface.php | 27 +++++++++++ tests/Common/FrameworkTestCase.php | 8 ++++ tests/Common/IterableMapperDecorator.php | 31 +++++++++++++ tests/Common/TestKernel.php | 2 + tests/Fixtures/Basic/Person.php | 33 +++++++++++++ tests/Fixtures/Basic/PersonDto.php | 20 ++++++++ tests/IntegrationTest/IterableMapperTest.php | 49 ++++++++++++++++++++ 10 files changed, 222 insertions(+), 2 deletions(-) create mode 100644 src/IterableMapperInterface.php create mode 100644 tests/Common/IterableMapperDecorator.php create mode 100644 tests/Fixtures/Basic/Person.php create mode 100644 tests/Fixtures/Basic/PersonDto.php create mode 100644 tests/IntegrationTest/IterableMapperTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 53edc99..d48032c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 1.2.0 + +* feat: add `IterableMapperInterface` for mapping iterables + ## 1.1.2 * fix: property info caching if another bundle is decorating cache ([#47](https://github.com/rekalogika/mapper/issues/47)) diff --git a/config/services.php b/config/services.php index ba13c4f..c7c6a39 100644 --- a/config/services.php +++ b/config/services.php @@ -20,6 +20,7 @@ use Rekalogika\Mapper\CustomMapper\Implementation\PropertyMapperResolver; use Rekalogika\Mapper\CustomMapper\Implementation\WarmableObjectMapperTableFactory; use Rekalogika\Mapper\Implementation\Mapper; +use Rekalogika\Mapper\IterableMapperInterface; use Rekalogika\Mapper\MainTransformer\Implementation\MainTransformer; use Rekalogika\Mapper\MapperInterface; use Rekalogika\Mapper\Mapping\Implementation\MappingCacheWarmer; @@ -64,7 +65,6 @@ use Rekalogika\Mapper\TypeResolver\Implementation\TypeResolver; use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\PropertyInfo\PropertyInfoCacheExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\PropertyInfo\PropertyReadInfoExtractorInterface; use Symfony\Component\PropertyInfo\PropertyWriteInfoExtractorInterface; @@ -452,6 +452,9 @@ $services ->alias(MapperInterface::class, 'rekalogika.mapper.mapper'); + $services + ->alias(IterableMapperInterface::class, 'rekalogika.mapper.mapper'); + # console command $services diff --git a/src/Implementation/Mapper.php b/src/Implementation/Mapper.php index d6f9eb5..ae29d95 100644 --- a/src/Implementation/Mapper.php +++ b/src/Implementation/Mapper.php @@ -15,6 +15,7 @@ use Rekalogika\Mapper\Context\Context; use Rekalogika\Mapper\Exception\UnexpectedValueException; +use Rekalogika\Mapper\IterableMapperInterface; use Rekalogika\Mapper\MainTransformer\MainTransformerInterface; use Rekalogika\Mapper\MapperInterface; use Rekalogika\Mapper\Util\TypeFactory; @@ -22,7 +23,7 @@ /** * @internal */ -final readonly class Mapper implements MapperInterface +final readonly class Mapper implements MapperInterface, IterableMapperInterface { public function __construct( private MainTransformerInterface $transformer, @@ -38,12 +39,14 @@ public function map(object $source, object|string $target, ?Context $context = n { if (is_string($target)) { $targetClass = $target; + if ( !class_exists($targetClass) && !\interface_exists($targetClass) ) { throw new UnexpectedValueException(sprintf('The target class "%s" does not exist.', $targetClass)); } + $targetType = TypeFactory::objectOfClass($targetClass); $target = null; } else { @@ -71,4 +74,44 @@ public function map(object $source, object|string $target, ?Context $context = n return $target; } + + /** + * @template T of object + * @param iterable $source + * @param class-string $target + * @return iterable + */ + public function mapIterable( + iterable $source, + string $target, + ?Context $context = null + ): iterable { + $targetClass = $target; + + if ( + !class_exists($targetClass) + && !\interface_exists($targetClass) + ) { + throw new UnexpectedValueException(sprintf('The target class "%s" does not exist.', $targetClass)); + } + + $targetType = TypeFactory::objectOfClass($targetClass); + + /** @var mixed $item */ + foreach ($source as $item) { + $result = $this->transformer->transform( + source: $item, + target: null, + sourceType: null, + targetTypes: [$targetType], + context: $context ?? Context::create(), + ); + + if (!is_object($result) || !is_a($result, $target)) { + throw new UnexpectedValueException(sprintf('The mapper did not return the variable of expected class, expecting "%s", returned "%s".', $targetClass, get_debug_type($target))); + } + + yield $result; + } + } } diff --git a/src/IterableMapperInterface.php b/src/IterableMapperInterface.php new file mode 100644 index 0000000..ff3314b --- /dev/null +++ b/src/IterableMapperInterface.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; + +use Rekalogika\Mapper\Context\Context; + +interface IterableMapperInterface +{ + /** + * @template T of object + * @param iterable $source + * @param class-string $target + * @return iterable + */ + public function mapIterable(iterable $source, string $target, ?Context $context = null): iterable; +} diff --git a/tests/Common/FrameworkTestCase.php b/tests/Common/FrameworkTestCase.php index 0574e96..0ebf5c5 100644 --- a/tests/Common/FrameworkTestCase.php +++ b/tests/Common/FrameworkTestCase.php @@ -20,6 +20,7 @@ use Rekalogika\Mapper\Context\Context; use Rekalogika\Mapper\Debug\MapperDataCollector; use Rekalogika\Mapper\Debug\TraceableTransformer; +use Rekalogika\Mapper\IterableMapperInterface; use Rekalogika\Mapper\MapperInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; @@ -30,6 +31,8 @@ abstract class FrameworkTestCase extends TestCase private ContainerInterface $container; /** @psalm-suppress MissingConstructor */ protected MapperInterface $mapper; + /** @psalm-suppress MissingConstructor */ + protected IterableMapperInterface $iterableMapper; public function setUp(): void { @@ -41,6 +44,11 @@ public function setUp(): void $this->get(MapperInterface::class), $this->getMapperContext() ); + + $this->iterableMapper = new IterableMapperDecorator( + $this->get(IterableMapperInterface::class), + $this->getMapperContext() + ); } /** diff --git a/tests/Common/IterableMapperDecorator.php b/tests/Common/IterableMapperDecorator.php new file mode 100644 index 0000000..f79c347 --- /dev/null +++ b/tests/Common/IterableMapperDecorator.php @@ -0,0 +1,31 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Common; + +use Rekalogika\Mapper\Context\Context; +use Rekalogika\Mapper\IterableMapperInterface; + +final class IterableMapperDecorator implements IterableMapperInterface +{ + public function __construct( + private IterableMapperInterface $decorated, + private Context $defaultContext + ) { + } + + public function mapIterable(iterable $source, string $target, ?Context $context = null): iterable + { + return $this->decorated->mapIterable($source, $target, $context ?? $this->defaultContext); + } +} diff --git a/tests/Common/TestKernel.php b/tests/Common/TestKernel.php index 97313b7..9f1c1d2 100644 --- a/tests/Common/TestKernel.php +++ b/tests/Common/TestKernel.php @@ -14,6 +14,7 @@ namespace Rekalogika\Mapper\Tests\Common; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; +use Rekalogika\Mapper\IterableMapperInterface; use Rekalogika\Mapper\MapperInterface; use Rekalogika\Mapper\Mapping\MappingFactoryInterface; use Rekalogika\Mapper\RekalogikaMapperBundle; @@ -69,6 +70,7 @@ public function registerContainerConfiguration(LoaderInterface $loader): void public static function getServiceIds(): iterable { yield MapperInterface::class; + yield IterableMapperInterface::class; yield 'rekalogika.mapper.property_info'; // yield 'rekalogika.mapper.cache.property_info'; diff --git a/tests/Fixtures/Basic/Person.php b/tests/Fixtures/Basic/Person.php new file mode 100644 index 0000000..5012c94 --- /dev/null +++ b/tests/Fixtures/Basic/Person.php @@ -0,0 +1,33 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Fixtures\Basic; + +class Person +{ + public function __construct( + private string $name, + private int $age + ) { + } + + public function getName(): string + { + return $this->name; + } + + public function getAge(): int + { + return $this->age; + } +} diff --git a/tests/Fixtures/Basic/PersonDto.php b/tests/Fixtures/Basic/PersonDto.php new file mode 100644 index 0000000..bfb4b0c --- /dev/null +++ b/tests/Fixtures/Basic/PersonDto.php @@ -0,0 +1,20 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Fixtures\Basic; + +class PersonDto +{ + public ?string $name = null; + public ?int $age = null; +} diff --git a/tests/IntegrationTest/IterableMapperTest.php b/tests/IntegrationTest/IterableMapperTest.php new file mode 100644 index 0000000..283a3b8 --- /dev/null +++ b/tests/IntegrationTest/IterableMapperTest.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\IntegrationTest; + +use Rekalogika\Mapper\Tests\Common\FrameworkTestCase; +use Rekalogika\Mapper\Tests\Fixtures\Basic\Person; +use Rekalogika\Mapper\Tests\Fixtures\Basic\PersonDto; + +class IterableMapperTest extends FrameworkTestCase +{ + public function testAdder(): void + { + $result = $this->iterableMapper->mapIterable($this->getIterableInput(), PersonDto::class); + /** @psalm-suppress InvalidArgument */ + $result = iterator_to_array($result); + + $this->assertCount(3, $result); + $this->assertInstanceOf(PersonDto::class, $result[0]); + $this->assertInstanceOf(PersonDto::class, $result[1]); + $this->assertInstanceOf(PersonDto::class, $result[2]); + $this->assertSame('John Doe', $result[0]->name); + $this->assertSame(30, $result[0]->age); + $this->assertSame('Jane Doe', $result[1]->name); + $this->assertSame(25, $result[1]->age); + $this->assertSame('Foo Bar', $result[2]->name); + $this->assertSame(99, $result[2]->age); + } + + /** + * @return iterable + */ + private function getIterableInput(): iterable + { + yield new Person('John Doe', 30); + yield new Person('Jane Doe', 25); + yield new Person('Foo Bar', 99); + } +}