diff --git a/src/Attribute/AsDbalType.php b/src/Attribute/AsDbalType.php new file mode 100644 index 000000000..3381b2965 --- /dev/null +++ b/src/Attribute/AsDbalType.php @@ -0,0 +1,15 @@ +getParameter('doctrine.dbal.connection_factory.types'); + + foreach ($this->findTaggedResourceIds($container) as $id => $tags) { + foreach ($tags as $tag) { + if (! array_key_exists('name', $tag)) { + throw new InvalidArgumentException(sprintf('The "name" attribute is mandatory for the "doctrine.dbal.type" tag on the "%s" type.', $id)); + } + + $class = $container->getDefinition($id)->getClass(); + if (! $class) { + throw new InvalidArgumentException(sprintf('The definition of "%s" must define its class.', $id)); + } + + if (! is_subclass_of($class, Type::class)) { + throw new InvalidArgumentException(sprintf('The "%s" class must extends "%s".', $class, Type::class)); + } + + $types[$tag['name']] = ['class' => $class]; + } + } + + $container->setParameter('doctrine.dbal.connection_factory.types', $types); + } + + /** @return array> */ + private function findTaggedResourceIds(ContainerBuilder $container): array + { + $tagName = 'doctrine.dbal.type'; + + // Determine if the version of symfony/dependency-injection is >= 7.3 + /** @phpstan-ignore function.alreadyNarrowedType */ + if (method_exists($container, 'findTaggedResourceIds')) { + return $container->findTaggedResourceIds($tagName); + } + + // Needed to keep compatibility with symfony/dependency-injection < 7.3 + $tags = []; + foreach ($container->getDefinitions() as $id => $definition) { + if (! $definition->hasTag($tagName)) { + continue; + } + + if (! $definition->hasTag('container.excluded')) { + throw new InvalidArgumentException(sprintf('The resource "%s" tagged "%s" is missing the "container.excluded" tag.', $id, $tagName)); + } + + $class = $container->getParameterBag()->resolveValue($definition->getClass()); + if (! $class || $definition->isAbstract()) { + throw new InvalidArgumentException(sprintf('The resource "%s" tagged "%s" must have a class and not be abstract.', $id, $tagName)); + } + + if ($definition->getClass() !== $class) { + $definition->setClass($class); + } + + $tags[$id] = $definition->getTag($tagName); + } + + return $tags; + } +} diff --git a/src/DependencyInjection/DoctrineExtension.php b/src/DependencyInjection/DoctrineExtension.php index 5f372cc42..4073542cc 100644 --- a/src/DependencyInjection/DoctrineExtension.php +++ b/src/DependencyInjection/DoctrineExtension.php @@ -4,6 +4,7 @@ namespace Doctrine\Bundle\DoctrineBundle\DependencyInjection; +use Doctrine\Bundle\DoctrineBundle\Attribute\AsDbalType; use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener; use Doctrine\Bundle\DoctrineBundle\Attribute\AsMiddleware; @@ -80,6 +81,7 @@ use function interface_exists; use function is_dir; use function is_string; +use function method_exists; use function realpath; use function reset; use function sprintf; @@ -550,6 +552,23 @@ private function dbalLoad(array $config, ContainerBuilder $container): void $this->loadDbalConnection($name, $connection, $container); } + $container->registerAttributeForAutoconfiguration(AsDbalType::class, static function (ChildDefinition $definition, AsDbalType $type): void { + $tag = 'doctrine.dbal.type'; + $attributes = [ + 'name' => $type->name, + ]; + + // Determine if the version of symfony/dependency-injection is >= 7.3 + /** @phpstan-ignore function.alreadyNarrowedType */ + if (method_exists($definition, 'addResourceTag')) { + $definition->addResourceTag($tag, $attributes); + } else { + // Needed to keep compatibility with symfony/dependency-injection < 7.3 + $definition->addTag('doctrine.dbal.type', $attributes) + ->addTag('container.excluded', ['source' => sprintf('by tag "%s"', $tag)]); + } + }); + $container->registerForAutoconfiguration(MiddlewareInterface::class)->addTag('doctrine.middleware'); $container->registerAttributeForAutoconfiguration(AsMiddleware::class, static function (ChildDefinition $definition, AsMiddleware $attribute): void { diff --git a/src/DoctrineBundle.php b/src/DoctrineBundle.php index 02d6856cd..a4266d0bd 100644 --- a/src/DoctrineBundle.php +++ b/src/DoctrineBundle.php @@ -9,6 +9,7 @@ use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\EntityListenerPass; use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\IdGeneratorPass; use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\MiddlewaresPass; +use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\RegisterDbalTypePass; use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\RemoveLoggingMiddlewarePass; use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\RemoveProfilerControllerPass; use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\ServiceRepositoryCompilerPass; @@ -68,6 +69,7 @@ public function process(ContainerBuilder $container): void $container->addCompilerPass(new RemoveLoggingMiddlewarePass()); $container->addCompilerPass(new MiddlewaresPass()); $container->addCompilerPass(new RegisterUidTypePass()); + $container->addCompilerPass(new RegisterDbalTypePass()); if (! class_exists(RegisterDatePointTypePass::class)) { return; diff --git a/tests/BundleTest.php b/tests/BundleTest.php index 3f99b6913..1c557bd44 100644 --- a/tests/BundleTest.php +++ b/tests/BundleTest.php @@ -5,6 +5,7 @@ namespace Doctrine\Bundle\DoctrineBundle\Tests; use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\DbalSchemaFilterPass; +use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\RegisterDbalTypePass; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; use PHPUnit\Framework\TestCase; use Symfony\Bridge\Doctrine\DependencyInjection\CompilerPass\DoctrineValidationPass; @@ -22,9 +23,10 @@ public function testBuildCompilerPasses(): void $config = $container->getCompilerPassConfig(); $passes = $config->getBeforeOptimizationPasses(); - $foundEventListener = false; - $foundValidation = false; - $foundSchemaFilter = false; + $foundEventListener = false; + $foundValidation = false; + $foundSchemaFilter = false; + $foundRegisterDbalType = false; foreach ($passes as $pass) { if ($pass instanceof RegisterEventListenersAndSubscribersPass) { @@ -33,11 +35,14 @@ public function testBuildCompilerPasses(): void $foundValidation = true; } elseif ($pass instanceof DbalSchemaFilterPass) { $foundSchemaFilter = true; + } elseif ($pass instanceof RegisterDbalTypePass) { + $foundRegisterDbalType = true; } } $this->assertTrue($foundEventListener, 'RegisterEventListenersAndSubscribersPass was not found'); $this->assertTrue($foundValidation, 'DoctrineValidationPass was not found'); $this->assertTrue($foundSchemaFilter, 'DbalSchemaFilterPass was not found'); + $this->assertTrue($foundRegisterDbalType, 'RegisterDbalTypePass was not found'); } } diff --git a/tests/DependencyInjection/Compiler/RegisterDbalTypePassTest.php b/tests/DependencyInjection/Compiler/RegisterDbalTypePassTest.php new file mode 100644 index 000000000..d16d8b888 --- /dev/null +++ b/tests/DependencyInjection/Compiler/RegisterDbalTypePassTest.php @@ -0,0 +1,82 @@ +addCompilerPass(new RegisterDbalTypePass()); + + $container->setParameter('doctrine.dbal.connection_factory.types', []); + + $container->register(BarType::class) + ->addTag('doctrine.dbal.type', ['name' => 'bar']) + ->addTag('container.excluded'); + + $container->compile(); + + self::assertSame(['bar' => ['class' => BarType::class]], $container->getParameter('doctrine.dbal.connection_factory.types')); + } + + public function testTagMustHaveANameAttribute(): void + { + $container = new ContainerBuilder(); + $container->addCompilerPass(new RegisterDbalTypePass()); + + $container->setParameter('doctrine.dbal.connection_factory.types', []); + + $container->register(BarType::class) + ->addTag('doctrine.dbal.type') + ->addTag('container.excluded'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + sprintf('The "name" attribute is mandatory for the "doctrine.dbal.type" tag on the "%s" type.', BarType::class), + ); + + $container->compile(); + } + + public function testTypeMustBeASubclassOfTheDbalBaseType(): void + { + $container = new ContainerBuilder(); + $container->addCompilerPass(new RegisterDbalTypePass()); + + $container->setParameter('doctrine.dbal.connection_factory.types', []); + + $container->register(NotASubClassOfDbalBaseType::class) + ->addTag('doctrine.dbal.type', ['name' => 'invalid_type']) + ->addTag('container.excluded'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage(sprintf('The "%s" class must extends "%s".', NotASubClassOfDbalBaseType::class, Type::class)); + + $container->compile(); + } +} + +class BarType extends Type +{ + /** @param array $column */ + public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + { + return 'bar'; + } +} + +class NotASubClassOfDbalBaseType +{ +} diff --git a/tests/DependencyInjection/DoctrineExtensionTest.php b/tests/DependencyInjection/DoctrineExtensionTest.php index 0b896e5e9..8d1bf9ba6 100644 --- a/tests/DependencyInjection/DoctrineExtensionTest.php +++ b/tests/DependencyInjection/DoctrineExtensionTest.php @@ -5,11 +5,13 @@ namespace Doctrine\Bundle\DoctrineBundle\Tests\DependencyInjection; use Closure; +use Doctrine\Bundle\DoctrineBundle\Attribute\AsDbalType; use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener; use Doctrine\Bundle\DoctrineBundle\CacheWarmer\DoctrineMetadataCacheWarmer; use Doctrine\Bundle\DoctrineBundle\DependencyInjection\DoctrineExtension; use Doctrine\Bundle\DoctrineBundle\Tests\Builder\BundleConfigurationBuilder; +use Doctrine\Bundle\DoctrineBundle\Tests\DependencyInjection\Fixtures\DbalType; use Doctrine\Bundle\DoctrineBundle\Tests\DependencyInjection\Fixtures\Php8EntityListener; use Doctrine\Bundle\DoctrineBundle\Tests\DependencyInjection\Fixtures\Php8EventListener; use Doctrine\DBAL\Connection; @@ -947,6 +949,35 @@ public static function cacheConfigurationProvider(): array ]; } + public function testAsDbalTypeAttribute(): void + { + $container = $this->getContainer(); + $extension = new DoctrineExtension(); + + $config = BundleConfigurationBuilder::createBuilder() + ->addBaseConnection() + ->build(); + + $extension->load([$config], $container); + + /** @phpstan-ignore function.alreadyNarrowedType */ + $attributes = method_exists($container, 'getAttributeAutoconfigurators') + ? array_map(static fn (array $arr) => $arr[0], $container->getAttributeAutoconfigurators()) + /** @phpstan-ignore method.notFound */ + : $container->getAutoconfiguredAttributes(); + $this->assertInstanceOf(Closure::class, $attributes[AsDbalType::class]); + + $reflector = new ReflectionClass(DbalType::class); + $definition = new ChildDefinition(''); + $attribute = $reflector->getAttributes(AsDbalType::class)[0]->newInstance(); + + $attributes[AsDbalType::class]($definition, $attribute); + + $expected = ['name' => 'dbal_type']; + $this->assertSame([$expected], $definition->getTag('doctrine.dbal.type')); + $this->assertSame([['source' => 'by tag "doctrine.dbal.type"']], $definition->getTag('container.excluded')); + } + /** @return array */ public static function provideAttributeExcludedFromContainer(): array { diff --git a/tests/DependencyInjection/Fixtures/DbalType.php b/tests/DependencyInjection/Fixtures/DbalType.php new file mode 100644 index 000000000..bf5fef26e --- /dev/null +++ b/tests/DependencyInjection/Fixtures/DbalType.php @@ -0,0 +1,19 @@ + $column */ + public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + { + return 'dbal_type'; + } +}