diff --git a/docs/en/dbal-type.rst b/docs/en/dbal-type.rst new file mode 100644 index 000000000..25967abba --- /dev/null +++ b/docs/en/dbal-type.rst @@ -0,0 +1,88 @@ +DBAL Types +========== + +Custom DBAL types can be registered using the ``AsDbalType`` attribute. This +attribute allows you to define a name for your custom type directly in the class +definition. If the name is not provided, the class name will be used as the default. + +To register a custom DBAL type, create a class that extends +``Doctrine\DBAL\Types\Type`` and add the ``#[AsDbalType]`` attribute to it: + +.. code-block:: php + + namespace App\Doctrine\Type; + + use Doctrine\Bundle\DoctrineBundle\Attribute\AsDbalType; + use Doctrine\DBAL\Platforms\AbstractPlatform; + use Doctrine\DBAL\Types\Type; + + #[AsDbalType(name: 'money')] + class MoneyType extends Type + { + public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + { + return $platform->getDecimalTypeDeclarationSQL($column); + } + + public function convertToPHPValue(mixed $value, AbstractPlatform $platform): mixed + { + return $value; + } + + public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): mixed + { + return $value; + } + } + +When using the ``AsDbalType`` attribute, the type will be automatically +registered with Doctrine. + +Manual Registration +------------------- + +Alternatively, you can register custom types in your configuration: + +.. configuration-block:: + + .. code-block:: yaml + + # config/packages/doctrine.yaml + doctrine: + dbal: + types: + money: App\Doctrine\Type\MoneyType + + .. code-block:: xml + + + + + + + App\Doctrine\Type\MoneyType + + + + + .. code-block:: php + + // config/packages/doctrine.php + use App\Doctrine\Type\MoneyType; + use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator; + + return static function (ContainerConfigurator $containerConfigurator): void { + $containerConfigurator->extension('doctrine', [ + 'dbal' => [ + 'types' => [ + 'money' => MoneyType::class, + ], + ], + ]); + }; diff --git a/docs/en/index.rst b/docs/en/index.rst index 6e535144f..a408f6f40 100644 --- a/docs/en/index.rst +++ b/docs/en/index.rst @@ -8,6 +8,7 @@ configuration options, console commands and even a web debug toolbar collector. installation doctrine-console + dbal-type entity-listeners event-listeners custom-id-generators diff --git a/src/Attribute/AsDbalType.php b/src/Attribute/AsDbalType.php new file mode 100644 index 000000000..dee4e8bf4 --- /dev/null +++ b/src/Attribute/AsDbalType.php @@ -0,0 +1,15 @@ + $reflector + * + * @template T of ReflectionClass + */ + public static function autoconfigureFromAttribute(ChildDefinition $definition, AsDbalType $type, ReflectionClass $reflector): void + { + $attributes = [ + 'type_name' => $type->name ?? $reflector->name, + ]; + + // Determine if the version of symfony/dependency-injection is >= 7.3 + /** @phpstan-ignore function.alreadyNarrowedType */ + if (method_exists($definition, 'addResourceTag')) { + $definition->addResourceTag(self::TAG, $attributes); + } else { + // Needed to keep compatibility with symfony/dependency-injection < 7.3 + $definition->addTag(self::TAG, $attributes) + ->addTag('container.excluded', ['source' => sprintf('by tag "%s"', self::TAG)]); + } + } + + public function process(ContainerBuilder $container): void + { + $types = $container->getParameter('doctrine.dbal.connection_factory.types'); + + foreach ($this->findTaggedResourceIds($container) as $id => $tags) { + foreach ($tags as $tag) { + $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['type_name'] ?? $id] = ['class' => $class]; + } + } + + $container->setParameter('doctrine.dbal.connection_factory.types', $types); + } + + /** @return array> */ + private function findTaggedResourceIds(ContainerBuilder $container): array + { + // Determine if the version of symfony/dependency-injection is >= 7.3 + /** @phpstan-ignore function.alreadyNarrowedType */ + if (method_exists($container, 'findTaggedResourceIds')) { + return $container->findTaggedResourceIds(self::TAG); + } + + // Needed to keep compatibility with symfony/dependency-injection < 7.3 + $tags = []; + foreach ($container->getDefinitions() as $id => $definition) { + if (! $definition->hasTag(self::TAG)) { + continue; + } + + if (! $definition->hasTag('container.excluded')) { + throw new InvalidArgumentException(sprintf('The resource "%s" tagged "%s" is missing the "container.excluded" tag.', $id, self::TAG)); + } + + $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, self::TAG)); + } + + if ($definition->getClass() !== $class) { + $definition->setClass($class); + } + + $tags[$id] = $definition->getTag(self::TAG); + } + + return $tags; + } +} diff --git a/src/DependencyInjection/DoctrineExtension.php b/src/DependencyInjection/DoctrineExtension.php index 1db266b04..a505af539 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; @@ -11,6 +12,7 @@ use Doctrine\Bundle\DoctrineBundle\ConnectionFactory; use Doctrine\Bundle\DoctrineBundle\Dbal\RegexSchemaAssetFilter; use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\IdGeneratorPass; +use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\RegisterDbalTypePass; use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\ServiceRepositoryCompilerPass; use Doctrine\Bundle\DoctrineBundle\Mapping\ContainerEntityListenerResolver; use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepositoryInterface; @@ -550,6 +552,9 @@ private function dbalLoad(array $config, ContainerBuilder $container): void $this->loadDbalConnection($name, $connection, $container); } + /** @phpstan-ignore argument.type (Needed for the $reflector parameter) */ + $container->registerAttributeForAutoconfiguration(AsDbalType::class, RegisterDbalTypePass::autoconfigureFromAttribute(...)); + $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..48ee2f543 --- /dev/null +++ b/tests/DependencyInjection/Compiler/RegisterDbalTypePassTest.php @@ -0,0 +1,80 @@ +addCompilerPass(new RegisterDbalTypePass()); + + $container->setParameter('doctrine.dbal.connection_factory.types', []); + + $container->register(BarType::class) + ->addTag('doctrine.dbal.type', ['type_name' => 'bar']) + ->addTag('container.excluded'); + + $container->compile(); + + self::assertSame(['bar' => ['class' => BarType::class]], $container->getParameter('doctrine.dbal.connection_factory.types')); + } + + public function testServiceIdMustBeUsedAsTypeNameIfNotDefined(): void + { + $container = new ContainerBuilder(); + $container->addCompilerPass(new RegisterDbalTypePass()); + + $container->setParameter('doctrine.dbal.connection_factory.types', []); + + $container->register('doctrine.dbal.type.bar') + ->setClass(BarType::class) + ->addTag('doctrine.dbal.type') + ->addTag('container.excluded'); + + $container->compile(); + + self::assertSame(['doctrine.dbal.type.bar' => ['class' => BarType::class]], $container->getParameter('doctrine.dbal.connection_factory.types')); + } + + 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', ['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 69d6b9d98..f7381e437 100644 --- a/tests/DependencyInjection/DoctrineExtensionTest.php +++ b/tests/DependencyInjection/DoctrineExtensionTest.php @@ -5,11 +5,14 @@ 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\DbalTypeNoName; use Doctrine\Bundle\DoctrineBundle\Tests\DependencyInjection\Fixtures\Php8EntityListener; use Doctrine\Bundle\DoctrineBundle\Tests\DependencyInjection\Fixtures\Php8EventListener; use Doctrine\DBAL\Connection; @@ -948,6 +951,46 @@ public static function cacheConfigurationProvider(): array ]; } + /** @param class-string $typeClassname */ + #[DataProvider('provideDatabaseTypeAttribute')] + public function testAsDatabaseTypeAttribute(string $typeClassname, string $expectedTypeName): 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($typeClassname); + $definition = new ChildDefinition(''); + $attribute = $reflector->getAttributes(AsDbalType::class)[0]->newInstance(); + + $attributes[AsDbalType::class]($definition, $attribute, $reflector); + + $expected = ['type_name' => $expectedTypeName]; + $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 provideDatabaseTypeAttribute(): array + { + return [ + 'with type name' => [DbalType::class, 'dbal_type'], + 'without type name' => [DbalTypeNoName::class, DbalTypeNoName::class], + ]; + } + /** @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'; + } +} diff --git a/tests/DependencyInjection/Fixtures/DbalTypeNoName.php b/tests/DependencyInjection/Fixtures/DbalTypeNoName.php new file mode 100644 index 000000000..fffabd6f7 --- /dev/null +++ b/tests/DependencyInjection/Fixtures/DbalTypeNoName.php @@ -0,0 +1,19 @@ + $column */ + public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + { + return 'dbal_type_no_name'; + } +}