From 480b5af8b5731a3c81b8cc8bc5fcbbb0132ff042 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Fri, 3 Apr 2026 13:28:08 +0200 Subject: [PATCH 1/4] Add AsDatabaseType attribute to register DBAL types as Symfony services Introduces the `#[AsDatabaseType]` PHP attribute and a `DatabaseTypePass` compiler pass that builds a per-connection `TypeRegistry` and wires it to both the DBAL `Configuration` and the ORM `Configuration` of each entity manager, so custom types are resolved without touching the global static registry. Types can be restricted to specific connections by repeating the attribute with a `connection` argument. Config-based types (`doctrine.dbal.types`) are included in the registry as inline definitions. --- UPGRADE-3.3.md | 39 ++ composer.json | 12 + docs/en/custom-dbal-types.rst | 167 +++++++++ docs/en/index.rst | 1 + src/Attribute/AsDatabaseType.php | 17 + .../Compiler/DatabaseTypePass.php | 87 +++++ src/DependencyInjection/DoctrineExtension.php | 12 + src/DoctrineBundle.php | 2 + .../Compiler/DatabaseTypePassTest.php | 339 ++++++++++++++++++ 9 files changed, 676 insertions(+) create mode 100644 UPGRADE-3.3.md create mode 100644 docs/en/custom-dbal-types.rst create mode 100644 src/Attribute/AsDatabaseType.php create mode 100644 src/DependencyInjection/Compiler/DatabaseTypePass.php create mode 100644 tests/DependencyInjection/Compiler/DatabaseTypePassTest.php diff --git a/UPGRADE-3.3.md b/UPGRADE-3.3.md new file mode 100644 index 000000000..72daddfaf --- /dev/null +++ b/UPGRADE-3.3.md @@ -0,0 +1,39 @@ +UPGRADE FROM 3.2 to 3.3 +======================= + +DBAL Types as Services +---------------------- + +DBAL types can now be registered as Symfony services using the `#[AsDatabaseType]` +attribute. Each DBAL connection gets its own `TypeRegistry` instance, set on its +`Doctrine\DBAL\Configuration` via `setTypeRegistry()`. + +```php +use Doctrine\Bundle\DoctrineBundle\Attribute\AsDatabaseType; +use Doctrine\DBAL\Platforms\AbstractPlatform; +use Doctrine\DBAL\Types\Type; + +#[AsDatabaseType(name: 'money')] +final class MoneyType extends Type +{ + // Doctrine DBAL type implementation... +} +``` + +To restrict a type to specific connections, repeat the attribute: + +```php +#[AsDatabaseType(name: 'money', connection: 'default')] +#[AsDatabaseType(name: 'money', connection: 'reporting')] +final class MoneyType extends Type { ... } +``` + +### Stateful type classes + +DBAL type classes are expected to be stateless flyweights. If you have a type +class that stores state, it will now receive a separate instance per connection +when registered via `#[AsDatabaseType]` (service-based registration is shared +by default), or when mixed with types from other connections. + +Types registered through the `doctrine.dbal.types` configuration key continue +to behave as before (one shared instance per type class). diff --git a/composer.json b/composer.json index 7341bdcfc..7347d9e31 100644 --- a/composer.json +++ b/composer.json @@ -74,6 +74,18 @@ "doctrine/orm": "The Doctrine ORM integration is optional in the bundle.", "symfony/web-profiler-bundle": "To use the data collector." }, + "repositories": [ + { + "type": "path", + "url": "../doctrine-dbal", + "options": {"symlink": true, "versions": {"doctrine/dbal": "4.4.x-dev"}} + }, + { + "type": "path", + "url": "../doctrine-orm", + "options": {"symlink": true, "versions": {"doctrine/orm": "3.6.x-dev"}} + } + ], "minimum-stability": "dev", "autoload": { "psr-4": { diff --git a/docs/en/custom-dbal-types.rst b/docs/en/custom-dbal-types.rst new file mode 100644 index 000000000..d975cb280 --- /dev/null +++ b/docs/en/custom-dbal-types.rst @@ -0,0 +1,167 @@ +Custom DBAL Types +================= + +Doctrine DBAL supports custom types that handle the conversion of values between +PHP and the database. You can register them either via configuration or as +Symfony services using the ``#[AsDatabaseType]`` PHP attribute. + +Creating a Custom Type +---------------------- + +A custom type must extend ``Doctrine\DBAL\Types\Type`` and implement at least +``getSQLDeclaration()``. Here is a simple example that stores a ``Money`` +value object as a ``NUMERIC`` column: + +.. code-block:: php + + getDecimalTypeDeclarationSQL($column); + } + + public function convertToPHPValue(mixed $value, AbstractPlatform $platform): ?Money + { + return $value !== null ? Money::fromString($value) : null; + } + + public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): ?string + { + return $value instanceof Money ? (string) $value : null; + } + } + +Registering via the ``#[AsDatabaseType]`` Attribute +----------------------------------------------------- + +The recommended way to register a custom type is to tag it with the +``#[AsDatabaseType]`` attribute. When ``autoconfigure`` is enabled, DoctrineBundle +will automatically register the type in the DBAL ``TypeRegistry`` of each +connection: + +.. code-block:: php + + hasParameter('doctrine.connections')) { + return; + } + + $taggedServiceIds = $container->findTaggedServiceIds('doctrine.dbal.type'); + if ($taggedServiceIds === []) { + return; + } + + // Config-based types (apply to all connections) + /** @var array $configTypes */ + $configTypes = $container->getParameter('doctrine.dbal.connection_factory.types'); + + // Map connection name → ORM configuration service IDs (when ORM is installed) + $connectionToOrmConfigs = []; + if ($container->hasParameter('doctrine.entity_managers')) { + $connectionServiceToName = array_flip($container->getParameter('doctrine.connections')); + foreach (array_keys($container->getParameter('doctrine.entity_managers')) as $emName) { + $emDef = $container->getDefinition(sprintf('doctrine.orm.%s_entity_manager', $emName)); + $connName = $connectionServiceToName[(string) $emDef->getArgument(0)] ?? null; + if ($connName === null) { + continue; + } + + $connectionToOrmConfigs[$connName][] = (string) $emDef->getArgument(1); + } + } + + foreach (array_keys($container->getParameter('doctrine.connections')) as $name) { + $instances = []; + + // Config-based types become inline definitions + foreach ($configTypes as $typeName => $typeConfig) { + $instances[$typeName] = (new Definition($typeConfig['class']))->setShared(false); + } + + // Service-tagged types: global (no connection restriction) or matching this connection + foreach ($taggedServiceIds as $id => $tags) { + foreach ($tags as $tag) { + if (! isset($tag['type'])) { + continue; + } + + if ($name !== ($tag['connection'] ?? $name)) { + continue; + } + + $instances[$tag['type']] = new Reference($id); + } + } + + $registryId = sprintf('doctrine.dbal.%s_connection.type_registry', $name); + $registryRef = new Reference($registryId); + + $container->setDefinition($registryId, new Definition(TypeRegistry::class, [$instances])); + + $container + ->getDefinition(sprintf('doctrine.dbal.%s_connection.configuration', $name)) + ->addMethodCall('setTypeRegistry', [$registryRef]); + + foreach ($connectionToOrmConfigs[$name] ?? [] as $ormConfigId) { + $container->getDefinition($ormConfigId)->addMethodCall('setTypeRegistry', [$registryRef]); + } + } + } +} diff --git a/src/DependencyInjection/DoctrineExtension.php b/src/DependencyInjection/DoctrineExtension.php index 1db266b04..d9ade02e0 100644 --- a/src/DependencyInjection/DoctrineExtension.php +++ b/src/DependencyInjection/DoctrineExtension.php @@ -6,6 +6,7 @@ use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener; +use Doctrine\Bundle\DoctrineBundle\Attribute\AsDatabaseType; use Doctrine\Bundle\DoctrineBundle\Attribute\AsMiddleware; use Doctrine\Bundle\DoctrineBundle\CacheWarmer\DoctrineMetadataCacheWarmer; use Doctrine\Bundle\DoctrineBundle\ConnectionFactory; @@ -552,6 +553,17 @@ private function dbalLoad(array $config, ContainerBuilder $container): void $container->registerForAutoconfiguration(MiddlewareInterface::class)->addTag('doctrine.middleware'); + $container->registerAttributeForAutoconfiguration(AsDatabaseType::class, static function (ChildDefinition $definition, AsDatabaseType $attribute): void { + $tag = ['type' => $attribute->name]; + + if ($attribute->connection !== null) { + $tag['connection'] = $attribute->connection; + } + + $definition->addTag('doctrine.dbal.type', $tag); + }); + + $container->registerAttributeForAutoconfiguration(AsMiddleware::class, static function (ChildDefinition $definition, AsMiddleware $attribute): void { $priority = isset($attribute->priority) ? ['priority' => $attribute->priority] : []; diff --git a/src/DoctrineBundle.php b/src/DoctrineBundle.php index 02d6856cd..382b3599a 100644 --- a/src/DoctrineBundle.php +++ b/src/DoctrineBundle.php @@ -5,6 +5,7 @@ namespace Doctrine\Bundle\DoctrineBundle; use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\CacheSchemaSubscriberPass; +use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\DatabaseTypePass; use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\DbalSchemaFilterPass; use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\EntityListenerPass; use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\IdGeneratorPass; @@ -67,6 +68,7 @@ public function process(ContainerBuilder $container): void $container->addCompilerPass(new RemoveProfilerControllerPass()); $container->addCompilerPass(new RemoveLoggingMiddlewarePass()); $container->addCompilerPass(new MiddlewaresPass()); + $container->addCompilerPass(new DatabaseTypePass()); $container->addCompilerPass(new RegisterUidTypePass()); if (! class_exists(RegisterDatePointTypePass::class)) { diff --git a/tests/DependencyInjection/Compiler/DatabaseTypePassTest.php b/tests/DependencyInjection/Compiler/DatabaseTypePassTest.php new file mode 100644 index 000000000..13d437795 --- /dev/null +++ b/tests/DependencyInjection/Compiler/DatabaseTypePassTest.php @@ -0,0 +1,339 @@ +createContainer(static function (ContainerBuilder $container): void { + $container->setAlias('conf_conn1', 'doctrine.dbal.conn1_connection.configuration') + ->setPublic(true); + }); + + foreach ($container->getDefinition('conf_conn1')->getMethodCalls() as [$method]) { + self::assertNotSame('setTypeRegistry', $method); + } + } + + public function testGlobalTypeIsRegisteredOnAllConnections(): void + { + $container = $this->createContainer(static function (ContainerBuilder $container): void { + $container->register('my_type', MoneyType::class) + ->addTag('doctrine.dbal.type', ['type' => 'money']); + + $container->setAlias('conf_conn1', 'doctrine.dbal.conn1_connection.configuration')->setPublic(true); + $container->setAlias('conf_conn2', 'doctrine.dbal.conn2_connection.configuration')->setPublic(true); + $container->setAlias('registry_conn1', 'doctrine.dbal.conn1_connection.type_registry')->setPublic(true); + $container->setAlias('registry_conn2', 'doctrine.dbal.conn2_connection.type_registry')->setPublic(true); + }); + + $this->assertTypeRegistered($container, 'conn1', 'money', MoneyType::class); + $this->assertTypeRegistered($container, 'conn2', 'money', MoneyType::class); + } + + public function testTypeRestrictedToOneConnection(): void + { + $container = $this->createContainer(static function (ContainerBuilder $container): void { + $container->register('my_type', MoneyType::class) + ->addTag('doctrine.dbal.type', ['type' => 'money', 'connection' => 'conn1']); + + $container->setAlias('conf_conn1', 'doctrine.dbal.conn1_connection.configuration')->setPublic(true); + $container->setAlias('conf_conn2', 'doctrine.dbal.conn2_connection.configuration')->setPublic(true); + $container->setAlias('registry_conn1', 'doctrine.dbal.conn1_connection.type_registry')->setPublic(true); + $container->setAlias('registry_conn2', 'doctrine.dbal.conn2_connection.type_registry')->setPublic(true); + }); + + $this->assertTypeRegistered($container, 'conn1', 'money', MoneyType::class); + $this->assertTypeNotRegistered($container, 'conn2', 'money'); + } + + public function testTypeRegisteredOnMultipleConnectionsViaRepeatedAttribute(): void + { + $container = $this->createContainer(static function (ContainerBuilder $container): void { + $container->register('my_type', MultiConnectionType::class) + ->setAutoconfigured(true); + + $container->setAlias('conf_conn1', 'doctrine.dbal.conn1_connection.configuration')->setPublic(true); + $container->setAlias('conf_conn2', 'doctrine.dbal.conn2_connection.configuration')->setPublic(true); + $container->setAlias('registry_conn1', 'doctrine.dbal.conn1_connection.type_registry')->setPublic(true); + $container->setAlias('registry_conn2', 'doctrine.dbal.conn2_connection.type_registry')->setPublic(true); + }); + + $this->assertTypeRegistered($container, 'conn1', 'multi', MultiConnectionType::class); + $this->assertTypeNotRegistered($container, 'conn1', 'multi_alias'); + $this->assertTypeNotRegistered($container, 'conn2', 'multi'); + $this->assertTypeRegistered($container, 'conn2', 'multi_alias', MultiConnectionType::class); + } + + public function testAutoconfiguredTypeViaAttribute(): void + { + $container = $this->createContainer(static function (ContainerBuilder $container): void { + $container->register('my_type', AutoconfiguredMoneyType::class) + ->setAutoconfigured(true); + + $container->setAlias('conf_conn1', 'doctrine.dbal.conn1_connection.configuration')->setPublic(true); + $container->setAlias('conf_conn2', 'doctrine.dbal.conn2_connection.configuration')->setPublic(true); + $container->setAlias('registry_conn1', 'doctrine.dbal.conn1_connection.type_registry')->setPublic(true); + $container->setAlias('registry_conn2', 'doctrine.dbal.conn2_connection.type_registry')->setPublic(true); + }); + + $this->assertTypeRegistered($container, 'conn1', 'money', AutoconfiguredMoneyType::class); + $this->assertTypeRegistered($container, 'conn2', 'money', AutoconfiguredMoneyType::class); + } + + public function testAutoconfiguredTypeRestrictedToConnectionViaAttribute(): void + { + $container = $this->createContainer(static function (ContainerBuilder $container): void { + $container->register('my_type', AutoconfiguredMoneyTypeForConn1::class) + ->setAutoconfigured(true); + + $container->setAlias('conf_conn1', 'doctrine.dbal.conn1_connection.configuration')->setPublic(true); + $container->setAlias('conf_conn2', 'doctrine.dbal.conn2_connection.configuration')->setPublic(true); + $container->setAlias('registry_conn1', 'doctrine.dbal.conn1_connection.type_registry')->setPublic(true); + $container->setAlias('registry_conn2', 'doctrine.dbal.conn2_connection.type_registry')->setPublic(true); + }); + + $this->assertTypeRegistered($container, 'conn1', 'money', AutoconfiguredMoneyTypeForConn1::class); + $this->assertTypeNotRegistered($container, 'conn2', 'money'); + } + + public function testConfigTypesAreIncludedInRegistry(): void + { + $container = $this->createContainer( + static function (ContainerBuilder $container): void { + $container->register('my_type', MoneyType::class) + ->addTag('doctrine.dbal.type', ['type' => 'money']); + + $container->setAlias('registry_conn1', 'doctrine.dbal.conn1_connection.type_registry')->setPublic(true); + }, + configTypes: ['uuid' => ['class' => UuidType::class]], + ); + + $this->assertTypeRegistered($container, 'conn1', 'money', MoneyType::class); + $this->assertConfigTypeRegistered($container, 'conn1', 'uuid', UuidType::class); + } + + public function testTypeRegistryIsSetOnConfiguration(): void + { + $container = $this->createContainer(static function (ContainerBuilder $container): void { + $container->register('my_type', MoneyType::class) + ->addTag('doctrine.dbal.type', ['type' => 'money']); + + $container->setAlias('conf_conn1', 'doctrine.dbal.conn1_connection.configuration')->setPublic(true); + }); + + $setTypeRegistryCalls = array_filter( + $container->getDefinition('conf_conn1')->getMethodCalls(), + static fn (array $call): bool => $call[0] === 'setTypeRegistry', + ); + + self::assertCount(1, $setTypeRegistryCalls); + $registryArg = array_values($setTypeRegistryCalls)[0][1][0]; + + if ($registryArg instanceof Reference) { + $registryArg = $container->getDefinition((string) $registryArg); + } + + self::assertInstanceOf(Definition::class, $registryArg); + self::assertSame(TypeRegistry::class, $registryArg->getClass()); + } + + public function testTypeRegistryIsSetOnOrmConfiguration(): void + { + $container = $this->createContainer( + static function (ContainerBuilder $container): void { + $container->register('my_type', MoneyType::class) + ->addTag('doctrine.dbal.type', ['type' => 'money']); + + $container->setAlias('orm_conf_conn1', 'doctrine.orm.em_conn1_configuration')->setPublic(true); + $container->setAlias('orm_conf_conn2', 'doctrine.orm.em_conn2_configuration')->setPublic(true); + }, + withOrm: true, + ); + + foreach (['orm_conf_conn1', 'orm_conf_conn2'] as $alias) { + $setTypeRegistryCalls = array_filter( + $container->getDefinition($alias)->getMethodCalls(), + static fn (array $call): bool => $call[0] === 'setTypeRegistry', + ); + + self::assertCount(1, $setTypeRegistryCalls, sprintf('setTypeRegistry not called on %s', $alias)); + } + } + + + /** @param array $configTypes */ + private function createContainer( + callable $func, + array $configTypes = [], + bool $withOrm = false, + ): ContainerBuilder { + $params = ['kernel.debug' => false]; + if ($withOrm) { + $params['kernel.bundles'] = []; + $params['kernel.bundles_metadata'] = []; + $params['kernel.project_dir'] = sys_get_temp_dir(); + $params['kernel.environment'] = 'test'; + $params['kernel.build_dir'] = sys_get_temp_dir(); + } + + $container = new ContainerBuilder(new ParameterBag($params)); + + $container->registerExtension(new DoctrineExtension()); + + $doctrineConfig = [ + 'dbal' => [ + 'connections' => [ + 'conn1' => ['url' => 'mysql://user:pass@server1.tld:3306/db1'], + 'conn2' => ['url' => 'mysql://user:pass@server2.tld:3306/db2'], + ], + 'types' => $configTypes, + ], + ]; + + if ($withOrm) { + $doctrineConfig['orm'] = [ + 'entity_managers' => [ + 'em_conn1' => ['connection' => 'conn1'], + 'em_conn2' => ['connection' => 'conn2'], + ], + ]; + } + + $container->loadFromExtension('doctrine', $doctrineConfig); + + $container->addCompilerPass(new DatabaseTypePass()); + + $func($container); + + $container->compile(); + + return $container; + } + + private function assertTypeRegistered( + ContainerBuilder $container, + string $connName, + string $typeName, + string $typeClass, + ): void { + $registryDef = $container->getDefinition(sprintf('registry_%s', $connName)); + $instances = $registryDef->getArgument(0); + + self::assertArrayHasKey($typeName, $instances, sprintf( + 'Type "%s" not found in TypeRegistry for connection "%s".', + $typeName, + $connName, + )); + + self::assertSame($typeClass, $this->resolveTypeClass($container, $instances[$typeName])); + } + + private function resolveTypeClass(ContainerBuilder $container, mixed $entry): string + { + if ($entry instanceof Reference) { + $entry = $container->getDefinition((string) $entry); + } + + return $entry instanceof Definition ? $entry->getClass() ?? '' : ''; + } + + private function assertTypeNotRegistered(ContainerBuilder $container, string $connName, string $typeName): void + { + $registryId = sprintf('doctrine.dbal.%s_connection.type_registry', $connName); + + if (! $container->hasDefinition($registryId)) { + // No TypeRegistry at all for this connection — type is definitely not registered + return; + } + + $instances = $container->getDefinition($registryId)->getArgument(0); + + self::assertArrayNotHasKey($typeName, $instances, sprintf( + 'Type "%s" should not be registered in TypeRegistry for connection "%s".', + $typeName, + $connName, + )); + } + + private function assertConfigTypeRegistered( + ContainerBuilder $container, + string $connName, + string $typeName, + string $typeClass, + ): void { + $registryDef = $container->getDefinition(sprintf('registry_%s', $connName)); + $instances = $registryDef->getArgument(0); + + self::assertArrayHasKey($typeName, $instances, sprintf( + 'Config type "%s" not found in TypeRegistry for connection "%s".', + $typeName, + $connName, + )); + + $typeDef = $instances[$typeName]; + self::assertInstanceOf(Definition::class, $typeDef); + self::assertSame($typeClass, $typeDef->getClass()); + } +} + +class MoneyType extends Type +{ + public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + { + return 'NUMERIC(10,2)'; + } +} + +class UuidType extends Type +{ + public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + { + return 'CHAR(36)'; + } +} + +#[AsDatabaseType(name: 'money')] +class AutoconfiguredMoneyType extends Type +{ + public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + { + return 'NUMERIC(10,2)'; + } +} + +#[AsDatabaseType(name: 'money', connection: 'conn1')] +class AutoconfiguredMoneyTypeForConn1 extends Type +{ + public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + { + return 'NUMERIC(10,2)'; + } +} + +#[AsDatabaseType(name: 'multi', connection: 'conn1')] +#[AsDatabaseType(name: 'multi_alias', connection: 'conn2')] +class MultiConnectionType extends Type +{ + public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + { + return 'VARCHAR(255)'; + } +} From a14d21d2a1f0a8ba513e4861bf1eaaad6988ef2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Fri, 3 Apr 2026 15:22:38 +0200 Subject: [PATCH 2/4] Point composer dependencies to GromNaN forks for CI Replace local path repositories with VCS repositories pointing to the GitHub forks that contain the required changes: - GromNaN/dbal branch type-registry-config (TypeRegistry in Configuration) - GromNaN/doctrine-orm branch type-registry-instance (instance-based type lookups) --- composer.json | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/composer.json b/composer.json index 7347d9e31..09790ee53 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,7 @@ "homepage": "https://www.doctrine-project.org", "require": { "php": "^8.4", - "doctrine/dbal": "^4.0", + "doctrine/dbal": "dev-type-registry-config as 4.4.x-dev", "doctrine/deprecations": "^1.0", "doctrine/persistence": "^4", "doctrine/sql-formatter": "^1.0.1", @@ -44,7 +44,7 @@ }, "require-dev": { "doctrine/coding-standard": "^14", - "doctrine/orm": "^3.4.4", + "doctrine/orm": "dev-type-registry-instance as 3.6.x-dev", "phpstan/phpstan": "^2.1.13", "phpstan/phpstan-phpunit": "2.0.3", "phpstan/phpstan-strict-rules": "^2", @@ -75,16 +75,8 @@ "symfony/web-profiler-bundle": "To use the data collector." }, "repositories": [ - { - "type": "path", - "url": "../doctrine-dbal", - "options": {"symlink": true, "versions": {"doctrine/dbal": "4.4.x-dev"}} - }, - { - "type": "path", - "url": "../doctrine-orm", - "options": {"symlink": true, "versions": {"doctrine/orm": "3.6.x-dev"}} - } + {"type": "vcs", "url": "https://github.com/GromNaN/dbal.git"}, + {"type": "vcs", "url": "https://github.com/GromNaN/doctrine-orm.git"} ], "minimum-stability": "dev", "autoload": { From 1db2468a7d765df9c8a7d1eb4de12dd12306c5b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Fri, 3 Apr 2026 15:35:21 +0200 Subject: [PATCH 3/4] Make AsDatabaseType name optional, defaulting to the service id When no name is provided, the FQCN (service id) is used as the type name, allowing #[Column(type: MyType::class)] without declaring an explicit name. This also works for YAML/PHP service configuration: any service tagged with doctrine.dbal.type without a type attribute falls back to its service id. --- docs/en/custom-dbal-types.rst | 27 +++++--------- src/Attribute/AsDatabaseType.php | 15 +++++++- .../Compiler/DatabaseTypePass.php | 6 +--- .../Compiler/DatabaseTypePassTest.php | 35 +++++++++++++++++++ 4 files changed, 59 insertions(+), 24 deletions(-) diff --git a/docs/en/custom-dbal-types.rst b/docs/en/custom-dbal-types.rst index d975cb280..7dd66cf54 100644 --- a/docs/en/custom-dbal-types.rst +++ b/docs/en/custom-dbal-types.rst @@ -60,13 +60,14 @@ connection: use Doctrine\DBAL\Platforms\AbstractPlatform; use Doctrine\DBAL\Types\Type; - #[AsDatabaseType(name: 'money')] + #[AsDatabaseType] final class MoneyType extends Type { // ... } -You can now use the type by name in your entity mappings: +When no ``name`` is given, the fully-qualified class name is used as the type +name. You can then reference the type with ``::class`` in your entity mappings: .. code-block:: php @@ -86,28 +87,18 @@ You can now use the type by name in your entity mappings: #[ORM\Column] private int $id; - #[ORM\Column(type: MoneyType::NAME)] + #[ORM\Column(type: MoneyType::class)] private Money $price; } -It is recommended to define the type name as a constant on the type class to -avoid duplicating the string: +An explicit name can still be provided when the type needs to be referenced by +a short string (e.g. from PHP or YAML mappings): .. code-block:: php - $tags) { foreach ($tags as $tag) { - if (! isset($tag['type'])) { - continue; - } - if ($name !== ($tag['connection'] ?? $name)) { continue; } - $instances[$tag['type']] = new Reference($id); + $instances[$tag['type'] ?? $id] = new Reference($id); } } diff --git a/tests/DependencyInjection/Compiler/DatabaseTypePassTest.php b/tests/DependencyInjection/Compiler/DatabaseTypePassTest.php index 13d437795..4f20fb000 100644 --- a/tests/DependencyInjection/Compiler/DatabaseTypePassTest.php +++ b/tests/DependencyInjection/Compiler/DatabaseTypePassTest.php @@ -82,6 +82,32 @@ public function testTypeRegisteredOnMultipleConnectionsViaRepeatedAttribute(): v $this->assertTypeRegistered($container, 'conn2', 'multi_alias', MultiConnectionType::class); } + public function testTypeWithNoNameDefaultsToServiceId(): void + { + $container = $this->createContainer(static function (ContainerBuilder $container): void { + $container->register(AutoconfiguredAnonymousType::class, AutoconfiguredAnonymousType::class) + ->setAutoconfigured(true); + + $container->setAlias('registry_conn1', 'doctrine.dbal.conn1_connection.type_registry')->setPublic(true); + $container->setAlias('registry_conn2', 'doctrine.dbal.conn2_connection.type_registry')->setPublic(true); + }); + + $this->assertTypeRegistered($container, 'conn1', AutoconfiguredAnonymousType::class, AutoconfiguredAnonymousType::class); + $this->assertTypeRegistered($container, 'conn2', AutoconfiguredAnonymousType::class, AutoconfiguredAnonymousType::class); + } + + public function testTaggedTypeWithNoTypeAttributeDefaultsToServiceId(): void + { + $container = $this->createContainer(static function (ContainerBuilder $container): void { + $container->register('my_type', MoneyType::class) + ->addTag('doctrine.dbal.type'); + + $container->setAlias('registry_conn1', 'doctrine.dbal.conn1_connection.type_registry')->setPublic(true); + }); + + $this->assertTypeRegistered($container, 'conn1', 'my_type', MoneyType::class); + } + public function testAutoconfiguredTypeViaAttribute(): void { $container = $this->createContainer(static function (ContainerBuilder $container): void { @@ -328,6 +354,15 @@ public function getSQLDeclaration(array $column, AbstractPlatform $platform): st } } +#[AsDatabaseType] +class AutoconfiguredAnonymousType extends Type +{ + public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + { + return 'VARCHAR(255)'; + } +} + #[AsDatabaseType(name: 'multi', connection: 'conn1')] #[AsDatabaseType(name: 'multi_alias', connection: 'conn2')] class MultiConnectionType extends Type From fd11d62ad24ee363e10c00769cb92b1522f9ec06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Fri, 3 Apr 2026 19:16:07 +0200 Subject: [PATCH 4/4] Inject a ServiceLocator into TypeRegistry for lazy type resolution Replace the eager array of type instances/definitions with a Symfony ServiceLocator, leveraging the new ServiceProviderInterface support in DBAL's TypeRegistry. Types are now instantiated on first use rather than at container build time. --- .../Compiler/DatabaseTypePass.php | 13 ++-- src/DependencyInjection/DoctrineExtension.php | 3 +- .../Compiler/DatabaseTypePassTest.php | 63 +++++++------------ 3 files changed, 33 insertions(+), 46 deletions(-) diff --git a/src/DependencyInjection/Compiler/DatabaseTypePass.php b/src/DependencyInjection/Compiler/DatabaseTypePass.php index 039ff1ba9..91beaa849 100644 --- a/src/DependencyInjection/Compiler/DatabaseTypePass.php +++ b/src/DependencyInjection/Compiler/DatabaseTypePass.php @@ -6,6 +6,7 @@ use Doctrine\DBAL\Types\TypeRegistry; use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Definition; use Symfony\Component\DependencyInjection\Reference; @@ -48,11 +49,11 @@ public function process(ContainerBuilder $container): void } foreach (array_keys($container->getParameter('doctrine.connections')) as $name) { - $instances = []; + $services = []; - // Config-based types become inline definitions + // Config-based types become inline definitions in the ServiceLocator foreach ($configTypes as $typeName => $typeConfig) { - $instances[$typeName] = (new Definition($typeConfig['class']))->setShared(false); + $services[$typeName] = new Definition($typeConfig['class']); } // Service-tagged types: global (no connection restriction) or matching this connection @@ -62,14 +63,16 @@ public function process(ContainerBuilder $container): void continue; } - $instances[$tag['type'] ?? $id] = new Reference($id); + $services[$tag['type'] ?? $id] = new Reference($id); } } $registryId = sprintf('doctrine.dbal.%s_connection.type_registry', $name); $registryRef = new Reference($registryId); - $container->setDefinition($registryId, new Definition(TypeRegistry::class, [$instances])); + // Inject a ServiceLocator so types are resolved lazily on first use + $locatorRef = ServiceLocatorTagPass::register($container, $services); + $container->setDefinition($registryId, new Definition(TypeRegistry::class, [$locatorRef])); $container ->getDefinition(sprintf('doctrine.dbal.%s_connection.configuration', $name)) diff --git a/src/DependencyInjection/DoctrineExtension.php b/src/DependencyInjection/DoctrineExtension.php index d9ade02e0..2ffdcf754 100644 --- a/src/DependencyInjection/DoctrineExtension.php +++ b/src/DependencyInjection/DoctrineExtension.php @@ -4,9 +4,9 @@ namespace Doctrine\Bundle\DoctrineBundle\DependencyInjection; +use Doctrine\Bundle\DoctrineBundle\Attribute\AsDatabaseType; use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener; -use Doctrine\Bundle\DoctrineBundle\Attribute\AsDatabaseType; use Doctrine\Bundle\DoctrineBundle\Attribute\AsMiddleware; use Doctrine\Bundle\DoctrineBundle\CacheWarmer\DoctrineMetadataCacheWarmer; use Doctrine\Bundle\DoctrineBundle\ConnectionFactory; @@ -563,7 +563,6 @@ private function dbalLoad(array $config, ContainerBuilder $container): void $definition->addTag('doctrine.dbal.type', $tag); }); - $container->registerAttributeForAutoconfiguration(AsMiddleware::class, static function (ChildDefinition $definition, AsMiddleware $attribute): void { $priority = isset($attribute->priority) ? ['priority' => $attribute->priority] : []; diff --git a/tests/DependencyInjection/Compiler/DatabaseTypePassTest.php b/tests/DependencyInjection/Compiler/DatabaseTypePassTest.php index 4f20fb000..0f8cf5825 100644 --- a/tests/DependencyInjection/Compiler/DatabaseTypePassTest.php +++ b/tests/DependencyInjection/Compiler/DatabaseTypePassTest.php @@ -16,7 +16,10 @@ use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag; use Symfony\Component\DependencyInjection\Reference; +use function array_filter; +use function array_values; use function sprintf; +use function sys_get_temp_dir; class DatabaseTypePassTest extends TestCase { @@ -204,7 +207,6 @@ static function (ContainerBuilder $container): void { } } - /** @param array $configTypes */ private function createContainer( callable $func, @@ -213,11 +215,11 @@ private function createContainer( ): ContainerBuilder { $params = ['kernel.debug' => false]; if ($withOrm) { - $params['kernel.bundles'] = []; - $params['kernel.bundles_metadata'] = []; - $params['kernel.project_dir'] = sys_get_temp_dir(); - $params['kernel.environment'] = 'test'; - $params['kernel.build_dir'] = sys_get_temp_dir(); + $params['kernel.bundles'] = []; + $params['kernel.bundles_metadata'] = []; + $params['kernel.project_dir'] = sys_get_temp_dir(); + $params['kernel.environment'] = 'test'; + $params['kernel.build_dir'] = sys_get_temp_dir(); } $container = new ContainerBuilder(new ParameterBag($params)); @@ -260,25 +262,15 @@ private function assertTypeRegistered( string $typeName, string $typeClass, ): void { - $registryDef = $container->getDefinition(sprintf('registry_%s', $connName)); - $instances = $registryDef->getArgument(0); + $registry = $this->getRegistry($container, $connName); - self::assertArrayHasKey($typeName, $instances, sprintf( + self::assertTrue($registry->has($typeName), sprintf( 'Type "%s" not found in TypeRegistry for connection "%s".', $typeName, $connName, )); - self::assertSame($typeClass, $this->resolveTypeClass($container, $instances[$typeName])); - } - - private function resolveTypeClass(ContainerBuilder $container, mixed $entry): string - { - if ($entry instanceof Reference) { - $entry = $container->getDefinition((string) $entry); - } - - return $entry instanceof Definition ? $entry->getClass() ?? '' : ''; + self::assertInstanceOf($typeClass, $registry->get($typeName)); } private function assertTypeNotRegistered(ContainerBuilder $container, string $connName, string $typeName): void @@ -286,13 +278,12 @@ private function assertTypeNotRegistered(ContainerBuilder $container, string $co $registryId = sprintf('doctrine.dbal.%s_connection.type_registry', $connName); if (! $container->hasDefinition($registryId)) { - // No TypeRegistry at all for this connection — type is definitely not registered return; } - $instances = $container->getDefinition($registryId)->getArgument(0); + $registry = $this->getRegistry($container, $connName); - self::assertArrayNotHasKey($typeName, $instances, sprintf( + self::assertFalse($registry->has($typeName), sprintf( 'Type "%s" should not be registered in TypeRegistry for connection "%s".', $typeName, $connName, @@ -305,24 +296,18 @@ private function assertConfigTypeRegistered( string $typeName, string $typeClass, ): void { - $registryDef = $container->getDefinition(sprintf('registry_%s', $connName)); - $instances = $registryDef->getArgument(0); - - self::assertArrayHasKey($typeName, $instances, sprintf( - 'Config type "%s" not found in TypeRegistry for connection "%s".', - $typeName, - $connName, - )); + $this->assertTypeRegistered($container, $connName, $typeName, $typeClass); + } - $typeDef = $instances[$typeName]; - self::assertInstanceOf(Definition::class, $typeDef); - self::assertSame($typeClass, $typeDef->getClass()); + private function getRegistry(ContainerBuilder $container, string $connName): TypeRegistry + { + return $container->get(sprintf('registry_%s', $connName)); } } class MoneyType extends Type { - public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + public function getSQLDeclaration(mixed $column, AbstractPlatform $platform): string { return 'NUMERIC(10,2)'; } @@ -330,7 +315,7 @@ public function getSQLDeclaration(array $column, AbstractPlatform $platform): st class UuidType extends Type { - public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + public function getSQLDeclaration(mixed $column, AbstractPlatform $platform): string { return 'CHAR(36)'; } @@ -339,7 +324,7 @@ public function getSQLDeclaration(array $column, AbstractPlatform $platform): st #[AsDatabaseType(name: 'money')] class AutoconfiguredMoneyType extends Type { - public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + public function getSQLDeclaration(mixed $column, AbstractPlatform $platform): string { return 'NUMERIC(10,2)'; } @@ -348,7 +333,7 @@ public function getSQLDeclaration(array $column, AbstractPlatform $platform): st #[AsDatabaseType(name: 'money', connection: 'conn1')] class AutoconfiguredMoneyTypeForConn1 extends Type { - public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + public function getSQLDeclaration(mixed $column, AbstractPlatform $platform): string { return 'NUMERIC(10,2)'; } @@ -357,7 +342,7 @@ public function getSQLDeclaration(array $column, AbstractPlatform $platform): st #[AsDatabaseType] class AutoconfiguredAnonymousType extends Type { - public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + public function getSQLDeclaration(mixed $column, AbstractPlatform $platform): string { return 'VARCHAR(255)'; } @@ -367,7 +352,7 @@ public function getSQLDeclaration(array $column, AbstractPlatform $platform): st #[AsDatabaseType(name: 'multi_alias', connection: 'conn2')] class MultiConnectionType extends Type { - public function getSQLDeclaration(array $column, AbstractPlatform $platform): string + public function getSQLDeclaration(mixed $column, AbstractPlatform $platform): string { return 'VARCHAR(255)'; }