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> + */ + private \SplObjectStorage $cache; + + /** + * @var \SplObjectStorage> + */ + private \SplObjectStorage $preCache; + + public function __construct() + { + $this->cache = new \SplObjectStorage(); + $this->preCache = new \SplObjectStorage(); + } + + private function isBlacklisted(mixed $source): bool + { + return $source instanceof \DateTimeInterface; + } + + /** + * Precaching indicates we want to cache the target, but haven't done so + * yet. If the object is still in precached status, obtaining it from the + * cache will yield an exception. If the target is finally cached, it is + * no longer in precached status. + * + * @param mixed $source + * @param Type $targetType + * @return void + */ + public function preCache(mixed $source, Type $targetType): void + { + if (!is_object($source)) { + return; + } + + if ($this->isBlacklisted($source)) { + return; + } + + if (!TypeUtil::isSimpleType($targetType)) { + throw new LogicException('Target type must be simple type'); + } + + $targetTypeString = TypeUtil::getTypeString($targetType); + + if (!isset($this->preCache[$source])) { + /** @var \ArrayObject */ + $arrayObject = new \ArrayObject(); + $this->preCache[$source] = $arrayObject; + } + + $this->preCache->offsetGet($source)->offsetSet($targetTypeString, true); + } + + private function isPreCached(mixed $source, Type $targetType): bool + { + if (!is_object($source)) { + return false; + } + + if (!TypeUtil::isSimpleType($targetType)) { + throw new LogicException('Target type must be simple type'); + } + + $targetTypeString = TypeUtil::getTypeString($targetType); + + return isset($this->preCache[$source][$targetTypeString]); + } + + private function removePrecache(mixed $source, Type $targetType): void + { + if (!is_object($source)) { + return; + } + + if (!TypeUtil::isSimpleType($targetType)) { + throw new LogicException('Target type must be simple type'); + } + + $targetTypeString = TypeUtil::getTypeString($targetType); + + if (isset($this->preCache[$source][$targetTypeString])) { + unset($this->preCache[$source][$targetTypeString]); + } + } + + public function containsTarget(mixed $source, Type $targetType): bool + { + if (!is_object($source)) { + return false; + } + + if ($this->isBlacklisted($source)) { + return false; + } + + if (!TypeUtil::isSimpleType($targetType)) { + throw new LogicException('Target type must be simple type'); + } + + $targetTypeString = TypeUtil::getTypeString($targetType); + + return isset($this->cache[$source][$targetTypeString]); + } + + public function getTarget(mixed $source, Type $targetType): mixed + { + if ($this->isPreCached($source, $targetType)) { + throw new CircularReferenceException($source, $targetType); + } + + if ($this->isBlacklisted($source)) { + throw new CachedTargetObjectNotFoundException(); + } + + if (!is_object($source)) { + throw new CachedTargetObjectNotFoundException(); + } + + if (!TypeUtil::isSimpleType($targetType)) { + throw new LogicException('Target type must be simple type'); + } + + $targetTypeString = TypeUtil::getTypeString($targetType); + + /** @var object */ + return $this->cache[$source][$targetTypeString] + ?? throw new CachedTargetObjectNotFoundException(); + } + + public function saveTarget( + mixed $source, + Type $targetType, + mixed $target + ): void { + if (!is_object($source) || !is_object($target)) { + return; + } + + if ($this->isBlacklisted($source)) { + return; + } + + $targetTypeString = TypeUtil::getTypeString($targetType); + + if (isset($this->cache[$source][$targetTypeString])) { + throw new LogicException(sprintf( + 'Target object for source object "%s" and target type "%s" already exists', + get_class($source), + $targetTypeString + )); + } + + if (!isset($this->cache[$source])) { + /** @var \ArrayObject */ + $arrayObject = new \ArrayObject(); + $this->cache[$source] = $arrayObject; + } + + $this->cache->offsetGet($source)->offsetSet($targetTypeString, $target); + $this->removePrecache($source, $targetType); + } +} diff --git a/src/Model/ServiceLocator.php b/src/Model/ServiceLocator.php new file mode 100644 index 00000000..40df1eb6 --- /dev/null +++ b/src/Model/ServiceLocator.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Model; + +use Psr\Container\ContainerInterface; +use Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException; + +/** + * Simple container for non-framework use + */ +final class ServiceLocator implements ContainerInterface +{ + /** + * @param array $services + */ + public function __construct( + private array $services = [] + ) { + } + + public function get(string $id): mixed + { + return $this->services[$id] ?? throw new ServiceNotFoundException($id); + } + + public function has(string $id): bool + { + return isset($this->services[$id]); + } +} diff --git a/src/RekalogikaMapperBundle.php b/src/RekalogikaMapperBundle.php new file mode 100644 index 00000000..e66344e1 --- /dev/null +++ b/src/RekalogikaMapperBundle.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; + +use Rekalogika\Mapper\Contracts\TransformerInterface; +use Rekalogika\Mapper\Tests\Common\TestKernel; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; +use Symfony\Component\HttpKernel\Bundle\AbstractBundle; + +class RekalogikaMapperBundle extends AbstractBundle +{ + /** + * @param array $config + */ + public function loadExtension( + array $config, + ContainerConfigurator $container, + ContainerBuilder $builder + ): void { + // load services + + $container->import(__DIR__ . '/../config/services.php'); + + // autoconfigure services + + $builder->registerForAutoconfiguration(TransformerInterface::class) + ->addTag('rekalogika.mapper.transformer'); + + // load service configuration for test environment + + $env = $builder->getParameter('kernel.environment'); + + if ($env === 'test' && class_exists(TestKernel::class)) { + $container->import(__DIR__ . '/../config/tests.php'); + } + } +} diff --git a/src/Transformer/ArrayToObjectTransformer.php b/src/Transformer/ArrayToObjectTransformer.php new file mode 100644 index 00000000..881eb60b --- /dev/null +++ b/src/Transformer/ArrayToObjectTransformer.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Transformer; + +use Rekalogika\Mapper\Contracts\TransformerInterface; +use Rekalogika\Mapper\Contracts\TypeMapping; +use Rekalogika\Mapper\Exception\InvalidArgumentException; +use Rekalogika\Mapper\Util\TypeCheck; +use Rekalogika\Mapper\Util\TypeFactory; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\Normalizer\AbstractNormalizer; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; + +/** + * Map an array to an object. Uses the Symfony Serializer component as the + * backend. + */ +final class ArrayToObjectTransformer implements TransformerInterface +{ + public function __construct( + private DenormalizerInterface $denormalizer, + private ?string $denormalizerFormat = null + ) { + } + + public function transform( + mixed $source, + mixed $target, + Type $sourceType, + Type $targetType, + array $context + ): mixed { + if (!is_array($source)) { + throw new InvalidArgumentException(sprintf('Source must be array, "%s" given', get_debug_type($source))); + } + + if (!TypeCheck::isObject($targetType)) { + throw new InvalidArgumentException(sprintf('Target type must be an object, "%s" given', TypeCheck::getDebugType($targetType))); + } + + if ($target !== null) { + if (!is_object($target)) { + throw new InvalidArgumentException(sprintf('Target must be an object, "%s" given', get_debug_type($target))); + } + + $targetClass = $target::class; + $context[AbstractNormalizer::OBJECT_TO_POPULATE] = $target; + } else { + $targetClass = $targetType->getClassName() ?? \stdClass::class; + } + + return $this->denormalizer->denormalize( + $source, + $targetClass, + $this->denormalizerFormat, + $context + ); + } + + public function getSupportedTransformation(): iterable + { + yield new TypeMapping(TypeFactory::array(), TypeFactory::object()); + } +} diff --git a/src/Transformer/DateTimeTransformer.php b/src/Transformer/DateTimeTransformer.php new file mode 100644 index 00000000..a35e173b --- /dev/null +++ b/src/Transformer/DateTimeTransformer.php @@ -0,0 +1,130 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Transformer; + +use Rekalogika\Mapper\Contracts\TransformerInterface; +use Rekalogika\Mapper\Contracts\TypeMapping; +use Rekalogika\Mapper\Exception\InvalidArgumentException; +use Rekalogika\Mapper\Util\TypeCheck; +use Rekalogika\Mapper\Util\TypeFactory; +use Symfony\Component\Clock\DatePoint; +use Symfony\Component\PropertyInfo\Type; + +/** + * Map between DateTime and string. If a string is involved + */ +final class DateTimeTransformer implements TransformerInterface +{ + public function transform( + mixed $source, + mixed $target, + Type $sourceType, + Type $targetType, + array $context + ): mixed { + if (is_string($source)) { + $source = new DatePoint($source); + } + + if (!$source instanceof \DateTimeInterface) { + throw new InvalidArgumentException(sprintf('Source must be DateTimeInterface, "%s" given', get_debug_type($source))); + } + + // if target is mutable, just set directly on the instance and return it + if ($target instanceof \DateTime) { + $target->setTimestamp($source->getTimestamp()); + + return $target; + } + + if ($target !== null) { + throw new InvalidArgumentException(sprintf('Target must be null unless it is a DateTime, "%s" given', get_debug_type($target))); + } + + if (TypeCheck::isObjectOfType($targetType, \DateTime::class)) { + return \DateTime::createFromInterface($source); + } + + if (TypeCheck::isObjectOfType($targetType, DatePoint::class)) { + return DatePoint::createFromInterface($source); + } + + if (TypeCheck::isObjectOfType( + $targetType, + \DateTimeInterface::class, + \DateTimeImmutable::class + )) { + return \DateTimeImmutable::createFromInterface($source); + } + + // @todo: maybe make format configurable. reuse serializer metadata? + if (TypeCheck::isString($targetType)) { + return $source->format(\DateTimeInterface::ATOM); + } + + throw new InvalidArgumentException(sprintf('Target must be DateTime, DateTimeImmutable, or DatePoint, "%s" given', get_debug_type($targetType))); + } + + public function getSupportedTransformation(): iterable + { + // from string + + yield new TypeMapping( + TypeFactory::string(), + TypeFactory::objectOfClass(\DateTimeInterface::class) + ); + + yield new TypeMapping( + TypeFactory::string(), + TypeFactory::objectOfClass(\DateTime::class) + ); + + yield new TypeMapping( + TypeFactory::string(), + TypeFactory::objectOfClass(\DateTimeImmutable::class) + ); + + yield new TypeMapping( + TypeFactory::string(), + TypeFactory::objectOfClass(DatePoint::class) + ); + + // from DateTimeInterface + + yield new TypeMapping( + TypeFactory::objectOfClass(\DateTimeInterface::class), + TypeFactory::objectOfClass(\DateTimeInterface::class) + ); + + yield new TypeMapping( + TypeFactory::objectOfClass(\DateTimeInterface::class), + TypeFactory::objectOfClass(\DateTime::class) + ); + + yield new TypeMapping( + TypeFactory::objectOfClass(\DateTimeInterface::class), + TypeFactory::objectOfClass(\DateTimeImmutable::class) + ); + + yield new TypeMapping( + TypeFactory::objectOfClass(\DateTimeInterface::class), + TypeFactory::objectOfClass(DatePoint::class) + ); + + yield new TypeMapping( + TypeFactory::objectOfClass(\DateTimeInterface::class), + TypeFactory::string() + ); + } +} diff --git a/src/Transformer/NormalizerDenormalizerAdapterTransformer.php b/src/Transformer/NormalizerDenormalizerAdapterTransformer.php new file mode 100644 index 00000000..b9753c5f --- /dev/null +++ b/src/Transformer/NormalizerDenormalizerAdapterTransformer.php @@ -0,0 +1,95 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Transformer; + +use Rekalogika\Mapper\Contracts\TransformerInterface; +use Rekalogika\Mapper\Contracts\TypeMapping; +use Rekalogika\Mapper\Exception\InvalidArgumentException; +use Rekalogika\Mapper\Util\TypeFactory; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Leverages existing normalizer & denormalizer to perform mapping. It does + * the mapping by normalizing the source object to array, then denormalizing + * the array to target object. + */ +final class NormalizerDenormalizerAdapterTransformer implements TransformerInterface +{ + public function __construct( + private NormalizerInterface $normalizer, + private DenormalizerInterface $denormalizer, + private string $normalizerFormat = null, + private string $denormalizerFormat = null + ) { + } + + public function transform( + mixed $source, + mixed $target, + Type $sourceType, + Type $targetType, + array $context + ): mixed { + $arrayForm = $this->normalizer->normalize( + $source, + $this->normalizerFormat, + $context + ); + + $targetClass = $targetType->getClassName(); + if ($targetClass === null || !class_exists($targetClass)) { + throw new InvalidArgumentException(sprintf('Target type must be an object, "%s" given', get_debug_type($targetType))); + } + + return $this->denormalizer->denormalize( + $arrayForm, + $targetClass, + $this->denormalizerFormat, + $context + ); + } + + public function getSupportedTransformation(): iterable + { + $normalizerSupports = $this->normalizer + ->getSupportedTypes($this->normalizerFormat); + + $sourceTypes = []; + /** @var bool|null $value */ + foreach ($normalizerSupports as $normalizerSupport => $value) { + if (is_string($normalizerSupport) && class_exists($normalizerSupport)) { + $sourceTypes[] = TypeFactory::objectOfClass($normalizerSupport); + } + } + + $denormalizerSupports = $this->denormalizer + ->getSupportedTypes($this->denormalizerFormat); + + $targetTypes = []; + /** @var bool|null $value */ + foreach ($denormalizerSupports as $denormalizerSupport => $value) { + if (is_string($denormalizerSupport) && class_exists($denormalizerSupport)) { + $targetTypes[] = TypeFactory::objectOfClass($denormalizerSupport); + } + } + + foreach ($sourceTypes as $sourceType) { + foreach ($targetTypes as $targetType) { + yield new TypeMapping($sourceType, $targetType); + } + } + } +} diff --git a/src/Transformer/NullTransformer.php b/src/Transformer/NullTransformer.php new file mode 100644 index 00000000..e23b49cb --- /dev/null +++ b/src/Transformer/NullTransformer.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Transformer; + +use Rekalogika\Mapper\Contracts\TransformerInterface; +use Rekalogika\Mapper\Contracts\TypeMapping; +use Rekalogika\Mapper\Exception\InvalidArgumentException; +use Rekalogika\Mapper\Util\TypeCheck; +use Rekalogika\Mapper\Util\TypeFactory; +use Symfony\Component\PropertyInfo\Type; + +final class NullTransformer implements TransformerInterface +{ + public function transform( + mixed $source, + mixed $target, + Type $sourceType, + Type $targetType, + array $context + ): mixed { + if ($target !== null) { + throw new InvalidArgumentException('Target must be null'); + } + + if (TypeCheck::isString($targetType)) { + return ''; + } + + if (TypeCheck::isInt($targetType)) { + return 0; + } + + if (TypeCheck::isFloat($targetType)) { + return 0.0; + } + + if (TypeCheck::isBool($targetType)) { + return false; + } + + if (TypeCheck::isArray($targetType)) { + return []; + } + + throw new InvalidArgumentException(sprintf('Target must be scalar, "%s" given', get_debug_type($targetType))); + } + + public function getSupportedTransformation(): iterable + { + yield new TypeMapping(TypeFactory::null(), TypeFactory::string()); + yield new TypeMapping(TypeFactory::null(), TypeFactory::int()); + yield new TypeMapping(TypeFactory::null(), TypeFactory::float()); + yield new TypeMapping(TypeFactory::null(), TypeFactory::bool()); + yield new TypeMapping(TypeFactory::null(), TypeFactory::array()); + yield new TypeMapping(TypeFactory::mixed(), TypeFactory::null()); + } +} diff --git a/src/Transformer/ObjectToArrayTransformer.php b/src/Transformer/ObjectToArrayTransformer.php new file mode 100644 index 00000000..088684de --- /dev/null +++ b/src/Transformer/ObjectToArrayTransformer.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Transformer; + +use Rekalogika\Mapper\Contracts\TransformerInterface; +use Rekalogika\Mapper\Contracts\TypeMapping; +use Rekalogika\Mapper\Exception\InvalidArgumentException; +use Rekalogika\Mapper\Util\TypeFactory; +use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\Normalizer\NormalizerInterface; + +/** + * Map an object to an array. Uses the Symfony Serializer component as the + * backend. + */ +final class ObjectToArrayTransformer implements TransformerInterface +{ + public function __construct( + private NormalizerInterface $normalizer, + private ?string $normalizerFormat = null + ) { + } + + public function transform( + mixed $source, + mixed $target, + Type $sourceType, + Type $targetType, + array $context + ): mixed { + if (!is_object($source)) { + throw new InvalidArgumentException(sprintf('Source must be object, "%s" given', get_debug_type($source))); + } + + return $this->normalizer->normalize( + $source, + $this->normalizerFormat, + $context + ); + } + + public function getSupportedTransformation(): iterable + { + yield new TypeMapping(TypeFactory::object(), TypeFactory::array()); + } +} diff --git a/src/Transformer/ObjectToObjectTransformer.php b/src/Transformer/ObjectToObjectTransformer.php new file mode 100644 index 00000000..c927d510 --- /dev/null +++ b/src/Transformer/ObjectToObjectTransformer.php @@ -0,0 +1,258 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Transformer; + +use Rekalogika\Mapper\Contracts\MainTransformerAwareInterface; +use Rekalogika\Mapper\Contracts\MainTransformerAwareTrait; +use Rekalogika\Mapper\Contracts\TransformerInterface; +use Rekalogika\Mapper\Contracts\TypeMapping; +use Rekalogika\Mapper\Exception\CachedTargetObjectNotFoundException; +use Rekalogika\Mapper\Exception\InvalidArgumentException; +use Rekalogika\Mapper\MainTransformer; +use Rekalogika\Mapper\Model\ObjectCache; +use Rekalogika\Mapper\Util\TypeCheck; +use Rekalogika\Mapper\Util\TypeFactory; +use Rekalogika\Mapper\Util\TypeUtil; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\PropertyInfo\PropertyAccessExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyInitializableExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyListExtractorInterface; +use Symfony\Component\PropertyInfo\PropertyTypeExtractorInterface; +use Symfony\Component\PropertyInfo\Type; + +final class ObjectToObjectTransformer implements TransformerInterface, MainTransformerAwareInterface +{ + use MainTransformerAwareTrait; + + public function __construct( + private PropertyListExtractorInterface $propertyListExtractor, + private PropertyTypeExtractorInterface $propertyTypeExtractor, + private PropertyInitializableExtractorInterface $propertyInitializableExtractor, + private PropertyAccessExtractorInterface $propertyAccessExtractor, + private PropertyAccessorInterface $propertyAccessor, + ) { + } + + public function transform( + mixed $source, + mixed $target, + Type $sourceType, + Type $targetType, + array $context + ): mixed { + // get object cache + + if (!isset($context[MainTransformer::OBJECT_CACHE])) { + $objectCache = new ObjectCache(); + $context[MainTransformer::OBJECT_CACHE] = $objectCache; + } else { + /** @var ObjectCache */ + $objectCache = $context[MainTransformer::OBJECT_CACHE]; + } + + // return from cache if already exists + + try { + return $objectCache->getTarget($source, $targetType); + } catch (CachedTargetObjectNotFoundException) { + } + + // get source object & class + + if (!is_object($source)) { + throw new InvalidArgumentException(sprintf('The source must be an object, "%s" given.', get_debug_type($source))); + } + + $sourceType = TypeUtil::guessTypeFromVariable($source); + + $targetClass = $targetType->getClassName(); + + if (null === $targetClass || !\class_exists($targetClass)) { + throw new InvalidArgumentException('Cannot get class name from target type.'); + } + + // if sourceType and targetType are the same, just return the source + + if (null === $target && TypeCheck::isSomewhatIdentical($sourceType, $targetType)) { + return $source; + } + + // list properties + + $sourceProperties = $this->listSourceAttributes($sourceType, $context); + $writableTargetProperties = $this + ->listTargetWritableAttributes($targetType, $context); + + // initialize target, add to cache after initialization + + if (null === $target) { + $objectCache->preCache($source, $targetType); + $target = $this->initialize($targetType); + } else { + if (!is_object($target)) { + throw new InvalidArgumentException(sprintf('The target must be an object, "%s" given.', get_debug_type($target))); + } + } + + $objectCache->saveTarget($source, $targetType, $target); + + // calculate applicable properties + + $propertiesToMap = array_intersect($sourceProperties, $writableTargetProperties); + + // map properties + + foreach ($propertiesToMap as $property) { + /** @var array|null */ + $targetPropertyTypes = $this->propertyTypeExtractor->getTypes($targetClass, $property, $context); + + if (null === $targetPropertyTypes || count($targetPropertyTypes) === 0) { + throw new InvalidArgumentException(sprintf('Cannot get type of target property "%s::$%s".', $targetClass, $property)); + } + + /** @var mixed */ + $sourcePropertyValue = $this->propertyAccessor->getValue($source, $property); + /** @var mixed */ + $targetPropertyValue = $this->propertyAccessor->getValue($target, $property); + + /** @var mixed */ + $targetPropertyValue = $this->mainTransformer?->transform( + source: $sourcePropertyValue, + target: $targetPropertyValue, + targetType: $targetPropertyTypes, + context: $context + ); + + $this->propertyAccessor->setValue($target, $property, $targetPropertyValue); + } + + return $target; + } + + public function getSupportedTransformation(): iterable + { + yield new TypeMapping(TypeFactory::object(), TypeFactory::object()); + } + + /** + * @todo support constructor initialization + */ + protected function initialize(Type $targetType): object + { + $class = $targetType->getClassName(); + + if (null === $class || !\class_exists($class)) { + throw new InvalidArgumentException('Cannot get class name from target type.'); + } + + // $initializableTargetProperties = $this->listTargetInitializableAttributes($targetClass); + + // $writableAndNotInitializableTargetProperties = array_diff( + // $writableTargetProperties, + // $initializableTargetProperties + // ); + + return (new \ReflectionClass($class)) + ->newInstanceWithoutConstructor(); + } + + /** + * @param array $context + * @return array + * @todo cache result + */ + protected function listSourceAttributes( + Type $sourceType, + array $context + ): array { + $class = $sourceType->getClassName(); + + if (null === $class) { + throw new InvalidArgumentException('Cannot get class name from source type.'); + } + + $attributes = $this->propertyListExtractor->getProperties($class, $context); + + if (null === $attributes) { + throw new InvalidArgumentException(sprintf('Cannot get properties from source class "%s".', $class)); + } + + $readableAttributes = []; + + foreach ($attributes as $attribute) { + if ($this->propertyAccessExtractor->isReadable($class, $attribute, $context)) { + $readableAttributes[] = $attribute; + } + } + + return $readableAttributes; + } + + /** + * @param array $context + * @return array + * @todo cache result + */ + protected function listTargetWritableAttributes( + Type $targetType, + array $context + ): array { + $class = $targetType->getClassName(); + + if (null === $class) { + throw new InvalidArgumentException('Cannot get class name from source type.'); + } + + $attributes = $this->propertyListExtractor->getProperties($class, $context); + + if (null === $attributes) { + throw new InvalidArgumentException(sprintf('Cannot get properties from target class "%s".', $class)); + } + + $writableAttributes = []; + + foreach ($attributes as $attribute) { + if ($this->propertyAccessExtractor->isWritable($class, $attribute, $context)) { + $writableAttributes[] = $attribute; + } + } + + return $writableAttributes; + } + + /** + * @param class-string $class + * @param array $context + * @return array + * @todo cache result + */ + protected function listTargetInitializableAttributes(string $class, array $context): array + { + $attributes = $this->propertyListExtractor->getProperties($class, $context); + + if (null === $attributes) { + throw new InvalidArgumentException(sprintf('Cannot get properties from target class "%s".', $class)); + } + + $initializableAttributes = []; + + foreach ($attributes as $attribute) { + if ($this->propertyInitializableExtractor->isInitializable($class, $attribute, $context)) { + $initializableAttributes[] = $attribute; + } + } + + return $initializableAttributes; + } +} diff --git a/src/Transformer/ObjectToStringTransformer.php b/src/Transformer/ObjectToStringTransformer.php new file mode 100644 index 00000000..33fd3fba --- /dev/null +++ b/src/Transformer/ObjectToStringTransformer.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\Transformer; + +use Rekalogika\Mapper\Contracts\TransformerInterface; +use Rekalogika\Mapper\Contracts\TypeMapping; +use Rekalogika\Mapper\Exception\InvalidArgumentException; +use Rekalogika\Mapper\Util\TypeFactory; +use Symfony\Component\PropertyInfo\Type; + +final class ObjectToStringTransformer implements TransformerInterface +{ + public function transform( + mixed $source, + mixed $target, + Type $sourceType, + Type $targetType, + array $context + ): mixed { + if ($source instanceof \Stringable) { + return (string) $source; + } elseif ($source instanceof \BackedEnum) { + return $source->value; + } elseif ($source instanceof \UnitEnum) { + return $source->name; + } + + throw new InvalidArgumentException(sprintf('Source must be instance of "\Stringable" or "\UnitEnum", "%s" given', get_debug_type($source))); + } + + public function getSupportedTransformation(): iterable + { + yield new TypeMapping( + TypeFactory::objectOfClass(\Stringable::class), + TypeFactory::string(), + ); + + yield new TypeMapping( + TypeFactory::objectOfClass(\UnitEnum::class), + TypeFactory::string(), + ); + } +} diff --git a/src/Transformer/ScalarToScalarTransformer.php b/src/Transformer/ScalarToScalarTransformer.php new file mode 100644 index 00000000..974314fa --- /dev/null +++ b/src/Transformer/ScalarToScalarTransformer.php @@ -0,0 +1,70 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Transformer; + +use Rekalogika\Mapper\Contracts\TransformerInterface; +use Rekalogika\Mapper\Contracts\TypeMapping; +use Rekalogika\Mapper\Exception\InvalidArgumentException; +use Rekalogika\Mapper\Util\TypeCheck; +use Rekalogika\Mapper\Util\TypeFactory; +use Symfony\Component\PropertyInfo\Type; + +final class ScalarToScalarTransformer implements TransformerInterface +{ + public function transform( + mixed $source, + mixed $target, + Type $sourceType, + Type $targetType, + array $context + ): mixed { + if (!is_scalar($source)) { + throw new InvalidArgumentException(sprintf('Source must be scalar, "%s" given', get_debug_type($source))); + } + + if (TypeCheck::isInt($targetType)) { + return (int) $source; + } + + if (TypeCheck::isFloat($targetType)) { + return (float) $source; + } + + if (TypeCheck::isString($targetType)) { + return (string) $source; + } + + if (TypeCheck::isBool($targetType)) { + return (bool) $source; + } + + throw new InvalidArgumentException(sprintf('Target must be scalar, "%s" given', get_debug_type($targetType))); + } + + public function getSupportedTransformation(): iterable + { + $types = [ + TypeFactory::int(), + TypeFactory::float(), + TypeFactory::string(), + TypeFactory::bool(), + ]; + + foreach ($types as $type1) { + foreach ($types as $type2) { + yield new TypeMapping($type1, $type2); + } + } + } +} diff --git a/src/Transformer/StringToBackedEnumTransformer.php b/src/Transformer/StringToBackedEnumTransformer.php new file mode 100644 index 00000000..9ac25367 --- /dev/null +++ b/src/Transformer/StringToBackedEnumTransformer.php @@ -0,0 +1,57 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Transformer; + +use Rekalogika\Mapper\Contracts\TransformerInterface; +use Rekalogika\Mapper\Contracts\TypeMapping; +use Rekalogika\Mapper\Exception\InvalidArgumentException; +use Rekalogika\Mapper\Util\TypeFactory; +use Symfony\Component\PropertyInfo\Type; + +final class StringToBackedEnumTransformer implements TransformerInterface +{ + public function transform( + mixed $source, + mixed $target, + Type $sourceType, + Type $targetType, + array $context + ): mixed { + if (!is_string($source)) { + throw new InvalidArgumentException(sprintf('Source must be string, "%s" given', get_debug_type($source))); + } + + $class = $targetType->getClassName(); + + if ($class === null || !\enum_exists($class)) { + throw new InvalidArgumentException(sprintf('Target must be an enum class-string, "%s" given', get_debug_type($class))); + } + + // @todo maybe add option to handle values not in the enum + if (is_a($class, \BackedEnum::class, true)) { + /** @var class-string<\BackedEnum> $class */ + return $class::from($source); + } + + throw new InvalidArgumentException(sprintf('Target must be an enum class-string, "%s" given', get_debug_type($target))); + } + + public function getSupportedTransformation(): iterable + { + yield new TypeMapping( + TypeFactory::string(), + TypeFactory::objectOfClass(\BackedEnum::class), + ); + } +} diff --git a/src/Transformer/TraversableToArrayAccessTransformer.php b/src/Transformer/TraversableToArrayAccessTransformer.php new file mode 100644 index 00000000..752c8032 --- /dev/null +++ b/src/Transformer/TraversableToArrayAccessTransformer.php @@ -0,0 +1,248 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Transformer; + +use Doctrine\Common\Collections\ArrayCollection; +use Doctrine\Common\Collections\Collection; +use Rekalogika\Mapper\Contracts\MainTransformerAwareInterface; +use Rekalogika\Mapper\Contracts\MainTransformerAwareTrait; +use Rekalogika\Mapper\Contracts\TransformerInterface; +use Rekalogika\Mapper\Contracts\TypeMapping; +use Rekalogika\Mapper\Exception\CachedTargetObjectNotFoundException; +use Rekalogika\Mapper\Exception\InvalidArgumentException; +use Rekalogika\Mapper\Exception\MissingMemberKeyTypeException; +use Rekalogika\Mapper\Exception\MissingMemberValueTypeException; +use Rekalogika\Mapper\MainTransformer; +use Rekalogika\Mapper\Model\ObjectCache; +use Rekalogika\Mapper\Util\TypeCheck; +use Rekalogika\Mapper\Util\TypeFactory; +use Symfony\Component\PropertyInfo\Type; + +final class TraversableToArrayAccessTransformer implements TransformerInterface, MainTransformerAwareInterface +{ + use MainTransformerAwareTrait; + + public function transform( + mixed $source, + mixed $target, + Type $sourceType, + Type $targetType, + array $context + ): mixed { + // get object cache + + if (!isset($context[MainTransformer::OBJECT_CACHE])) { + $objectCache = new ObjectCache(); + $context[MainTransformer::OBJECT_CACHE] = $objectCache; + } else { + /** @var ObjectCache */ + $objectCache = $context[MainTransformer::OBJECT_CACHE]; + } + + // return from cache if already exists + + try { + return $objectCache->getTarget($source, $targetType); + } catch (CachedTargetObjectNotFoundException) { + } + + // The source must be a Traversable or an array (a.k.a. iterable). + + if (!$source instanceof \Traversable && !is_array($source)) { + throw new InvalidArgumentException(sprintf('Source must be instance of "\Traversable" or "array", "%s" given', get_debug_type($source))); + } + + // If the target is provided, make sure it is an array|ArrayAccess + + if ($target !== null && !$target instanceof \ArrayAccess && !is_array($target)) { + throw new InvalidArgumentException(sprintf('If target is provided, it must be an instance of "\ArrayAccess" or "array", "%s" given', get_debug_type($target))); + } + + // If the target is not provided, instantiate it, and add to cache. + + if ($target === null) { + $objectCache->preCache($source, $targetType); + $target = $this->instantiateArrayAccessOrArray($targetType); + } + + $objectCache->saveTarget($source, $targetType, $target); + + // We can't work if the target type doesn't contain the information + // about the type of its member objects + + $targetMemberValueType = $targetType->getCollectionValueTypes(); + + if (count($targetMemberValueType) === 0) { + throw new MissingMemberValueTypeException($sourceType, $targetType); + } + + // Prepare variables for the output loop + + $targetMemberKeyType = $targetType->getCollectionKeyTypes(); + $targetMemberKeyTypeIsMissing = count($targetMemberKeyType) === 0; + $targetMemberKeyTypeIsInt = count($targetMemberKeyType) === 1 + && TypeCheck::isInt($targetMemberKeyType[0]); + + /** @var mixed $sourceMemberValue */ + foreach ($source as $sourceMemberKey => $sourceMemberValue) { + /** @var mixed $sourceMemberKey */ + + if (is_string($sourceMemberKey) || is_int($sourceMemberKey)) { + // if the key is a simple type: int|string + + if ($targetMemberKeyTypeIsInt && is_string($sourceMemberKey)) { + // if target has int key type but the source has string key type, + // we discard the source key & use null (i.e. $target[] = $value) + + $targetMemberKey = null; + } else { + $targetMemberKey = $sourceMemberKey; + } + } else { + // If the type of the key is a complex type (not int or string). + // i.e. an ArrayObject can have an object as its key. + + // Refuse to continue if the target key type is not provided + + if ($targetMemberKeyTypeIsMissing) { + throw new MissingMemberKeyTypeException($sourceType, $targetType); + } + + // If provided, we transform the source key to the key type of + // the target + + /** @var mixed */ + $targetMemberKey = $this->getMainTransformer()->transform( + source: $sourceMemberKey, + target: null, + targetType: $targetMemberKeyType, + context: $context, + ); + } + + // Get the existing member value from the target + + /** @var mixed $targetMemberValue */ + $targetMemberValue = $target[$sourceMemberKey] ?? null; + + // if target member value is not an object we delete it because it + // will be removed anyway + + if (!is_object($targetMemberValue)) { + $targetMemberValue = null; + } + + // now transform the source member value to the type of the target + // member value + + /** @var mixed */ + $targetMemberValue = $this->getMainTransformer()->transform( + source: $sourceMemberValue, + target: $targetMemberValue, + targetType: $targetMemberValueType, + context: $context, + ); + + if ($targetMemberKey === null) { + $target[] = $targetMemberValue; + } else { + $target[$targetMemberKey] = $targetMemberValue; + } + } + + return $target; + } + + /** + * @return \ArrayAccess|array + */ + private function instantiateArrayAccessOrArray( + Type $targetType, + ): \ArrayAccess|array { + // if it wants an array, just return it. easy. + + if (TypeCheck::isArray($targetType)) { + return []; + } + + $class = $targetType->getClassName(); + + if ($class === null) { + throw new InvalidArgumentException(sprintf('Target must be an instance of "\ArrayAccess" or "array", "%s" given', TypeCheck::getDebugType($targetType))); + } + + if (!class_exists($class) && !\interface_exists($class)) { + throw new InvalidArgumentException(sprintf('Target class "%s" does not exist', $class)); + } + + $reflectionClass = new \ReflectionClass($class); + + if (!$reflectionClass->implementsInterface(\ArrayAccess::class)) { + throw new InvalidArgumentException(sprintf('Target class "%s" must implement "\ArrayAccess"', $class)); + } + + // if instantiable, instantiate + + if ($reflectionClass->isInstantiable()) { + try { + $result = $reflectionClass->newInstance(); + } catch (\ReflectionException) { + throw new InvalidArgumentException(sprintf('We do not know how to instantiate class "%s"', $class)); + } + + if (!$result instanceof \ArrayAccess) { + throw new InvalidArgumentException(sprintf('Instantiated class "%s" does not implement "\ArrayAccess"', $class)); + } + + return $result; + } + + // at this point, $class must be an interface or an abstract class. + // the following is a heuristic for some popular situations + + $concreteClass = match ($class) { + \ArrayAccess::class => \ArrayObject::class, + Collection::class => ArrayCollection::class, + default => throw new InvalidArgumentException(sprintf('We do not know how to create an instance of "%s"', $class)), + }; + + if (!class_exists($concreteClass)) { + throw new InvalidArgumentException(sprintf('Concrete class "%s" does not exist', $concreteClass)); + } + + return new $concreteClass(); + } + + public function getSupportedTransformation(): iterable + { + $sourceTypes = [ + TypeFactory::objectOfClass(\Traversable::class), + TypeFactory::array(), + ]; + + $targetTypes = [ + TypeFactory::objectOfClass(\ArrayAccess::class), + TypeFactory::array(), + ]; + + foreach ($sourceTypes as $sourceType) { + foreach ($targetTypes as $targetType) { + yield new TypeMapping( + $sourceType, + $targetType, + ); + } + } + } +} diff --git a/src/Transformer/TraversableToTraversableTransformer.php b/src/Transformer/TraversableToTraversableTransformer.php new file mode 100644 index 00000000..fd7fe06e --- /dev/null +++ b/src/Transformer/TraversableToTraversableTransformer.php @@ -0,0 +1,177 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Transformer; + +use Rekalogika\Mapper\Contracts\MainTransformerAwareInterface; +use Rekalogika\Mapper\Contracts\MainTransformerAwareTrait; +use Rekalogika\Mapper\Contracts\TransformerInterface; +use Rekalogika\Mapper\Contracts\TypeMapping; +use Rekalogika\Mapper\Exception\CachedTargetObjectNotFoundException; +use Rekalogika\Mapper\Exception\InvalidArgumentException; +use Rekalogika\Mapper\Exception\MissingMemberKeyTypeException; +use Rekalogika\Mapper\Exception\MissingMemberValueTypeException; +use Rekalogika\Mapper\MainTransformer; +use Rekalogika\Mapper\Model\ObjectCache; +use Rekalogika\Mapper\Util\TypeCheck; +use Rekalogika\Mapper\Util\TypeFactory; +use Symfony\Component\PropertyInfo\Type; + +final class TraversableToTraversableTransformer implements TransformerInterface, MainTransformerAwareInterface +{ + use MainTransformerAwareTrait; + + public function transform( + mixed $source, + mixed $target, + Type $sourceType, + Type $targetType, + array $context + ): mixed { + // get object cache + + if (!isset($context[MainTransformer::OBJECT_CACHE])) { + $objectCache = new ObjectCache(); + $context[MainTransformer::OBJECT_CACHE] = $objectCache; + } else { + /** @var ObjectCache */ + $objectCache = $context[MainTransformer::OBJECT_CACHE]; + } + + // return from cache if already exists + + try { + return $objectCache->getTarget($source, $targetType); + } catch (CachedTargetObjectNotFoundException) { + } + + // The source must be a Traversable or an array (a.k.a. iterable). + + if (!$source instanceof \Traversable && !is_array($source)) { + throw new InvalidArgumentException(sprintf('Source must be instance of "\Traversable" or "array", "%s" given', get_debug_type($source))); + } + + // We cannot work with an existing Traversable value + + if ($target !== null) { + throw new InvalidArgumentException(sprintf('This transformer does not support existing value, "%s" found.', get_debug_type($target))); + } + + // We can't work if the target type doesn't contain the information + // about the type of its member objects + + $targetMemberValueType = $targetType->getCollectionValueTypes(); + + if (count($targetMemberValueType) === 0) { + throw new MissingMemberValueTypeException($sourceType, $targetType); + } + + // Prepare variables for the output loop + + $targetMemberKeyType = $targetType->getCollectionKeyTypes(); + $targetMemberKeyTypeIsMissing = count($targetMemberKeyType) === 0; + $targetMemberKeyTypeIsInt = count($targetMemberKeyType) === 1 + && TypeCheck::isInt($targetMemberKeyType[0]); + + // create generator + + $objectCache->preCache($source, $targetType); + + $target = (function () use ( + $source, + $targetMemberKeyTypeIsInt, + $targetMemberKeyTypeIsMissing, + $sourceType, + $targetType, + $targetMemberKeyType, + $targetMemberValueType, + $context + ): \Traversable { + /** @var mixed $sourcePropertyValue */ + foreach ($source as $sourcePropertyKey => $sourcePropertyValue) { + /** @var mixed $sourcePropertyKey */ + + if (is_string($sourcePropertyKey) || is_int($sourcePropertyKey)) { + // if the key is a simple type: int|string + + if ($targetMemberKeyTypeIsInt && is_string($sourcePropertyKey)) { + // if target has int key type but the source has string key type, + // we discard the source key & use null (i.e. $target[] = $value) + + $targetPropertyKey = null; + } else { + $targetPropertyKey = $sourcePropertyKey; + } + } else { + // If the type of the key is a complex type (not int or string). + // i.e. an ArrayObject can have an object as its key. + + // Refuse to continue if the target key type is not provided + + if ($targetMemberKeyTypeIsMissing) { + throw new MissingMemberKeyTypeException($sourceType, $targetType); + } + + // If provided, we transform the source key to the key type of + // the target + + /** @var mixed */ + $targetPropertyKey = $this->getMainTransformer()->transform( + source: $sourcePropertyKey, + target: null, + targetType: $targetMemberKeyType, + context: $context, + ); + } + + // now transform the source member value to the type of the target + // member value + + /** @var mixed */ + $targetPropertyValue = $this->getMainTransformer()->transform( + source: $sourcePropertyValue, + target: null, + targetType: $targetMemberValueType, + context: $context, + ); + + yield $targetPropertyKey => $targetPropertyValue; + } + })(); + + $objectCache->saveTarget($source, $targetType, $target); + + return $target; + } + + public function getSupportedTransformation(): iterable + { + $sourceTypes = [ + TypeFactory::objectOfClass(\Traversable::class), + TypeFactory::array(), + ]; + + $targetTypes = [ + TypeFactory::objectOfClass(\Traversable::class), + ]; + + foreach ($sourceTypes as $sourceType) { + foreach ($targetTypes as $targetType) { + yield new TypeMapping( + $sourceType, + $targetType, + ); + } + } + } +} diff --git a/src/TypeStringHelper.php b/src/TypeStringHelper.php new file mode 100644 index 00000000..9585cf52 --- /dev/null +++ b/src/TypeStringHelper.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; + +use Rekalogika\Mapper\Model\MixedType; +use Rekalogika\Mapper\Util\TypeUtil; +use Symfony\Component\PropertyInfo\Type; + +class TypeStringHelper +{ + /** + * Example: If the variable type is + * 'IteratorAggregate>', then this method + * will return ['IteratorAggregate>', + * 'IteratorAggregate>', + * 'Traversable>', + * 'Traversable>'] + * + * Note: IteratorAggregate extends Traversable + * + * @param array|Type|MixedType $type + * @return array + */ + public function getApplicableTypeStrings(array|Type|MixedType $type): array + { + if ($type instanceof MixedType) { + $type = ['mixed']; + return $type; + } + + if ($type instanceof Type) { + $type = [$type]; + } + + $typeStrings = []; + + foreach ($type as $type) { + $typeStrings = array_merge($typeStrings, TypeUtil::getAllTypeStrings($type, true)); + } + + return $typeStrings; + } +} diff --git a/src/Util/TypeCheck.php b/src/Util/TypeCheck.php new file mode 100644 index 00000000..05c140e9 --- /dev/null +++ b/src/Util/TypeCheck.php @@ -0,0 +1,256 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Util; + +use Rekalogika\Mapper\Exception\LogicException; +use Rekalogika\Mapper\Model\MixedType; +use Symfony\Component\PropertyInfo\Type; + +class TypeCheck +{ + private function __construct() + { + } + + public static function getDebugType(Type $type): string + { + return TypeUtil::getTypeString($type); + } + + /** + * Checks if the name is a valid class, interface, or enum + * + * @phpstan-assert class-string $class + */ + public static function nameExists(string $class): bool + { + return class_exists($class) + || interface_exists($class) + || enum_exists($class); + } + + public static function isInt(Type $type): bool + { + return $type->getBuiltinType() === Type::BUILTIN_TYPE_INT; + } + + public static function isFloat(Type $type): bool + { + return $type->getBuiltinType() === Type::BUILTIN_TYPE_FLOAT; + } + + public static function isString(Type $type): bool + { + return $type->getBuiltinType() === Type::BUILTIN_TYPE_STRING; + } + + public static function isBool(Type $type): bool + { + return $type->getBuiltinType() === Type::BUILTIN_TYPE_BOOL; + } + + public static function isArray(Type $type): bool + { + return $type->getBuiltinType() === Type::BUILTIN_TYPE_ARRAY; + } + + public static function isObject(Type $type): bool + { + if ($type->getBuiltinType() !== Type::BUILTIN_TYPE_OBJECT) { + return false; + } + + $class = $type->getClassName(); + + if ($class !== null) { + return self::nameExists($class); + } + + return true; + } + + /** + * @param class-string $classes + */ + public static function isObjectOfType(Type $type, string ...$classes): bool + { + if (!self::isObject($type)) { + return false; + } + + $class = $type->getClassName(); + + if ($class === null) { + return false; + } + + foreach ($classes as $classToMatch) { + if (is_a($class, $classToMatch, true)) { + return true; + } + } + + return false; + } + + public static function isEnum(Type $type): bool + { + return $type->getBuiltinType() === Type::BUILTIN_TYPE_OBJECT + && $type->getClassName() !== null + && enum_exists($type->getClassName()); + } + + public static function isResource(Type $type): bool + { + return $type->getBuiltinType() === Type::BUILTIN_TYPE_RESOURCE; + } + + public static function isNull(Type $type): bool + { + return $type->getBuiltinType() === Type::BUILTIN_TYPE_NULL; + } + + public static function isScalar(Type $type): bool + { + return self::isInt($type) + || self::isFloat($type) + || self::isString($type) + || self::isBool($type); + } + + public static function isIterable(Type $type): bool + { + return $type->getBuiltinType() === Type::BUILTIN_TYPE_ITERABLE; + } + + /** + * Check for identity between Types, disregarding collection types + * + * @param Type $type1 + * @param Type $type2 + * @return boolean + */ + public static function isSomewhatIdentical(Type $type1, Type $type2): bool + { + return $type1->getBuiltinType() === $type2->getBuiltinType() + && $type1->getClassName() === $type2->getClassName() + && $type1->isNullable() === $type2->isNullable(); + } + + /** + * @param Type|MixedType $typeToCheck + * @param Type|MixedType $type + */ + public static function isTypeInstanceOf( + Type|MixedType $typeToCheck, + Type|MixedType $type + ): bool { + // instanceof mixed + if ($type instanceof MixedType) { + return true; + } + + // mixed instanceof non mixed + if ($typeToCheck instanceof MixedType) { + return false; + } + + if ($typeToCheck->isNullable() || $type->isNullable()) { + throw new LogicException('Nullable types are not supported'); + } + + if ($typeToCheck->getBuiltinType() !== $type->getBuiltinType()) { + return false; + } + + // if not an object this is as far as we can go + if ($typeToCheck->getBuiltinType() !== Type::BUILTIN_TYPE_OBJECT) { + return true; + } + + $typeToCheckClassName = $typeToCheck->getClassName(); + $className = $type->getClassName(); + + // anyobject instanceof object + if ($className === null) { + return true; + } + + // object instanceof specificobject + if ($typeToCheckClassName === null) { + return false; + } + + if (!self::nameExists($className)) { + throw new LogicException(sprintf('Class "%s" does not exist', $className)); + } + + if (!self::nameExists($typeToCheckClassName)) { + throw new LogicException(sprintf('Class "%s" does not exist', $typeToCheckClassName)); + } + + return is_a($typeToCheckClassName, $className, true); + } + + /** + * @todo support generics + */ + public static function isVariableInstanceOf(mixed $variable, Type $type): bool + { + if (self::isObject($type)) { + $class = $type->getClassName(); + + if ($class !== null) { + return self::nameExists($class) + && $variable instanceof $class; + } + + return true; + } + + if (self::isInt($type)) { + return is_int($variable); + } + + if (self::isFloat($type)) { + return is_float($variable); + } + + if (self::isString($type)) { + return is_string($variable); + } + + if (self::isBool($type)) { + return is_bool($variable); + } + + if (self::isArray($type)) { + return is_array($variable); + } + + if (self::isResource($type)) { + return is_resource($variable); + } + + if (self::isNull($type)) { + return is_null($variable); + } + + if (self::isIterable($type)) { + return is_iterable($variable); + } + + return false; + } +} diff --git a/src/Util/TypeFactory.php b/src/Util/TypeFactory.php new file mode 100644 index 00000000..ab060045 --- /dev/null +++ b/src/Util/TypeFactory.php @@ -0,0 +1,232 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Util; + +use Rekalogika\Mapper\Exception\InvalidArgumentException; +use Rekalogika\Mapper\Model\MixedType; +use Symfony\Component\PropertyInfo\Type; + +/** + * Convenience factory to instantiate Type objects + */ +class TypeFactory +{ + private function __construct() + { + } + + /** + * Convert a string type notation to a Type object. Currently does not do + * anything fancy. Just supports the built-in types and class names. + * + * @return Type|MixedType + */ + public static function fromString(string $string): Type|MixedType + { + $result = match ($string) { + 'mixed' => self::mixed(), + 'null' => self::null(), + 'string' => self::string(), + 'int' => self::int(), + 'float' => self::float(), + 'bool' => self::bool(), + 'resource' => self::resource(), + 'true' => self::true(), + 'false' => self::false(), + 'callable' => self::callable(), + 'array' => self::array(), + 'object' => self::object(), + 'iterable' => self::iterable(), + default => null, + }; + + if ($result !== null) { + return $result; + } + + // remove possible nullable prefix + $string = preg_replace('/^\?/', '', $string) ?? throw new InvalidArgumentException(sprintf('"%s" does not appear to be a valid type.', $string)); + + // remove generics notation + $string = preg_replace('/<.*>/', '', $string) ?? throw new InvalidArgumentException(sprintf('"%s" does not appear to be a valid type.', $string)); + + if (!TypeCheck::nameExists($string)) { + throw new InvalidArgumentException(sprintf('"%s" does not appear to be a valid type.', $string)); + } + + return self::objectOfClass($string); + } + + public static function fromBuiltIn(string $builtIn): Type + { + return new Type( + builtinType: $builtIn + ); + } + + public static function mixed(): MixedType + { + return MixedType::instance(); + } + + public static function null(): Type + { + return new Type( + builtinType: 'null' + ); + } + + public static function scalar(string $type): Type + { + if (!in_array($type, ['string', 'int', 'float', 'bool'], true)) { + throw new InvalidArgumentException(sprintf('"%s" is not a valid scalar type.', $type)); + } + + return new Type( + builtinType: $type + ); + } + + public static function string(): Type + { + return new Type( + builtinType: 'string' + ); + } + + public static function int(): Type + { + return new Type( + builtinType: 'int' + ); + } + + public static function float(): Type + { + return new Type( + builtinType: 'float' + ); + } + + public static function bool(): Type + { + return new Type( + builtinType: 'bool' + ); + } + + public static function resource(): Type + { + return new Type( + builtinType: 'resource' + ); + } + + public static function true(): Type + { + return new Type( + builtinType: 'true' + ); + } + + public static function false(): Type + { + return new Type( + builtinType: 'false' + ); + } + + public static function callable(): Type + { + return new Type( + builtinType: 'callable' + ); + } + + public static function array(): Type + { + return new Type( + builtinType: 'array', + ); + } + + public static function iterable(): Type + { + return new Type( + builtinType: 'iterable', + ); + } + + public static function arrayWithKeyValue( + null|Type $keyType, + null|Type $valueType + ): Type { + return new Type( + builtinType: 'array', + collection: true, + collectionKeyType: $keyType, + collectionValueType: $valueType + ); + } + + public static function arrayOfObject(string $class): Type + { + if (!TypeCheck::nameExists($class)) { + throw new InvalidArgumentException(sprintf('"%s" is not a valid class.', $class)); + } + + return new Type( + builtinType: 'array', + collection: true, + collectionValueType: self::objectOfClass($class) + ); + } + + public static function object(): Type + { + return new Type( + builtinType: 'object', + ); + } + + public static function objectOfClass(string $class): Type + { + if (!TypeCheck::nameExists($class)) { + throw new InvalidArgumentException(sprintf('"%s" is not a valid class.', $class)); + } + + return new Type( + builtinType: 'object', + class: $class + ); + } + + public static function objectWithKeyValue( + ?string $class, + null|Type $keyType, + null|Type $valueType + ): Type { + if ($class === null || !TypeCheck::nameExists($class)) { + throw new InvalidArgumentException(sprintf('"%s" is not a valid class.', $class === null ? 'null' : $class)); + } + + return new Type( + builtinType: 'object', + class: $class, + collection: true, + collectionKeyType: $keyType, + collectionValueType: $valueType + ); + } +} diff --git a/src/Util/TypeUtil.php b/src/Util/TypeUtil.php new file mode 100644 index 00000000..d9569393 --- /dev/null +++ b/src/Util/TypeUtil.php @@ -0,0 +1,356 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Util; + +use Rekalogika\Mapper\Exception\InvalidArgumentException; +use Rekalogika\Mapper\Model\MixedType; +use Symfony\Component\PropertyInfo\Type; + +class TypeUtil +{ + private function __construct() + { + } + + public static function guessTypeFromVariable(mixed $variable): Type + { + if (is_object($variable)) { + return TypeFactory::objectOfClass($variable::class); + } + + if (is_null($variable)) { + return TypeFactory::null(); + } + + if (is_array($variable)) { + return TypeFactory::array(); + } + + if (is_bool($variable)) { + return TypeFactory::bool(); + } + + if (is_int($variable)) { + return TypeFactory::int(); + } + + if (is_float($variable)) { + return TypeFactory::float(); + } + + if (is_string($variable)) { + return TypeFactory::string(); + } + + if (is_resource($variable)) { + return TypeFactory::resource(); + } + + throw new InvalidArgumentException(sprintf( + 'Cannot determine type of variable "%s"', + get_debug_type($variable), + )); + } + + /** + * Simple Type is a type that is not nullable, and does not have more + * than one key type or value type. + */ + public static function isSimpleType(Type $type): bool + { + if ($type->isNullable()) { + return false; + } + + // iterable is Traversable|array + if (TypeCheck::isIterable($type)) { + return false; + } + + if ($type->isCollection()) { + $keyTypes = $type->getCollectionKeyTypes(); + $valueTypes = $type->getCollectionValueTypes(); + + if (count($keyTypes) != 1) { + return false; + } + + if (count($valueTypes) != 1) { + return false; + } + + return self::isSimpleType($keyTypes[0]) + && self::isSimpleType($valueTypes[0]); + } + + return true; + } + + /** + * Gets all the possible simple types from a Type + * + * @param Type|array $type + * @return array + */ + public static function getSimpleTypes(Type|array $type, bool $withParents = false): array + { + if (!is_array($type)) { + $type = [$type]; + } + + return self::getTypePermutations($type, withParents: $withParents); + } + + /** + * Generates all the possible simple type permutations from an array of + * Types + * + * @param array $types + * @return array + */ + public static function getTypePermutations( + array $types, + bool $withParents = false, + ): array { + $permutations = []; + + $hasNullable = false; + + if ($withParents) { + $newTypes = []; + + foreach ($types as $type) { + if (TypeCheck::isIterable($type)) { + throw new InvalidArgumentException( + 'Iterable are not supported in source or target specification. Use Traversable or array instead.' + ); + } + + if ( + TypeCheck::isObject($type) + && null !== $type->getClassName() + ) { + /** @var class-string */ + $typeClass = $type->getClassName(); + + foreach (self::getAllClassesFromObject($typeClass) as $class) { + $newTypes[] = new Type( + builtinType: $type->getBuiltinType(), + nullable: $type->isNullable(), + class: $class, + collection: $type->isCollection(), + collectionKeyType: $type->getCollectionKeyTypes(), + collectionValueType: $type->getCollectionValueTypes(), + ); + } + + $newTypes[] = new Type( + builtinType: 'object', + nullable: $type->isNullable(), + ); + } else { + $newTypes[] = $type; + } + } + + $types = $newTypes; + } + + foreach ($types as $type) { + if ($type->isNullable()) { + $hasNullable = true; + } + + if (!$type->isCollection()) { + // iterable is Traversable|array + if (TypeCheck::isIterable($type)) { + $permutations[] = TypeFactory::array(); + $permutations[] = TypeFactory::objectOfClass(\Traversable::class); + } else { + $permutations[] = new Type( + builtinType: $type->getBuiltinType(), + class: $type->getClassName(), + ); + } + + continue; + } + + // the following is only applicable for collections + + $keyTypes = self::getTypePermutations( + $type->getCollectionKeyTypes(), + $withParents + ); + $valueTypes = self::getTypePermutations( + $type->getCollectionValueTypes(), + $withParents + ); + + // 'mixed' key and value types + if ($withParents) { + $keyTypes[] = null; + $valueTypes[] = null; + } + + if (count($keyTypes) === 0) { + $keyTypes = [null]; + } + + if (count($valueTypes) === 0) { + $valueTypes = [null]; + } + + foreach ($keyTypes as $keyType) { + foreach ($valueTypes as $valueType) { + // iterable is Traversable|array + if (TypeCheck::isIterable($type)) { + $permutations[] = TypeFactory::arrayWithKeyValue( + $keyType, + $valueType + ); + $permutations[] = TypeFactory::objectWithKeyValue( + \Traversable::class, + $keyType, + $valueType + ); + } else { + $permutations[] = new Type( + builtinType: $type->getBuiltinType(), + class: $type->getClassName(), + collection: true, + collectionKeyType: $keyType, + collectionValueType: $valueType, + ); + } + } + } + + if ($withParents) { + if (TypeCheck::isIterable($type)) { + $permutations[] = TypeFactory::array(); + $permutations[] = TypeFactory::objectOfClass(\Traversable::class); + } else { + $permutations[] = new Type( + builtinType: $type->getBuiltinType(), + class: $type->getClassName(), + collection: false, + ); + } + } + } + + if ($hasNullable) { + $permutations[] = TypeFactory::null(); + } + + return $permutations; + } + + /** + * @param Type|MixedType $type + * @return string + */ + public static function getTypeString(Type|MixedType $type): string + { + if ($type instanceof MixedType) { + return 'mixed'; + } + + $typeString = $type->getBuiltinType(); + if ($typeString === 'object') { + $typeString = $type->getClassName(); + if (null === $typeString) { + $typeString = 'object'; + } + } + + if ($type->isCollection()) { + $keyTypes = $type->getCollectionKeyTypes(); + + if ($keyTypes) { + $keyTypesString = []; + foreach ($keyTypes as $keyType) { + $keyTypesString[] = self::getTypeString($keyType); + } + $keyTypesString = implode('|', $keyTypesString); + } else { + $keyTypesString = 'mixed'; + } + + $valueTypes = $type->getCollectionValueTypes(); + + if ($valueTypes) { + $valueTypesString = []; + foreach ($valueTypes as $valueType) { + $valueTypesString[] = self::getTypeString($valueType); + } + $valueTypesString = implode('|', $valueTypesString); + } else { + $valueTypesString = 'mixed'; + } + + $typeString .= sprintf('<%s,%s>', $keyTypesString, $valueTypesString); + } + + return $typeString; + } + + /** + * @return array + */ + public static function getAllTypeStrings( + Type $type, + bool $withParents = false + ): array { + $simpleTypes = self::getSimpleTypes($type, $withParents); + + $typeStrings = []; + + foreach ($simpleTypes as $simpleType) { + $typeStrings[] = self::getTypeString($simpleType); + } + + $typeStrings = array_unique($typeStrings); + + if ($withParents) { + $typeStrings[] = 'mixed'; + } + + return $typeStrings; + } + + /** + * @param object|class-string $objectOrClass + * @return array + */ + public static function getAllClassesFromObject( + object|string $objectOrClass + ): array { + $classes = []; + + $class = is_object($objectOrClass) ? $objectOrClass::class : $objectOrClass; + $classes[] = $class; + + foreach (class_parents($class) as $parentClass) { + $classes[] = $parentClass; + } + + foreach (class_implements($class) as $interface) { + $classes[] = $interface; + } + + return $classes; + } +} diff --git a/tests/Common/AbstractIntegrationTest.php b/tests/Common/AbstractIntegrationTest.php new file mode 100644 index 00000000..3e23bdb3 --- /dev/null +++ b/tests/Common/AbstractIntegrationTest.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\Common; + +use PHPUnit\Framework\TestCase; +use Rekalogika\Mapper\MainTransformer; +use Rekalogika\Mapper\MapperInterface; + +abstract class AbstractIntegrationTest extends TestCase +{ + protected MapperTestFactory $factory; + protected MapperInterface $mapper; + protected MainTransformer $mainTransformer; + + public function setUp(): void + { + $this->factory = new MapperTestFactory(); + $this->mapper = $this->factory->getMapper(); + $this->mainTransformer = $this->factory->getMainTransformer(); + } +} diff --git a/tests/Common/MapperTestFactory.php b/tests/Common/MapperTestFactory.php new file mode 100644 index 00000000..7b4e23cf --- /dev/null +++ b/tests/Common/MapperTestFactory.php @@ -0,0 +1,42 @@ + + * + * 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\MainTransformer; +use Rekalogika\Mapper\MapperFactory; +use Rekalogika\Mapper\Mapping\MappingFactory; +use Rekalogika\Mapper\TypeStringHelper; + +class MapperTestFactory extends MapperFactory +{ + public function getTransformersIterator(): iterable + { + return parent::getTransformersIterator(); + } + + public function getTypeStringHelper(): TypeStringHelper + { + return parent::getTypeStringHelper(); + } + + public function getMainTransformer(): MainTransformer + { + return parent::getMainTransformer(); + } + + public function getMappingFactory(): MappingFactory + { + return parent::getMappingFactory(); + } +} diff --git a/tests/Common/TestKernel.php b/tests/Common/TestKernel.php new file mode 100644 index 00000000..7b4a197d --- /dev/null +++ b/tests/Common/TestKernel.php @@ -0,0 +1,78 @@ + + * + * 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\RekalogikaMapperBundle; +use Symfony\Bundle\FrameworkBundle\FrameworkBundle; +use Symfony\Component\Config\Loader\LoaderInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\HttpKernel\Kernel; + +class TestKernel extends Kernel +{ + /** + * @param array $config + */ + public function __construct(private array $config = []) + { + parent::__construct('test', true); + } + + public function registerBundles(): iterable + { + yield new FrameworkBundle(); + yield new RekalogikaMapperBundle(); + } + + public function registerContainerConfiguration(LoaderInterface $loader): void + { + $loader->load(function (ContainerBuilder $container) { + $container->loadFromExtension('framework', [ + 'http_method_override' => false, + 'handle_all_throwables' => true, + 'php_errors' => [ + 'log' => true, + ], + ]); + + $container->loadFromExtension('rekalogika_mapper', $this->config); + }); + } + + /** + * @return iterable + */ + public static function getServiceIds(): iterable + { + yield 'rekalogika.mapper.property_info'; + yield 'rekalogika.mapper.cache.property_info'; + yield 'rekalogika.mapper.property_info.cache'; + yield 'rekalogika.mapper.transformer.scalar_to_scalar'; + yield 'rekalogika.mapper.transformer.datetime'; + yield 'rekalogika.mapper.transformer.string_to_backed_enum'; + yield 'rekalogika.mapper.transformer.object_to_string'; + yield 'rekalogika.mapper.transformer.traversable_to_arrayaccess'; + yield 'rekalogika.mapper.transformer.traversable_to_traversable'; + yield 'rekalogika.mapper.transformer.object_to_array'; + yield 'rekalogika.mapper.transformer.array_to_object'; + yield 'rekalogika.mapper.transformer.object_to_object'; + yield 'rekalogika.mapper.transformer.null'; + yield 'rekalogika.mapper.type_string_helper'; + yield 'rekalogika.mapper.mapping_factory'; + yield 'rekalogika.mapper.main_transformer'; + yield 'rekalogika.mapper.mapper'; + yield 'rekalogika.mapper.command.mapping'; + yield 'rekalogika.mapper.command.try'; + } +} diff --git a/tests/Fixtures/ArrayLike/ObjectWithArrayAccessProperty.php b/tests/Fixtures/ArrayLike/ObjectWithArrayAccessProperty.php new file mode 100644 index 00000000..7da13c80 --- /dev/null +++ b/tests/Fixtures/ArrayLike/ObjectWithArrayAccessProperty.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\Tests\Fixtures\ArrayLike; + +use Rekalogika\Mapper\Tests\Fixtures\Scalar\ObjectWithScalarProperties; + +class ObjectWithArrayAccessProperty +{ + /** + * @var \ArrayAccess + */ + public \ArrayAccess $property; + + public function __construct() + { + $this->property = new \ArrayObject([ + 1 => new ObjectWithScalarProperties(), + 2 => new ObjectWithScalarProperties(), + 3 => new ObjectWithScalarProperties(), + ]); + } + +} diff --git a/tests/Fixtures/ArrayLike/ObjectWithArrayProperty.php b/tests/Fixtures/ArrayLike/ObjectWithArrayProperty.php new file mode 100644 index 00000000..4a112e9d --- /dev/null +++ b/tests/Fixtures/ArrayLike/ObjectWithArrayProperty.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\Tests\Fixtures\ArrayLike; + +use Rekalogika\Mapper\Tests\Fixtures\Scalar\ObjectWithScalarProperties; + +class ObjectWithArrayProperty +{ + /** + * @var array + */ + public array $property; + + public function __construct() + { + $this->property = [ + new ObjectWithScalarProperties(), + new ObjectWithScalarProperties(), + new ObjectWithScalarProperties(), + ]; + } + +} diff --git a/tests/Fixtures/ArrayLike/ObjectWithArrayPropertyWithStringKey.php b/tests/Fixtures/ArrayLike/ObjectWithArrayPropertyWithStringKey.php new file mode 100644 index 00000000..ccabbe07 --- /dev/null +++ b/tests/Fixtures/ArrayLike/ObjectWithArrayPropertyWithStringKey.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\Tests\Fixtures\ArrayLike; + +use Rekalogika\Mapper\Tests\Fixtures\Scalar\ObjectWithScalarProperties; + +class ObjectWithArrayPropertyWithStringKey +{ + /** + * @var array + */ + public array $property; + + public function __construct() + { + $this->property = [ + 'a' => new ObjectWithScalarProperties(), + 'b' => new ObjectWithScalarProperties(), + 'c' => new ObjectWithScalarProperties(), + ]; + } + +} diff --git a/tests/Fixtures/ArrayLike/ObjectWithTraversableProperties.php b/tests/Fixtures/ArrayLike/ObjectWithTraversableProperties.php new file mode 100644 index 00000000..223baaf9 --- /dev/null +++ b/tests/Fixtures/ArrayLike/ObjectWithTraversableProperties.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\ArrayLike; + +use Rekalogika\Mapper\Tests\Fixtures\Scalar\ObjectWithScalarProperties; + +class ObjectWithTraversableProperties +{ + /** + * @var \Traversable + */ + public \Traversable $property; + + public function __construct() + { + $this->property = (function () { + yield new ObjectWithScalarProperties(); + yield new ObjectWithScalarProperties(); + yield new ObjectWithScalarProperties(); + })(); + } +} diff --git a/tests/Fixtures/ArrayLikeDto/ObjectWithArrayAccessPropertyDto.php b/tests/Fixtures/ArrayLikeDto/ObjectWithArrayAccessPropertyDto.php new file mode 100644 index 00000000..54ffac18 --- /dev/null +++ b/tests/Fixtures/ArrayLikeDto/ObjectWithArrayAccessPropertyDto.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Fixtures\ArrayLikeDto; + +use Rekalogika\Mapper\Tests\Fixtures\Scalar\ObjectWithScalarPropertiesDto; + +class ObjectWithArrayAccessPropertyDto +{ + /** + * @var ?\ArrayAccess + */ + public ?\ArrayAccess $property = null; + + public static function initialized(): self + { + $instance = new self(); + + $instance->property = new \ArrayObject([ + 1 => new ObjectWithScalarPropertiesDto(), + 2 => new ObjectWithScalarPropertiesDto(), + 3 => new ObjectWithScalarPropertiesDto(), + ]); + + return $instance; + } +} diff --git a/tests/Fixtures/ArrayLikeDto/ObjectWithArrayPropertyDto.php b/tests/Fixtures/ArrayLikeDto/ObjectWithArrayPropertyDto.php new file mode 100644 index 00000000..994c2832 --- /dev/null +++ b/tests/Fixtures/ArrayLikeDto/ObjectWithArrayPropertyDto.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Fixtures\ArrayLikeDto; + +use Rekalogika\Mapper\Tests\Fixtures\Scalar\ObjectWithScalarPropertiesDto; + +class ObjectWithArrayPropertyDto +{ + /** + * @var ?array + */ + public ?array $property = null; + + public static function initialized(): self + { + $instance = new self(); + + $instance->property = [ + 1 => new ObjectWithScalarPropertiesDto(), + 2 => new ObjectWithScalarPropertiesDto(), + 3 => new ObjectWithScalarPropertiesDto(), + ]; + + return $instance; + } +} diff --git a/tests/Fixtures/ArrayLikeDto/ObjectWithArrayPropertyDtoWithIntKey.php b/tests/Fixtures/ArrayLikeDto/ObjectWithArrayPropertyDtoWithIntKey.php new file mode 100644 index 00000000..bd072d77 --- /dev/null +++ b/tests/Fixtures/ArrayLikeDto/ObjectWithArrayPropertyDtoWithIntKey.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Fixtures\ArrayLikeDto; + +use Rekalogika\Mapper\Tests\Fixtures\Scalar\ObjectWithScalarPropertiesDto; + +class ObjectWithArrayPropertyDtoWithIntKey +{ + /** + * @var ?array + */ + public ?array $property = null; +} diff --git a/tests/Fixtures/ArrayLikeDto/ObjectWithTraversablePropertyDto.php b/tests/Fixtures/ArrayLikeDto/ObjectWithTraversablePropertyDto.php new file mode 100644 index 00000000..1c05e43f --- /dev/null +++ b/tests/Fixtures/ArrayLikeDto/ObjectWithTraversablePropertyDto.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\Fixtures\ArrayLikeDto; + +use Rekalogika\Mapper\Tests\Fixtures\Scalar\ObjectWithScalarPropertiesDto; + +class ObjectWithTraversablePropertyDto +{ + /** + * @var ?\Traversable + */ + public ?\Traversable $property = null; +} diff --git a/tests/Fixtures/ClassImplementingStringable.php b/tests/Fixtures/ClassImplementingStringable.php new file mode 100644 index 00000000..b97b6ec9 --- /dev/null +++ b/tests/Fixtures/ClassImplementingStringable.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; + +class ClassImplementingStringable implements \Stringable +{ + public function __toString(): string + { + return 'foo'; + } +} diff --git a/tests/Fixtures/Recursive/ChildObject.php b/tests/Fixtures/Recursive/ChildObject.php new file mode 100644 index 00000000..7aa07424 --- /dev/null +++ b/tests/Fixtures/Recursive/ChildObject.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\Recursive; + +class ChildObject +{ + public ?ParentObject $parent = null; + public string $string = 'child'; +} diff --git a/tests/Fixtures/Recursive/ObjectWithRefToItself.php b/tests/Fixtures/Recursive/ObjectWithRefToItself.php new file mode 100644 index 00000000..8f10201d --- /dev/null +++ b/tests/Fixtures/Recursive/ObjectWithRefToItself.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\Recursive; + +class ObjectWithRefToItself +{ + public ?self $ref = null; + public ?string $string = 'string'; +} diff --git a/tests/Fixtures/Recursive/ParentObject.php b/tests/Fixtures/Recursive/ParentObject.php new file mode 100644 index 00000000..49a275af --- /dev/null +++ b/tests/Fixtures/Recursive/ParentObject.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\Recursive; + +class ParentObject +{ + public ?ChildObject $child = null; + public string $string = 'parent'; +} diff --git a/tests/Fixtures/RecursiveDto/ChildObjectDto.php b/tests/Fixtures/RecursiveDto/ChildObjectDto.php new file mode 100644 index 00000000..87ef5aab --- /dev/null +++ b/tests/Fixtures/RecursiveDto/ChildObjectDto.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\RecursiveDto; + +class ChildObjectDto +{ + public ?ParentObjectDto $parent = null; + public ?string $string = null; +} diff --git a/tests/Fixtures/RecursiveDto/ObjectWithRefToItselfDto.php b/tests/Fixtures/RecursiveDto/ObjectWithRefToItselfDto.php new file mode 100644 index 00000000..8927bed9 --- /dev/null +++ b/tests/Fixtures/RecursiveDto/ObjectWithRefToItselfDto.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\RecursiveDto; + +class ObjectWithRefToItselfDto +{ + public ?self $ref = null; + public ?string $string = null; +} diff --git a/tests/Fixtures/RecursiveDto/ParentObjectDto.php b/tests/Fixtures/RecursiveDto/ParentObjectDto.php new file mode 100644 index 00000000..4a217687 --- /dev/null +++ b/tests/Fixtures/RecursiveDto/ParentObjectDto.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\RecursiveDto; + +class ParentObjectDto +{ + public ?ChildObjectDto $child = null; + public ?string $string = null; +} diff --git a/tests/Fixtures/Scalar/ObjectWithBoolPropertiesDto.php b/tests/Fixtures/Scalar/ObjectWithBoolPropertiesDto.php new file mode 100644 index 00000000..492951e8 --- /dev/null +++ b/tests/Fixtures/Scalar/ObjectWithBoolPropertiesDto.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\Scalar; + +class ObjectWithBoolPropertiesDto +{ + public ?bool $a = null; + public ?bool $b = null; + public ?bool $c = null; + public ?bool $d = null; +} diff --git a/tests/Fixtures/Scalar/ObjectWithFloatPropertiesDto.php b/tests/Fixtures/Scalar/ObjectWithFloatPropertiesDto.php new file mode 100644 index 00000000..f88e0def --- /dev/null +++ b/tests/Fixtures/Scalar/ObjectWithFloatPropertiesDto.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\Scalar; + +class ObjectWithFloatPropertiesDto +{ + public ?float $a = null; + public ?float $b = null; + public ?float $c = null; + public ?float $d = null; +} diff --git a/tests/Fixtures/Scalar/ObjectWithIntPropertiesDto.php b/tests/Fixtures/Scalar/ObjectWithIntPropertiesDto.php new file mode 100644 index 00000000..39ae280c --- /dev/null +++ b/tests/Fixtures/Scalar/ObjectWithIntPropertiesDto.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\Scalar; + +class ObjectWithIntPropertiesDto +{ + public ?int $a = null; + public ?int $b = null; + public ?int $c = null; + public ?int $d = null; +} diff --git a/tests/Fixtures/Scalar/ObjectWithScalarProperties.php b/tests/Fixtures/Scalar/ObjectWithScalarProperties.php new file mode 100644 index 00000000..f12e6843 --- /dev/null +++ b/tests/Fixtures/Scalar/ObjectWithScalarProperties.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\Scalar; + +class ObjectWithScalarProperties +{ + public int $a = 1; + public string $b = 'string'; + public bool $c = true; + public float $d = 1.1; +} diff --git a/tests/Fixtures/Scalar/ObjectWithScalarPropertiesDto.php b/tests/Fixtures/Scalar/ObjectWithScalarPropertiesDto.php new file mode 100644 index 00000000..1bd1bfda --- /dev/null +++ b/tests/Fixtures/Scalar/ObjectWithScalarPropertiesDto.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\Scalar; + +class ObjectWithScalarPropertiesDto +{ + public ?int $a = null; + public ?string $b = null; + public ?bool $c = null; + public ?float $d = null; +} diff --git a/tests/Fixtures/Scalar/ObjectWithStringPropertiesDto.php b/tests/Fixtures/Scalar/ObjectWithStringPropertiesDto.php new file mode 100644 index 00000000..428b4b41 --- /dev/null +++ b/tests/Fixtures/Scalar/ObjectWithStringPropertiesDto.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\Scalar; + +class ObjectWithStringPropertiesDto +{ + public ?string $a = null; + public ?string $b = null; + public ?string $c = null; + public ?string $d = null; +} diff --git a/tests/Fixtures/SomeBackedEnum.php b/tests/Fixtures/SomeBackedEnum.php new file mode 100644 index 00000000..1635a58d --- /dev/null +++ b/tests/Fixtures/SomeBackedEnum.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; + +enum SomeBackedEnum: string +{ + case Foo = 'foo'; + case Bar = 'bar'; +} diff --git a/tests/Fixtures/SomeEnum.php b/tests/Fixtures/SomeEnum.php new file mode 100644 index 00000000..9e900e24 --- /dev/null +++ b/tests/Fixtures/SomeEnum.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; + +enum SomeEnum +{ + case Foo; + case Bar; +} diff --git a/tests/FrameworkTest/FrameworkTest.php b/tests/FrameworkTest/FrameworkTest.php new file mode 100644 index 00000000..96d3f8e7 --- /dev/null +++ b/tests/FrameworkTest/FrameworkTest.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\FrameworkTest; + +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Rekalogika\Mapper\Tests\Common\TestKernel; + +class FrameworkTest extends TestCase +{ + private ?ContainerInterface $container = null; + + public function setUp(): void + { + $kernel = new TestKernel(); + $kernel->boot(); + $this->container = $kernel->getContainer(); + } + + public function testWiring(): void + { + + foreach (TestKernel::getServiceIds() as $serviceId) { + $service = $this->container?->get('test.' . $serviceId); + + $this->assertIsObject($service); + } + } + +} diff --git a/tests/IntegrationTest/ArrayAndObjectMappingTest.php b/tests/IntegrationTest/ArrayAndObjectMappingTest.php new file mode 100644 index 00000000..6f624c42 --- /dev/null +++ b/tests/IntegrationTest/ArrayAndObjectMappingTest.php @@ -0,0 +1,50 @@ + + * + * 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\AbstractIntegrationTest; +use Rekalogika\Mapper\Tests\Fixtures\Scalar\ObjectWithScalarProperties; +use Rekalogika\Mapper\Tests\Fixtures\Scalar\ObjectWithScalarPropertiesDto; + +class ArrayAndObjectMappingTest extends AbstractIntegrationTest +{ + public function testArrayToObject(): void + { + $array = [ + 'a' => 1, + 'b' => 'string', + 'c' => true, + 'd' => 1.1, + ]; + $dto = $this->mapper->map($array, ObjectWithScalarPropertiesDto::class); + + $this->assertEquals(1, $dto->a); + $this->assertEquals('string', $dto->b); + $this->assertEquals(true, $dto->c); + $this->assertEquals(1.1, $dto->d); + } + + public function testObjectToArray(): void + { + $class = new ObjectWithScalarProperties(); + + $array = $this->mapper->map($class, 'array'); + + $this->assertEquals(1, $array['a'] ?? null); + $this->assertEquals('string', $array['b'] ?? null); + $this->assertEquals(true, $array['c'] ?? null); + $this->assertEquals(1.1, $array['d'] ?? null); + } + +} diff --git a/tests/IntegrationTest/DateTimeMappingTest.php b/tests/IntegrationTest/DateTimeMappingTest.php new file mode 100644 index 00000000..64bbe149 --- /dev/null +++ b/tests/IntegrationTest/DateTimeMappingTest.php @@ -0,0 +1,56 @@ + + * + * 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\AbstractIntegrationTest; +use Symfony\Component\Clock\DatePoint; + +class DateTimeMappingTest extends AbstractIntegrationTest +{ + /** + * @param class-string $sourceClass + * @param class-string $targetClass + * @dataProvider dateTimeProvider + */ + public function testDateTime(string $sourceClass, string $targetClass): void + { + /** @psalm-suppress MixedMethodCall */ + $source = new $sourceClass('2021-01-01 00:00:00'); + $target = $this->mapper->map($source, $targetClass); + + $this->assertInstanceOf($targetClass, $target); + } + + /** + * @return iterable,1:class-string<\DateTimeInterface>}> + */ + public function dateTimeProvider(): iterable + { + $types = [ + \DateTimeInterface::class, + \DateTime::class, + \DateTimeImmutable::class, + DatePoint::class, + ]; + + foreach ($types as $sourceClass) { + foreach ($types as $targetClass) { + if ($sourceClass === \DateTimeInterface::class) { + continue; + } + yield sprintf("%s to %s", $sourceClass, $targetClass) => [$sourceClass, $targetClass]; + } + } + } +} diff --git a/tests/IntegrationTest/ObjectEnumStringMappingTest.php b/tests/IntegrationTest/ObjectEnumStringMappingTest.php new file mode 100644 index 00000000..170f3ac0 --- /dev/null +++ b/tests/IntegrationTest/ObjectEnumStringMappingTest.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\Tests\IntegrationTest; + +use Rekalogika\Mapper\Tests\Common\AbstractIntegrationTest; +use Rekalogika\Mapper\Tests\Fixtures\ClassImplementingStringable; +use Rekalogika\Mapper\Tests\Fixtures\SomeBackedEnum; +use Rekalogika\Mapper\Tests\Fixtures\SomeEnum; + +class ObjectEnumStringMappingTest extends AbstractIntegrationTest +{ + public function testObjectToString(): void + { + $object = new ClassImplementingStringable(); + $result = $this->mapper->map($object, 'string'); + + $this->assertSame('foo', $result); + } + + public function testBackedEnumToString(): void + { + $enum = SomeBackedEnum::Foo; + $result = $this->mapper->map($enum, 'string'); + + $this->assertSame('foo', $result); + } + + public function testUnitEnumToString(): void + { + $enum = SomeEnum::Foo; + $result = $this->mapper->map($enum, 'string'); + + $this->assertSame('Foo', $result); + } +} diff --git a/tests/IntegrationTest/RecursionTest.php b/tests/IntegrationTest/RecursionTest.php new file mode 100644 index 00000000..c06c9c56 --- /dev/null +++ b/tests/IntegrationTest/RecursionTest.php @@ -0,0 +1,82 @@ + + * + * 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\AbstractIntegrationTest; +use Rekalogika\Mapper\Tests\Fixtures\Recursive\ChildObject; +use Rekalogika\Mapper\Tests\Fixtures\Recursive\ObjectWithRefToItself; +use Rekalogika\Mapper\Tests\Fixtures\Recursive\ParentObject; +use Rekalogika\Mapper\Tests\Fixtures\RecursiveDto\ChildObjectDto; +use Rekalogika\Mapper\Tests\Fixtures\RecursiveDto\ObjectWithRefToItselfDto; +use Rekalogika\Mapper\Tests\Fixtures\RecursiveDto\ParentObjectDto; + +class RecursionTest extends AbstractIntegrationTest +{ + public function testParentChild(): void + { + $parent = new ParentObject(); + $child = new ChildObject(); + $parent->child = $child; + $child->parent = $parent; + + $result = $this->mapper->map($parent, ParentObjectDto::class); + + $this->assertInstanceOf(ParentObjectDto::class, $result); + $this->assertInstanceOf(ChildObjectDto::class, $result->child); + $this->assertInstanceOf(ParentObjectDto::class, $result->child->parent); + $this->assertSame($result, $result->child->parent); + } + + public function testCircular(): void + { + $object1 = new ObjectWithRefToItself(); + $object1->string = '1'; + + $object2 = new ObjectWithRefToItself(); + $object2->string = '2'; + $object1->ref = $object2; + + $object3 = new ObjectWithRefToItself(); + $object3->string = '3'; + $object2->ref = $object3 + ; + $object4 = new ObjectWithRefToItself(); + $object4->string = '4'; + $object3->ref = $object4; + + $object5 = new ObjectWithRefToItself(); + $object5->string = '5'; + $object4->ref = $object5; + $object5->ref = $object1; + + $result = $this->mapper->map($object1, ObjectWithRefToItselfDto::class); + + $this->assertInstanceOf(ObjectWithRefToItselfDto::class, $result); + $this->assertInstanceOf(ObjectWithRefToItselfDto::class, $result->ref); + $this->assertInstanceOf(ObjectWithRefToItselfDto::class, $result->ref->ref); + $this->assertInstanceOf(ObjectWithRefToItselfDto::class, $result->ref->ref->ref); + $this->assertInstanceOf(ObjectWithRefToItselfDto::class, $result->ref->ref->ref->ref); + $this->assertSame($result, $result->ref->ref->ref->ref->ref); + + $this->assertSame('1', $result->string); + // @phpstan-ignore-next-line + $this->assertSame('2', $result->ref->string); + // @phpstan-ignore-next-line + $this->assertSame('3', $result->ref->ref->string); + // @phpstan-ignore-next-line + $this->assertSame('4', $result->ref->ref->ref->string); + // @phpstan-ignore-next-line + $this->assertSame('5', $result->ref->ref->ref->ref->string); + } +} diff --git a/tests/IntegrationTest/ScalarPropertiesMappingTest.php b/tests/IntegrationTest/ScalarPropertiesMappingTest.php new file mode 100644 index 00000000..ea5ffb5c --- /dev/null +++ b/tests/IntegrationTest/ScalarPropertiesMappingTest.php @@ -0,0 +1,80 @@ + + * + * 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\AbstractIntegrationTest; +use Rekalogika\Mapper\Tests\Fixtures\Scalar\ObjectWithBoolPropertiesDto; +use Rekalogika\Mapper\Tests\Fixtures\Scalar\ObjectWithFloatPropertiesDto; +use Rekalogika\Mapper\Tests\Fixtures\Scalar\ObjectWithIntPropertiesDto; +use Rekalogika\Mapper\Tests\Fixtures\Scalar\ObjectWithScalarProperties; +use Rekalogika\Mapper\Tests\Fixtures\Scalar\ObjectWithScalarPropertiesDto; +use Rekalogika\Mapper\Tests\Fixtures\Scalar\ObjectWithStringPropertiesDto; + +class ScalarPropertiesMappingTest extends AbstractIntegrationTest +{ + public function testScalarIdentity(): void + { + $class = new ObjectWithScalarProperties(); + $dto = $this->mapper->map($class, ObjectWithScalarPropertiesDto::class); + + $this->assertEquals($class->a, $dto->a); + $this->assertEquals($class->b, $dto->b); + $this->assertEquals($class->c, $dto->c); + $this->assertEquals($class->d, $dto->d); + } + + public function testScalarToInt(): void + { + $class = new ObjectWithScalarProperties(); + $dto = $this->mapper->map($class, ObjectWithIntPropertiesDto::class); + + $this->assertEquals(1, $dto->a); + $this->assertEquals(0, $dto->b); + $this->assertEquals(1, $dto->c); + $this->assertEquals(1, $dto->d); + } + + public function testScalarToString(): void + { + $class = new ObjectWithScalarProperties(); + $dto = $this->mapper->map($class, ObjectWithStringPropertiesDto::class); + + $this->assertEquals('1', $dto->a); + $this->assertEquals('string', $dto->b); + $this->assertEquals('1', $dto->c); + $this->assertEquals('1.1', $dto->d); + } + + public function testScalarToBool(): void + { + $class = new ObjectWithScalarProperties(); + $dto = $this->mapper->map($class, ObjectWithBoolPropertiesDto::class); + + $this->assertEquals(true, $dto->a); + $this->assertEquals(true, $dto->b); + $this->assertEquals(true, $dto->c); + $this->assertEquals(true, $dto->d); + } + + public function testScalarToFloat(): void + { + $class = new ObjectWithScalarProperties(); + $dto = $this->mapper->map($class, ObjectWithFloatPropertiesDto::class); + + $this->assertEquals(1.0, $dto->a); + $this->assertEquals(0.0, $dto->b); + $this->assertEquals(1.0, $dto->c); + $this->assertEquals(1.1, $dto->d); + } +} diff --git a/tests/IntegrationTest/TraversableToArrayAccessMappingTest.php b/tests/IntegrationTest/TraversableToArrayAccessMappingTest.php new file mode 100644 index 00000000..faeedf7e --- /dev/null +++ b/tests/IntegrationTest/TraversableToArrayAccessMappingTest.php @@ -0,0 +1,247 @@ + + * + * 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\Exception\MissingMemberValueTypeException; +use Rekalogika\Mapper\Mapper; +use Rekalogika\Mapper\Tests\Common\AbstractIntegrationTest; +use Rekalogika\Mapper\Tests\Fixtures\ArrayLike\ObjectWithArrayProperty; +use Rekalogika\Mapper\Tests\Fixtures\ArrayLike\ObjectWithArrayPropertyWithStringKey; +use Rekalogika\Mapper\Tests\Fixtures\ArrayLike\ObjectWithTraversableProperties; +use Rekalogika\Mapper\Tests\Fixtures\ArrayLikeDto\ObjectWithArrayAccessPropertyDto; +use Rekalogika\Mapper\Tests\Fixtures\ArrayLikeDto\ObjectWithArrayPropertyDto; +use Rekalogika\Mapper\Tests\Fixtures\ArrayLikeDto\ObjectWithArrayPropertyDtoWithIntKey; +use Rekalogika\Mapper\Tests\Fixtures\Scalar\ObjectWithScalarPropertiesDto; + +class TraversableToArrayAccessMappingTest extends AbstractIntegrationTest +{ + public function testFailedArrayToArray(): void + { + $source = [ + 'a' => 1, + 'b' => "string", + 'c' => true, + 'd' => 1.1, + ]; + + // target array does not have information about the type of its + // elements + + $this->expectException(MissingMemberValueTypeException::class); + $result = $this->mapper->map($source, 'array'); + } + + public function testFailedTraversableToArrayAccess(): void + { + $source = [ + 'a' => 1, + 'b' => "string", + 'c' => true, + 'd' => 1.1, + ]; + + // cannot do a direct mapping from array to \ArrayAccess because + // it does not have information about the type of its elements + + $this->expectException(MissingMemberValueTypeException::class); + + $result = $this->mapper->map( + $source, + \ArrayObject::class + ); + } + + public function testArrayOfArrayToArrayOfDto(): void + { + $source = [ + [ + 'a' => 1, + 'b' => "foo", + 'c' => true, + 'd' => 1.1, + ], + [ + 'a' => 2, + 'b' => "bar", + 'c' => false, + 'd' => 0.1, + ], + ]; + + $result = $this->mapper->map($source, 'array', [ + Mapper::TARGET_KEY_TYPE => 'string', + Mapper::TARGET_VALUE_TYPE => ObjectWithScalarPropertiesDto::class, + ]); + + // @phpstan-ignore-next-line + $first = $result[0]; + $this->assertInstanceOf(ObjectWithScalarPropertiesDto::class, $first); + $this->assertSame(1, $first->a); + $this->assertSame("foo", $first->b); + $this->assertTrue($first->c); + $this->assertSame(1.1, $first->d); + + // @phpstan-ignore-next-line + $second = $result[1]; + $this->assertInstanceOf(ObjectWithScalarPropertiesDto::class, $second); + $this->assertSame(2, $second->a); + $this->assertSame("bar", $second->b); + $this->assertFalse($second->c); + $this->assertSame(0.1, $second->d); + } + + // + // class property containing array-like + // + + public function testTraversableToArrayAccessDto(): void + { + $source = new ObjectWithTraversableProperties(); + + $result = $this->mapper->map($source, ObjectWithArrayAccessPropertyDto::class); + + $this->assertInstanceOf(ObjectWithArrayAccessPropertyDto::class, $result); + $this->assertInstanceOf(\ArrayAccess::class, $result->property); + $this->assertEquals(1, $result->property[1]?->a); + $this->assertEquals("string", $result->property[1]?->b); + $this->assertEquals(true, $result->property[1]?->c); + $this->assertEquals(1.1, $result->property[1]?->d); + } + + public function testTraversableToArrayDto(): void + { + $source = new ObjectWithTraversableProperties(); + + $result = $this->mapper->map($source, ObjectWithArrayPropertyDto::class); + + $one = $result->property[1] ?? null; + $this->assertInstanceOf(ObjectWithScalarPropertiesDto::class, $one); + + $this->assertEquals(1, $one->a); + $this->assertEquals("string", $one->b); + $this->assertEquals(true, $one->c); + $this->assertEquals(1.1, $one->d); + } + + public function testArrayToArrayAccessDto(): void + { + $source = new ObjectWithArrayProperty(); + + $result = $this->mapper->map($source, ObjectWithArrayAccessPropertyDto::class); + + $this->assertInstanceOf(ObjectWithArrayAccessPropertyDto::class, $result); + $this->assertInstanceOf(\ArrayAccess::class, $result->property); + $this->assertEquals(1, $result->property[1]?->a); + $this->assertEquals("string", $result->property[1]?->b); + $this->assertEquals(true, $result->property[1]?->c); + $this->assertEquals(1.1, $result->property[1]?->d); + } + + public function testArrayToArrayDto(): void + { + $source = new ObjectWithArrayProperty(); + + $result = $this->mapper->map($source, ObjectWithArrayPropertyDto::class); + + $one = $result->property[1] ?? null; + $this->assertInstanceOf(ObjectWithScalarPropertiesDto::class, $one); + + $this->assertEquals(1, $one->a); + $this->assertEquals("string", $one->b); + $this->assertEquals(true, $one->c); + $this->assertEquals(1.1, $one->d); + } + + // + // preinitialized + // + + public function testTraversableToArrayAccessDtoPreInit(): void + { + $source = new ObjectWithTraversableProperties(); + + $result = $this->mapper->map($source, ObjectWithArrayAccessPropertyDto::initialized()); + + $this->assertInstanceOf(ObjectWithArrayAccessPropertyDto::class, $result); + $this->assertInstanceOf(\ArrayAccess::class, $result->property); + $this->assertEquals(1, $result->property[1]?->a); + $this->assertEquals("string", $result->property[1]?->b); + $this->assertEquals(true, $result->property[1]?->c); + $this->assertEquals(1.1, $result->property[1]?->d); + } + + public function testTraversableToArrayDtoPreInit(): void + { + $source = new ObjectWithTraversableProperties(); + + $result = $this->mapper->map($source, ObjectWithArrayPropertyDto::initialized()); + + $one = $result->property[1] ?? null; + $this->assertInstanceOf(ObjectWithScalarPropertiesDto::class, $one); + + $this->assertEquals(1, $one->a); + $this->assertEquals("string", $one->b); + $this->assertEquals(true, $one->c); + $this->assertEquals(1.1, $one->d); + } + + public function testArrayToArrayAccessDtoPreInit(): void + { + $source = new ObjectWithArrayProperty(); + + $result = $this->mapper->map($source, ObjectWithArrayAccessPropertyDto::initialized()); + $this->assertInstanceOf(ObjectWithArrayAccessPropertyDto::class, $result); + $this->assertInstanceOf(\ArrayAccess::class, $result->property); + $this->assertEquals(1, $result->property[1]?->a); + $this->assertEquals("string", $result->property[1]?->b); + $this->assertEquals(true, $result->property[1]?->c); + $this->assertEquals(1.1, $result->property[1]?->d); + } + + public function testArrayToArrayDtoPreInit(): void + { + $source = new ObjectWithArrayProperty(); + + $result = $this->mapper->map($source, ObjectWithArrayPropertyDto::initialized()); + + $one = $result->property[1] ?? null; + $this->assertInstanceOf(ObjectWithScalarPropertiesDto::class, $one); + + $this->assertEquals(1, $one->a); + $this->assertEquals("string", $one->b); + $this->assertEquals(true, $one->c); + $this->assertEquals(1.1, $one->d); + } + + // + // + // + + public function testSourceStringKeyToTargetIntKey(): void + { + $source = new ObjectWithArrayPropertyWithStringKey(); + + $result = $this->mapper->map($source, ObjectWithArrayPropertyDtoWithIntKey::class); + + $this->assertInstanceOf(ObjectWithArrayPropertyDtoWithIntKey::class, $result); + $this->assertIsArray($result->property); + $this->assertCount(3, $result->property); + $this->assertArrayHasKey(0, $result->property); + $this->assertArrayHasKey(1, $result->property); + $this->assertArrayHasKey(2, $result->property); + } + + + +} diff --git a/tests/IntegrationTest/TraversableToTraversableMappingTest.php b/tests/IntegrationTest/TraversableToTraversableMappingTest.php new file mode 100644 index 00000000..2154cbb7 --- /dev/null +++ b/tests/IntegrationTest/TraversableToTraversableMappingTest.php @@ -0,0 +1,65 @@ + + * + * 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\AbstractIntegrationTest; +use Rekalogika\Mapper\Tests\Fixtures\ArrayLike\ObjectWithArrayProperty; +use Rekalogika\Mapper\Tests\Fixtures\ArrayLike\ObjectWithTraversableProperties; +use Rekalogika\Mapper\Tests\Fixtures\ArrayLikeDto\ObjectWithTraversablePropertyDto; +use Rekalogika\Mapper\Tests\Fixtures\Scalar\ObjectWithScalarPropertiesDto; + +class TraversableToTraversableMappingTest extends AbstractIntegrationTest +{ + // + // class property containing array-like + // + + public function testTraversableToTraversableDto(): void + { + $source = new ObjectWithTraversableProperties(); + + $result = $this->mapper->map($source, ObjectWithTraversablePropertyDto::class); + + $this->assertInstanceOf(ObjectWithTraversablePropertyDto::class, $result); + $this->assertNotNull($result->property); + + foreach ($result->property as $item) { + $this->assertInstanceOf(ObjectWithScalarPropertiesDto::class, $item); + + $this->assertEquals(1, $item->a); + $this->assertEquals("string", $item->b); + $this->assertEquals(true, $item->c); + $this->assertEquals(1.1, $item->d); + } + } + + public function testArrayToTraversableDto(): void + { + $source = new ObjectWithArrayProperty(); + + $result = $this->mapper->map($source, ObjectWithTraversablePropertyDto::class); + + $this->assertInstanceOf(ObjectWithTraversablePropertyDto::class, $result); + $this->assertNotNull($result->property); + + foreach ($result->property as $item) { + $this->assertInstanceOf(ObjectWithScalarPropertiesDto::class, $item); + + $this->assertEquals(1, $item->a); + $this->assertEquals("string", $item->b); + $this->assertEquals(true, $item->c); + $this->assertEquals(1.1, $item->d); + } + } +} diff --git a/tests/UnitTest/MainTransformerTest.php b/tests/UnitTest/MainTransformerTest.php new file mode 100644 index 00000000..bb5da95e --- /dev/null +++ b/tests/UnitTest/MainTransformerTest.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\UnitTest; + +use Rekalogika\Mapper\Tests\Common\AbstractIntegrationTest; +use Rekalogika\Mapper\Transformer\DateTimeTransformer; +use Rekalogika\Mapper\Transformer\ScalarToScalarTransformer; +use Symfony\Component\Clock\DatePoint; + +class MainTransformerTest extends AbstractIntegrationTest +{ + public function testMapping(): void + { + $mapping = $this->factory->getMappingFactory()->getMapping(); + + $this->assertEquals( + $mapping->getMappingBySourceAndTarget(['string'], ['string'])[0]->getClass(), + ScalarToScalarTransformer::class + ); + + $this->assertEquals( + $mapping->getMappingBySourceAndTarget(['string'], [DatePoint::class])[0]->getClass(), + DateTimeTransformer::class + ); + } +} diff --git a/tests/UnitTest/Model/ObjectCacheTest.php b/tests/UnitTest/Model/ObjectCacheTest.php new file mode 100644 index 00000000..a668f563 --- /dev/null +++ b/tests/UnitTest/Model/ObjectCacheTest.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\UnitTest\Model; + +use PHPUnit\Framework\TestCase; +use Rekalogika\Mapper\Util\TypeFactory; + +class ObjectCacheTest extends TestCase +{ + public function testObjectCache(): void + { + $objectCache = new \Rekalogika\Mapper\Model\ObjectCache(); + $source = new \stdClass(); + + $this->assertFalse($objectCache->containsTarget($source, TypeFactory::int())); + + $target = new \stdClass(); + $objectCache->saveTarget($source, TypeFactory::int(), $target); + + $this->assertTrue($objectCache->containsTarget($source, TypeFactory::int())); + $this->assertSame($target, $objectCache->getTarget($source, TypeFactory::int())); + + $this->expectException(\Rekalogika\Mapper\Exception\CachedTargetObjectNotFoundException::class); + $objectCache->getTarget($source, TypeFactory::float()); + } +} diff --git a/tests/UnitTest/Util/TypeCheckTest.php b/tests/UnitTest/Util/TypeCheckTest.php new file mode 100644 index 00000000..136d4a2e --- /dev/null +++ b/tests/UnitTest/Util/TypeCheckTest.php @@ -0,0 +1,90 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\UnitTest\Util; + +use PHPUnit\Framework\TestCase; +use Rekalogika\Mapper\Tests\Fixtures\SomeBackedEnum; +use Rekalogika\Mapper\Tests\Fixtures\SomeEnum; +use Rekalogika\Mapper\Util\TypeCheck; +use Rekalogika\Mapper\Util\TypeFactory; + +class TypeCheckTest extends TestCase +{ + public function testCheck(): void + { + $this->assertTrue(TypeCheck::nameExists(\DateTimeImmutable::class)); + $this->assertTrue(TypeCheck::nameExists(\DateTimeInterface::class)); + $this->assertTrue(TypeCheck::nameExists(SomeEnum::class)); + $this->assertTrue(TypeCheck::nameExists(SomeBackedEnum::class)); + $this->assertFalse(TypeCheck::nameExists('FooBar')); + + $this->assertTrue(TypeCheck::isInt(TypeFactory::int())); + $this->assertTrue(TypeCheck::isFloat(TypeFactory::float())); + $this->assertTrue(TypeCheck::isString(TypeFactory::string())); + $this->assertTrue(TypeCheck::isBool(TypeFactory::bool())); + $this->assertTrue(TypeCheck::isArray(TypeFactory::array())); + $this->assertTrue(TypeCheck::isObject(TypeFactory::objectOfClass(\DateTime::class))); + $this->assertTrue(TypeCheck::isResource(TypeFactory::resource())); + $this->assertTrue(TypeCheck::isNull(TypeFactory::null())); + + $this->assertTrue(TypeCheck::isScalar(TypeFactory::int())); + $this->assertTrue(TypeCheck::isScalar(TypeFactory::float())); + $this->assertTrue(TypeCheck::isScalar(TypeFactory::string())); + $this->assertTrue(TypeCheck::isScalar(TypeFactory::bool())); + + $this->assertTrue(TypeCheck::isSomewhatIdentical( + TypeFactory::int(), + TypeFactory::int() + )); + $this->assertTrue(TypeCheck::isSomewhatIdentical( + TypeFactory::float(), + TypeFactory::float() + )); + $this->assertTrue(TypeCheck::isSomewhatIdentical( + TypeFactory::string(), + TypeFactory::string() + )); + $this->assertTrue(TypeCheck::isSomewhatIdentical( + TypeFactory::bool(), + TypeFactory::bool() + )); + $this->assertTrue(TypeCheck::isSomewhatIdentical( + TypeFactory::array(), + TypeFactory::array() + )); + $this->assertTrue(TypeCheck::isSomewhatIdentical( + TypeFactory::objectOfClass(\DateTime::class), + TypeFactory::objectOfClass(\DateTime::class) + )); + $this->assertFalse(TypeCheck::isSomewhatIdentical( + TypeFactory::objectOfClass(\DateTime::class), + TypeFactory::objectOfClass(\DateTimeImmutable::class) + )); + $this->assertTrue(TypeCheck::isSomewhatIdentical( + TypeFactory::resource(), + TypeFactory::resource() + )); + $this->assertTrue(TypeCheck::isSomewhatIdentical( + TypeFactory::null(), + TypeFactory::null() + )); + + $this->assertTrue( + TypeCheck::isVariableInstanceOf(new \DateTime(), TypeFactory::objectOfClass(\DateTime::class)) + ); + $this->assertTrue( + TypeCheck::isVariableInstanceOf(new \DateTime(), TypeFactory::object()) + ); + } +} diff --git a/tests/UnitTest/Util/TypeFactoryTest.php b/tests/UnitTest/Util/TypeFactoryTest.php new file mode 100644 index 00000000..bbb62b6a --- /dev/null +++ b/tests/UnitTest/Util/TypeFactoryTest.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\UnitTest\Util; + +use PHPUnit\Framework\TestCase; +use Rekalogika\Mapper\Util\TypeFactory; + +class TypeFactoryTest extends TestCase +{ + public function testTypes(): void + { + $this->assertSame('null', TypeFactory::null()->getBuiltinType()); + $this->assertSame('string', TypeFactory::string()->getBuiltinType()); + $this->assertSame('int', TypeFactory::int()->getBuiltinType()); + $this->assertSame('float', TypeFactory::float()->getBuiltinType()); + $this->assertSame('bool', TypeFactory::bool()->getBuiltinType()); + $this->assertSame('array', TypeFactory::array()->getBuiltinType()); + $this->assertSame('resource', TypeFactory::resource()->getBuiltinType()); + $this->assertSame('callable', TypeFactory::callable()->getBuiltinType()); + $this->assertSame('true', TypeFactory::true()->getBuiltinType()); + $this->assertSame('false', TypeFactory::false()->getBuiltinType()); + + $object = TypeFactory::objectOfClass(\DateTime::class); + $this->assertSame('object', $object->getBuiltinType()); + $this->assertSame(\DateTime::class, $object->getClassName()); + + $arrayWithKeyValue = TypeFactory::arrayWithKeyValue( + TypeFactory::string(), + TypeFactory::int() + ); + $this->assertSame('array', $arrayWithKeyValue->getBuiltinType()); + $this->assertSame('string', $arrayWithKeyValue->getCollectionKeyTypes()[0]->getBuiltinType()); + $this->assertSame('int', $arrayWithKeyValue->getCollectionValueTypes()[0]->getBuiltinType()); + + $objectWithKeyValue = TypeFactory::objectWithKeyValue( + \Traversable::class, + TypeFactory::string(), + TypeFactory::int() + ); + $this->assertSame('object', $objectWithKeyValue->getBuiltinType()); + $this->assertSame(\Traversable::class, $objectWithKeyValue->getClassName()); + $this->assertSame('string', $objectWithKeyValue->getCollectionKeyTypes()[0]->getBuiltinType()); + $this->assertSame('int', $objectWithKeyValue->getCollectionValueTypes()[0]->getBuiltinType()); + } +} diff --git a/tests/UnitTest/Util/TypeUtil2Test.php b/tests/UnitTest/Util/TypeUtil2Test.php new file mode 100644 index 00000000..0459d1eb --- /dev/null +++ b/tests/UnitTest/Util/TypeUtil2Test.php @@ -0,0 +1,432 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\UnitTest\Util; + +use PHPUnit\Framework\TestCase; +use Rekalogika\Mapper\Util\TypeFactory; +use Rekalogika\Mapper\Util\TypeUtil; +use Symfony\Component\PropertyInfo\Type; + +class TypeUtil2Test extends TestCase +{ + /** + * @dataProvider getSimpleTypesProvider + * @param array $expected + */ + public function testGetAllTypeStrings( + Type $type, + array $expected, + bool $withParents = false + ): void { + $result = TypeUtil::getAllTypeStrings($type, $withParents); + $this->assertEquals($expected, $result); + } + + /** + * @return iterable,2?:bool}> + */ + public function getSimpleTypesProvider(): iterable + { + yield [ + TypeFactory::null(), + [ + 'null', + ] + ]; + + yield [ + TypeFactory::bool(), + [ + 'bool', + ] + ]; + + yield [ + TypeFactory::int(), + [ + 'int', + ] + ]; + + yield [ + TypeFactory::float(), + [ + 'float', + ] + ]; + + yield [ + TypeFactory::string(), + [ + 'string', + ] + ]; + + yield [ + TypeFactory::array(), + [ + 'array', + ] + ]; + + yield [ + TypeFactory::objectOfClass(\DateTime::class), + [ + 'DateTime', + ] + ]; + + yield [ + TypeFactory::resource(), + [ + 'resource', + ] + ]; + + yield [ + TypeFactory::arrayWithKeyValue( + TypeFactory::string(), + TypeFactory::int() + ), + [ + 'array', + ] + ]; + + yield [ + TypeFactory::objectWithKeyValue( + \Traversable::class, + TypeFactory::string(), + TypeFactory::int() + ), + [ + 'Traversable', + ] + ]; + + yield [ + new Type( + builtinType: 'iterable', + ), + [ + 'array', + 'Traversable' + ] + ]; + + yield [ + new Type( + builtinType: 'iterable', + collection: true, + collectionKeyType: [ + TypeFactory::string() + ], + collectionValueType: [ + TypeFactory::int() + ], + ), + [ + 'array', + 'Traversable' + ] + ]; + + yield [ + new Type( + builtinType: 'iterable', + collection: true, + collectionKeyType: [ + TypeFactory::string(), + TypeFactory::int(), + ], + collectionValueType: [ + TypeFactory::int(), + TypeFactory::objectOfClass(\DateTime::class), + ], + ), + [ + "array", + "Traversable", + "array", + "Traversable", + "array", + "Traversable", + "array", + "Traversable", + ] + ]; + + yield [ + new Type( + builtinType: 'iterable', + nullable: true, + collection: true, + collectionKeyType: [ + TypeFactory::string(), + TypeFactory::int(), + ], + collectionValueType: [ + TypeFactory::int(), + TypeFactory::objectOfClass(\DateTime::class), + ], + ), + [ + 'array', + 'Traversable', + 'array', + 'Traversable', + 'array', + 'Traversable', + 'array', + 'Traversable', + 'null', + ] + ]; + + yield [ + new Type( + builtinType: 'object', + class: \Traversable::class, + nullable: true, + collection: true, + collectionKeyType: [ + TypeFactory::string(), + TypeFactory::int(), + ], + collectionValueType: [ + TypeFactory::int(), + TypeFactory::objectOfClass(\DateTime::class), + ], + ), + [ + 'Traversable', + 'Traversable', + 'Traversable', + 'Traversable', + 'null', + ] + ]; + + yield [ + new Type( + builtinType: 'object', + class: \Traversable::class, + nullable: true, + collection: true, + collectionKeyType: [ + TypeFactory::int(), + ], + collectionValueType: [ + new Type( + builtinType: 'object', + class: \Traversable::class, + collection: true, + collectionKeyType: [ + TypeFactory::string(), + TypeFactory::int(), + ], + collectionValueType: [ + TypeFactory::int(), + TypeFactory::objectOfClass(\DateTime::class), + ], + ), + ], + ), + [ + "Traversable>", + "Traversable>", + "Traversable>", + "Traversable>", + "null", + ], + ]; + + yield [ + new Type( + builtinType: 'object', + class: \ArrayObject::class, + nullable: true, + collection: true, + collectionKeyType: [ + TypeFactory::int(), + ], + collectionValueType: [ + new Type( + builtinType: 'object', + class: \ArrayObject::class, + collection: true, + collectionKeyType: [ + TypeFactory::string(), + TypeFactory::int(), + ], + collectionValueType: [ + TypeFactory::int(), + TypeFactory::objectOfClass(\DateTime::class), + ], + ), + ], + ), + [ + "ArrayObject>", + "ArrayObject>", + "ArrayObject>", + "ArrayObject>", + "null", + ] + ]; + + yield [ + new Type( + builtinType: 'object', + class: \IteratorAggregate::class, + nullable: true, + collection: true, + collectionKeyType: [ + TypeFactory::int(), + ], + collectionValueType: [ + TypeFactory::string(), + ], + ), + [ + 'IteratorAggregate', + 'IteratorAggregate', + 'IteratorAggregate', + 'IteratorAggregate', + 'IteratorAggregate', + 'Traversable', + 'Traversable', + 'Traversable', + 'Traversable', + 'Traversable', + 'object', + 'null', + 'mixed', + ], + true + ]; + + yield [ + new Type( + builtinType: 'object', + class: \Traversable::class, + collection: true, + collectionKeyType: [ + TypeFactory::int(), + ], + collectionValueType: [ + new Type( + builtinType: 'object', + class: \IteratorAggregate::class, + collection: true, + collectionKeyType: [ + TypeFactory::int(), + ], + collectionValueType: [ + TypeFactory::string(), + ], + ), + ], + ), + [ + "Traversable>", + "Traversable>", + "Traversable>", + "Traversable>", + "Traversable", + "Traversable>", + "Traversable>", + "Traversable>", + "Traversable>", + "Traversable", + "Traversable", + "Traversable", + "Traversable>", + "Traversable>", + "Traversable>", + "Traversable>", + "Traversable", + "Traversable>", + "Traversable>", + "Traversable>", + "Traversable>", + "Traversable", + "Traversable", + "Traversable", + "Traversable", + "object", + "mixed", + ], + true + ]; + + yield [ + new Type( + builtinType: 'object', + class: \Traversable::class, + collection: true, + collectionKeyType: [ + TypeFactory::int(), + ], + collectionValueType: [ + new Type( + builtinType: 'object', + class: \IteratorAggregate::class, + nullable: true, + collection: true, + collectionKeyType: [ + TypeFactory::int(), + ], + collectionValueType: [ + TypeFactory::string(), + ], + ), + ], + ), + [ + "Traversable>", + "Traversable>", + "Traversable>", + "Traversable>", + "Traversable", + "Traversable>", + "Traversable>", + "Traversable>", + "Traversable>", + "Traversable", + "Traversable", + "Traversable", + "Traversable", + "Traversable>", + "Traversable>", + "Traversable>", + "Traversable>", + "Traversable", + "Traversable>", + "Traversable>", + "Traversable>", + "Traversable>", + "Traversable", + "Traversable", + "Traversable", + "Traversable", + "Traversable", + "object", + "mixed", + ], + true + ]; + + } +} diff --git a/tests/UnitTest/Util/TypeUtilTest.php b/tests/UnitTest/Util/TypeUtilTest.php new file mode 100644 index 00000000..21e7d507 --- /dev/null +++ b/tests/UnitTest/Util/TypeUtilTest.php @@ -0,0 +1,239 @@ + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests\UnitTest\Util; + +use PHPUnit\Framework\TestCase; +use Rekalogika\Mapper\Util\TypeFactory; +use Rekalogika\Mapper\Util\TypeUtil; +use Symfony\Component\PropertyInfo\Type; + +class TypeUtilTest extends TestCase +{ + /** + * @dataProvider typeGuessProvider + */ + public function testTypeGuess( + mixed $object, + string $builtInType, + ?string $className = null + ): void { + $type = TypeUtil::guessTypeFromVariable($object); + + $this->assertSame($builtInType, $type->getBuiltinType()); + $this->assertSame($className, $type->getClassName()); + } + + /** + * @return iterable + */ + public function typeGuessProvider(): iterable + { + yield [null, 'null']; + yield [true, 'bool']; + yield [false, 'bool']; + yield [1, 'int']; + yield [1.1, 'float']; + yield ['string', 'string']; + yield [new \ArrayObject(), 'object', \ArrayObject::class]; + yield [[], 'array']; + yield [fopen('php://memory', 'r'), 'resource']; + } + + /** + * @dataProvider isSimpleTypeProvider + */ + public function testIsSimpleType(Type $type, bool $isSimple): void + { + $this->assertSame($isSimple, TypeUtil::isSimpleType($type)); + } + + /** + * @return iterable + */ + public function isSimpleTypeProvider(): iterable + { + yield [TypeFactory::null(), true]; + yield [TypeFactory::bool(), true]; + yield [TypeFactory::int(), true]; + yield [TypeFactory::float(), true]; + yield [TypeFactory::string(), true]; + yield [TypeFactory::array(), true]; + yield [TypeFactory::objectOfClass(\DateTime::class), true]; + yield [TypeFactory::resource(), true]; + + yield [ + TypeFactory::arrayWithKeyValue( + TypeFactory::string(), + TypeFactory::int() + ), + true + ]; + + yield [ + TypeFactory::objectWithKeyValue( + \Traversable::class, + TypeFactory::string(), + TypeFactory::int() + ), + true + ]; + + yield [ + new Type( + builtinType: 'iterable', + collectionKeyType: [ + TypeFactory::string() + ], + collectionValueType: [ + TypeFactory::int() + ], + ), + false + ]; + + yield [ + new Type( + builtinType: 'object', + class: \Traversable::class, + collectionKeyType: [ + TypeFactory::string() + ], + collectionValueType: [ + TypeFactory::int() + ], + ), + true + ]; + + yield [ + new Type( + builtinType: 'object', + class: \Traversable::class, + collection: true, + collectionKeyType: [ + TypeFactory::string(), + TypeFactory::int() + ], + collectionValueType: [ + TypeFactory::int() + ], + ), + false + ]; + + yield [ + new Type( + builtinType: 'object', + class: \Traversable::class, + nullable: true, + ), + false + ]; + } + + /** + * @dataProvider getTypeStringProvider + */ + public function testGetTypeString(Type $type, string $expected): void + { + $this->assertSame($expected, TypeUtil::getTypeString($type)); + } + + /** + * @return iterable + */ + public function getTypeStringProvider(): iterable + { + yield [TypeFactory::null(), 'null']; + yield [TypeFactory::bool(), 'bool']; + yield [TypeFactory::int(), 'int']; + yield [TypeFactory::float(), 'float']; + yield [TypeFactory::string(), 'string']; + yield [TypeFactory::array(), 'array']; + yield [TypeFactory::resource(), 'resource']; + yield [TypeFactory::callable(), 'callable']; + yield [TypeFactory::true(), 'true']; + yield [TypeFactory::false(), 'false']; + + yield [ + TypeFactory::objectOfClass(\Traversable::class), + \Traversable::class + ]; + + yield [ + TypeFactory::objectWithKeyValue( + \Traversable::class, + TypeFactory::string(), + TypeFactory::int() + ), + \Traversable::class . '' + ]; + + yield [ + TypeFactory::arrayWithKeyValue( + TypeFactory::string(), + TypeFactory::int() + ), + 'array' + ]; + + yield [ + new Type( + builtinType: 'object', + class: \Traversable::class, + collection: true, + collectionKeyType: [ + TypeFactory::string(), + ], + collectionValueType: [ + new Type( + builtinType: 'object', + class: \Traversable::class, + collection: true, + collectionKeyType: [ + TypeFactory::string(), + ], + collectionValueType: [ + TypeFactory::int(), + ], + ), + ], + ), + 'Traversable>' + ]; + + yield [ + new Type( + builtinType: 'object', + class: \Traversable::class, + collection: true, + collectionKeyType: null, + collectionValueType: [ + new Type( + builtinType: 'object', + class: \Traversable::class, + collection: true, + collectionKeyType: [ + TypeFactory::string(), + ], + collectionValueType: [ + TypeFactory::int(), + ], + ), + ], + ), + 'Traversable>' + ]; + } +} diff --git a/tests/console b/tests/console new file mode 100755 index 00000000..2ba70fda --- /dev/null +++ b/tests/console @@ -0,0 +1,22 @@ +#!/usr/bin/env php + + * + * For the full copyright and license information, please view the LICENSE file + * that was distributed with this source code. + */ + +namespace Rekalogika\Mapper\Tests; + +require_once __DIR__ . '/../vendor/autoload.php'; + +use Rekalogika\Mapper\Tests\Common\MapperTestFactory; + +$factory = new MapperTestFactory(); +$application = $factory->getApplication()->run();