Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions UPGRADE-3.3.md
Original file line number Diff line number Diff line change
@@ -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).
8 changes: 6 additions & 2 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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": {
Expand Down
158 changes: 158 additions & 0 deletions docs/en/custom-dbal-types.rst
Original file line number Diff line number Diff line change
@@ -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

<?php

namespace App\Doctrine\Type;

use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;

final class MoneyType extends Type
{
public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
{
$column['precision'] ??= 10;
$column['scale'] ??= 2;

return $platform->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

<?php

namespace App\Doctrine\Type;

use Doctrine\Bundle\DoctrineBundle\Attribute\AsDatabaseType;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;

#[AsDatabaseType]
final class MoneyType extends Type
{
// ...
}

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

<?php

namespace App\Entity;

use App\Doctrine\Type\MoneyType;
use App\ValueObject\Money;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity]
class Product
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private int $id;

#[ORM\Column(type: MoneyType::class)]
private Money $price;
}

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

#[AsDatabaseType(name: 'money')]
final class MoneyType extends Type
{
// ...
}

Restricting a Type to Specific Connections
-------------------------------------------

If your application has several DBAL connections, you can limit a type to a
subset of connections by repeating the attribute with a ``connection`` argument:

.. code-block:: php

<?php

namespace App\Doctrine\Type;

use Doctrine\Bundle\DoctrineBundle\Attribute\AsDatabaseType;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Types\Type;

#[AsDatabaseType(connection: 'default')]
#[AsDatabaseType(connection: 'reporting')]
final class MoneyType extends Type
{
// ...
}

Without a ``connection`` argument the type is registered for all connections.

Registering via Configuration
------------------------------

Types can also be registered through the bundle configuration. This is the
legacy approach and does not require any PHP attribute:

.. code-block:: yaml

# config/packages/doctrine.yaml
doctrine:
dbal:
types:
money: App\Doctrine\Type\MoneyType

.. note::

When ``autoconfigure`` is disabled, the ``doctrine.dbal.type`` tag must be
added manually with a ``type`` attribute. An optional ``connection`` attribute
restricts the type to a single connection:

.. code-block:: yaml

# config/services.yaml
services:
App\Doctrine\Type\MoneyType:
tags:
- { name: doctrine.dbal.type, type: money }
# or for a specific connection:
- { name: doctrine.dbal.type, type: money, connection: default }
1 change: 1 addition & 0 deletions docs/en/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ configuration options, console commands and even a web debug toolbar collector.
event-listeners
custom-id-generators
middlewares
custom-dbal-types
configuration
30 changes: 30 additions & 0 deletions src/Attribute/AsDatabaseType.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace Doctrine\Bundle\DoctrineBundle\Attribute;

use Attribute;

/**
* Registers a DBAL type as a service, with full dependency injection support.
*
* The type is registered in the TypeRegistry of the specified connection(s),
* replacing the global static type registry for that connection.
*/
#[Attribute(Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
class AsDatabaseType
{
/**
* @param string|null $name The DBAL type name used in column mappings (e.g. in #[Column(type: ...)]).
* Defaults to the fully-qualified class name of the type when omitted,
* so that #[Column(type: MyType::class)] works without declaring a name.
* @param string|null $connection Restrict the type to a specific named connection.
* When null (default), the type is registered for all connections.
*/
public function __construct(
public readonly string|null $name = null,
public readonly string|null $connection = null,
) {
}
}
86 changes: 86 additions & 0 deletions src/DependencyInjection/Compiler/DatabaseTypePass.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
<?php

declare(strict_types=1);

namespace Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler;

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;

use function array_flip;
use function array_keys;
use function sprintf;

/** @internal */
final class DatabaseTypePass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (! $container->hasParameter('doctrine.connections')) {
return;
}

$taggedServiceIds = $container->findTaggedServiceIds('doctrine.dbal.type');
if ($taggedServiceIds === []) {
return;
}

// Config-based types (apply to all connections)
/** @var array<string, array{class: string}> $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]);
}
}
}
}
11 changes: 11 additions & 0 deletions src/DependencyInjection/DoctrineExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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] : [];

Expand Down
2 changes: 2 additions & 0 deletions src/DoctrineBundle.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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)) {
Expand Down
Loading
Loading