diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 00000000..b0d34dc8
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,19 @@
+; top-most EditorConfig file
+root = true
+
+; Unix-style newlines
+[*]
+charset = utf-8
+end_of_line = LF
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{php,html,twig}]
+indent_style = space
+indent_size = 4
+
+[*.md]
+max_line_length = 80
+
+[COMMIT_EDITMSG]
+max_line_length = 0
\ No newline at end of file
diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml
new file mode 100644
index 00000000..675f1fdc
--- /dev/null
+++ b/.github/workflows/php.yml
@@ -0,0 +1,65 @@
+name: Test
+
+on:
+ push:
+ branches: [ "*" ]
+ pull_request:
+ branches: [ "main" ]
+
+permissions:
+ contents: read
+
+jobs:
+ build:
+
+ strategy:
+ matrix:
+ operating-system: [ubuntu-latest]
+ php: [ '8.2', '8.3' ]
+ symfony: [ '6.4.*', '7.*' ]
+ dep: [highest,lowest]
+
+ runs-on: ${{ matrix.operating-system }}
+
+ name: Symfony ${{ matrix.symfony }}, ${{ matrix.dep }} deps, PHP ${{ matrix.php }}, ${{ matrix.operating-system }}
+
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Install PHP
+ uses: shivammathur/setup-php@v2
+ with:
+ php-version: ${{ matrix.php }}
+ extensions: intl
+ tools: flex
+
+ - name: Validate composer.json and composer.lock
+ run: composer validate --strict
+
+ - name: Cache Composer packages
+ id: composer-cache
+ uses: actions/cache@v3
+ with:
+ path: vendor
+ key: ${{ runner.os }}-php-${{ hashFiles('**/composer.lock') }}
+ restore-keys: |
+ ${{ runner.os }}-php-
+
+ - name: Install dependencies
+ uses: ramsey/composer-install@v2
+ with:
+ dependency-versions: ${{ matrix.dep }}
+ composer-options: --prefer-dist --no-progress --ignore-platform-reqs
+ env:
+ SYMFONY_REQUIRE: ${{ matrix.symfony }}
+
+ - name: Run psalm
+ run: vendor/bin/psalm
+
+ - name: Run phpstan
+ run: vendor/bin/phpstan analyse
+
+ - name: Run phpunit
+ run: |
+ export SYMFONY_DEPRECATIONS_HELPER='max[direct]=0'
+ vendor/bin/phpunit --testdox -v
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 00000000..2fe60603
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,6 @@
+composer.lock
+vendor/
+.phpunit.cache
+tools
+.php-cs-fixer.cache
+var
\ No newline at end of file
diff --git a/.phive/phars.xml b/.phive/phars.xml
new file mode 100644
index 00000000..544558f5
--- /dev/null
+++ b/.phive/phars.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
new file mode 100644
index 00000000..41b10dfb
--- /dev/null
+++ b/.php-cs-fixer.dist.php
@@ -0,0 +1,44 @@
+in(__DIR__ . '/src')
+ ->in(__DIR__ . '/tests')
+ ->in(__DIR__ . '/config')
+;
+
+$config = new PhpCsFixer\Config();
+return $config->setRules([
+ '@PSR12' => true,
+ 'array_syntax' => ['syntax' => 'short'],
+
+ // imports
+ 'fully_qualified_strict_types' => true,
+ 'global_namespace_import' => [
+ 'import_classes' => false,
+ 'import_constants' => false,
+ 'import_functions' => false,
+ ],
+ 'no_leading_import_slash' => true,
+ 'no_unneeded_import_alias' => true,
+ 'no_unused_imports' => true,
+ 'ordered_imports' => [
+ 'sort_algorithm' => 'alpha',
+ 'imports_order' => ['const', 'class', 'function']
+ ],
+ 'single_line_after_imports' => true,
+ 'no_useless_else' => true,
+ 'no_useless_return' => true,
+ 'declare_strict_types' => true,
+ 'header_comment' => [
+ 'header' => <<
+
+For the full copyright and license information, please view the LICENSE file
+that was distributed with this source code.
+EOF,
+ ]
+ ])
+ ->setFinder($finder)
+;
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 00000000..4591f22a
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,2 @@
+# CHANGELOG
+
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..c34d9bcc
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,18 @@
+Copyright (c) 2024-present Priyadi Iman Nurcahyo
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..c4d16b9a
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,27 @@
+.PHONY: test
+test: dump phpstan psalm phpunit
+
+.PHONY: dump
+dump:
+ composer dump-autoload --optimize
+
+.PHONY: phpstan
+phpstan:
+ vendor/bin/phpstan analyse
+
+.PHONY: psalm
+psalm:
+ vendor/bin/psalm
+
+.PHONY: phpunit
+phpunit:
+ $(eval c ?=)
+ vendor/bin/phpunit --testdox -v $(c)
+
+.PHONY: php-cs-fixer
+php-cs-fixer: tools/php-cs-fixer
+ $< fix --config=.php-cs-fixer.dist.php --verbose --allow-risky=yes
+
+.PHONY: tools/php-cs-fixer
+tools/php-cs-fixer:
+ phive install php-cs-fixer
diff --git a/README.md b/README.md
new file mode 100644
index 00000000..46863f02
--- /dev/null
+++ b/README.md
@@ -0,0 +1,36 @@
+# rekalogika/mapper
+
+An object mapper (also called automapper) for PHP and Symfony. It maps an object
+to another object. Primarily used to map an entity to a DTO, but also useful for
+other mapping purposes.
+
+## Installation
+
+```bash
+composer require rekalogika/mapper
+```
+## Usage
+
+In Symfony projects, simply autowire `MapperInterface`. In non Symfony projects,
+instantiate a `MapperFactory`, and use `getMapper()` to get an instance of
+`MapperInterface`.
+
+To map objects, you can use the `map()` method of `MapperInterface`.
+
+```php
+use Rekalogika\Mapper\MapperInterface;
+
+/** @var MapperInterface $mapper */
+
+$book = new Book();
+$result = $mapper->map($book, BookDto::class);
+
+// or map to an existing object
+
+$book = new Book();
+$bookDto = new BookDto();
+$mapper->map($book, $bookDto);
+```
+## License
+
+MIT
diff --git a/composer.json b/composer.json
new file mode 100644
index 00000000..d38d50fe
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,57 @@
+{
+ "name": "rekalogika/mapper",
+ "description": "An object mapper for PHP and Symfony. Maps an object to another object. Primarily used for transforming an entity to a DTO and vice versa.",
+ "homepage": "https://rekalogika.dev/mapper",
+ "type": "symfony-bundle",
+ "license": "MIT",
+ "keywords": [
+ "mapper",
+ "automapper",
+ "dto",
+ "transformation",
+ "mapping",
+ "api",
+ "symfony",
+ "api-platform"
+ ],
+ "authors": [
+ {
+ "name": "Priyadi Iman Nurcahyo",
+ "email": "priyadi@rekalogika.com"
+ }
+ ],
+ "require": {
+ "psr/container": "^2.0",
+ "symfony/clock": "^6.4",
+ "symfony/config": "^6.4",
+ "symfony/dependency-injection": "^6.4",
+ "symfony/property-access": "^6.4",
+ "symfony/property-info": "^6.4",
+ "symfony/serializer": "^6.4"
+ },
+ "require-dev": {
+ "bnf/phpstan-psr-container": "^1.0",
+ "doctrine/collections": "^2.1",
+ "ekino/phpstan-banned-code": "^1.0",
+ "phpstan/phpstan": "^1.10.50",
+ "phpstan/phpstan-deprecation-rules": "^1.1",
+ "phpstan/phpstan-phpunit": "^1.3",
+ "phpunit/phpunit": "^9.6",
+ "psalm/plugin-phpunit": "^0.18.4",
+ "symfony/framework-bundle": "^6.4",
+ "symfony/http-kernel": "^6.4",
+ "symfony/phpunit-bridge": "^6.4",
+ "symfony/var-dumper": "^6.4",
+ "vimeo/psalm": "^5.18"
+ },
+ "autoload": {
+ "psr-4": {
+ "Rekalogika\\Mapper\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "psr-4": {
+ "Rekalogika\\Mapper\\Tests\\": "tests/"
+ }
+ }
+}
diff --git a/config/services.php b/config/services.php
new file mode 100644
index 00000000..8a13594f
--- /dev/null
+++ b/config/services.php
@@ -0,0 +1,151 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+use Rekalogika\Mapper\Command\MappingCommand;
+use Rekalogika\Mapper\Command\TryCommand;
+use Rekalogika\Mapper\MainTransformer;
+use Rekalogika\Mapper\Mapper;
+use Rekalogika\Mapper\MapperInterface;
+use Rekalogika\Mapper\Mapping\MappingFactory;
+use Rekalogika\Mapper\Mapping\MappingFactoryInterface;
+use Rekalogika\Mapper\Transformer\ArrayToObjectTransformer;
+use Rekalogika\Mapper\Transformer\DateTimeTransformer;
+use Rekalogika\Mapper\Transformer\NullTransformer;
+use Rekalogika\Mapper\Transformer\ObjectToArrayTransformer;
+use Rekalogika\Mapper\Transformer\ObjectToObjectTransformer;
+use Rekalogika\Mapper\Transformer\ObjectToStringTransformer;
+use Rekalogika\Mapper\Transformer\ScalarToScalarTransformer;
+use Rekalogika\Mapper\Transformer\StringToBackedEnumTransformer;
+use Rekalogika\Mapper\Transformer\TraversableToArrayAccessTransformer;
+use Rekalogika\Mapper\Transformer\TraversableToTraversableTransformer;
+use Rekalogika\Mapper\TypeStringHelper;
+use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
+use Symfony\Component\PropertyInfo\PropertyInfoCacheExtractor;
+use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
+
+use function Symfony\Component\DependencyInjection\Loader\Configurator\service;
+use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_iterator;
+use function Symfony\Component\DependencyInjection\Loader\Configurator\tagged_locator;
+
+return static function (ContainerConfigurator $containerConfigurator): void {
+ $services = $containerConfigurator->services()
+
+ # Property info
+
+ ->set('rekalogika.mapper.property_info', PropertyInfoExtractor::class)
+ ->args([
+ '$listExtractors' => [
+ service('property_info.reflection_extractor')
+ ],
+ '$typeExtractors' => [
+ service('property_info.phpstan_extractor'),
+ service('property_info.reflection_extractor'),
+ ],
+ '$accessExtractors' => [
+ service('property_info.reflection_extractor')
+ ],
+ '$initializableExtractors' => [
+ service('property_info.reflection_extractor')
+ ],
+
+ ])
+
+ ->set('rekalogika.mapper.cache.property_info')
+ ->parent('cache.system')
+ ->tag('cache.pool')
+
+ ->set('rekalogika.mapper.property_info.cache', PropertyInfoCacheExtractor::class)
+ ->decorate('rekalogika.mapper.property_info')
+ ->args([
+ service('rekalogika.mapper.property_info.cache.inner'),
+ service('rekalogika.mapper.cache.property_info')
+ ])
+
+ # transformers
+
+ ->set('rekalogika.mapper.transformer.scalar_to_scalar', ScalarToScalarTransformer::class)
+ ->tag('rekalogika.mapper.transformer', ['priority' => -550])
+
+ ->set('rekalogika.mapper.transformer.datetime', DateTimeTransformer::class)
+ ->tag('rekalogika.mapper.transformer', ['priority' => -600])
+
+ ->set('rekalogika.mapper.transformer.string_to_backed_enum', StringToBackedEnumTransformer::class)
+ ->tag('rekalogika.mapper.transformer', ['priority' => -650])
+
+ ->set('rekalogika.mapper.transformer.object_to_string', ObjectToStringTransformer::class)
+ ->tag('rekalogika.mapper.transformer', ['priority' => -700])
+
+ ->set('rekalogika.mapper.transformer.traversable_to_arrayaccess', TraversableToArrayAccessTransformer::class)
+ ->tag('rekalogika.mapper.transformer', ['priority' => -750])
+
+ ->set('rekalogika.mapper.transformer.traversable_to_traversable', TraversableToTraversableTransformer::class)
+ ->tag('rekalogika.mapper.transformer', ['priority' => -800])
+
+ ->set('rekalogika.mapper.transformer.object_to_array', ObjectToArrayTransformer::class)
+ ->args([service(NormalizerInterface::class)])
+ ->tag('rekalogika.mapper.transformer', ['priority' => -850])
+
+ ->set('rekalogika.mapper.transformer.array_to_object', ArrayToObjectTransformer::class)
+ ->args([service(DenormalizerInterface::class)])
+ ->tag('rekalogika.mapper.transformer', ['priority' => -900])
+
+ ->set('rekalogika.mapper.transformer.object_to_object', ObjectToObjectTransformer::class)
+ ->args([
+ '$propertyListExtractor' => service('rekalogika.mapper.property_info'),
+ '$propertyTypeExtractor' => service('rekalogika.mapper.property_info'),
+ '$propertyInitializableExtractor' => service('rekalogika.mapper.property_info'),
+ '$propertyAccessExtractor' => service('rekalogika.mapper.property_info'),
+ '$propertyAccessor' => service('property_accessor'),
+ ])
+ ->tag('rekalogika.mapper.transformer', ['priority' => -950])
+
+ ->set('rekalogika.mapper.transformer.null', NullTransformer::class)
+ ->tag('rekalogika.mapper.transformer', ['priority' => -1000])
+
+ # other services
+
+ ->set('rekalogika.mapper.type_string_helper', TypeStringHelper::class)
+
+ ->set('rekalogika.mapper.mapping_factory', MappingFactory::class)
+ ->args([tagged_iterator('rekalogika.mapper.transformer')])
+
+ ->alias(MappingFactoryInterface::class, 'rekalogika.mapper.mapping_factory')
+
+ ->set('rekalogika.mapper.main_transformer', MainTransformer::class)
+ ->args([
+ '$transformersLocator' => tagged_locator('rekalogika.mapper.transformer'),
+ '$typeStringHelper' => service('rekalogika.mapper.type_string_helper'),
+ '$mappingFactory' => service('rekalogika.mapper.mapping_factory'),
+ ])
+
+ ->set('rekalogika.mapper.mapper', Mapper::class)
+ ->args([service('rekalogika.mapper.main_transformer')])
+
+ ->alias(MapperInterface::class, 'rekalogika.mapper.mapper')
+
+ # console command
+
+ ->set('rekalogika.mapper.command.mapping', MappingCommand::class)
+ ->args([service('rekalogika.mapper.mapping_factory')])
+ ->tag('console.command')
+
+ ->set('rekalogika.mapper.command.try', TryCommand::class)
+ ->args([
+ service('rekalogika.mapper.main_transformer'),
+ service('rekalogika.mapper.type_string_helper'),
+ ])
+ ->tag('console.command')
+ ;
+};
diff --git a/config/tests.php b/config/tests.php
new file mode 100644
index 00000000..bac648ec
--- /dev/null
+++ b/config/tests.php
@@ -0,0 +1,26 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+use Rekalogika\Mapper\Tests\Common\TestKernel;
+use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
+
+return static function (ContainerConfigurator $containerConfigurator): void {
+ $services = $containerConfigurator->services();
+
+ // add test aliases
+ $serviceIds = TestKernel::getServiceIds();
+
+ foreach ($serviceIds as $serviceId) {
+ $services->alias('test.' . $serviceId, $serviceId)->public();
+ };
+};
diff --git a/phpstan.neon.dist b/phpstan.neon.dist
new file mode 100644
index 00000000..127470b3
--- /dev/null
+++ b/phpstan.neon.dist
@@ -0,0 +1,16 @@
+parameters:
+ level: max
+ paths:
+ - src
+ - tests
+ checkBenevolentUnionTypes: true
+ checkExplicitMixedMissingReturn: true
+ checkFunctionNameCase: true
+ checkInternalClassCaseSensitivity: true
+ reportMaybesInPropertyPhpDocTypes: true
+includes:
+ - vendor/phpstan/phpstan-phpunit/extension.neon
+ - vendor/phpstan/phpstan-phpunit/rules.neon
+ - vendor/phpstan/phpstan-deprecation-rules/rules.neon
+ - vendor/bnf/phpstan-psr-container/extension.neon
+ - vendor/ekino/phpstan-banned-code/extension.neon
diff --git a/phpunit.xml.dist b/phpunit.xml.dist
new file mode 100644
index 00000000..30141d8d
--- /dev/null
+++ b/phpunit.xml.dist
@@ -0,0 +1,30 @@
+
+
+
+
+
+ tests
+
+
+
+
+
+ src
+
+
+
+
diff --git a/psalm.xml b/psalm.xml
new file mode 100644
index 00000000..baf7b717
--- /dev/null
+++ b/psalm.xml
@@ -0,0 +1,32 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Command/MappingCommand.php b/src/Command/MappingCommand.php
new file mode 100644
index 00000000..f8502b54
--- /dev/null
+++ b/src/Command/MappingCommand.php
@@ -0,0 +1,111 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+namespace Rekalogika\Mapper\Command;
+
+use Rekalogika\Mapper\Mapping\MappingFactory;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\Table;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Input\InputOption;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+#[AsCommand(name: 'debug:mapper:mapping', description: 'Dump mapping table')]
+class MappingCommand extends Command
+{
+ public function __construct(
+ private MappingFactory $mappingFactory
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void
+ {
+ $this
+ ->addOption('source', 's', InputOption::VALUE_OPTIONAL, 'Filter by source type')
+ ->addOption('target', 't', InputOption::VALUE_OPTIONAL, 'Filter by target type')
+ ->addOption('class', 'c', InputOption::VALUE_OPTIONAL, 'Filter by class name or service ID')
+ ->setHelp("The %command.name% command dumps the mapping table for the mapper.");
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ /** @var string|null */
+ $sourceOption = $input->getOption('source');
+ /** @var string|null */
+ $targetOption = $input->getOption('target');
+ /** @var string|null */
+ $classOption = $input->getOption('class');
+
+ $io = new SymfonyStyle($input, $output);
+ $title = 'Mapping Table';
+ $rows = [];
+
+ $mapping = $this->mappingFactory->getMapping();
+
+ foreach ($mapping as $entry) {
+ $order = $entry->getOrder();
+ $sourceType = $entry->getSourceType();
+ $targetType = $entry->getTargetType();
+ $class = $entry->getClass();
+ $id = $entry->getId();
+
+ if ($sourceOption) {
+ if (preg_match('/' . preg_quote($sourceOption) . '/i', $entry->getSourceType()) === 0) {
+ continue;
+ }
+
+ $sourceType = preg_replace('/(' . preg_quote($sourceOption) . ')/i', '$1>', $sourceType);
+ }
+
+ if ($targetOption) {
+ if (preg_match('/' . preg_quote($targetOption) . '/i', $entry->getTargetType()) === 0) {
+ continue;
+ }
+
+ $targetType = preg_replace('/(' . preg_quote($targetOption) . ')/i', '$1>', $targetType);
+ }
+
+ if ($classOption) {
+ if (
+ preg_match('/' . preg_quote($classOption) . '/i', $entry->getClass()) === 0
+ && preg_match('/' . preg_quote($classOption) . '/i', $entry->getId()) === 0
+ ) {
+ continue;
+ }
+
+ // $class = preg_replace('/(' . preg_quote($classOption) . ')/i', '$1>', $class);
+ $id = preg_replace('/(' . preg_quote($classOption) . ')/i', '$1>', $id);
+ }
+
+ $rows[] = [
+ $order,
+ $sourceType,
+ $targetType,
+ $id,
+ $class,
+ ];
+ }
+
+ $io->section($title);
+ $table = new Table($output);
+ $table->setHeaders(['Order', 'Source Type', 'Target Type', 'Service ID', 'Class']);
+ $table->setStyle('box');
+ $table->setRows($rows);
+ $table->render();
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Command/TryCommand.php b/src/Command/TryCommand.php
new file mode 100644
index 00000000..2d0101d0
--- /dev/null
+++ b/src/Command/TryCommand.php
@@ -0,0 +1,142 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+namespace Rekalogika\Mapper\Command;
+
+use Rekalogika\Mapper\MainTransformer;
+use Rekalogika\Mapper\TypeStringHelper;
+use Rekalogika\Mapper\Util\TypeFactory;
+use Symfony\Component\Console\Attribute\AsCommand;
+use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\Table;
+use Symfony\Component\Console\Helper\TableSeparator;
+use Symfony\Component\Console\Input\InputArgument;
+use Symfony\Component\Console\Input\InputInterface;
+use Symfony\Component\Console\Output\OutputInterface;
+use Symfony\Component\Console\Style\SymfonyStyle;
+
+#[AsCommand(name: 'debug:mapper:try', description: 'Gets the mapping result from a source and target type pair.')]
+class TryCommand extends Command
+{
+ public function __construct(
+ private MainTransformer $mainTransformer,
+ private TypeStringHelper $typeStringHelper
+ ) {
+ parent::__construct();
+ }
+
+ protected function configure(): void
+ {
+ $this
+ ->addArgument('source', InputArgument::REQUIRED, 'The source type')
+ ->addArgument('target', InputArgument::REQUIRED, 'The target type')
+ ->setHelp("The %command.name% displays the mapping result from a source type and a target type.");
+ }
+
+ protected function execute(InputInterface $input, OutputInterface $output): int
+ {
+ $io = new SymfonyStyle($input, $output);
+ $rows = [];
+
+ //
+ // source type
+ //
+
+ /** @var string */
+ $sourceTypeString = $input->getArgument('source');
+ $sourceType = TypeFactory::fromString($sourceTypeString);
+ $sourceTypeStrings = $this->typeStringHelper
+ ->getApplicableTypeStrings($sourceType);
+
+ $rows[] = ['Source type', $sourceTypeString];
+ $rows[] = new TableSeparator();
+ $rows[] = [
+ 'Transformer source types compatible with source',
+ implode("\n", $sourceTypeStrings)
+ ];
+
+ //
+ // target type
+ //
+
+ /** @var string */
+ $targetTypeString = $input->getArgument('target');
+ $targetType = TypeFactory::fromString($targetTypeString);
+ $targetTypeStrings = $this->typeStringHelper
+ ->getApplicableTypeStrings($targetType);
+
+ $rows[] = new TableSeparator();
+ $rows[] = ['Target type', $targetTypeString];
+ $rows[] = new TableSeparator();
+ $rows[] = [
+ 'Transformer target types compatible with target',
+ implode("\n", $targetTypeStrings)
+ ];
+
+ //
+ // render
+ //
+
+ $io->section('Type Compatibility');
+ $table = new Table($output);
+ $table->setHeaders(['Subject', 'Value']);
+ $table->setStyle('box');
+ $table->setRows($rows);
+ $table->render();
+
+ //
+ // get applicable transformers
+ //
+
+ $rows = [];
+
+ $transformers = $this->mainTransformer->getTransformerMapping(
+ $sourceType,
+ $targetType
+ );
+
+ foreach ($transformers as $entry) {
+ $rows[] = [
+ $entry->getOrder(),
+ $entry->getId(),
+ $entry->getClass(),
+ $entry->getSourceType(),
+ $entry->getTargetType()
+ ];
+ $rows[] = new TableSeparator();
+ }
+
+ array_pop($rows);
+
+ //
+ // render
+ //
+
+ $io->writeln('');
+ $io->section('Applicable Transformers');
+
+ if (count($rows) === 0) {
+ $io->error('No applicable transformers found.');
+
+ return Command::SUCCESS;
+ }
+
+ $table = new Table($output);
+ $table->setHeaders(['Order', 'Service ID', 'Class', 'Source Type', 'Target Type']);
+ $table->setStyle('box');
+ $table->setRows($rows);
+ $table->render();
+
+ return Command::SUCCESS;
+ }
+}
diff --git a/src/Contracts/MainTransformerAwareInterface.php b/src/Contracts/MainTransformerAwareInterface.php
new file mode 100644
index 00000000..fe1f5e2e
--- /dev/null
+++ b/src/Contracts/MainTransformerAwareInterface.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\Contracts;
+
+interface MainTransformerAwareInterface
+{
+ public function withMainTransformer(MainTransformerInterface $mainTransformer): static;
+}
diff --git a/src/Contracts/MainTransformerAwareTrait.php b/src/Contracts/MainTransformerAwareTrait.php
new file mode 100644
index 00000000..0aeb969f
--- /dev/null
+++ b/src/Contracts/MainTransformerAwareTrait.php
@@ -0,0 +1,38 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+namespace Rekalogika\Mapper\Contracts;
+
+use Rekalogika\Mapper\Exception\LogicException;
+
+trait MainTransformerAwareTrait
+{
+ protected ?MainTransformerInterface $mainTransformer = null;
+
+ public function withMainTransformer(MainTransformerInterface $mainTransformer): static
+ {
+ $clone = clone $this;
+ $clone->mainTransformer = $mainTransformer;
+
+ return $clone;
+ }
+
+ protected function getMainTransformer(): MainTransformerInterface
+ {
+ if ($this->mainTransformer === null) {
+ throw new LogicException('Main transformer is not set. Call "withMainTransformer()" first.');
+ }
+
+ return $this->mainTransformer;
+ }
+}
diff --git a/src/Contracts/MainTransformerInterface.php b/src/Contracts/MainTransformerInterface.php
new file mode 100644
index 00000000..ad648261
--- /dev/null
+++ b/src/Contracts/MainTransformerInterface.php
@@ -0,0 +1,30 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+namespace Rekalogika\Mapper\Contracts;
+
+use Symfony\Component\PropertyInfo\Type;
+
+interface MainTransformerInterface
+{
+ /**
+ * @param null|Type|array $targetType If provided, it will be used instead of guessing the type
+ * @param array $context
+ */
+ public function transform(
+ mixed $source,
+ mixed $target,
+ null|Type|array $targetType,
+ array $context
+ ): mixed;
+}
diff --git a/src/Contracts/TransformerInterface.php b/src/Contracts/TransformerInterface.php
new file mode 100644
index 00000000..90d993f8
--- /dev/null
+++ b/src/Contracts/TransformerInterface.php
@@ -0,0 +1,46 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+namespace Rekalogika\Mapper\Contracts;
+
+use Rekalogika\Mapper\Exception\CircularReferenceException;
+use Rekalogika\Mapper\Exception\ExceptionInterface;
+use Rekalogika\Mapper\Exception\InvalidArgumentException;
+use Rekalogika\Mapper\Exception\LogicException;
+use Symfony\Component\PropertyInfo\Type;
+
+interface TransformerInterface
+{
+ public const MIXED = 'mixed';
+
+ /**
+ * @param array $context
+ *
+ * @throws InvalidArgumentException
+ * @throws CircularReferenceException
+ * @throws LogicException
+ * @throws ExceptionInterface
+ */
+ public function transform(
+ mixed $source,
+ mixed $target,
+ Type $sourceType,
+ Type $targetType,
+ array $context
+ ): mixed;
+
+ /**
+ * @return iterable
+ */
+ public function getSupportedTransformation(): iterable;
+}
diff --git a/src/Contracts/TypeMapping.php b/src/Contracts/TypeMapping.php
new file mode 100644
index 00000000..f470a91c
--- /dev/null
+++ b/src/Contracts/TypeMapping.php
@@ -0,0 +1,71 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+namespace Rekalogika\Mapper\Contracts;
+
+use Rekalogika\Mapper\Model\MixedType;
+use Rekalogika\Mapper\Util\TypeUtil;
+use Symfony\Component\PropertyInfo\Type;
+
+class TypeMapping
+{
+ /**
+ * @param Type|MixedType $sourceType
+ * @param Type|MixedType $targetType
+ */
+ public function __construct(
+ private Type|MixedType $sourceType,
+ private Type|MixedType $targetType,
+ ) {
+ }
+
+ /**
+ * @return Type|MixedType
+ */
+ public function getSourceType(): Type|MixedType
+ {
+ return $this->sourceType;
+ }
+
+ /**
+ * @return array
+ */
+ public function getSimpleSourceTypes(): array
+ {
+ if ($this->sourceType instanceof MixedType) {
+ return [$this->sourceType];
+ }
+
+ return TypeUtil::getSimpleTypes($this->sourceType);
+ }
+
+ /**
+ * @return Type|MixedType
+ */
+ public function getTargetType(): Type|MixedType
+ {
+ return $this->targetType;
+ }
+
+ /**
+ * @return array
+ */
+ public function getSimpleTargetTypes(): array
+ {
+ if ($this->targetType instanceof MixedType) {
+ return [$this->targetType];
+ }
+
+ return TypeUtil::getSimpleTypes($this->targetType);
+ }
+}
diff --git a/src/Exception/CachedTargetObjectNotFoundException.php b/src/Exception/CachedTargetObjectNotFoundException.php
new file mode 100644
index 00000000..c34e434c
--- /dev/null
+++ b/src/Exception/CachedTargetObjectNotFoundException.php
@@ -0,0 +1,18 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+namespace Rekalogika\Mapper\Exception;
+
+class CachedTargetObjectNotFoundException extends RuntimeException
+{
+}
diff --git a/src/Exception/CircularReferenceException.php b/src/Exception/CircularReferenceException.php
new file mode 100644
index 00000000..f82000dc
--- /dev/null
+++ b/src/Exception/CircularReferenceException.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\Exception;
+
+use Rekalogika\Mapper\Util\TypeCheck;
+use Symfony\Component\PropertyInfo\Type;
+
+class CircularReferenceException extends \RuntimeException implements ExceptionInterface
+{
+ public function __construct(mixed $source, Type $targetType)
+ {
+ parent::__construct(
+ sprintf(
+ 'Circular reference detected when trying to get the object of type "%s" transformed to "%s"',
+ \get_debug_type($source),
+ TypeCheck::getDebugType($targetType)
+ )
+ );
+ }
+}
diff --git a/src/Exception/ExceptionInterface.php b/src/Exception/ExceptionInterface.php
new file mode 100644
index 00000000..6e1e1413
--- /dev/null
+++ b/src/Exception/ExceptionInterface.php
@@ -0,0 +1,18 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+namespace Rekalogika\Mapper\Exception;
+
+interface ExceptionInterface extends \Throwable
+{
+}
diff --git a/src/Exception/InvalidArgumentException.php b/src/Exception/InvalidArgumentException.php
new file mode 100644
index 00000000..2b364312
--- /dev/null
+++ b/src/Exception/InvalidArgumentException.php
@@ -0,0 +1,18 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+namespace Rekalogika\Mapper\Exception;
+
+class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface
+{
+}
diff --git a/src/Exception/LogicException.php b/src/Exception/LogicException.php
new file mode 100644
index 00000000..a2c5efa4
--- /dev/null
+++ b/src/Exception/LogicException.php
@@ -0,0 +1,18 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+namespace Rekalogika\Mapper\Exception;
+
+class LogicException extends \LogicException implements ExceptionInterface
+{
+}
diff --git a/src/Exception/MissingMemberKeyTypeException.php b/src/Exception/MissingMemberKeyTypeException.php
new file mode 100644
index 00000000..303b3a34
--- /dev/null
+++ b/src/Exception/MissingMemberKeyTypeException.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\Exception;
+
+use Rekalogika\Mapper\Util\TypeCheck;
+use Symfony\Component\PropertyInfo\Type;
+
+class MissingMemberKeyTypeException extends MissingMemberTypeException
+{
+ public function __construct(Type $sourceType, Type $targetType)
+ {
+ parent::__construct(sprintf('Trying to map collection type "%s" to "%s", but the source member key is not the simple array-key type, and the target does not have the type information about the key of its child members. Usually you can fix this by adding a PHPdoc to the property containing the collection type.', TypeCheck::getDebugType($sourceType), TypeCheck::getDebugType($targetType)));
+ }
+}
diff --git a/src/Exception/MissingMemberTypeException.php b/src/Exception/MissingMemberTypeException.php
new file mode 100644
index 00000000..91c92bb4
--- /dev/null
+++ b/src/Exception/MissingMemberTypeException.php
@@ -0,0 +1,18 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+namespace Rekalogika\Mapper\Exception;
+
+abstract class MissingMemberTypeException extends NotMappableValueException
+{
+}
diff --git a/src/Exception/MissingMemberValueTypeException.php b/src/Exception/MissingMemberValueTypeException.php
new file mode 100644
index 00000000..3f13530a
--- /dev/null
+++ b/src/Exception/MissingMemberValueTypeException.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\Exception;
+
+use Rekalogika\Mapper\Util\TypeCheck;
+use Symfony\Component\PropertyInfo\Type;
+
+class MissingMemberValueTypeException extends MissingMemberTypeException
+{
+ public function __construct(Type $sourceType, Type $targetType)
+ {
+ parent::__construct(sprintf('Trying to map collection type "%s" to "%s", but the target does not have the type information about the value of its child members. Usually you can fix this by adding a PHPdoc to the property containing the collection type.', TypeCheck::getDebugType($sourceType), TypeCheck::getDebugType($targetType)));
+ }
+}
diff --git a/src/Exception/NotMappableValueException.php b/src/Exception/NotMappableValueException.php
new file mode 100644
index 00000000..1552e2bc
--- /dev/null
+++ b/src/Exception/NotMappableValueException.php
@@ -0,0 +1,18 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+namespace Rekalogika\Mapper\Exception;
+
+abstract class NotMappableValueException extends UnexpectedValueException
+{
+}
diff --git a/src/Exception/RuntimeException.php b/src/Exception/RuntimeException.php
new file mode 100644
index 00000000..ed22ec26
--- /dev/null
+++ b/src/Exception/RuntimeException.php
@@ -0,0 +1,18 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+namespace Rekalogika\Mapper\Exception;
+
+class RuntimeException extends \RuntimeException implements ExceptionInterface
+{
+}
diff --git a/src/Exception/UnableToFindSuitableTransformerException.php b/src/Exception/UnableToFindSuitableTransformerException.php
new file mode 100644
index 00000000..41254970
--- /dev/null
+++ b/src/Exception/UnableToFindSuitableTransformerException.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\Exception;
+
+use Rekalogika\Mapper\Util\TypeCheck;
+use Symfony\Component\PropertyInfo\Type;
+
+class UnableToFindSuitableTransformerException extends NotMappableValueException
+{
+ /**
+ * @param Type $sourceType
+ * @param Type|array $targetType
+ */
+ public function __construct(Type $sourceType, Type|array $targetType)
+ {
+ if (is_array($targetType)) {
+ $targetType = implode(', ', array_map(fn (Type $type) => TypeCheck::getDebugType($type), $targetType));
+ } else {
+ $targetType = TypeCheck::getDebugType($targetType);
+ }
+
+ parent::__construct(sprintf('Unable to map the value "%s" to "%s"', TypeCheck::getDebugType($sourceType), $targetType));
+ }
+}
diff --git a/src/Exception/UnexpectedValueException.php b/src/Exception/UnexpectedValueException.php
new file mode 100644
index 00000000..7c7cf997
--- /dev/null
+++ b/src/Exception/UnexpectedValueException.php
@@ -0,0 +1,18 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+namespace Rekalogika\Mapper\Exception;
+
+class UnexpectedValueException extends \UnexpectedValueException implements ExceptionInterface
+{
+}
diff --git a/src/MainTransformer.php b/src/MainTransformer.php
new file mode 100644
index 00000000..73af09ce
--- /dev/null
+++ b/src/MainTransformer.php
@@ -0,0 +1,143 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+namespace Rekalogika\Mapper;
+
+use Psr\Container\ContainerInterface;
+use Rekalogika\Mapper\Contracts\MainTransformerAwareInterface;
+use Rekalogika\Mapper\Contracts\MainTransformerInterface;
+use Rekalogika\Mapper\Contracts\TransformerInterface;
+use Rekalogika\Mapper\Exception\LogicException;
+use Rekalogika\Mapper\Exception\UnableToFindSuitableTransformerException;
+use Rekalogika\Mapper\Mapping\MappingEntry;
+use Rekalogika\Mapper\Mapping\MappingFactory;
+use Rekalogika\Mapper\Model\MixedType;
+use Rekalogika\Mapper\Model\ObjectCache;
+use Rekalogika\Mapper\Util\TypeUtil;
+use Symfony\Component\PropertyInfo\Type;
+
+class MainTransformer implements MainTransformerInterface
+{
+ public const OBJECT_CACHE = 'object_cache';
+
+ public function __construct(
+ private ContainerInterface $transformersLocator,
+ private TypeStringHelper $typeStringHelper,
+ private MappingFactory $mappingFactory
+ ) {
+ }
+
+ private function getTransformer(string $id): TransformerInterface
+ {
+ $transformer = $this->transformersLocator->get($id);
+
+ if (!$transformer instanceof TransformerInterface) {
+ throw new LogicException(sprintf(
+ 'Transformer with id "%s" must implement %s',
+ $id,
+ TransformerInterface::class
+ ));
+ }
+
+ if ($transformer instanceof MainTransformerAwareInterface) {
+ return $transformer->withMainTransformer($this);
+ }
+ return $transformer;
+
+ }
+
+ public function transform(
+ mixed $source,
+ mixed $target,
+ null|Type|array $targetType,
+ array $context
+ ): mixed {
+ // if targettype is not provided, guess it from target
+ // if the target is also missing then throw exception
+
+ if ($targetType === null) {
+ if ($target === null) {
+ throw new LogicException('Either $target or $targetType must be provided');
+ }
+ $targetType = TypeUtil::guessTypeFromVariable($target);
+ }
+
+ // get object cache
+
+ if (!isset($context[self::OBJECT_CACHE])) {
+ $objectCache = new ObjectCache();
+ $context[self::OBJECT_CACHE] = $objectCache;
+ } else {
+ /** @var ObjectCache */
+ $objectCache = $context[self::OBJECT_CACHE];
+ }
+
+ // init vars
+
+ $targetType = TypeUtil::getSimpleTypes($targetType);
+ $sourceType = TypeUtil::guessTypeFromVariable($source);
+
+ foreach ($targetType as $singleTargetType) {
+ $transformers = $this->getTransformers($sourceType, $singleTargetType);
+
+ foreach ($transformers as $transformer) {
+ /** @var mixed */
+ $result = $transformer->transform(
+ source: $source,
+ target: $target,
+ sourceType: $sourceType,
+ targetType: $singleTargetType,
+ context: $context
+ );
+
+ return $result;
+ }
+ }
+
+ throw new UnableToFindSuitableTransformerException($sourceType, $targetType);
+ }
+
+ /**
+ * @param Type|MixedType $sourceType
+ * @param Type|MixedType $targetType
+ * @return iterable
+ */
+ private function getTransformers(
+ Type|MixedType $sourceType,
+ Type|MixedType $targetType,
+ ): iterable {
+ foreach ($this->getTransformerMapping($sourceType, $targetType) as $item) {
+ $id = $item->getId();
+ yield $this->getTransformer($id);
+ }
+ }
+
+ /**
+ * @param Type|MixedType $sourceType
+ * @param Type|MixedType $targetType
+ * @return array
+ */
+ public function getTransformerMapping(
+ Type|MixedType $sourceType,
+ Type|MixedType $targetType,
+ ): array {
+ $sourceTypeStrings = $this->typeStringHelper
+ ->getApplicableTypeStrings($sourceType);
+
+ $targetTypeStrings = $this->typeStringHelper
+ ->getApplicableTypeStrings($targetType);
+
+ return $this->mappingFactory->getMapping()
+ ->getMappingBySourceAndTarget($sourceTypeStrings, $targetTypeStrings);
+ }
+}
diff --git a/src/Mapper.php b/src/Mapper.php
new file mode 100644
index 00000000..1c146d7a
--- /dev/null
+++ b/src/Mapper.php
@@ -0,0 +1,134 @@
+
+ *
+ * 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\Contracts\MainTransformerInterface;
+use Rekalogika\Mapper\Exception\UnexpectedValueException;
+use Rekalogika\Mapper\Model\MixedType;
+use Rekalogika\Mapper\Util\TypeFactory;
+use Rekalogika\Mapper\Util\TypeUtil;
+use Symfony\Component\PropertyInfo\Type;
+
+final class Mapper implements MapperInterface
+{
+ /**
+ * Informs the key and value type of the member of the target collection.
+ */
+ public const TARGET_KEY_TYPE = 'target_key_type';
+ public const TARGET_VALUE_TYPE = 'target_value_type';
+
+ public function __construct(
+ private MainTransformerInterface $transformer,
+ ) {
+ }
+
+ public function map(mixed $source, mixed $target, array $context = []): mixed
+ {
+ $originalTarget = $target;
+
+ if (
+ is_string($target)
+ && (
+ class_exists($target)
+ || interface_exists($target)
+ || enum_exists($target)
+ )
+ ) {
+ /** @var class-string $target */
+ $targetClass = $target;
+ $targetType = TypeFactory::objectOfClass($targetClass);
+ $target = null;
+ } elseif (is_object($target)) {
+ /** @var object $target */
+ $targetClass = $target::class;
+ $targetType = TypeUtil::guessTypeFromVariable($target);
+ } else {
+ $targetClass = null;
+ $targetType = TypeFactory::fromBuiltIn($target);
+ $target = null;
+ }
+
+ /** @var ?string */
+ $contextTargetKeyType = $context[self::TARGET_KEY_TYPE] ?? null;
+ /** @var ?string */
+ $contextTargetValueType = $context[self::TARGET_VALUE_TYPE] ?? null;
+ unset($context[self::TARGET_KEY_TYPE]);
+ unset($context[self::TARGET_VALUE_TYPE]);
+
+ $targetKeyType = null;
+ $targetValueType = null;
+
+ if ($contextTargetKeyType) {
+ $targetKeyType = TypeFactory::fromString($contextTargetKeyType);
+ if ($targetKeyType instanceof MixedType) {
+ $targetKeyType = null;
+ }
+ }
+
+ if ($contextTargetValueType) {
+ $targetValueType = TypeFactory::fromString($contextTargetValueType);
+ if ($targetValueType instanceof MixedType) {
+ $targetValueType = null;
+ }
+ }
+
+ if ($targetKeyType !== null || $targetValueType !== null) {
+ $targetType = new Type(
+ builtinType: $targetType->getBuiltinType(),
+ nullable: $targetType->isNullable(),
+ class: $targetType->getClassName(),
+ collection: true,
+ collectionKeyType: $targetKeyType,
+ collectionValueType: $targetValueType,
+ );
+ }
+
+ /** @var mixed */
+ $target = $this->transformer->transform(
+ source: $source,
+ target: $target,
+ targetType: $targetType,
+ context: $context
+ );
+
+ if (is_object($target) && is_string($targetClass)) {
+ if (!is_a($target, $targetClass)) {
+ throw new UnexpectedValueException(sprintf('The transformer did not return the variable of expected class, expecting "%s", returned "%s".', $targetClass, get_debug_type($target)));
+ }
+ return $target;
+ }
+
+ if ($originalTarget === 'string' && is_string($target)) {
+ return $target;
+ }
+
+ if ($originalTarget === 'int' && is_int($target)) {
+ return $target;
+ }
+
+ if ($originalTarget === 'float' && is_float($target)) {
+ return $target;
+ }
+
+ if ($originalTarget === 'bool' && is_bool($target)) {
+ return $target;
+ }
+
+ if ($originalTarget === 'array' && is_array($target)) {
+ return $target;
+ }
+
+ throw new UnexpectedValueException(sprintf('The transformer did not return the variable of expected type, expecting "%s", returned "%s".', TypeUtil::getTypeString($targetType), get_debug_type($target)));
+ }
+}
diff --git a/src/MapperFactory.php b/src/MapperFactory.php
new file mode 100644
index 00000000..9f530fe4
--- /dev/null
+++ b/src/MapperFactory.php
@@ -0,0 +1,423 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+namespace Rekalogika\Mapper;
+
+use Psr\Container\ContainerInterface;
+use Rekalogika\Mapper\Command\MappingCommand;
+use Rekalogika\Mapper\Command\TryCommand;
+use Rekalogika\Mapper\Contracts\TransformerInterface;
+use Rekalogika\Mapper\Mapping\MappingFactory;
+use Rekalogika\Mapper\Model\ServiceLocator;
+use Rekalogika\Mapper\Transformer\ArrayToObjectTransformer;
+use Rekalogika\Mapper\Transformer\DateTimeTransformer;
+use Rekalogika\Mapper\Transformer\NullTransformer;
+use Rekalogika\Mapper\Transformer\ObjectToArrayTransformer;
+use Rekalogika\Mapper\Transformer\ObjectToObjectTransformer;
+use Rekalogika\Mapper\Transformer\ObjectToStringTransformer;
+use Rekalogika\Mapper\Transformer\ScalarToScalarTransformer;
+use Rekalogika\Mapper\Transformer\StringToBackedEnumTransformer;
+use Rekalogika\Mapper\Transformer\TraversableToArrayAccessTransformer;
+use Rekalogika\Mapper\Transformer\TraversableToTraversableTransformer;
+use Symfony\Component\Console\Application;
+use Symfony\Component\PropertyAccess\PropertyAccess;
+use Symfony\Component\PropertyAccess\PropertyAccessor;
+use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
+use Symfony\Component\PropertyInfo\Extractor\PhpStanExtractor;
+use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor;
+use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface;
+use Symfony\Component\PropertyInfo\PropertyInfoExtractor;
+use Symfony\Component\PropertyInfo\PropertyInitializableExtractorInterface;
+use Symfony\Component\PropertyInfo\PropertyListExtractorInterface;
+use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface;
+use Symfony\Component\Serializer\Mapping\Factory\ClassMetadataFactory;
+use Symfony\Component\Serializer\Mapping\Loader\AttributeLoader;
+use Symfony\Component\Serializer\Normalizer\BackedEnumNormalizer;
+use Symfony\Component\Serializer\Normalizer\DataUriNormalizer;
+use Symfony\Component\Serializer\Normalizer\DateIntervalNormalizer;
+use Symfony\Component\Serializer\Normalizer\DateTimeNormalizer;
+use Symfony\Component\Serializer\Normalizer\DateTimeZoneNormalizer;
+use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
+use Symfony\Component\Serializer\Normalizer\JsonSerializableNormalizer;
+use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
+use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
+use Symfony\Component\Serializer\Normalizer\UidNormalizer;
+use Symfony\Component\Serializer\Serializer;
+
+class MapperFactory
+{
+ private ?Serializer $serializer = null;
+
+ private ?NullTransformer $nullTransformer = null;
+ private ?ObjectToObjectTransformer $objectToObjectTransformer = null;
+ private ?ObjectToStringTransformer $objectToStringTransformer = null;
+ private ?ScalarToScalarTransformer $scalarToScalarTransformer = null;
+ private ?StringToBackedEnumTransformer $stringToBackedEnumTransformer = null;
+ private ?ArrayToObjectTransformer $arrayToObjectTransformer = null;
+ private ?ObjectToArrayTransformer $objectToArrayTransformer = null;
+ private ?DateTimeTransformer $dateTimeTransformer = null;
+ private ?TraversableToArrayAccessTransformer $traversableToArrayAccessTransformer = null;
+ private ?TraversableToTraversableTransformer $traversableToTraversableTransformer = null;
+
+ private ?PropertyTypeExtractorInterface $propertyTypeExtractor = null;
+ private ?TypeStringHelper $typeStringHelper = null;
+ private ?MainTransformer $mainTransformer = null;
+ private ?MapperInterface $mapper = null;
+ private ?MappingFactory $mappingFactory = null;
+
+ private ?MappingCommand $mappingCommand = null;
+ private ?TryCommand $tryCommand = null;
+ private ?Application $application = null;
+
+ /**
+ * @param array $additionalTransformers
+ */
+ public function __construct(
+ private array $additionalTransformers = [],
+ private ?ReflectionExtractor $reflectionExtractor = null,
+ private ?PhpStanExtractor $phpStanExtractor = null,
+ private ?PropertyAccessor $propertyAccessor = null,
+ private ?NormalizerInterface $normalizer = null,
+ private ?DenormalizerInterface $denormalizer = null,
+ ) {
+ }
+
+ public function getMapper(): MapperInterface
+ {
+ if (null === $this->mapper) {
+ $this->mapper = new Mapper($this->getMainTransformer());
+ }
+
+ return $this->mapper;
+ }
+
+ //
+ // concrete services
+ //
+
+ private function getReflectionExtractor(): ReflectionExtractor
+ {
+ if (null === $this->reflectionExtractor) {
+ $this->reflectionExtractor = new ReflectionExtractor();
+ }
+
+ return $this->reflectionExtractor;
+ }
+
+ private function getPhpStanExtractor(): PropertyTypeExtractorInterface
+ {
+ if (null === $this->phpStanExtractor) {
+ $this->phpStanExtractor = new PhpStanExtractor();
+ }
+
+ return $this->phpStanExtractor;
+ }
+
+ private function getConcretePropertyAccessor(): PropertyAccessorInterface
+ {
+ if (null === $this->propertyAccessor) {
+ $this->propertyAccessor = PropertyAccess::createPropertyAccessor();
+ }
+
+ return $this->propertyAccessor;
+ }
+
+ private function getSerializer(): Serializer
+ {
+ if (null === $this->serializer) {
+ $classMetadataFactory = new ClassMetadataFactory(new AttributeLoader());
+
+ $this->serializer = new Serializer([
+ new UidNormalizer(),
+ new DateTimeNormalizer(),
+ new DateTimeZoneNormalizer(),
+ new DateIntervalNormalizer(),
+ new BackedEnumNormalizer(),
+ new DataUriNormalizer(),
+ new JsonSerializableNormalizer(),
+ new ObjectNormalizer($classMetadataFactory),
+ ], []);
+ }
+
+ return $this->serializer;
+ }
+
+ //
+ // interfaces
+ //
+
+ private function getPropertyListExtractor(): PropertyListExtractorInterface
+ {
+ return $this->getReflectionExtractor();
+ }
+
+ private function getPropertyTypeExtractor(): PropertyTypeExtractorInterface
+ {
+ if ($this->propertyTypeExtractor === null) {
+ $this->propertyTypeExtractor = new PropertyInfoExtractor(
+ typeExtractors: [
+ $this->getPhpStanExtractor(),
+ $this->getReflectionExtractor(),
+ ],
+ );
+ }
+
+ return $this->propertyTypeExtractor;
+ }
+
+ private function getPropertyInitializableExtractor(): PropertyInitializableExtractorInterface
+ {
+ return $this->getReflectionExtractor();
+ }
+
+ private function getPropertyAccessExtractor(): PropertyAccessExtractorInterface
+ {
+ return $this->getReflectionExtractor();
+ }
+
+ private function getPropertyAccessor(): PropertyAccessorInterface
+ {
+ return $this->getConcretePropertyAccessor();
+ }
+
+ private function getNormalizer(): NormalizerInterface
+ {
+ if ($this->normalizer !== null) {
+ return $this->normalizer;
+ }
+
+ return $this->getSerializer();
+ }
+
+ private function getDenormalizer(): DenormalizerInterface
+ {
+ if ($this->denormalizer !== null) {
+ return $this->denormalizer;
+ }
+
+ return $this->getSerializer();
+ }
+
+ //
+ // transformers
+ //
+
+ protected function getNullTransformer(): TransformerInterface
+ {
+ if (null === $this->nullTransformer) {
+ $this->nullTransformer = new NullTransformer();
+ }
+
+ return $this->nullTransformer;
+ }
+
+ protected function getObjectToObjectTransformer(): TransformerInterface
+ {
+ if (null === $this->objectToObjectTransformer) {
+ $this->objectToObjectTransformer = new ObjectToObjectTransformer(
+ $this->getPropertyListExtractor(),
+ $this->getPropertyTypeExtractor(),
+ $this->getPropertyInitializableExtractor(),
+ $this->getPropertyAccessExtractor(),
+ $this->getPropertyAccessor()
+ );
+ }
+
+ return $this->objectToObjectTransformer;
+ }
+
+ protected function getObjectToStringTransformer(): TransformerInterface
+ {
+ if (null === $this->objectToStringTransformer) {
+ $this->objectToStringTransformer = new ObjectToStringTransformer();
+ }
+
+ return $this->objectToStringTransformer;
+ }
+
+ protected function getScalarToScalarTransformer(): TransformerInterface
+ {
+ if (null === $this->scalarToScalarTransformer) {
+ $this->scalarToScalarTransformer = new ScalarToScalarTransformer();
+ }
+
+ return $this->scalarToScalarTransformer;
+ }
+
+ protected function getStringToBackedEnumTransformer(): TransformerInterface
+ {
+ if (null === $this->stringToBackedEnumTransformer) {
+ $this->stringToBackedEnumTransformer = new StringToBackedEnumTransformer();
+ }
+
+ return $this->stringToBackedEnumTransformer;
+ }
+
+ protected function getArrayToObjectTransformer(): TransformerInterface
+ {
+ if (null === $this->arrayToObjectTransformer) {
+ $this->arrayToObjectTransformer = new ArrayToObjectTransformer(
+ $this->getDenormalizer()
+ );
+ }
+
+ return $this->arrayToObjectTransformer;
+ }
+
+ protected function getObjectToArrayTransformer(): TransformerInterface
+ {
+ if (null === $this->objectToArrayTransformer) {
+ $this->objectToArrayTransformer = new ObjectToArrayTransformer(
+ $this->getNormalizer()
+ );
+ }
+
+ return $this->objectToArrayTransformer;
+ }
+
+ protected function getDateTimeTransformer(): TransformerInterface
+ {
+ if (null === $this->dateTimeTransformer) {
+ $this->dateTimeTransformer = new DateTimeTransformer();
+ }
+
+ return $this->dateTimeTransformer;
+ }
+
+ protected function getTraversableToArrayAccessTransformer(): TransformerInterface
+ {
+ if (null === $this->traversableToArrayAccessTransformer) {
+ $this->traversableToArrayAccessTransformer = new TraversableToArrayAccessTransformer();
+ }
+
+ return $this->traversableToArrayAccessTransformer;
+ }
+
+ protected function getTraversableToTraversableTransformer(): TransformerInterface
+ {
+ if (null === $this->traversableToTraversableTransformer) {
+ $this->traversableToTraversableTransformer = new TraversableToTraversableTransformer();
+ }
+
+ return $this->traversableToTraversableTransformer;
+ }
+
+ //
+ // other services
+ //
+
+ protected function getTypeStringHelper(): TypeStringHelper
+ {
+ if (null === $this->typeStringHelper) {
+ $this->typeStringHelper = new TypeStringHelper();
+ }
+
+ return $this->typeStringHelper;
+ }
+
+ /**
+ * @return iterable
+ */
+ protected function getTransformersIterator(): iterable
+ {
+ yield from $this->additionalTransformers;
+ yield 'ScalarToScalarTransformer'
+ => $this->getScalarToScalarTransformer();
+ yield 'DateTimeTransformer'
+ => $this->getDateTimeTransformer();
+ yield 'StringToBackedEnumTransformer'
+ => $this->getStringToBackedEnumTransformer();
+ yield 'ObjectToStringTransformer'
+ => $this->getObjectToStringTransformer();
+ yield 'TraversableToArrayAccessTransformer'
+ => $this->getTraversableToArrayAccessTransformer();
+ yield 'TraversableToTraversableTransformer'
+ => $this->getTraversableToTraversableTransformer();
+ yield 'ObjectToArrayTransformer'
+ => $this->getObjectToArrayTransformer();
+ yield 'ArrayToObjectTransformer'
+ => $this->getArrayToObjectTransformer();
+ yield 'ObjectToObjectTransformer'
+ => $this->getObjectToObjectTransformer();
+ yield 'NullTransformer'
+ => $this->getNullTransformer();
+ }
+
+ protected function getTransformersLocator(): ContainerInterface
+ {
+ /** @psalm-suppress InvalidArgument */
+ return new ServiceLocator(iterator_to_array($this->getTransformersIterator()));
+ }
+
+ protected function getMainTransformer(): MainTransformer
+ {
+ if (null === $this->mainTransformer) {
+ $this->mainTransformer = new MainTransformer(
+ $this->getTransformersLocator(),
+ $this->getTypeStringHelper(),
+ $this->getMappingFactory(),
+ );
+ }
+
+ return $this->mainTransformer;
+ }
+
+ protected function getMappingFactory(): MappingFactory
+ {
+ if (null === $this->mappingFactory) {
+ $this->mappingFactory = new MappingFactory(
+ $this->getTransformersIterator()
+ );
+ }
+
+ return $this->mappingFactory;
+ }
+
+ //
+ // command
+ //
+
+ protected function getMappingCommand(): MappingCommand
+ {
+ if (null === $this->mappingCommand) {
+ $this->mappingCommand = new MappingCommand(
+ $this->getMappingFactory()
+ );
+ }
+
+ return $this->mappingCommand;
+ }
+
+ protected function getTryCommand(): TryCommand
+ {
+ if (null === $this->tryCommand) {
+ $this->tryCommand = new TryCommand(
+ $this->getMainTransformer(),
+ $this->getTypeStringHelper()
+ );
+ }
+
+ return $this->tryCommand;
+ }
+
+ public function getApplication(): Application
+ {
+ if (null === $this->application) {
+ $this->application = new Application();
+ $this->application->add($this->getMappingCommand());
+ $this->application->add($this->getTryCommand());
+ }
+
+ return $this->application;
+ }
+}
diff --git a/src/MapperInterface.php b/src/MapperInterface.php
new file mode 100644
index 00000000..027c29ad
--- /dev/null
+++ b/src/MapperInterface.php
@@ -0,0 +1,34 @@
+
+ *
+ * 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\Exception\CircularReferenceException;
+use Rekalogika\Mapper\Exception\ExceptionInterface;
+use Rekalogika\Mapper\Exception\InvalidArgumentException;
+use Rekalogika\Mapper\Exception\LogicException;
+
+interface MapperInterface
+{
+ /**
+ * @template T of object
+ * @param class-string|T|"int"|"string"|"float"|"bool"|"array" $target
+ * @param array $context
+ * @return ($target is class-string|T ? T : ($target is "int" ? int : ($target is "string" ? string : ($target is "float" ? float : ($target is "bool" ? bool : ($target is "array" ? array : mixed ))))))
+ * @throws InvalidArgumentException
+ * @throws CircularReferenceException
+ * @throws LogicException
+ * @throws ExceptionInterface
+ */
+ public function map(mixed $source, mixed $target, array $context = []): mixed;
+}
diff --git a/src/Mapping/Mapping.php b/src/Mapping/Mapping.php
new file mode 100644
index 00000000..b24593a9
--- /dev/null
+++ b/src/Mapping/Mapping.php
@@ -0,0 +1,85 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+namespace Rekalogika\Mapper\Mapping;
+
+/**
+ * @implements \IteratorAggregate
+ */
+final class Mapping implements \IteratorAggregate
+{
+ /**
+ * @var array
+ */
+ private array $entries = [];
+
+ /**
+ * @var array>>
+ */
+ private array $mappingBySourceAndTarget = [];
+
+ public function getIterator(): \Traversable
+ {
+ yield from $this->entries;
+ }
+
+ public function addEntry(
+ string $id,
+ string $class,
+ string $sourceType,
+ string $targetType
+ ): void {
+ $entry = new MappingEntry(
+ id: $id,
+ class: $class,
+ sourceType: $sourceType,
+ targetType: $targetType
+ );
+
+ $this->entries[$entry->getOrder()] = $entry;
+ $this->mappingBySourceAndTarget[$sourceType][$targetType][] = $entry;
+ }
+
+ /**
+ * @param array $sourceTypes
+ * @param array $targetTypes
+ * @return array
+ */
+ public function getMappingBySourceAndTarget(
+ array $sourceTypes,
+ array $targetTypes
+ ): array {
+ $result = [];
+
+ foreach ($sourceTypes as $sourceType) {
+ foreach ($targetTypes as $targetType) {
+ if (isset($this->mappingBySourceAndTarget[$sourceType][$targetType])) {
+ foreach ($this->mappingBySourceAndTarget[$sourceType][$targetType] as $mapper) {
+ $result[] = $mapper;
+ }
+ }
+ }
+ }
+
+ // sort by order
+
+ usort(
+ $result,
+ fn (MappingEntry $a, MappingEntry $b)
+ =>
+ $a->getOrder() <=> $b->getOrder()
+ );
+
+ return $result;
+ }
+}
diff --git a/src/Mapping/MappingEntry.php b/src/Mapping/MappingEntry.php
new file mode 100644
index 00000000..47f9254f
--- /dev/null
+++ b/src/Mapping/MappingEntry.php
@@ -0,0 +1,54 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+namespace Rekalogika\Mapper\Mapping;
+
+final class MappingEntry
+{
+ private static int $counter = 0;
+ private int $order;
+
+ public function __construct(
+ private string $id,
+ private string $class,
+ private string $sourceType,
+ private string $targetType,
+ ) {
+ $this->order = ++self::$counter;
+ }
+
+ public function getOrder(): int
+ {
+ return $this->order;
+ }
+
+ public function getId(): string
+ {
+ return $this->id;
+ }
+
+ public function getClass(): string
+ {
+ return $this->class;
+ }
+
+ public function getSourceType(): string
+ {
+ return $this->sourceType;
+ }
+
+ public function getTargetType(): string
+ {
+ return $this->targetType;
+ }
+}
diff --git a/src/Mapping/MappingFactory.php b/src/Mapping/MappingFactory.php
new file mode 100644
index 00000000..5131b1df
--- /dev/null
+++ b/src/Mapping/MappingFactory.php
@@ -0,0 +1,81 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+namespace Rekalogika\Mapper\Mapping;
+
+use Rekalogika\Mapper\Contracts\TransformerInterface;
+use Rekalogika\Mapper\Util\TypeUtil;
+
+/**
+ * Initialize transformer mappings
+ */
+final class MappingFactory implements MappingFactoryInterface
+{
+ private ?Mapping $mapping = null;
+
+ /**
+ * @param iterable $transformers
+ */
+ public function __construct(private iterable $transformers)
+ {
+ }
+
+ public function getMapping(): Mapping
+ {
+ if ($this->mapping === null) {
+ $this->mapping = self::createMapping($this->transformers);
+ }
+
+ return $this->mapping;
+ }
+
+ /**
+ * @param iterable $transformers
+ * @return Mapping
+ */
+ private static function createMapping(iterable $transformers): Mapping
+ {
+ $mapping = new Mapping();
+
+ foreach ($transformers as $id => $transformer) {
+ self::addMapping($mapping, $id, $transformer);
+ }
+
+ return $mapping;
+ }
+
+ private static function addMapping(
+ Mapping $mapping,
+ string $id,
+ TransformerInterface $transformer
+ ): void {
+ foreach ($transformer->getSupportedTransformation() as $typeMapping) {
+ $sourceTypes = $typeMapping->getSimpleSourceTypes();
+ $targetTypes = $typeMapping->getSimpleTargetTypes();
+
+ foreach ($sourceTypes as $sourceType) {
+ foreach ($targetTypes as $targetType) {
+ $sourceTypeString = TypeUtil::getTypeString($sourceType);
+ $targetTypeString = TypeUtil::getTypeString($targetType);
+
+ $mapping->addEntry(
+ id: $id,
+ class: get_class($transformer),
+ sourceType: $sourceTypeString,
+ targetType: $targetTypeString
+ );
+ }
+ }
+ }
+ }
+}
diff --git a/src/Mapping/MappingFactoryInterface.php b/src/Mapping/MappingFactoryInterface.php
new file mode 100644
index 00000000..baa0bbcf
--- /dev/null
+++ b/src/Mapping/MappingFactoryInterface.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\Mapping;
+
+/**
+ * Initialize transformer mappings
+ */
+interface MappingFactoryInterface
+{
+ public function getMapping(): Mapping;
+}
diff --git a/src/Model/MixedType.php b/src/Model/MixedType.php
new file mode 100644
index 00000000..5f4b49d2
--- /dev/null
+++ b/src/Model/MixedType.php
@@ -0,0 +1,36 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+namespace Rekalogika\Mapper\Model;
+
+/**
+ * Sentinel class to indicate mixed type
+ */
+
+final class MixedType
+{
+ private static ?self $instance = null;
+
+ private function __construct()
+ {
+ }
+
+ public static function instance(): self
+ {
+ if (self::$instance === null) {
+ self::$instance = new self();
+ }
+
+ return self::$instance;
+ }
+}
diff --git a/src/Model/ObjectCache.php b/src/Model/ObjectCache.php
new file mode 100644
index 00000000..684ee7a9
--- /dev/null
+++ b/src/Model/ObjectCache.php
@@ -0,0 +1,188 @@
+
+ *
+ * For the full copyright and license information, please view the LICENSE file
+ * that was distributed with this source code.
+ */
+
+namespace Rekalogika\Mapper\Model;
+
+use Rekalogika\Mapper\Exception\CachedTargetObjectNotFoundException;
+use Rekalogika\Mapper\Exception\CircularReferenceException;
+use Rekalogika\Mapper\Exception\LogicException;
+use Rekalogika\Mapper\Util\TypeUtil;
+use Symfony\Component\PropertyInfo\Type;
+
+final class ObjectCache
+{
+ /**
+ * @var \SplObjectStorage