diff --git a/src/Symfony/Bundle/FrameworkBundle/CacheWarmer/CachedObjectMapperCacheWarmer.php b/src/Symfony/Bundle/FrameworkBundle/CacheWarmer/CachedObjectMapperCacheWarmer.php new file mode 100644 index 0000000000000..45f6497bb786d --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/CacheWarmer/CachedObjectMapperCacheWarmer.php @@ -0,0 +1,61 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Bundle\FrameworkBundle\CacheWarmer; + +use Symfony\Component\HttpKernel\CacheWarmer\CacheWarmerInterface; +use Symfony\Component\ObjectMapper\Internal\MappingCacheGenerator; +use Symfony\Component\ObjectMapper\Internal\MappingCacheGeneratorInterface; +use Symfony\Component\ObjectMapper\Internal\MappingCacheTrait; +use Symfony\Component\ObjectMapper\Metadata\ReflectionObjectMapperMetadataFactory; + +/** + * @internal + */ +final class CachedObjectMapperCacheWarmer implements CacheWarmerInterface +{ + use MappingCacheTrait; + + /** + * @param iterable $mappedAttributes + */ + public function __construct( + private readonly string $cacheDir, + private readonly iterable $mappedAttributes, + ?MappingCacheGeneratorInterface $generator = null, + ) { + $this->generator = $generator ?? new MappingCacheGenerator(new ReflectionObjectMapperMetadataFactory()); + } + + public function warmUp(string $cacheDir, ?string $buildDir = null): array + { + if (!$this->mappedAttributes) { + return []; + } + + foreach ($this->mappedAttributes as ['source' => $sourceClass, 'target' => $targetClass]) { + $cacheFile = $this->getCacheFile($sourceClass, $targetClass); + + if (is_file($cacheFile)) { + continue; + } + + $this->writeCacheFile($cacheFile, $sourceClass, $targetClass); + } + + return []; + } + + public function isOptional(): bool + { + return true; + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php index 36e3ee1ae4376..c07116e26921b 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Compiler/UnusedTagsPass.php @@ -70,6 +70,8 @@ class UnusedTagsPass implements CompilerPassInterface 'mime.mime_type_guesser', 'monolog.logger', 'notifier.channel', + 'object_mapper.condition_callable', + 'object_mapper.transform_callable', 'property_info.access_extractor', 'property_info.constructor_extractor', 'property_info.initializable_extractor', @@ -108,8 +110,6 @@ class UnusedTagsPass implements CompilerPassInterface 'validator.group_provider', 'validator.initializer', 'workflow', - 'object_mapper.transform_callable', - 'object_mapper.condition_callable', ]; public function process(ContainerBuilder $container): void diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index d62b859329562..728cc262520cf 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -38,6 +38,7 @@ use Symfony\Component\Mailer\Mailer; use Symfony\Component\Messenger\MessageBusInterface; use Symfony\Component\Notifier\Notifier; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; use Symfony\Component\PropertyAccess\PropertyAccessor; use Symfony\Component\PropertyInfo\PropertyInfoExtractorInterface; use Symfony\Component\RateLimiter\Policy\TokenBucketLimiter; @@ -194,6 +195,7 @@ public function getConfigTreeBuilder(): TreeBuilder $this->addWebhookSection($rootNode, $enableIfStandalone); $this->addRemoteEventSection($rootNode, $enableIfStandalone); $this->addJsonStreamerSection($rootNode, $enableIfStandalone); + $this->addObjectMapperSection($rootNode, $enableIfStandalone); return $treeBuilder; } @@ -2751,4 +2753,16 @@ private function addJsonStreamerSection(ArrayNodeDefinition $rootNode, callable ->end() ; } + + private function addObjectMapperSection(ArrayNodeDefinition $rootNode, callable $enableIfStandalone): void + { + $rootNode + ->children() + ->arrayNode('object_mapper') + ->info('Object Mapper configuration') + ->{$enableIfStandalone('symfony/object-mapper', ObjectMapperInterface::class)}() + ->end() + ->end() + ; + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index d67ae2e20dd0e..07627f3c6ec89 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -137,6 +137,7 @@ use Symfony\Component\Notifier\Recipient\Recipient; use Symfony\Component\Notifier\TexterInterface; use Symfony\Component\Notifier\Transport\TransportFactoryInterface as NotifierTransportFactoryInterface; +use Symfony\Component\ObjectMapper\Attribute\Map; use Symfony\Component\ObjectMapper\ConditionCallableInterface; use Symfony\Component\ObjectMapper\ObjectMapperInterface; use Symfony\Component\ObjectMapper\TransformCallableInterface; @@ -612,12 +613,8 @@ public function load(array $configs, ContainerBuilder $container): void $loader->load('mime_type.php'); } - if (ContainerBuilder::willBeAvailable('symfony/object-mapper', ObjectMapperInterface::class, ['symfony/framework-bundle'])) { - $loader->load('object_mapper.php'); - $container->registerForAutoconfiguration(TransformCallableInterface::class) - ->addTag('object_mapper.transform_callable'); - $container->registerForAutoconfiguration(ConditionCallableInterface::class) - ->addTag('object_mapper.condition_callable'); + if ($this->readConfigEnabled('object_mapper', $container, $config['object_mapper'])) { + $this->registerObjectMapperConfiguration($container, $loader); } $container->registerForAutoconfiguration(PackageInterface::class) @@ -3430,6 +3427,47 @@ private function registerHtmlSanitizerConfiguration(array $config, ContainerBuil } } + private function registerObjectMapperConfiguration(ContainerBuilder $container, PhpFileLoader $loader): void + { + $loader->load('object_mapper.php'); + $container->setParameter('.object_mapper.cache_dir', '%kernel.cache_dir%/object_mapper'); + $container->registerForAutoconfiguration(TransformCallableInterface::class) + ->addTag('object_mapper.transform_callable'); + $container->registerForAutoconfiguration(ConditionCallableInterface::class) + ->addTag('object_mapper.condition_callable'); + + if ($container->getParameter('kernel.debug')) { + $container->setAlias(ObjectMapperInterface::class, 'object_mapper'); + + return; + } + + $container->registerAttributeForAutoconfiguration(Map::class, function (ChildDefinition $definition, Map $attribute, \ReflectionClass $reflector) { + $cl = $reflector->getName(); + $source = $attribute->source ?? $cl; + $target = $attribute->target ?? $cl; + + if ($source !== $target) { + $definition->addTag('object_mapper.attribute_metadata', [ + 'source' => $source, + 'target' => $target, + ]); + } + }); + + $container->setAlias(ObjectMapperInterface::class, 'object_mapper.cached'); + } + + public function getXsdValidationBasePath(): string|false + { + return \dirname(__DIR__).'/Resources/config/schema'; + } + + public function getNamespace(): string + { + return 'http://symfony.com/schema/dic/symfony'; + } + protected function isConfigEnabled(ContainerBuilder $container, array $config): bool { throw new \LogicException('To prevent using outdated configuration, you must use the "readConfigEnabled" method instead.'); diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/object_mapper.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/object_mapper.php index 8addad4da04fe..7769b9ac4fb9d 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/object_mapper.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/object_mapper.php @@ -11,10 +11,11 @@ namespace Symfony\Component\DependencyInjection\Loader\Configurator; +use Symfony\Bundle\FrameworkBundle\CacheWarmer\CachedObjectMapperCacheWarmer; +use Symfony\Component\ObjectMapper\CachedObjectMapper; use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; use Symfony\Component\ObjectMapper\Metadata\ReflectionObjectMapperMetadataFactory; use Symfony\Component\ObjectMapper\ObjectMapper; -use Symfony\Component\ObjectMapper\ObjectMapperInterface; return static function (ContainerConfigurator $container) { $container->services() @@ -22,12 +23,23 @@ ->alias(ObjectMapperMetadataFactoryInterface::class, 'object_mapper.metadata_factory') ->set('object_mapper', ObjectMapper::class) - ->args([ - service('object_mapper.metadata_factory'), - service('property_accessor')->ignoreOnInvalid(), - tagged_locator('object_mapper.transform_callable'), - tagged_locator('object_mapper.condition_callable'), - ]) - ->alias(ObjectMapperInterface::class, 'object_mapper') + ->args([ + service('object_mapper.metadata_factory'), + service('property_accessor')->ignoreOnInvalid(), + tagged_locator('object_mapper.transform_callable'), + tagged_locator('object_mapper.condition_callable'), + ]) + + ->set('object_mapper.cached', CachedObjectMapper::class) + ->args([ + param('.object_mapper.cache_dir'), + service('object_mapper.metadata_factory'), + service('property_accessor')->ignoreOnInvalid(), + tagged_locator('object_mapper.transform_callable'), + tagged_locator('object_mapper.condition_callable'), + ]) + + ->set('object_mapper.cached.cache_warmer', CachedObjectMapperCacheWarmer::class) + ->args([null, 'object_mapper.cached']) ; }; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/CachedObjectMapperCacheWarmerTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/CachedObjectMapperCacheWarmerTest.php new file mode 100644 index 0000000000000..d976abc028e13 --- /dev/null +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/CacheWarmer/CachedObjectMapperCacheWarmerTest.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 Symfony\Bundle\FrameworkBundle\Tests\CacheWarmer; + +use PHPUnit\Framework\TestCase; +use Symfony\Bundle\FrameworkBundle\CacheWarmer\CachedObjectMapperCacheWarmer; +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\ObjectMapper\Exception\MappingException; +use Symfony\Component\ObjectMapper\Tests\Fixtures\A; +use Symfony\Component\ObjectMapper\Tests\Fixtures\AbstractA; +use Symfony\Component\ObjectMapper\Tests\Fixtures\B; +use Symfony\Component\ObjectMapper\Tests\Fixtures\C; +use Symfony\Component\ObjectMapper\Tests\Fixtures\D; + +class CachedObjectMapperCacheWarmerTest extends TestCase +{ + private ?string $cacheDir = null; + + protected function setUp(): void + { + $this->cacheDir = sys_get_temp_dir().'/symfony_object_mapper_'.uniqid(); + (new Filesystem())->mkdir($this->cacheDir); + } + + protected function tearDown(): void + { + if (null !== $this->cacheDir) { + (new Filesystem())->remove($this->cacheDir); + } + } + + public function testWarmUp() + { + $mappingPairs = [ + ['source' => A::class, 'target' => B::class], + ['source' => C::class, 'target' => D::class], + ]; + + $warmer = new CachedObjectMapperCacheWarmer($this->cacheDir, $mappingPairs); + $warmer->warmUp($this->cacheDir); + + $this->assertFileExists($this->cacheDir.'/'.hash('xxh128', A::class.'-to-'.B::class).'.php'); + $this->assertFileExists($this->cacheDir.'/'.hash('xxh128', C::class.'-to-'.D::class).'.php'); + } + + public function testWarmUpWithNoPairs() + { + $warmer = new CachedObjectMapperCacheWarmer($this->cacheDir, []); + $warmer->warmUp($this->cacheDir); + + $this->assertEmpty(glob($this->cacheDir.'/*.php')); + } + + public function testWarmUpWithAbstractClass() + { + $this->expectException(MappingException::class); + $this->expectExceptionMessage('Can not generate mapping metadata from an abstract class "Symfony\Component\ObjectMapper\Tests\Fixtures\AbstractA".'); + $mappingPairs = [ + ['source' => AbstractA::class, 'target' => B::class], + ['source' => C::class, 'target' => D::class], + ]; + + $warmer = new CachedObjectMapperCacheWarmer($this->cacheDir, $mappingPairs); + $warmer->warmUp($this->cacheDir); + + $this->assertFileExists($this->cacheDir.'/'.hash('xxh128', C::class.'-to-'.D::class).'.php'); + $this->assertFileDoesNotExist($this->cacheDir.'/'.hash('xxh128', AbstractA::class.'-to-'.B::class).'.php'); + } +} diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index c322cd6a2816f..b936dca07ab0f 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -29,6 +29,7 @@ use Symfony\Component\Lock\Store\SemaphoreStore; use Symfony\Component\Mailer\Mailer; use Symfony\Component\Notifier\Notifier; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; use Symfony\Component\RateLimiter\Policy\TokenBucketLimiter; use Symfony\Component\RemoteEvent\RemoteEvent; use Symfony\Component\Scheduler\Messenger\SchedulerTransportFactory; @@ -986,6 +987,9 @@ class_exists(SemaphoreStore::class) && SemaphoreStore::isSupported() ? 'semaphor 'json_streamer' => [ 'enabled' => !class_exists(FullStack::class) && class_exists(JsonStreamWriter::class), ], + 'object_mapper' => [ + 'enabled' => class_exists(ObjectMapperInterface::class), + ], ]; } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php index 67b47bf841d1c..8f9a4893292a4 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/FrameworkExtensionTestCase.php @@ -2811,9 +2811,11 @@ public function testJsonStreamerEnabled() public function testObjectMapperEnabled() { $container = $this->createContainerFromClosure(function (ContainerBuilder $container) { - $container->loadFromExtension('framework', []); + $container->loadFromExtension('framework', ['object_mapper' => ['enabled' => true]]); }); $this->assertTrue($container->has('object_mapper')); + $this->assertTrue($container->has('object_mapper.cached')); + $this->assertTrue($container->hasParameter('.object_mapper.cache_dir')); } protected function createContainer(array $data = []) diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ObjectMapperTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ObjectMapperTest.php index e314ee1b029e5..28103ade8cbb2 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ObjectMapperTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/ObjectMapperTest.php @@ -15,7 +15,7 @@ use Symfony\Bundle\FrameworkBundle\Tests\Fixtures\ObjectMapper\ObjectToBeMapped; /** - * @author Kévin Dunglas + * @author Antoine Bluchet */ class ObjectMapperTest extends AbstractWebTestCase { @@ -28,4 +28,14 @@ public function testObjectMapper() $mapped = $objectMapper->map(new ObjectToBeMapped()); $this->assertSame($mapped->a, 'transformed'); } + + public function testCachedObjectMapper() + { + static::bootKernel(['test_case' => 'ObjectMapper']); + + /** @var Symfony\Component\ObjectMapper\ObjectMapperInterface */ + $objectMapper = static::getContainer()->get('object_mapper.cached.alias'); + $mapped = $objectMapper->map(new ObjectToBeMapped()); + $this->assertSame($mapped->a, 'transformed'); + } } diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ObjectMapper/config.yml b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ObjectMapper/config.yml index 3e3bd8702c6f6..b4cd3cd1761a3 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ObjectMapper/config.yml +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/Functional/app/ObjectMapper/config.yml @@ -1,9 +1,15 @@ imports: - { resource: ../config/default.yml } +framework: + object_mapper: + enabled: true services: object_mapper.alias: alias: object_mapper public: true Symfony\Bundle\FrameworkBundle\Tests\Fixtures\ObjectMapper\TransformCallable: autoconfigure: true + object_mapper.cached.alias: + alias: object_mapper.cached + public: true diff --git a/src/Symfony/Component/ObjectMapper/CachedObjectMapper.php b/src/Symfony/Component/ObjectMapper/CachedObjectMapper.php new file mode 100644 index 0000000000000..780bfae0e9acc --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/CachedObjectMapper.php @@ -0,0 +1,133 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper; + +use Psr\Container\ContainerInterface; +use Symfony\Component\ObjectMapper\Exception\MappingException; +use Symfony\Component\ObjectMapper\Internal\MappingCacheGenerator; +use Symfony\Component\ObjectMapper\Internal\MappingCacheGeneratorInterface; +use Symfony\Component\ObjectMapper\Internal\MappingCacheTrait; +use Symfony\Component\ObjectMapper\Internal\ObjectMapperTrait; +use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; +use Symfony\Component\ObjectMapper\Metadata\ReflectionObjectMapperMetadataFactory; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\VarExporter\LazyObjectInterface; + +/** + * Cached Object to object mapper that generates and caches compiled mapping functions. + * + * @author Antoine Bluchet + */ +final class CachedObjectMapper implements ObjectMapperInterface, ObjectMapperAwareInterface +{ + use MappingCacheTrait; + use ObjectMapperAwareTrait; + use ObjectMapperTrait; + + private array $cachedMappings = []; + private ?\SplObjectStorage $objectMap = null; + + public function __construct( + private readonly string $cacheDir, + ObjectMapperMetadataFactoryInterface $metadataFactory = new ReflectionObjectMapperMetadataFactory(), + ?PropertyAccessorInterface $propertyAccessor = null, + ?ContainerInterface $transformCallableLocator = null, + ?ContainerInterface $conditionCallableLocator = null, + ?ObjectMapperInterface $objectMapper = null, + ?MappingCacheGeneratorInterface $generator = null, + ) { + $this->metadataFactory = $metadataFactory; + $this->propertyAccessor = $propertyAccessor; + $this->transformCallableLocator = $transformCallableLocator; + $this->conditionCallableLocator = $conditionCallableLocator; + $this->objectMapper = $objectMapper; + $this->generator = $generator ?? new MappingCacheGenerator($metadataFactory); + } + + public function map(object $source, object|string|null $target = null): object + { + $sourceClass = $source::class; + $targetClass = $this->resolveTargetClass($source, $target); + + if (!$targetClass) { + throw new MappingException(\sprintf('Mapping target not found for source "%s".', get_debug_type($source))); + } + + if (\is_string($target) && !class_exists($target)) { + throw new MappingException(\sprintf('Mapping target class "%s" does not exist for source "%s".', $target, get_debug_type($source))); + } + + $cacheKey = hash('xxh128', $sourceClass.'-to-'.$targetClass); + + if (!isset($this->cachedMappings[$cacheKey])) { + $cacheFile = $this->getCacheFile($sourceClass, $targetClass); + + if (!is_file($cacheFile)) { + $this->writeCacheFile($cacheFile, $sourceClass, $targetClass); + } + + $this->cachedMappings[$cacheKey] = require $cacheFile; + } + + $mappingFunction = $this->cachedMappings[$cacheKey]; + $mappingToObject = \is_object($target); + $targetObject = \is_object($target) ? $target : (new \ReflectionClass($targetClass))->newInstanceWithoutConstructor(); + + if ($source instanceof LazyObjectInterface) { + $source->initializeLazyObject(); + } elseif (\PHP_VERSION_ID >= 80400) { + (new \ReflectionClass($source))->initializeLazyObject($source); + } + + $objectMapInitialized = null === $this->objectMap; + if ($objectMapInitialized) { + $this->objectMap = new \SplObjectStorage(); + } + + $mappedTarget = $mappingFunction( + $source, + $targetObject, + $this->objectMapper ?? $this, + $this->metadataFactory, + $this->objectMap, + $this->propertyAccessor, + $this->transformCallableLocator, + $this->conditionCallableLocator, + $mappingToObject + ); + + if ($objectMapInitialized) { + $this->objectMap = null; + } + + return $mappedTarget; + } + + private function resolveTargetClass(object $source, object|string|null $target): ?string + { + if (\is_string($target)) { + return $target; + } + + if (\is_object($target)) { + return $target::class; + } + + $metadata = $this->metadataFactory->create($source); + $map = $this->getMapTarget($metadata, null, $source, null); + if (\is_string($map?->target) && class_exists($map->target)) { + return $map->target; + } + + return null; + } +} diff --git a/src/Symfony/Component/ObjectMapper/DependencyInjection/AttributeMetadataPass.php b/src/Symfony/Component/ObjectMapper/DependencyInjection/AttributeMetadataPass.php new file mode 100644 index 0000000000000..025c115b3e9cd --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/DependencyInjection/AttributeMetadataPass.php @@ -0,0 +1,64 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\DependencyInjection; + +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; + +/** + * @author Antoine Bluchet + */ +final class AttributeMetadataPass implements CompilerPassInterface +{ + public function process(ContainerBuilder $container): void + { + $warmerServiceId = 'object_mapper.cached.cache_warmer'; + if (!$container->hasDefinition($warmerServiceId)) { + return; + } + + $mappedPairs = []; + $resolve = $container->getParameterBag()->resolveValue(...); + foreach ($container->getDefinitions() as $id => $definition) { + if (!$tags = $definition->getTag('object_mapper.attribute_metadata')) { + continue; + } + + if (!$definition->hasTag('container.excluded')) { + throw new InvalidArgumentException(\sprintf('The resource "%s" with a "Map" attribute must be tagged with "container.excluded".', $id)); + } + + foreach ($tags as $tag) { + if (!isset($tag['source']) || !isset($tag['target'])) { + continue; + } + + $source = $resolve($tag['source']); + $target = $resolve($tag['target']); + + if (class_exists($source) && class_exists($target)) { + $mappedPairs[] = ['source' => $source, 'target' => $target]; + } + } + + $container->removeDefinition($id); + } + + if (!$mappedPairs) { + return; + } + + $container->getDefinition($warmerServiceId) + ->replaceArgument(0, $mappedPairs); + } +} diff --git a/src/Symfony/Component/ObjectMapper/Internal/MappingCacheGenerator.php b/src/Symfony/Component/ObjectMapper/Internal/MappingCacheGenerator.php new file mode 100644 index 0000000000000..c9f95eac29f9c --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Internal/MappingCacheGenerator.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 Symfony\Component\ObjectMapper\Internal; + +use Symfony\Component\Filesystem\Filesystem; +use Symfony\Component\ObjectMapper\Exception\MappingException; +use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; +use Symfony\Component\ObjectMapper\Metadata\ReflectionObjectMapperMetadataFactory; +use Symfony\Component\VarExporter\Exception\NotInstantiableTypeException; +use Symfony\Component\VarExporter\VarExporter; + +/** + * @internal + */ +final class MappingCacheGenerator implements MappingCacheGeneratorInterface +{ + use ObjectMapperTrait; + + private ?Filesystem $fs = null; + + public function __construct( + ObjectMapperMetadataFactoryInterface $metadataFactory = new ReflectionObjectMapperMetadataFactory(), + ) { + $this->metadataFactory = $metadataFactory; + } + + public function generate(string $sourceClass, string $targetClass): string + { + $refl = new \ReflectionClass($sourceClass); + if ($refl->isAbstract()) { + throw new MappingException(\sprintf('Can not generate mapping metadata from an abstract class "%s".', $sourceClass)); + } + + $sourceObject = $refl->newInstanceWithoutConstructor(); + $mappingData = $this->getMappingMetadata($sourceObject, $targetClass); + + return $this->generateMappingCode($mappingData, $sourceClass, $targetClass); + } + + public function write(string $cacheFile, string $code): void + { + $this->fs ??= new Filesystem(); + + if (!$this->fs->exists(\dirname($cacheFile))) { + $this->fs->mkdir(\dirname($cacheFile)); + } + + $this->fs->dumpFile($cacheFile, $code); + } + + private function getMappingMetadata(object $source, string $targetClass): array + { + try { + $sourceRefl = $this->getSourceReflectionClass($source); + $targetRefl = new \ReflectionClass($targetClass); + } catch (\ReflectionException $e) { + throw new MappingException($e->getMessage(), $e->getCode(), $e); + } + + $refl = $sourceRefl ?? $targetRefl; + $readMetadataFrom = $source; + if (!$this->metadataFactory->create($source)) { + $targetInstance = $targetRefl->newInstanceWithoutConstructor(); + if ($this->metadataFactory->create($targetInstance)) { + $readMetadataFrom = $targetInstance; + } + } + + if ($refl === $targetRefl) { + $readMetadataFrom = $targetRefl->newInstanceWithoutConstructor(); + } + + $metadata = $this->metadataFactory->create($readMetadataFrom); + $map = $this->getMapTarget($metadata, null, $source, null); + + $constructorParams = []; + if ($constructor = $targetRefl->getConstructor()) { + foreach ($constructor->getParameters() as $parameter) { + $paramName = $parameter->getName(); + // If $sourceRefl is null (stdClass), assume it is mappable, runtime will fail if it needs to + $sourceIsMappable = !$sourceRefl || $sourceRefl->hasProperty($paramName); + + $constructorParams[$paramName] = [ + 'name' => $paramName, + 'hasDefault' => $parameter->isDefaultValueAvailable(), + 'defaultValue' => $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null, + 'propertyIsMappable' => $this->propertyIsMappable($targetRefl, $paramName), + 'sourceIsMappable' => $sourceIsMappable, + ]; + } + } + + return [ + 'properties' => $this->analyzeProperties($refl, $readMetadataFrom, $sourceRefl, $targetRefl, $source, $constructorParams), + 'hasConstructor' => null !== $targetRefl->getConstructor(), + 'constructorParams' => $constructorParams, + 'targetTransform' => $map?->transform, + ]; + } + + /** + * @param array $mappingData + */ + public function generateMappingCode(array $mappingData, string $sourceClass, string $targetClass): string + { + $lines = [ + 'getCode(), $e); + } + + $lines[] = " if ((\$transform = MappingHelper::getCallable({$transformVar}, \$transformCallableLocator))) {"; + $lines[] = ' $newTarget = MappingHelper::call($transform, $target, $source, null);'; + $lines[] = ' if (!is_object($newTarget)) {'; + $lines[] = " throw new MappingTransformException(\\sprintf('Cannot map \"%s\" to a non-object target of type \"%s\".', get_debug_type(\$source), get_debug_type(\$newTarget)));"; + $lines[] = ' }'; + $lines[] = ' if (!is_a($newTarget, $target::class, true)) {'; + $lines[] = " throw new MappingException(\\sprintf('Expected the mapped object to be an instance of \"%s\" but got \"%s\".', get_debug_type(\$target), get_debug_type(\$newTarget)));"; + $lines[] = ' }'; + $lines[] = ' $target = $newTarget;'; + $lines[] = ' }'; + $lines[] = ''; + } + + $lines[] = ' $ctorArguments = [];'; + if ($mappingData['hasConstructor']) { + foreach ($mappingData['constructorParams'] as ['hasDefault' => $hasDefault, 'defaultValue' => $defaultValue, 'name' => $name]) { + if ($hasDefault) { + $defaultValue = VarExporter::export($defaultValue); + $lines[] = " \$ctorArguments['{$name}'] = {$defaultValue};"; + continue; + } + } + $lines[] = ''; + } + + $lines[] = ' $mapToProperties = [];'; + $lines[] = ' $objectMap[$source] = $target;'; + $lines[] = ''; + + foreach ($mappingData['properties'] as ['source' => $sourceProperty, 'target' => $targetProperty, 'mapping' => $mapping, 'isConstructorParam' => $isConstructorParam]) { + $condition = $mapping?->if; + $transform = $mapping?->transform; + + $lines[] = " if (!property_exists(\$source, '{$sourceProperty}') || (new \\ReflectionProperty(\$source, '{$sourceProperty}'))->isInitialized(\$source)) {"; + $lines[] = " \$value = MappingHelper::getValue(\$source, '{$sourceProperty}', \$propertyAccessor);"; + + $indentation = ' '; + if ($condition) { + try { + $conditionVar = VarExporter::export($condition); + } catch (NotInstantiableTypeException $e) { + throw new MappingException(\sprintf('The mapping condition from "%s" to "%s" can not be exported.', $sourceProperty, $targetProperty), $e->getCode(), $e); + } + $lines[] = " \$condition = MappingHelper::getCallable({$conditionVar}, \$conditionCallableLocator);"; + $lines[] = ' if ($condition && MappingHelper::call($condition, $value, $source, $target)) {'; + $indentation = ' '; + } + + if ($transform) { + try { + $transformVar = VarExporter::export($transform); + } catch (NotInstantiableTypeException $e) { + throw new MappingException(\sprintf('The mapping transform from "%s" to "%s" can not be exported.', $sourceProperty, $targetProperty), $e->getCode(), $e); + } + $lines[] = "{$indentation}\$transform = MappingHelper::getCallable({$transformVar}, \$transformCallableLocator);"; + $lines[] = "{$indentation}if (\$transform) {"; + $lines[] = "{$indentation} \$value = MappingHelper::call(\$transform, \$value, \$source, \$target);"; + $lines[] = "{$indentation}}"; + } + + $lines[] = "{$indentation}if (is_object(\$value) && MappingHelper::hasMappingTarget(\$value, \$metadataFactory)) {"; + $lines[] = "{$indentation} \$value = match (true) {"; + $lines[] = "{$indentation} \$value === \$source => \$target,"; + $lines[] = "{$indentation} \$objectMap->offsetExists(\$value) => \$objectMap[\$value],"; + $lines[] = "{$indentation} default => \$objectMapper->map(\$value),"; + $lines[] = "{$indentation} };"; + $lines[] = "{$indentation}}"; + + if ($isConstructorParam) { + $lines[] = "{$indentation}\$ctorArguments['{$targetProperty}'] = \$value;"; + } else { + $lines[] = "{$indentation}\$mapToProperties['{$targetProperty}'] = \$value;"; + } + + if ($condition && true !== $condition) { + $lines[] = ' }'; + } + + $lines[] = ' }'; + $lines[] = ''; + } + + if ($mappingData['hasConstructor'] && !$mappingData['targetTransform']) { + $lines[] = ' if (!$mappingToObject) {'; + $lines[] = ' $target->__construct(...$ctorArguments);'; + $lines[] = ' }'; + } + + // Do not output the ctor `if` if we do not need to + if (array_filter($mappingData['constructorParams'], fn($v) => $v['propertyIsMappable'])) { + $lines[] = ' if ($mappingToObject && $ctorArguments) {'; + foreach ($mappingData['constructorParams'] as $property => $param) { + if ($param['propertyIsMappable'] && $param['sourceIsMappable']) { + $lines[] = " if (isset(\$ctorArguments['$property'])) {"; + $lines[] = " \$mapToProperties['$property'] = \$ctorArguments['$property'];"; + $lines[] = ' }'; + } + } + $lines[] = ' }'; + } + + $lines[] = ' foreach ($mapToProperties as $prop => $v) {'; + $lines[] = ' MappingHelper::setValue($target, $prop, $v, $propertyAccessor);'; + $lines[] = ' }'; + + $lines[] = ' return $target;'; + $lines[] = '};'; + $lines[] = "\n"; + + return implode("\n", $lines); + } +} + diff --git a/src/Symfony/Component/ObjectMapper/Internal/MappingCacheGeneratorInterface.php b/src/Symfony/Component/ObjectMapper/Internal/MappingCacheGeneratorInterface.php new file mode 100644 index 0000000000000..fc380e913f0b7 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Internal/MappingCacheGeneratorInterface.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 Symfony\Component\ObjectMapper\Internal; + +use Symfony\Component\ObjectMapper\Exception\MappingException; + +/** + * @internal + */ +interface MappingCacheGeneratorInterface +{ + /** + * Generates the PHP code for a mapping function between a source and a target class. + * + * @param class-string $sourceClass + * @param class-string $targetClass + * + * @throws MappingException if code generation fails + */ + public function generate(string $sourceClass, string $targetClass): string; + + /** + * Writes the generated mapping code to a cache file. + */ + public function write(string $cacheFile, string $code): void; +} diff --git a/src/Symfony/Component/ObjectMapper/Internal/MappingCacheTrait.php b/src/Symfony/Component/ObjectMapper/Internal/MappingCacheTrait.php new file mode 100644 index 0000000000000..3a8ebe950f6a7 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Internal/MappingCacheTrait.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 Symfony\Component\ObjectMapper\Internal; + +/** + * @internal + */ +trait MappingCacheTrait +{ + private readonly string $cacheDir; + private readonly MappingCacheGeneratorInterface $generator; + + private function getCacheFile(string $sourceClass, string $targetClass): string + { + $cacheKey = hash('xxh128', $sourceClass.'-to-'.$targetClass); + + return $this->cacheDir.'/'.$cacheKey.'.php'; + } + + private function writeCacheFile(string $cacheFile, string $sourceClass, string $targetClass): void + { + $code = $this->generator->generate($sourceClass, $targetClass); + $this->generator->write($cacheFile, $code); + } +} diff --git a/src/Symfony/Component/ObjectMapper/Internal/ObjectMapperTrait.php b/src/Symfony/Component/ObjectMapper/Internal/ObjectMapperTrait.php new file mode 100644 index 0000000000000..98b9b8621cc69 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Internal/ObjectMapperTrait.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 Symfony\Component\ObjectMapper\Internal; + +use Psr\Container\ContainerInterface; +use Symfony\Component\ObjectMapper\Exception\MappingException; +use Symfony\Component\ObjectMapper\MappingHelper; +use Symfony\Component\ObjectMapper\Metadata\Mapping; +use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\Component\VarExporter\LazyObjectInterface; + +/** + * @internal + */ +trait ObjectMapperTrait +{ + private ObjectMapperMetadataFactoryInterface $metadataFactory; + private ?PropertyAccessorInterface $propertyAccessor = null; + private ?ContainerInterface $transformCallableLocator = null; + private ?ContainerInterface $conditionCallableLocator = null; + + /** + * @param array $constructorParams + * + * @return list + */ + private function analyzeProperties(\ReflectionClass $refl, object $readMetadataFrom, ?\ReflectionClass $sourceRefl, \ReflectionClass $targetRefl, object $source, array $constructorParams): array + { + $properties = []; + foreach ($refl->getProperties() as $property) { + if ($property->isStatic()) { + continue; + } + + $propertyName = $property->getName(); + $propertyMappings = $this->metadataFactory->create($readMetadataFrom, $propertyName); + + foreach ($propertyMappings as $mapping) { + if (false === $mapping->if) { + continue; + } + + $sourcePropertyName = $propertyName; + if ($mapping->source && (!$sourceRefl || !$sourceRefl->hasProperty($propertyName))) { + $sourcePropertyName = $mapping->source; + } + + $targetPropertyName = $mapping->target ?? $propertyName; + if (!$targetRefl->hasProperty($targetPropertyName) && !isset($constructorParams[$targetPropertyName])) { + continue; + } + + $properties[] = [ + 'source' => $sourcePropertyName, + 'target' => $targetPropertyName, + 'mapping' => $mapping, + 'isConstructorParam' => isset($constructorParams[$targetPropertyName]), + ]; + } + + $sourceHasProperty = !$sourceRefl || $sourceRefl->hasProperty($propertyName); + if (!$propertyMappings && $targetRefl->hasProperty($propertyName) && $sourceHasProperty) { + $properties[] = [ + 'source' => $propertyName, + 'target' => $propertyName, + 'mapping' => null, + 'isConstructorParam' => isset($constructorParams[$propertyName]), + ]; + } + } + + foreach ($constructorParams as $paramName => $paramData) { + $sourceIsMappable = $paramData['sourceIsMappable']; + + if ($sourceIsMappable && !$targetRefl->hasProperty($paramName)) { + // Check if this property is already handled + foreach ($properties as $prop) { + if ($prop['target'] === $paramName) { + continue 2; + } + } + + $properties[] = [ + 'source' => $paramName, + 'target' => $paramName, + 'mapping' => null, // No mapping attributes, it's a direct constructor param map + 'isConstructorParam' => true, + ]; + } + } + + return $properties; + } + + private function checkCondition(Mapping $mapping, mixed $value, object $source, ?object $target): bool + { + if (($if = $mapping->if) && ($fn = MappingHelper::getCallable($if, $this->conditionCallableLocator)) && !MappingHelper::call($fn, $value, $source, $target)) { + return false; + } + + return true; + } + + private function applyTransforms(Mapping $map, mixed $value, object $source, ?object $target): mixed + { + if (!$transforms = $map->transform) { + return $value; + } + + if (\is_callable($transforms)) { + $transforms = [$transforms]; + } elseif (!\is_array($transforms)) { + $transforms = [$transforms]; + } + + foreach ($transforms as $transform) { + if ($fn = MappingHelper::getCallable($transform, $this->transformCallableLocator)) { + $value = MappingHelper::call($fn, $value, $source, $target); + } + } + + return $value; + } + + /** + * @param Mapping[] $metadata + */ + private function getMapTarget(array $metadata, mixed $value, object $source, ?object $target): ?Mapping + { + $mapTo = null; + foreach ($metadata as $mapAttribute) { + if (false === $this->checkCondition($mapAttribute, $value, $source, $target)) { + continue; + } + + $mapTo = $mapAttribute; + } + + return $mapTo; + } + + /** + * @return ?\ReflectionClass + */ + private function getSourceReflectionClass(object $source): ?\ReflectionClass + { + $metadata = $this->metadataFactory->create($source); + try { + $refl = new \ReflectionClass($source); + } catch (\ReflectionException $e) { + throw new MappingException($e->getMessage(), $e->getCode(), $e); + } + + if ($source instanceof LazyObjectInterface) { + $source->initializeLazyObject(); + } elseif (\PHP_VERSION_ID >= 80400 && $refl->isUninitializedLazyObject($source)) { + $refl->initializeLazyObject($source); + } + + if ($metadata) { + return $refl; + } + + foreach ($refl->getProperties() as $property) { + if ($this->metadataFactory->create($source, $property->getName())) { + return $refl; + } + } + + return null; + } + + private function propertyIsMappable(\ReflectionClass $targetRefl, int|string $property): bool + { + return $targetRefl->hasProperty($property) && $targetRefl->getProperty($property)->isPublic(); + } +} + diff --git a/src/Symfony/Component/ObjectMapper/MappingHelper.php b/src/Symfony/Component/ObjectMapper/MappingHelper.php new file mode 100644 index 0000000000000..00d8fa5a3eff7 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/MappingHelper.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper; + +use Psr\Container\ContainerInterface; +use Symfony\Component\ObjectMapper\Exception\NoSuchPropertyException; +use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; +use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException as PropertyAccessorNoSuchPropertyException; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; + +/** + * Helper class for cached mapping functions. + * + * @internal + */ +final class MappingHelper +{ + public static function getCallable(string|callable $fn, ?ContainerInterface $locator = null): ?callable + { + if (\is_callable($fn)) { + return $fn; + } + + if ($locator?->has($fn)) { + return $locator->get($fn); + } + + return null; + } + + public static function call(callable $fn, mixed $value, object $source, ?object $target = null): mixed + { + if (\is_string($fn)) { + return \call_user_func($fn, $value); + } + + return $fn($value, $source, $target); + } + + public static function getValue(object $source, string $propertyName, ?PropertyAccessorInterface $propertyAccessor = null): mixed + { + if ($propertyAccessor) { + try { + return $propertyAccessor->getValue($source, $propertyName); + } catch (PropertyAccessorNoSuchPropertyException $e) { + throw new NoSuchPropertyException($e->getMessage(), $e->getCode(), $e); + } + } + + if (!property_exists($source, $propertyName) && !isset($source->{$propertyName})) { + throw new NoSuchPropertyException(\sprintf('The property "%s" does not exist on "%s".', $propertyName, get_debug_type($source))); + } + + return $source->{$propertyName}; + } + + public static function setValue(object $target, string $propertyName, mixed $value, ?PropertyAccessorInterface $propertyAccessor = null): void + { + if ($propertyAccessor) { + $propertyAccessor->setValue($target, $propertyName, $value); + } else { + $target->{$propertyName} = $value; + } + } + + public static function hasMappingTarget(object $target, ObjectMapperMetadataFactoryInterface $metadataFactory): bool + { + $metadata = $metadataFactory->create($target); + foreach ($metadata as $mapping) { + if ($mapping->target && \is_string($mapping->target) && class_exists($mapping->target)) { + return true; + } + } + + return false; + } +} diff --git a/src/Symfony/Component/ObjectMapper/ObjectMapper.php b/src/Symfony/Component/ObjectMapper/ObjectMapper.php index a35f691aef2fb..4c06d104b6e2c 100644 --- a/src/Symfony/Component/ObjectMapper/ObjectMapper.php +++ b/src/Symfony/Component/ObjectMapper/ObjectMapper.php @@ -15,12 +15,12 @@ use Symfony\Component\ObjectMapper\Exception\MappingException; use Symfony\Component\ObjectMapper\Exception\MappingTransformException; use Symfony\Component\ObjectMapper\Exception\NoSuchPropertyException; +use Symfony\Component\ObjectMapper\Internal\ObjectMapperTrait; use Symfony\Component\ObjectMapper\Metadata\Mapping; use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; use Symfony\Component\ObjectMapper\Metadata\ReflectionObjectMapperMetadataFactory; use Symfony\Component\PropertyAccess\Exception\NoSuchPropertyException as PropertyAccessorNoSuchPropertyException; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\VarExporter\LazyObjectInterface; /** * Object to object mapper. @@ -29,26 +29,33 @@ */ final class ObjectMapper implements ObjectMapperInterface, ObjectMapperAwareInterface { + use ObjectMapperAwareTrait; + use ObjectMapperTrait; + /** * Tracks recursive references. */ private ?\SplObjectStorage $objectMap = null; public function __construct( - private readonly ObjectMapperMetadataFactoryInterface $metadataFactory = new ReflectionObjectMapperMetadataFactory(), - private readonly ?PropertyAccessorInterface $propertyAccessor = null, - private readonly ?ContainerInterface $transformCallableLocator = null, - private readonly ?ContainerInterface $conditionCallableLocator = null, - private ?ObjectMapperInterface $objectMapper = null, + ObjectMapperMetadataFactoryInterface $metadataFactory = new ReflectionObjectMapperMetadataFactory(), + ?PropertyAccessorInterface $propertyAccessor = null, + ?ContainerInterface $transformCallableLocator = null, + ?ContainerInterface $conditionCallableLocator = null, + ?ObjectMapperInterface $objectMapper = null, ) { + $this->metadataFactory = $metadataFactory; + $this->propertyAccessor = $propertyAccessor; + $this->transformCallableLocator = $transformCallableLocator; + $this->conditionCallableLocator = $conditionCallableLocator; + $this->objectMapper = $objectMapper; } public function map(object $source, object|string|null $target = null): object { - $objectMapInitialized = false; - if (null === $this->objectMap) { + $objectMapInitialized = null === $this->objectMap; + if ($objectMapInitialized) { $this->objectMap = new \SplObjectStorage(); - $objectMapInitialized = true; } $metadata = $this->metadataFactory->create($source); @@ -90,95 +97,93 @@ public function map(object $source, object|string|null $target = null): object } $this->objectMap[$source] = $mappedTarget; + $ctorArguments = []; + $ctorValues = []; $targetConstructor = $targetRefl->getConstructor(); + $sourceRefl = $this->getSourceReflectionClass($source); + foreach ($targetConstructor?->getParameters() ?? [] as $parameter) { $parameterName = $parameter->getName(); - - if ($targetRefl->hasProperty($parameterName)) { - $property = $targetRefl->getProperty($parameterName); - - if ($property->isReadOnly() && $property->isInitialized($mappedTarget)) { + $hasDefault = $parameter->isDefaultValueAvailable(); + $defaultValue = $hasDefault ? $parameter->getDefaultValue() : null; + $sourceIsMappable = $this->isReadable($source, $parameterName); + + if ( + $targetRefl->hasProperty($parameterName) && + ($property = $targetRefl->getProperty($parameterName)) && + $property->isReadOnly() && + $property->isInitialized($mappedTarget) + ) { + $ctorArguments[$parameterName] = [ + 'name' => $parameterName, + 'hasDefault' => $hasDefault, + 'defaultValue' => $defaultValue, + 'propertyIsMappable' => $this->propertyIsMappable($targetRefl, $parameterName), + 'sourceIsMappable' => $sourceIsMappable, + ]; continue; - } } - if ($this->isReadable($source, $parameterName)) { - $ctorArguments[$parameterName] = $this->getRawValue($source, $parameterName); + if ($sourceIsMappable) { + $ctorValues[$parameterName] = $this->getRawValue($source, $parameterName); } else { - $ctorArguments[$parameterName] = $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; + $ctorValues[$parameterName] = $defaultValue; } + + $ctorArguments[$parameterName] = [ + 'name' => $parameterName, + 'hasDefault' => $hasDefault, + 'defaultValue' => $defaultValue, + 'propertyIsMappable' => $this->propertyIsMappable($targetRefl, $parameterName), + 'sourceIsMappable' => $sourceIsMappable, + ]; } $readMetadataFrom = $source; - $refl = $this->getSourceReflectionClass($source) ?? $targetRefl; + $refl = $sourceRefl ?? $targetRefl; // When source contains no metadata, we read metadata on the target instead if ($refl === $targetRefl) { $readMetadataFrom = $mappedTarget; } + $properties = $this->analyzeProperties($refl, $readMetadataFrom, $sourceRefl, $targetRefl, $source, $ctorArguments); $mapToProperties = []; - foreach ($refl->getProperties() as $property) { - if ($property->isStatic()) { + + foreach ($properties as ['source' => $sourceProperty, 'target' => $targetProperty, 'mapping' => $mapping]) { + if ($sourceRefl?->hasProperty($sourceProperty) && !$sourceRefl->getProperty($sourceProperty)->isInitialized($source)) { continue; } - $propertyName = $property->getName(); - $mappings = $this->metadataFactory->create($readMetadataFrom, $propertyName); - foreach ($mappings as $mapping) { - $sourcePropertyName = $propertyName; - if ($mapping->source && (!$refl->hasProperty($propertyName) || !isset($source->$propertyName))) { - $sourcePropertyName = $mapping->source; - } - - if (false === $if = $mapping->if) { - continue; - } - - $value = $this->getRawValue($source, $sourcePropertyName); - if ($if && ($fn = $this->getCallable($if, $this->conditionCallableLocator)) && !$this->call($fn, $value, $source, $mappedTarget)) { - continue; - } + $value = $this->getRawValue($source, $sourceProperty); - $targetPropertyName = $mapping->target ?? $propertyName; - if (!$targetRefl->hasProperty($targetPropertyName)) { - continue; - } - - $value = $this->getSourceValue($source, $mappedTarget, $value, $this->objectMap, $mapping); - $this->storeValue($targetPropertyName, $mapToProperties, $ctorArguments, $value); + if ($mapping && false === $this->checkCondition($mapping, $value, $source, $mappedTarget)) { + continue; } - if (!$mappings && $targetRefl->hasProperty($propertyName)) { - $sourceProperty = $refl->getProperty($propertyName); - if ($refl->isInstance($source) && !$sourceProperty->isInitialized($source)) { - continue; - } - - $value = $this->getSourceValue($source, $mappedTarget, $this->getRawValue($source, $propertyName), $this->objectMap); - $this->storeValue($propertyName, $mapToProperties, $ctorArguments, $value); - } + $value = $this->getSourceValue($source, $mappedTarget, $value, $this->objectMap, $mapping); + $this->storeValue($targetProperty, $mapToProperties, $ctorValues, $value); } if (!$mappingToObject && !$map?->transform && $targetConstructor) { try { - $mappedTarget->__construct(...$ctorArguments); + $mappedTarget->__construct(...$ctorValues); } catch (\ReflectionException $e) { throw new MappingException($e->getMessage(), $e->getCode(), $e); } } - if ($mappingToObject && $ctorArguments) { - foreach ($ctorArguments as $property => $value) { - if ($this->propertyIsMappable($refl, $property) && $this->propertyIsMappable($targetRefl, $property)) { + if ($mappingToObject && $ctorValues) { + foreach ($ctorValues as $property => $value) { + if ($ctorArguments[$property]['propertyIsMappable'] && $ctorArguments[$property]['sourceIsMappable']) { $mapToProperties[$property] = $value; } } } foreach ($mapToProperties as $property => $value) { - $this->propertyAccessor ? $this->propertyAccessor->setValue($mappedTarget, $property, $value) : ($mappedTarget->{$property} = $value); + MappingHelper::setValue($mappedTarget, $property, $value, $this->propertyAccessor); } if ($objectMapInitialized) { @@ -260,114 +265,5 @@ private function storeValue(string $propertyName, array &$mapToProperties, array $mapToProperties[$propertyName] = $value; } - - /** - * @param callable(): mixed $fn - */ - private function call(callable $fn, mixed $value, object $source, ?object $target = null): mixed - { - if (\is_string($fn)) { - return \call_user_func($fn, $value); - } - - return $fn($value, $source, $target); - } - - /** - * @param Mapping[] $metadata - */ - private function getMapTarget(array $metadata, mixed $value, object $source, ?object $target): ?Mapping - { - $mapTo = null; - foreach ($metadata as $mapAttribute) { - if (($if = $mapAttribute->if) && ($fn = $this->getCallable($if, $this->conditionCallableLocator)) && !$this->call($fn, $value, $source, $target)) { - continue; - } - - $mapTo = $mapAttribute; - } - - return $mapTo; - } - - private function applyTransforms(Mapping $map, mixed $value, object $source, ?object $target): mixed - { - if (!$transforms = $map->transform) { - return $value; - } - - if (\is_callable($transforms)) { - $transforms = [$transforms]; - } elseif (!\is_array($transforms)) { - $transforms = [$transforms]; - } - - foreach ($transforms as $transform) { - if ($fn = $this->getCallable($transform, $this->transformCallableLocator)) { - $value = $this->call($fn, $value, $source, $target); - } - } - - return $value; - } - - /** - * @param (string|callable(mixed $value, object $object): mixed) $fn - */ - private function getCallable(string|callable $fn, ?ContainerInterface $locator = null): ?callable - { - if (\is_callable($fn)) { - return $fn; - } - - if ($locator?->has($fn)) { - return $locator->get($fn); - } - - return null; - } - - /** - * @return ?\ReflectionClass - */ - private function getSourceReflectionClass(object $source): ?\ReflectionClass - { - $metadata = $this->metadataFactory->create($source); - try { - $refl = new \ReflectionClass($source); - } catch (\ReflectionException $e) { - throw new MappingException($e->getMessage(), $e->getCode(), $e); - } - - if ($source instanceof LazyObjectInterface) { - $source->initializeLazyObject(); - } elseif ($refl->isUninitializedLazyObject($source)) { - $refl->initializeLazyObject($source); - } - - if ($metadata) { - return $refl; - } - - foreach ($refl->getProperties() as $property) { - if ($this->metadataFactory->create($source, $property->getName())) { - return $refl; - } - } - - return null; - } - - private function propertyIsMappable(\ReflectionClass $targetRefl, int|string $property): bool - { - return $targetRefl->hasProperty($property) && $targetRefl->getProperty($property)->isPublic(); - } - - public function withObjectMapper(ObjectMapperInterface $objectMapper): static - { - $clone = clone $this; - $clone->objectMapper = $objectMapper; - - return $clone; - } } + diff --git a/src/Symfony/Component/ObjectMapper/ObjectMapperAwareTrait.php b/src/Symfony/Component/ObjectMapper/ObjectMapperAwareTrait.php new file mode 100644 index 0000000000000..2f163e70977ae --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/ObjectMapperAwareTrait.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 Symfony\Component\ObjectMapper; + +trait ObjectMapperAwareTrait +{ + private ?ObjectMapperInterface $objectMapper = null; + + public function withObjectMapper(ObjectMapperInterface $objectMapper): static + { + $clone = clone $this; + $clone->objectMapper = $objectMapper; + + return $clone; + } +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/CachedObjectMapperTest.php b/src/Symfony/Component/ObjectMapper/Tests/CachedObjectMapperTest.php new file mode 100644 index 0000000000000..1046689518a38 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/CachedObjectMapperTest.php @@ -0,0 +1,229 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests; + +use PHPUnit\Framework\TestCase; +use Psr\Container\ContainerInterface; +use Symfony\Component\ObjectMapper\CachedObjectMapper; +use Symfony\Component\ObjectMapper\Exception\MappingTransformException; +use Symfony\Component\ObjectMapper\Metadata\Mapping; +use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; +use Symfony\Component\ObjectMapper\Tests\Fixtures\A; +use Symfony\Component\ObjectMapper\Tests\Fixtures\B; +use Symfony\Component\ObjectMapper\Tests\Fixtures\C; +use Symfony\Component\ObjectMapper\Tests\Fixtures\D; +use Symfony\Component\ObjectMapper\Tests\Fixtures\ServiceLocator\A as ServiceLocatorA; +use Symfony\Component\ObjectMapper\Tests\Fixtures\ServiceLocator\B as ServiceLocatorB; +use Symfony\Component\ObjectMapper\Tests\Fixtures\ServiceLocator\ConditionCallable; +use Symfony\Component\ObjectMapper\Tests\Fixtures\ServiceLocator\TransformCallable; +use Symfony\Component\ObjectMapper\Tests\Fixtures\SimpleSource; +use Symfony\Component\ObjectMapper\Tests\Fixtures\SimpleTarget; + +final class CachedObjectMapperTest extends TestCase +{ + private static ?string $cacheDir = null; + + public static function getCacheDir(): string + { + if (self::$cacheDir) { + return self::$cacheDir; + } + + self::$cacheDir = sys_get_temp_dir().'/symfony_object_mapper_cache_test_'.uniqid(); + if (!is_dir(self::$cacheDir)) { + mkdir(self::$cacheDir, 0o775, true); + } + + return self::$cacheDir; + } + + public static function tearDownAfterClass(): void + { + if (is_dir(self::$cacheDir)) { + array_map('unlink', glob(self::$cacheDir.'/*')); + rmdir(self::$cacheDir); + } + } + + public function testBasicMapping() + { + $mapper = new CachedObjectMapper(self::getCacheDir()); + + $d = new D(baz: 'foo', bat: 'bar'); + $c = new C(foo: 'foo', bar: 'bar'); + $a = new A(); + $a->foo = 'test'; + $a->transform = 'test'; + $a->baz = 'me'; + $a->notinb = 'test'; + $a->relation = $c; + $a->relationNotMapped = $d; + + $expected = new B('test'); + $expected->transform = 'TEST'; + $expected->baz = 'me'; + $expected->nomap = true; + $expected->concat = 'testme'; + $expected->relation = $d; + $expected->relationNotMapped = $d; + + $result = $mapper->map($a); + + $this->assertEquals($expected, $result); + $this->assertInstanceOf(B::class, $result); + } + + public function testCacheFileIsGenerated() + { + $mapper = new CachedObjectMapper(self::getCacheDir()); + + $source = new SimpleSource(); + $source->foo = 'test'; + + $result = $mapper->map($source); + $fileName = hash('xxh128', SimpleSource::class.'-to-'.SimpleTarget::class).'.php'; + $this->assertFileExists(self::getCacheDir().'/'.$fileName); + $this->assertInstanceOf(SimpleTarget::class, $result); + $this->assertEquals('test', $result->bar); + } + + public function testTransformToWrongValueType() + { + $this->expectException(MappingTransformException::class); + $this->expectExceptionMessage('Cannot map "stdClass" to a non-object target of type "string".'); + + $u = new \stdClass(); + $metadata = $this->createStub(ObjectMapperMetadataFactoryInterface::class); + $metadata->method('create')->with($u)->willReturn([new Mapping(target: \stdClass::class, transform: [self::class, 'transform'])]); + $mapper = new CachedObjectMapper(self::getCacheDir(), $metadata); + $mapper->map($u); + } + + public static function transform(): string + { + return 'str'; + } + + public function testTransformToWrongObject() + { + $this->expectException(MappingTransformException::class); + $this->expectExceptionMessage('Cannot map "stdClass" to a non-object target of type "string".'); + + $u = new \stdClass(); + $u->foo = 'bar'; + + $metadata = $this->createStub(ObjectMapperMetadataFactoryInterface::class); + $metadata->method('create')->with($u)->willReturn([new Mapping(target: \stdClass::class, transform: [self::class, 'transform'])]); + $mapper = new CachedObjectMapper(self::getCacheDir(), $metadata); + $mapper->map($u); + } + + public static function getStdClass(): \stdClass + { + return new \stdClass(); + } + + public function testCacheIsReused() + { + $mapper = new CachedObjectMapper(self::getCacheDir()); + + $source1 = new SimpleSource(); + $source1->foo = 'test1'; + + $source2 = new SimpleSource(); + $source2->foo = 'test2'; + + $result1 = $mapper->map($source1); + $cacheFiles = glob(self::getCacheDir().'/*.php'); + $initialCacheTime = filemtime($cacheFiles[0]); + + usleep(1000); + + $result2 = $mapper->map($source2); + + $this->assertSame($initialCacheTime, filemtime($cacheFiles[0])); + $this->assertEquals('test1', $result1->bar); + $this->assertEquals('test2', $result2->bar); + } + + public function testMappingToExistingObject() + { + $mapper = new CachedObjectMapper(self::getCacheDir()); + + $source = new SimpleSource(); + $source->foo = 'test'; + + $existingTarget = new SimpleTarget(); + $existingTarget->bar = 'existing'; + + $result = $mapper->map($source, $existingTarget); + + $this->assertSame($existingTarget, $result); + $this->assertEquals('test', $result->bar); + } + + public function testMappingWithExplicitTargetClass() + { + $mapper = new CachedObjectMapper(self::getCacheDir()); + + $source = new SimpleSource(); + $source->foo = 'test'; + + $result = $mapper->map($source, SimpleTarget::class); + + $this->assertInstanceOf(SimpleTarget::class, $result); + $this->assertEquals('test', $result->bar); + } + + public function testServiceLocator() + { + $fileName = hash('xxh128', ServiceLocatorA::class.'-to-'.ServiceLocatorB::class).'.php'; + $conditionCallableLocator = self::getServiceLocator([ConditionCallable::class => new ConditionCallable()]); + $transformCallableLocator = self::getServiceLocator([TransformCallable::class => new TransformCallable()]); + + $objectMapper = new CachedObjectMapper( + self::getCacheDir(), + conditionCallableLocator: $conditionCallableLocator, + transformCallableLocator: $transformCallableLocator, + ); + $a = new ServiceLocatorA(); + $a->foo = 'nok'; + + $b = $objectMapper->map($a); + $this->assertSame($b->bar, 'notmapped'); + $this->assertInstanceOf(ServiceLocatorB::class, $b); + + $a->foo = 'ok'; + $b = $objectMapper->map($a); + $this->assertInstanceOf(ServiceLocatorB::class, $b); + $this->assertSame($b->bar, 'transformedok'); + } + + private static function getServiceLocator(array $factories): ContainerInterface + { + return new class($factories) implements ContainerInterface { + public function __construct(private array $factories) + { + } + + public function has(string $id): bool + { + return isset($this->factories[$id]); + } + + public function get(string $id): mixed + { + return $this->factories[$id]; + } + }; + } +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/DependencyInjection/AttributeMetadataPassTest.php b/src/Symfony/Component/ObjectMapper/Tests/DependencyInjection/AttributeMetadataPassTest.php new file mode 100644 index 0000000000000..0c2782b1fd1ae --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/DependencyInjection/AttributeMetadataPassTest.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 Symfony\Component\ObjectMapper\Tests\DependencyInjection; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException; +use Symfony\Component\ObjectMapper\DependencyInjection\AttributeMetadataPass; +use Symfony\Component\ObjectMapper\Tests\Fixtures\A; +use Symfony\Component\ObjectMapper\Tests\Fixtures\B; +use Symfony\Component\ObjectMapper\Tests\Fixtures\C; +use Symfony\Component\ObjectMapper\Tests\Fixtures\ClassWithoutTarget; +use Symfony\Component\ObjectMapper\Tests\Fixtures\D; + +class AttributeMetadataPassTest extends TestCase +{ + public function testProcessWithNoWarmer() + { + $container = new ContainerBuilder(); + (new AttributeMetadataPass())->process($container); + $this->expectNotToPerformAssertions(); + } + + public function testProcessWithWarmerButNoTaggedServices() + { + $container = new ContainerBuilder(); + $container->register('object_mapper.cached.cache_warmer'); + + (new AttributeMetadataPass())->process($container); + + $this->assertCount(0, $container->getDefinition('object_mapper.cached.cache_warmer')->getArguments()); + } + + public function testProcessThrowsExceptionForMissingExcludeTag() + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(\sprintf('The resource "%s" with a "Map" attribute must be tagged with "container.excluded".', A::class)); + + $container = new ContainerBuilder(); + $container->register('object_mapper.cached.cache_warmer', \stdClass::class)->addArgument([]); + $container->register(A::class) + ->addTag('object_mapper.attribute_metadata', ['source' => A::class, 'target' => B::class]); + + (new AttributeMetadataPass())->process($container); + } + + public function testProcessWithTaggedServices() + { + $container = new ContainerBuilder(); + $container->setParameter('source.class_a', A::class); + $container->register('object_mapper.cached.cache_warmer', \stdClass::class)->addArgument([]); + + $container->register('service1', '%source.class_a%') + ->addTag('object_mapper.attribute_metadata', ['source' => '%source.class_a%', 'target' => B::class]) + ->addTag('container.excluded'); + $container->register('service2', C::class) + ->addTag('object_mapper.attribute_metadata', ['source' => C::class, 'target' => D::class]) + ->addTag('container.excluded'); + $container->register('service3', ClassWithoutTarget::class) + ->addTag('object_mapper.attribute_metadata', ['source' => ClassWithoutTarget::class]) + ->addTag('container.excluded'); + + (new AttributeMetadataPass())->process($container); + + $warmerDef = $container->getDefinition('object_mapper.cached.cache_warmer'); + $this->assertCount(1, $warmerDef->getArguments()); + $mappedPairs = $warmerDef->getArgument(0); + + $expectedPairs = [ + ['source' => A::class, 'target' => B::class], + ['source' => C::class, 'target' => D::class], + ]; + + $this->assertCount(2, $mappedPairs); + $this->assertEquals($expectedPairs, $mappedPairs); + + $this->assertFalse($container->hasDefinition('service1')); + $this->assertFalse($container->hasDefinition('service2')); + $this->assertFalse($container->hasDefinition('service3')); + } +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/AbstractA.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/AbstractA.php new file mode 100644 index 0000000000000..181dbe4a33a6d --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/AbstractA.php @@ -0,0 +1,7 @@ + 'test', + default => throw new \LogicException($key), + }; + } +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MapStruct/AToBMapper.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MapStruct/AToBMapper.php index 3eb09fdc23001..74a68e6c27077 100644 --- a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MapStruct/AToBMapper.php +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/MapStruct/AToBMapper.php @@ -17,7 +17,7 @@ #[Map(source: Source::class, target: Target::class)] class AToBMapper implements ObjectMapperInterface { - public function __construct(private readonly ObjectMapper $objectMapper) + public function __construct(private readonly ObjectMapperInterface $objectMapper) { } diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/SimpleSource.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/SimpleSource.php new file mode 100644 index 0000000000000..50fbd88cdd016 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/SimpleSource.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 Symfony\Component\ObjectMapper\Tests\Fixtures; + +use Symfony\Component\ObjectMapper\Attribute\Map; + +#[Map(target: SimpleTarget::class)] +class SimpleSource +{ + #[Map(target: 'bar')] + public string $foo = ''; +} + diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/SimpleTarget.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/SimpleTarget.php new file mode 100644 index 0000000000000..d0a68f15359db --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/SimpleTarget.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 Symfony\Component\ObjectMapper\Tests\Fixtures; + +class SimpleTarget +{ + public string $bar = ''; +} + diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/generated/a460713f6773437414a547074965e45e.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/generated/a460713f6773437414a547074965e45e.php new file mode 100644 index 0000000000000..35ab9e4dd94eb --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/generated/a460713f6773437414a547074965e45e.php @@ -0,0 +1,50 @@ +isInitialized($source)) { + $value = MappingHelper::getValue($source, 'foo', $propertyAccessor); + $condition = MappingHelper::getCallable('Symfony\\Component\\ObjectMapper\\Tests\\Fixtures\\ServiceLocator\\ConditionCallable', $conditionCallableLocator); + if ($condition && MappingHelper::call($condition, $value, $source, $target)) { + $transform = MappingHelper::getCallable('Symfony\\Component\\ObjectMapper\\Tests\\Fixtures\\ServiceLocator\\TransformCallable', $transformCallableLocator); + if ($transform) { + $value = MappingHelper::call($transform, $value, $source, $target); + } + if (is_object($value) && MappingHelper::hasMappingTarget($value, $metadataFactory)) { + $value = match (true) { + $value === $source => $target, + $objectMap->offsetExists($value) => $objectMap[$value], + default => $objectMapper->map($value), + }; + } + $mapToProperties['bar'] = $value; + } + } + + foreach ($mapToProperties as $prop => $v) { + MappingHelper::setValue($target, $prop, $v, $propertyAccessor); + } + return $target; +}; + diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/generated/promoted_constructor_mapping.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/generated/promoted_constructor_mapping.php new file mode 100644 index 0000000000000..728567b05cad8 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/generated/promoted_constructor_mapping.php @@ -0,0 +1,67 @@ +isInitialized($source)) { + $value = MappingHelper::getValue($source, 'id', $propertyAccessor); + if (is_object($value) && MappingHelper::hasMappingTarget($value, $metadataFactory)) { + $value = match (true) { + $value === $source => $target, + $objectMap->offsetExists($value) => $objectMap[$value], + default => $objectMapper->map($value), + }; + } + $ctorArguments['id'] = $value; + } + + if (!property_exists($source, 'name') || (new \ReflectionProperty($source, 'name'))->isInitialized($source)) { + $value = MappingHelper::getValue($source, 'name', $propertyAccessor); + if (is_object($value) && MappingHelper::hasMappingTarget($value, $metadataFactory)) { + $value = match (true) { + $value === $source => $target, + $objectMap->offsetExists($value) => $objectMap[$value], + default => $objectMapper->map($value), + }; + } + $ctorArguments['name'] = $value; + } + + if (!$mappingToObject) { + $target->__construct(...$ctorArguments); + } + if ($mappingToObject && $ctorArguments) { + if (isset($ctorArguments['id'])) { + $mapToProperties['id'] = $ctorArguments['id']; + } + if (isset($ctorArguments['name'])) { + $mapToProperties['name'] = $ctorArguments['name']; + } + } + foreach ($mapToProperties as $prop => $v) { + MappingHelper::setValue($target, $prop, $v, $propertyAccessor); + } + return $target; +}; + diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/generated/simple_source_to_simple_target_mapping.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/generated/simple_source_to_simple_target_mapping.php new file mode 100644 index 0000000000000..fca2f29dd12b5 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/generated/simple_source_to_simple_target_mapping.php @@ -0,0 +1,43 @@ +isInitialized($source)) { + $value = MappingHelper::getValue($source, 'foo', $propertyAccessor); + if (is_object($value) && MappingHelper::hasMappingTarget($value, $metadataFactory)) { + $value = match (true) { + $value === $source => $target, + $objectMap->offsetExists($value) => $objectMap[$value], + default => $objectMapper->map($value), + }; + } + $mapToProperties['bar'] = $value; + } + + foreach ($mapToProperties as $prop => $v) { + MappingHelper::setValue($target, $prop, $v, $propertyAccessor); + } + return $target; +}; + diff --git a/src/Symfony/Component/ObjectMapper/Tests/Fixtures/generated/stdclass_to_d_constructor_mapping.php b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/generated/stdclass_to_d_constructor_mapping.php new file mode 100644 index 0000000000000..84f02bd0f4d57 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/Fixtures/generated/stdclass_to_d_constructor_mapping.php @@ -0,0 +1,47 @@ +isInitialized($source)) { + $value = MappingHelper::getValue($source, 'bar', $propertyAccessor); + if (is_object($value) && MappingHelper::hasMappingTarget($value, $metadataFactory)) { + $value = match (true) { + $value === $source => $target, + $objectMap->offsetExists($value) => $objectMap[$value], + default => $objectMapper->map($value), + }; + } + $ctorArguments['bar'] = $value; + } + + if (!$mappingToObject) { + $target->__construct(...$ctorArguments); + } + foreach ($mapToProperties as $prop => $v) { + MappingHelper::setValue($target, $prop, $v, $propertyAccessor); + } + return $target; +}; + diff --git a/src/Symfony/Component/ObjectMapper/Tests/MappingCacheGeneratorTest.php b/src/Symfony/Component/ObjectMapper/Tests/MappingCacheGeneratorTest.php new file mode 100644 index 0000000000000..05979831c1a12 --- /dev/null +++ b/src/Symfony/Component/ObjectMapper/Tests/MappingCacheGeneratorTest.php @@ -0,0 +1,148 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\ObjectMapper\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\ObjectMapper\Exception\MappingException; +use Symfony\Component\ObjectMapper\Internal\MappingCacheGenerator; +use Symfony\Component\ObjectMapper\Metadata\Mapping; +use Symfony\Component\ObjectMapper\Metadata\ObjectMapperMetadataFactoryInterface; +use Symfony\Component\ObjectMapper\Metadata\ReflectionObjectMapperMetadataFactory; +use Symfony\Component\ObjectMapper\ObjectMapperInterface; +use Symfony\Component\ObjectMapper\Tests\Fixtures\ClassWithoutTarget; +use Symfony\Component\ObjectMapper\Tests\Fixtures\ServiceLocator\A as ServiceLocatorA; +use Symfony\Component\ObjectMapper\Tests\Fixtures\ServiceLocator\B as ServiceLocatorB; +use Symfony\Component\ObjectMapper\Tests\Fixtures\SimpleSource; +use Symfony\Component\ObjectMapper\Tests\Fixtures\SimpleTarget; + +final class MappingCacheGeneratorTest extends TestCase +{ + private static ?string $cacheDir = null; + + public static function getCacheDir(): string + { + if (self::$cacheDir) { + return self::$cacheDir; + } + + self::$cacheDir = sys_get_temp_dir().'/symfony_object_mapper_cache_test_generator_'.uniqid(); + if (!is_dir(self::$cacheDir)) { + mkdir(self::$cacheDir, 0o775, true); + } + + return self::$cacheDir; + } + + public static function tearDownAfterClass(): void + { + if (is_dir(self::$cacheDir)) { + array_map('unlink', glob(self::$cacheDir.'/*')); + rmdir(self::$cacheDir); + } + } + + public function testGenerateAndWriteCacheFile() + { + $metadataFactory = new ReflectionObjectMapperMetadataFactory(); + $generator = new MappingCacheGenerator($metadataFactory); + + $sourceClass = SimpleSource::class; + $targetClass = SimpleTarget::class; + + $generatedCode = $generator->generate($sourceClass, $targetClass); + $this->assertIsString($generatedCode); + + $cacheFile = self::getCacheDir().'/'.hash('xxh128', $sourceClass.'-to-'.$targetClass).'.php'; + $generator->write($cacheFile, $generatedCode); + + $this->assertFileExists($cacheFile); + + $mappingFunction = require $cacheFile; + $this->assertIsCallable($mappingFunction); + + $source = new SimpleSource(); + $source->foo = 'test_value'; + $target = new SimpleTarget(); + + $objectMapper = $this->createStub(ObjectMapperInterface::class); + $objectMap = new \SplObjectStorage(); + + $mappedTarget = $mappingFunction( + $source, + $target, + $objectMapper, + $metadataFactory, + $objectMap, + null, + null, + null, + false + ); + + $this->assertInstanceOf(SimpleTarget::class, $mappedTarget); + $this->assertEquals('test_value', $mappedTarget->bar); + + $expectedFixturePath = __DIR__.'/Fixtures/generated/simple_source_to_simple_target_mapping.php'; + $this->assertStringEqualsFile($expectedFixturePath, $generatedCode); + } + + public function testClosure() + { + $this->expectException(MappingException::class); + $this->expectExceptionMessage('The transform on "stdClass" can not be exported.'); + + $u = new \stdClass(); + $metadata = $this->createStub(ObjectMapperMetadataFactoryInterface::class); + $metadata->method('create')->with($u)->willReturn([new Mapping(target: ClassWithoutTarget::class, transform: fn () => new \stdClass())]); + $generator = new MappingCacheGenerator($metadata); + $generator->generate($u::class, ClassWithoutTarget::class); + } + + public function testServiceLocatorMappingCacheGeneration() + { + $metadataFactory = new ReflectionObjectMapperMetadataFactory(); + $generator = new MappingCacheGenerator($metadataFactory); + + $sourceClass = ServiceLocatorA::class; + $targetClass = ServiceLocatorB::class; + + $generatedCode = $generator->generate($sourceClass, $targetClass); + $expectedFixturePath = __DIR__.'/Fixtures/generated/a460713f6773437414a547074965e45e.php'; + $this->assertStringEqualsFile($expectedFixturePath, $generatedCode); + } + + public function testPromotedConstructorMappingCacheGeneration() + { + $metadataFactory = new ReflectionObjectMapperMetadataFactory(); + $generator = new MappingCacheGenerator($metadataFactory); + + $sourceClass = Fixtures\PromotedConstructor\Source::class; + $targetClass = Fixtures\PromotedConstructor\Target::class; + + $generatedCode = $generator->generate($sourceClass, $targetClass); + $expectedFixturePath = __DIR__.'/Fixtures/generated/promoted_constructor_mapping.php'; + $this->assertStringEqualsFile($expectedFixturePath, $generatedCode); + } + + public function testStdClassToDConstructorMappingCacheGeneration() + { + $metadataFactory = new ReflectionObjectMapperMetadataFactory(); + $generator = new MappingCacheGenerator($metadataFactory); + + $sourceClass = \stdClass::class; + $targetClass = Fixtures\InitializedConstructor\D::class; + + $generatedCode = $generator->generate($sourceClass, $targetClass); + $expectedFixturePath = __DIR__.'/Fixtures/generated/stdclass_to_d_constructor_mapping.php'; + $this->assertStringEqualsFile($expectedFixturePath, $generatedCode); + } +} diff --git a/src/Symfony/Component/ObjectMapper/Tests/ObjectMapperTest.php b/src/Symfony/Component/ObjectMapper/Tests/ObjectMapperTest.php index 4f1b4c1fc31de..5caab835280b7 100644 --- a/src/Symfony/Component/ObjectMapper/Tests/ObjectMapperTest.php +++ b/src/Symfony/Component/ObjectMapper/Tests/ObjectMapperTest.php @@ -12,8 +12,12 @@ namespace Symfony\Component\ObjectMapper\Tests; use PHPUnit\Framework\Attributes\DataProvider; +use PHPUnit\Framework\Attributes\Group; +use PHPUnit\Framework\Attributes\IgnorePhpunitDeprecations; +use PHPUnit\Framework\Attributes\RequiresPhp; use PHPUnit\Framework\TestCase; use Psr\Container\ContainerInterface; +use Symfony\Component\ObjectMapper\CachedObjectMapper; use Symfony\Component\ObjectMapper\Exception\MappingException; use Symfony\Component\ObjectMapper\Exception\MappingTransformException; use Symfony\Component\ObjectMapper\Exception\NoSuchPropertyException; @@ -35,6 +39,7 @@ use Symfony\Component\ObjectMapper\Tests\Fixtures\Flatten\TargetUser; use Symfony\Component\ObjectMapper\Tests\Fixtures\Flatten\User; use Symfony\Component\ObjectMapper\Tests\Fixtures\Flatten\UserProfile; +use Symfony\Component\ObjectMapper\Tests\Fixtures\HydrateObject\MagicMethods; use Symfony\Component\ObjectMapper\Tests\Fixtures\HydrateObject\SourceOnly; use Symfony\Component\ObjectMapper\Tests\Fixtures\InitializedConstructor\A as InitializedConstructorA; use Symfony\Component\ObjectMapper\Tests\Fixtures\InitializedConstructor\B as InitializedConstructorB; @@ -79,13 +84,46 @@ final class ObjectMapperTest extends TestCase { + private static ?string $cacheDir = null; + + public static function getCacheDir(): string + { + if (self::$cacheDir) { + return self::$cacheDir; + } + + self::$cacheDir = sys_get_temp_dir().'/symfony_object_mapper_cache_test_'.uniqid(); + if (!is_dir(self::$cacheDir)) { + mkdir(self::$cacheDir, 0o775, true); + } + + return self::$cacheDir; + } + + public static function tearDownAfterClass(): void + { + if (is_dir(self::$cacheDir)) { + array_map('unlink', glob(self::$cacheDir.'/*')); + rmdir(self::$cacheDir); + } + } + #[DataProvider('mapProvider')] - public function testMap($expect, $args, array $deps = []) + public function testMap($expect, $args, ObjectMapperInterface $mapper) { - $mapper = new ObjectMapper(...$deps); $this->assertEquals($expect, $mapper->map(...$args)); } + /** + * @return iterable + */ + public static function objectMapperProvider(): iterable + { + yield [new ObjectMapper()]; + yield [new ObjectMapper(new ReflectionObjectMapperMetadataFactory(), PropertyAccess::createPropertyAccessor())]; + yield [new CachedObjectMapper(self::getCacheDir())]; + } + /** * @return iterable */ @@ -108,80 +146,87 @@ public static function mapProvider(): iterable $b->concat = 'testme'; $b->relation = $d; $b->relationNotMapped = $d; - yield [$b, [$a]]; + yield [$b, [$a], new ObjectMapper()]; + yield [$b, [$a], new CachedObjectMapper(self::getCacheDir())]; $c = clone $b; $c->id = 1; - yield [$c, [$a, $c]]; + yield [$c, [$a, $c], new ObjectMapper()]; + yield [$c, [$a, $c], new CachedObjectMapper(self::getCacheDir())]; $d = clone $b; // with propertyAccessor we call the getter $d->concat = 'shouldtestme'; - yield [$d, [$a], [new ReflectionObjectMapperMetadataFactory(), PropertyAccess::createPropertyAccessor()]]; + yield [$d, [$a], new ObjectMapper(new ReflectionObjectMapperMetadataFactory(), PropertyAccess::createPropertyAccessor())]; + yield [$d, [$a], new CachedObjectMapper(self::getCacheDir(), new ReflectionObjectMapperMetadataFactory(), PropertyAccess::createPropertyAccessor())]; - yield [new MultipleTargetsC(foo: 'bar'), [new MultipleTargetsA()]]; + yield [new MultipleTargetsC(foo: 'bar'), [new MultipleTargetsA()], new ObjectMapper()]; + yield [new MultipleTargetsC(foo: 'bar'), [new MultipleTargetsA()], new CachedObjectMapper(self::getCacheDir())]; } - public function testHasNothingToMapTo() + #[DataProvider('objectMapperProvider')] + public function testHasNothingToMapTo(ObjectMapperInterface $objectMapper) { $this->expectException(MappingException::class); $this->expectExceptionMessage('Mapping target not found for source "class@anonymous".'); - (new ObjectMapper())->map(new class {}); + $objectMapper->map(new class {}); } - public function testHasNothingToMapToWithNamedClass() + #[DataProvider('objectMapperProvider')] + public function testHasNothingToMapToWithNamedClass(ObjectMapperInterface $objectMapper) { $this->expectException(MappingException::class); $this->expectExceptionMessage(\sprintf('Mapping target not found for source "%s".', ClassWithoutTarget::class)); - (new ObjectMapper())->map(new ClassWithoutTarget()); + $objectMapper->map(new ClassWithoutTarget()); } - public function testTargetNotFound() + #[DataProvider('objectMapperProvider')] + public function testTargetNotFound(ObjectMapperInterface $objectMapper) { $this->expectException(MappingException::class); $this->expectExceptionMessage(\sprintf('Mapping target class "InexistantClass" does not exist for source "%s".', ClassWithoutTarget::class)); - (new ObjectMapper())->map(new ClassWithoutTarget(), 'InexistantClass'); + $objectMapper->map(new ClassWithoutTarget(), 'InexistantClass'); } - public function testRecursion() + #[DataProvider('objectMapperProvider')] + public function testRecursion(ObjectMapperInterface $objectMapper) { $ab = new AB(); $ab->ab = $ab; - $mapper = new ObjectMapper(); - $mapped = $mapper->map($ab); + $mapped = $objectMapper->map($ab); $this->assertInstanceOf(Dto::class, $mapped); $this->assertSame($mapped, $mapped->dto); } - public function testDeeperRecursion() + #[DataProvider('objectMapperProvider')] + public function testDeeperRecursion(ObjectMapperInterface $objectMapper) { $recursive = new Recursive(); $recursive->name = 'hi'; $recursive->relation = new Relation(); $recursive->relation->recursion = $recursive; - $mapper = new ObjectMapper(); + $mapper = $objectMapper; $mapped = $mapper->map($recursive); $this->assertSame($mapped->relation->recursion, $mapped); $this->assertInstanceOf(RecursiveDto::class, $mapped); $this->assertInstanceOf(RelationDto::class, $mapped->relation); } - public function testMapWithInitializedConstructor() + #[DataProvider('propertyAccessorObjectMapperProvider')] + public function testMapWithInitializedConstructor(ObjectMapperInterface $objectMapper) { $a = new InitializedConstructorA(); - $mapper = new ObjectMapper(propertyAccessor: PropertyAccess::createPropertyAccessor()); - $b = $mapper->map($a, InitializedConstructorB::class); + $b = $objectMapper->map($a, InitializedConstructorB::class); $this->assertInstanceOf(InitializedConstructorB::class, $b); $this->assertEquals($b->tags, ['foo', 'bar']); } - public function testMapReliesOnConstructorsOwnInitialization() + #[DataProvider('propertyAccessorObjectMapperProvider')] + public function testMapReliesOnConstructorsOwnInitialization(ObjectMapperInterface $mapper) { $expected = 'bar'; - $mapper = new ObjectMapper(propertyAccessor: PropertyAccess::createPropertyAccessor()); - $source = new \stdClass(); $source->bar = $expected; @@ -191,12 +236,11 @@ public function testMapReliesOnConstructorsOwnInitialization() $this->assertEquals($expected, $c->bar); } - public function testMapConstructorArgumentsDifferFromClassFields() + #[DataProvider('propertyAccessorObjectMapperProvider')] + public function testMapConstructorArgumentsDifferFromClassFields(ObjectMapperInterface $mapper) { $expected = 'bar'; - $mapper = new ObjectMapper(propertyAccessor: PropertyAccess::createPropertyAccessor()); - $source = new \stdClass(); $source->bar = $expected; @@ -206,43 +250,62 @@ public function testMapConstructorArgumentsDifferFromClassFields() $this->assertStringContainsStringIgnoringCase($expected, $actual->barUpperCase); } - public function testMapToWithInstanceHook() + /** + * @return iterable + */ + public static function propertyAccessorObjectMapperProvider(): iterable + { + $propertyAccessor = PropertyAccess::createPropertyAccessor(); + yield [new ObjectMapper(propertyAccessor: $propertyAccessor)]; + yield [new CachedObjectMapper(self::getCacheDir(), propertyAccessor: $propertyAccessor)]; + } + + #[DataProvider('objectMapperProvider')] + public function testMapToWithInstanceHook(ObjectMapperInterface $objectMapper) { $a = new InstanceCallbackA(); - $mapper = new ObjectMapper(); - $b = $mapper->map($a, InstanceCallbackB::class); + $b = $objectMapper->map($a, InstanceCallbackB::class); $this->assertInstanceOf(InstanceCallbackB::class, $b); $this->assertSame($b->getId(), 1); $this->assertSame($b->name, 'test'); } - public function testMapToWithInstanceHookWithArguments() + #[DataProvider('objectMapperProvider')] + public function testMapToWithInstanceHookWithArguments(ObjectMapperInterface $objectMapper) { $a = new InstanceCallbackWithArgumentsA(); - $mapper = new ObjectMapper(); - $b = $mapper->map($a); + $b = $objectMapper->map($a); $this->assertInstanceOf(InstanceCallbackWithArgumentsB::class, $b); $this->assertSame($a, $b->transformSource); $this->assertInstanceOf(InstanceCallbackWithArgumentsB::class, $b->transformValue); } - public function testMapStruct() + #[DataProvider('mapStructObjectMapperProvider')] + public function testMapStruct(ObjectMapperInterface $objectMapper) { $a = new Source('a', 'b', 'c'); - $metadata = new MapStructMapperMetadataFactory(AToBMapper::class); - $mapper = new ObjectMapper($metadata); - $aToBMapper = new AToBMapper($mapper); + $aToBMapper = new AToBMapper($objectMapper); $b = $aToBMapper->map($a); $this->assertInstanceOf(Target::class, $b); $this->assertSame($b->propertyD, 'a'); $this->assertSame($b->propertyC, 'c'); } - public function testMultipleMapProperty() + /** + * @return iterable + */ + public static function mapStructObjectMapperProvider(): iterable + { + $metadata = new MapStructMapperMetadataFactory(AToBMapper::class); + yield [new ObjectMapper($metadata)]; + yield [new CachedObjectMapper(self::getCacheDir(), $metadata)]; + } + + #[DataProvider('objectMapperProvider')] + public function testMultipleMapProperty(ObjectMapperInterface $objectMapper) { $u = new User(email: 'hello@example.com', profile: new UserProfile(firstName: 'soyuka', lastName: 'arakusa')); - $mapper = new ObjectMapper(); - $b = $mapper->map($u); + $b = $objectMapper->map($u); $this->assertInstanceOf(TargetUser::class, $b); $this->assertSame($b->firstName, 'soyuka'); $this->assertSame($b->lastName, 'arakusa'); @@ -250,25 +313,28 @@ public function testMultipleMapProperty() public function testServiceLocator() { - $a = new ServiceLocatorA(); - $a->foo = 'nok'; + $conditionCallableLocator = self::getServiceLocator([ConditionCallable::class => new ConditionCallable()]); + $transformCallableLocator = self::getServiceLocator([TransformCallable::class => new TransformCallable()]); - $mapper = new ObjectMapper( - conditionCallableLocator: $this->getServiceLocator([ConditionCallable::class => new ConditionCallable()]), - transformCallableLocator: $this->getServiceLocator([TransformCallable::class => new TransformCallable()]) + $objectMapper = new ObjectMapper( + conditionCallableLocator: $conditionCallableLocator, + transformCallableLocator: $transformCallableLocator, ); - $b = $mapper->map($a); + $a = new ServiceLocatorA(); + $a->foo = 'nok'; + + $b = $objectMapper->map($a); $this->assertSame($b->bar, 'notmapped'); $this->assertInstanceOf(ServiceLocatorB::class, $b); $a->foo = 'ok'; - $b = $mapper->map($a); + $b = $objectMapper->map($a); $this->assertInstanceOf(ServiceLocatorB::class, $b); $this->assertSame($b->bar, 'transformedok'); } - protected function getServiceLocator(array $factories): ContainerInterface + private static function getServiceLocator(array $factories): ContainerInterface { return new class($factories) implements ContainerInterface { public function __construct(private array $factories) @@ -287,35 +353,20 @@ public function get(string $id): mixed }; } - public function testSourceOnly() + #[DataProvider('objectMapperProvider')] + public function testSourceOnly(ObjectMapperInterface $objectMapper) { $a = new \stdClass(); $a->name = 'test'; - $mapper = new ObjectMapper(); - $mapped = $mapper->map($a, SourceOnly::class); + $mapped = $objectMapper->map($a, SourceOnly::class); $this->assertInstanceOf(SourceOnly::class, $mapped); $this->assertSame('test', $mapped->mappedName); } - public function testSourceOnlyWithMagicMethods() + #[DataProvider('objectMapperProvider')] + public function testSourceOnlyWithMagicMethods(ObjectMapperInterface $objectMapper) { - $mapper = new ObjectMapper(); - $a = new class { - public function __isset($key): bool - { - return 'name' === $key; - } - - public function __get(string $key): string - { - return match ($key) { - 'name' => 'test', - default => throw new \LogicException($key), - }; - } - }; - - $mapped = $mapper->map($a, SourceOnly::class); + $mapped = $objectMapper->map(new MagicMethods(), SourceOnly::class); $this->assertInstanceOf(SourceOnly::class, $mapped); $this->assertSame('test', $mapped->mappedName); } @@ -348,89 +399,94 @@ public function testTransformToWrongObject() $mapper->map($u); } - public function testMapTargetToSource() + #[DataProvider('objectMapperProvider')] + public function testMapTargetToSource(ObjectMapperInterface $objectMapper) { $a = new MapTargetToSourceA('str'); - $mapper = new ObjectMapper(); - $b = $mapper->map($a, MapTargetToSourceB::class); + $b = $objectMapper->map($a, MapTargetToSourceB::class); $this->assertInstanceOf(MapTargetToSourceB::class, $b); $this->assertSame('str', $b->target); } - public function testMultipleTargetMapProperty() + #[DataProvider('objectMapperProvider')] + public function testMultipleTargetMapProperty(ObjectMapperInterface $objectMapper) { $u = new MultipleTargetPropertyA(); - $mapper = new ObjectMapper(); - $b = $mapper->map($u, MultipleTargetPropertyB::class); + $b = $objectMapper->map($u, MultipleTargetPropertyB::class); $this->assertInstanceOf(MultipleTargetPropertyB::class, $b); $this->assertEquals('TEST', $b->foo); - $c = $mapper->map($u, MultipleTargetPropertyC::class); + $c = $objectMapper->map($u, MultipleTargetPropertyC::class); $this->assertInstanceOf(MultipleTargetPropertyC::class, $c); $this->assertEquals('test', $c->bar); $this->assertEquals('donotmap', $c->foo); $this->assertEquals('foo', $c->doesNotExistInTargetB); } - public function testDefaultValueStdClass() + #[DataProvider('objectMapperProvider')] + public function testDefaultValueStdClass(ObjectMapperInterface $objectMapper) { $this->expectException(NoSuchPropertyException::class); $u = new \stdClass(); $u->id = 'abc'; - $mapper = new ObjectMapper(); - $b = $mapper->map($u, TargetDto::class); + $b = $objectMapper->map($u, TargetDto::class); } - public function testDefaultValueStdClassWithPropertyInfo() + #[DataProvider('configuredPropertyAccessorObjectMapperProvider')] + public function testDefaultValueStdClassWithPropertyInfo(ObjectMapperInterface $objectMapper) { $u = new \stdClass(); $u->id = 'abc'; - $mapper = new ObjectMapper(propertyAccessor: PropertyAccess::createPropertyAccessorBuilder()->disableExceptionOnInvalidPropertyPath()->getPropertyAccessor()); - $b = $mapper->map($u, TargetDto::class); + $b = $objectMapper->map($u, TargetDto::class); $this->assertInstanceOf(TargetDto::class, $b); $this->assertSame('abc', $b->id); $this->assertNull($b->optional); } + /** + * @return iterable + */ + public static function configuredPropertyAccessorObjectMapperProvider(): iterable + { + $propertyAccessor = PropertyAccess::createPropertyAccessorBuilder()->disableExceptionOnInvalidPropertyPath()->getPropertyAccessor(); + yield [new ObjectMapper(propertyAccessor: $propertyAccessor)]; + yield [new CachedObjectMapper(self::getCacheDir(), propertyAccessor: $propertyAccessor)]; + } + #[DataProvider('objectMapperProvider')] - public function testUpdateObjectWithConstructorPromotedProperties(ObjectMapperInterface $mapper) + public function testUpdateObjectWithConstructorPromotedProperties(ObjectMapperInterface $objectMapper) { $a = new PromotedConstructorSource(1, 'foo'); $b = new PromotedConstructorTarget(1, 'bar'); - $v = $mapper->map($a, $b); + $v = $objectMapper->map($a, $b); $this->assertSame($v->name, 'foo'); } #[DataProvider('objectMapperProvider')] - public function testUpdateMappedObjectWithAdditionalConstructorPromotedProperties(ObjectMapperInterface $mapper) + public function testUpdateMappedObjectWithAdditionalConstructorPromotedProperties(ObjectMapperInterface $objectMapper) { $a = new PromotedConstructorWithMetadataSource(3, 'foo-will-get-updated'); $b = new PromotedConstructorWithMetadataTarget('notOnSourceButRequired', 1, 'bar'); - $v = $mapper->map($a, $b); + $v = $objectMapper->map($a, $b); $this->assertSame($v->name, $a->name); $this->assertSame($v->number, $a->number); } - /** - * @return iterable - */ - public static function objectMapperProvider(): iterable - { - yield [new ObjectMapper()]; - yield [new ObjectMapper(new ReflectionObjectMapperMetadataFactory(), PropertyAccess::createPropertyAccessor())]; - } - - public function testMapInitializesLazyObject() + #[IgnorePhpunitDeprecations] + #[Group('legacy')] + #[DataProvider('objectMapperProvider')] + public function testMapInitializesLazyObject(ObjectMapperInterface $objectMapper) { $lazy = new LazyFoo(); - $mapper = new ObjectMapper(); - $mapper->map($lazy, \stdClass::class); + $objectMapper->map($lazy, \stdClass::class); $this->assertTrue($lazy->isLazyObjectInitialized()); } - public function testMapInitializesNativePhp84LazyObject() + #[RequiresPhp('>=8.4')] + #[DataProvider('objectMapperProvider')] + public function testMapInitializesNativePhp84LazyObject(ObjectMapperInterface $objectMapper) { $initialized = false; $initializer = function () use (&$initialized) { @@ -445,8 +501,7 @@ public function testMapInitializesNativePhp84LazyObject() $r = new \ReflectionClass(MyProxy::class); $lazyObj = $r->newLazyProxy($initializer); $this->assertFalse($initialized); - $mapper = new ObjectMapper(); - $d = $mapper->map($lazyObj, MyProxy::class); + $d = $objectMapper->map($lazyObj, MyProxy::class); $this->assertSame('test', $d->name); $this->assertTrue($initialized); } @@ -494,14 +549,16 @@ public function map(object $source, object|string|null $target = null): object } #[DataProvider('validPartialInputProvider')] - public function testMapPartially(PartialInput $actual, FinalInput $expected) + public function testMapPartially(PartialInput $actual, FinalInput $expected, ObjectMapperInterface $objectMapper) { - $mapper = new ObjectMapper(); - $this->assertEquals($expected, $mapper->map($actual)); + $this->assertEquals($expected, $objectMapper->map($actual)); } public static function validPartialInputProvider(): iterable { + $o = new ObjectMapper(); + $c = new CachedObjectMapper(self::getCacheDir()); + $p = new PartialInput(); $p->uuid = '6a9eb6dd-c4dc-4746-bb99-f6bad716acb2'; $p->website = 'https://updated.website.com'; @@ -510,7 +567,8 @@ public static function validPartialInputProvider(): iterable $f->uuid = $p->uuid; $f->website = $p->website; - yield [$p, $f]; + yield [$p, $f, $o]; + yield [$p, $f, $c]; $p = new PartialInput(); $p->uuid = '6a9eb6dd-c4dc-4746-bb99-f6bad716acb2'; @@ -519,7 +577,8 @@ public static function validPartialInputProvider(): iterable $f = new FinalInput(); $f->uuid = $p->uuid; - yield [$p, $f]; + yield [$p, $f, $o]; + yield [$p, $f, $c]; $p = new PartialInput(); $p->uuid = '6a9eb6dd-c4dc-4746-bb99-f6bad716acb2'; @@ -531,16 +590,17 @@ public static function validPartialInputProvider(): iterable $f->website = $p->website; $f->email = $p->email; - yield [$p, $f]; + yield [$p, $f, $o]; + yield [$p, $f, $c]; } - public function testMapWithSourceTransform() + #[DataProvider('objectMapperProvider')] + public function testMapWithSourceTransform(ObjectMapperInterface $objectMapper) { $source = new SourceEntity(); $source->name = 'test'; - $mapper = new ObjectMapper(); - $target = $mapper->map($source, TargetTransformTargetDto::class); + $target = $objectMapper->map($source, TargetTransformTargetDto::class); $this->assertInstanceOf(TargetTransformTargetDto::class, $target); $this->assertTrue($target->transformed); diff --git a/src/Symfony/Component/ObjectMapper/composer.json b/src/Symfony/Component/ObjectMapper/composer.json index 1e593f34908a3..73b726a5618b4 100644 --- a/src/Symfony/Component/ObjectMapper/composer.json +++ b/src/Symfony/Component/ObjectMapper/composer.json @@ -17,11 +17,12 @@ ], "require": { "php": ">=8.4", - "psr/container": "^2.0" + "psr/container": "^2.0", + "symfony/filesystem": "^7.4|^8.0", + "symfony/var-exporter": "^7.4|^8.0" }, "require-dev": { - "symfony/property-access": "^7.4|^8.0", - "symfony/var-exporter": "^7.4|^8.0" + "symfony/property-access": "^7.4|^8.0" }, "autoload": { "psr-4": {