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..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", @@ -74,6 +74,10 @@ "doctrine/orm": "The Doctrine ORM integration is optional in the bundle.", "symfony/web-profiler-bundle": "To use the data collector." }, + "repositories": [ + {"type": "vcs", "url": "https://github.com/GromNaN/dbal.git"}, + {"type": "vcs", "url": "https://github.com/GromNaN/doctrine-orm.git"} + ], "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..7dd66cf54 --- /dev/null +++ b/docs/en/custom-dbal-types.rst @@ -0,0 +1,158 @@ +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) { + $services = []; + + // Config-based types become inline definitions in the ServiceLocator + foreach ($configTypes as $typeName => $typeConfig) { + $services[$typeName] = new Definition($typeConfig['class']); + } + + // Service-tagged types: global (no connection restriction) or matching this connection + foreach ($taggedServiceIds as $id => $tags) { + foreach ($tags as $tag) { + if ($name !== ($tag['connection'] ?? $name)) { + continue; + } + + $services[$tag['type'] ?? $id] = new Reference($id); + } + } + + $registryId = sprintf('doctrine.dbal.%s_connection.type_registry', $name); + $registryRef = new Reference($registryId); + + // 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)) + ->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..2ffdcf754 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\AsDatabaseType; use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener; use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener; use Doctrine\Bundle\DoctrineBundle\Attribute\AsMiddleware; @@ -552,6 +553,16 @@ 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..0f8cf5825 --- /dev/null +++ b/tests/DependencyInjection/Compiler/DatabaseTypePassTest.php @@ -0,0 +1,359 @@ +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 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 { + $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 { + $registry = $this->getRegistry($container, $connName); + + self::assertTrue($registry->has($typeName), sprintf( + 'Type "%s" not found in TypeRegistry for connection "%s".', + $typeName, + $connName, + )); + + self::assertInstanceOf($typeClass, $registry->get($typeName)); + } + + private function assertTypeNotRegistered(ContainerBuilder $container, string $connName, string $typeName): void + { + $registryId = sprintf('doctrine.dbal.%s_connection.type_registry', $connName); + + if (! $container->hasDefinition($registryId)) { + return; + } + + $registry = $this->getRegistry($container, $connName); + + self::assertFalse($registry->has($typeName), 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 { + $this->assertTypeRegistered($container, $connName, $typeName, $typeClass); + } + + private function getRegistry(ContainerBuilder $container, string $connName): TypeRegistry + { + return $container->get(sprintf('registry_%s', $connName)); + } +} + +class MoneyType extends Type +{ + public function getSQLDeclaration(mixed $column, AbstractPlatform $platform): string + { + return 'NUMERIC(10,2)'; + } +} + +class UuidType extends Type +{ + public function getSQLDeclaration(mixed $column, AbstractPlatform $platform): string + { + return 'CHAR(36)'; + } +} + +#[AsDatabaseType(name: 'money')] +class AutoconfiguredMoneyType extends Type +{ + public function getSQLDeclaration(mixed $column, AbstractPlatform $platform): string + { + return 'NUMERIC(10,2)'; + } +} + +#[AsDatabaseType(name: 'money', connection: 'conn1')] +class AutoconfiguredMoneyTypeForConn1 extends Type +{ + public function getSQLDeclaration(mixed $column, AbstractPlatform $platform): string + { + return 'NUMERIC(10,2)'; + } +} + +#[AsDatabaseType] +class AutoconfiguredAnonymousType extends Type +{ + public function getSQLDeclaration(mixed $column, AbstractPlatform $platform): string + { + return 'VARCHAR(255)'; + } +} + +#[AsDatabaseType(name: 'multi', connection: 'conn1')] +#[AsDatabaseType(name: 'multi_alias', connection: 'conn2')] +class MultiConnectionType extends Type +{ + public function getSQLDeclaration(mixed $column, AbstractPlatform $platform): string + { + return 'VARCHAR(255)'; + } +}