Skip to content

Commit f048045

Browse files
committed
feat: add AsDatabaseType attribute
1 parent e87ac5f commit f048045

11 files changed

Lines changed: 382 additions & 3 deletions

File tree

docs/en/dbal-type.rst

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
DBAL Types
2+
==========
3+
4+
Custom DBAL types can be registered using the ``AsDbalType`` attribute. This
5+
attribute allows you to define a name for your custom type directly in the class
6+
definition. If the name is not provided, the class name will be used as the default.
7+
8+
To register a custom DBAL type, create a class that extends
9+
``Doctrine\DBAL\Types\Type`` and add the ``#[AsDbalType]`` attribute to it:
10+
11+
.. code-block:: php
12+
13+
namespace App\Doctrine\Type;
14+
15+
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDbalType;
16+
use Doctrine\DBAL\Platforms\AbstractPlatform;
17+
use Doctrine\DBAL\Types\Type;
18+
19+
#[AsDbalType(name: 'money')]
20+
class MoneyType extends Type
21+
{
22+
public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
23+
{
24+
return $platform->getDecimalTypeDeclarationSQL($column);
25+
}
26+
27+
public function convertToPHPValue(mixed $value, AbstractPlatform $platform): mixed
28+
{
29+
return $value;
30+
}
31+
32+
public function convertToDatabaseValue(mixed $value, AbstractPlatform $platform): mixed
33+
{
34+
return $value;
35+
}
36+
}
37+
38+
When using the ``AsDbalType`` attribute, the type will be automatically
39+
registered with Doctrine.
40+
41+
Manual Registration
42+
-------------------
43+
44+
Alternatively, you can register custom types in your configuration:
45+
46+
.. configuration-block::
47+
48+
.. code-block:: yaml
49+
50+
# config/packages/doctrine.yaml
51+
doctrine:
52+
dbal:
53+
types:
54+
money: App\Doctrine\Type\MoneyType
55+
56+
.. code-block:: xml
57+
58+
<!-- config/packages/doctrine.xml -->
59+
<container xmlns="http://symfony.com/schema/dic/services"
60+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
61+
xmlns:doctrine="http://symfony.com/schema/dic/doctrine"
62+
xsi:schemaLocation="http://symfony.com/schema/dic/services
63+
http://symfony.com/schema/dic/services/services-1.0.xsd
64+
http://symfony.com/schema/dic/doctrine
65+
http://symfony.com/schema/dic/doctrine/doctrine-1.0.xsd">
66+
67+
<doctrine:config>
68+
<doctrine:dbal>
69+
<doctrine:type name="money">App\Doctrine\Type\MoneyType</doctrine:type>
70+
</doctrine:dbal>
71+
</doctrine:config>
72+
</container>
73+
74+
.. code-block:: php
75+
76+
// config/packages/doctrine.php
77+
use App\Doctrine\Type\MoneyType;
78+
use Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
79+
80+
return static function (ContainerConfigurator $containerConfigurator): void {
81+
$containerConfigurator->extension('doctrine', [
82+
'dbal' => [
83+
'types' => [
84+
'money' => MoneyType::class,
85+
],
86+
],
87+
]);
88+
};

docs/en/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ configuration options, console commands and even a web debug toolbar collector.
88

99
installation
1010
doctrine-console
11+
dbal-type
1112
entity-listeners
1213
event-listeners
1314
custom-id-generators

src/Attribute/AsDbalType.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\Bundle\DoctrineBundle\Attribute;
6+
7+
use Attribute;
8+
9+
#[Attribute(Attribute::TARGET_CLASS)]
10+
final readonly class AsDbalType
11+
{
12+
public function __construct(public string|null $name = null)
13+
{
14+
}
15+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler;
6+
7+
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDbalType;
8+
use Doctrine\DBAL\Types\Type;
9+
use ReflectionClass;
10+
use Symfony\Component\DependencyInjection\ChildDefinition;
11+
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
12+
use Symfony\Component\DependencyInjection\ContainerBuilder;
13+
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
14+
15+
use function is_subclass_of;
16+
use function method_exists;
17+
use function sprintf;
18+
19+
/** @internal */
20+
final class RegisterDbalTypePass implements CompilerPassInterface
21+
{
22+
private const string TAG = 'doctrine.dbal.type';
23+
24+
/**
25+
* @param ReflectionClass<T> $reflector
26+
*
27+
* @template T of ReflectionClass
28+
*/
29+
public static function autoconfigureFromAttribute(ChildDefinition $definition, AsDbalType $type, ReflectionClass $reflector): void
30+
{
31+
$attributes = [
32+
'type_name' => $type->name ?? $reflector->name,
33+
];
34+
35+
// Determine if the version of symfony/dependency-injection is >= 7.3
36+
/** @phpstan-ignore function.alreadyNarrowedType */
37+
if (method_exists($definition, 'addResourceTag')) {
38+
$definition->addResourceTag(self::TAG, $attributes);
39+
} else {
40+
// Needed to keep compatibility with symfony/dependency-injection < 7.3
41+
$definition->addTag(self::TAG, $attributes)
42+
->addTag('container.excluded', ['source' => sprintf('by tag "%s"', self::TAG)]);
43+
}
44+
}
45+
46+
public function process(ContainerBuilder $container): void
47+
{
48+
$types = $container->getParameter('doctrine.dbal.connection_factory.types');
49+
50+
foreach ($this->findTaggedResourceIds($container) as $id => $tags) {
51+
foreach ($tags as $tag) {
52+
$class = $container->getDefinition($id)->getClass();
53+
if (! $class) {
54+
throw new InvalidArgumentException(sprintf('The definition of "%s" must define its class.', $id));
55+
}
56+
57+
if (! is_subclass_of($class, Type::class)) {
58+
throw new InvalidArgumentException(sprintf('The "%s" class must extends "%s".', $class, Type::class));
59+
}
60+
61+
$types[$tag['type_name'] ?? $id] = ['class' => $class];
62+
}
63+
}
64+
65+
$container->setParameter('doctrine.dbal.connection_factory.types', $types);
66+
}
67+
68+
/** @return array<string, array<array{type_name?: string}>> */
69+
private function findTaggedResourceIds(ContainerBuilder $container): array
70+
{
71+
// Determine if the version of symfony/dependency-injection is >= 7.3
72+
/** @phpstan-ignore function.alreadyNarrowedType */
73+
if (method_exists($container, 'findTaggedResourceIds')) {
74+
return $container->findTaggedResourceIds(self::TAG);
75+
}
76+
77+
// Needed to keep compatibility with symfony/dependency-injection < 7.3
78+
$tags = [];
79+
foreach ($container->getDefinitions() as $id => $definition) {
80+
if (! $definition->hasTag(self::TAG)) {
81+
continue;
82+
}
83+
84+
if (! $definition->hasTag('container.excluded')) {
85+
throw new InvalidArgumentException(sprintf('The resource "%s" tagged "%s" is missing the "container.excluded" tag.', $id, self::TAG));
86+
}
87+
88+
$class = $container->getParameterBag()->resolveValue($definition->getClass());
89+
if (! $class || $definition->isAbstract()) {
90+
throw new InvalidArgumentException(sprintf('The resource "%s" tagged "%s" must have a class and not be abstract.', $id, self::TAG));
91+
}
92+
93+
if ($definition->getClass() !== $class) {
94+
$definition->setClass($class);
95+
}
96+
97+
$tags[$id] = $definition->getTag(self::TAG);
98+
}
99+
100+
return $tags;
101+
}
102+
}

src/DependencyInjection/DoctrineExtension.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44

55
namespace Doctrine\Bundle\DoctrineBundle\DependencyInjection;
66

7+
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDbalType;
78
use Doctrine\Bundle\DoctrineBundle\Attribute\AsDoctrineListener;
89
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
910
use Doctrine\Bundle\DoctrineBundle\Attribute\AsMiddleware;
1011
use Doctrine\Bundle\DoctrineBundle\CacheWarmer\DoctrineMetadataCacheWarmer;
1112
use Doctrine\Bundle\DoctrineBundle\ConnectionFactory;
1213
use Doctrine\Bundle\DoctrineBundle\Dbal\RegexSchemaAssetFilter;
1314
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\IdGeneratorPass;
15+
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\RegisterDbalTypePass;
1416
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\ServiceRepositoryCompilerPass;
1517
use Doctrine\Bundle\DoctrineBundle\Mapping\ContainerEntityListenerResolver;
1618
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepositoryInterface;
@@ -550,6 +552,9 @@ private function dbalLoad(array $config, ContainerBuilder $container): void
550552
$this->loadDbalConnection($name, $connection, $container);
551553
}
552554

555+
/** @phpstan-ignore argument.type (Needed for the $reflector parameter) */
556+
$container->registerAttributeForAutoconfiguration(AsDbalType::class, RegisterDbalTypePass::autoconfigureFromAttribute(...));
557+
553558
$container->registerForAutoconfiguration(MiddlewareInterface::class)->addTag('doctrine.middleware');
554559

555560
$container->registerAttributeForAutoconfiguration(AsMiddleware::class, static function (ChildDefinition $definition, AsMiddleware $attribute): void {

src/DoctrineBundle.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\EntityListenerPass;
1010
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\IdGeneratorPass;
1111
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\MiddlewaresPass;
12+
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\RegisterDbalTypePass;
1213
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\RemoveLoggingMiddlewarePass;
1314
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\RemoveProfilerControllerPass;
1415
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\ServiceRepositoryCompilerPass;
@@ -68,6 +69,7 @@ public function process(ContainerBuilder $container): void
6869
$container->addCompilerPass(new RemoveLoggingMiddlewarePass());
6970
$container->addCompilerPass(new MiddlewaresPass());
7071
$container->addCompilerPass(new RegisterUidTypePass());
72+
$container->addCompilerPass(new RegisterDbalTypePass());
7173

7274
if (! class_exists(RegisterDatePointTypePass::class)) {
7375
return;

tests/BundleTest.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
namespace Doctrine\Bundle\DoctrineBundle\Tests;
66

77
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\DbalSchemaFilterPass;
8+
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\RegisterDbalTypePass;
89
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
910
use PHPUnit\Framework\TestCase;
1011
use Symfony\Bridge\Doctrine\DependencyInjection\CompilerPass\DoctrineValidationPass;
@@ -22,9 +23,10 @@ public function testBuildCompilerPasses(): void
2223
$config = $container->getCompilerPassConfig();
2324
$passes = $config->getBeforeOptimizationPasses();
2425

25-
$foundEventListener = false;
26-
$foundValidation = false;
27-
$foundSchemaFilter = false;
26+
$foundEventListener = false;
27+
$foundValidation = false;
28+
$foundSchemaFilter = false;
29+
$foundRegisterDbalType = false;
2830

2931
foreach ($passes as $pass) {
3032
if ($pass instanceof RegisterEventListenersAndSubscribersPass) {
@@ -33,11 +35,14 @@ public function testBuildCompilerPasses(): void
3335
$foundValidation = true;
3436
} elseif ($pass instanceof DbalSchemaFilterPass) {
3537
$foundSchemaFilter = true;
38+
} elseif ($pass instanceof RegisterDbalTypePass) {
39+
$foundRegisterDbalType = true;
3640
}
3741
}
3842

3943
$this->assertTrue($foundEventListener, 'RegisterEventListenersAndSubscribersPass was not found');
4044
$this->assertTrue($foundValidation, 'DoctrineValidationPass was not found');
4145
$this->assertTrue($foundSchemaFilter, 'DbalSchemaFilterPass was not found');
46+
$this->assertTrue($foundRegisterDbalType, 'RegisterDbalTypePass was not found');
4247
}
4348
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Doctrine\Bundle\DoctrineBundle\Tests\DependencyInjection\Compiler;
6+
7+
use Doctrine\Bundle\DoctrineBundle\DependencyInjection\Compiler\RegisterDbalTypePass;
8+
use Doctrine\DBAL\Platforms\AbstractPlatform;
9+
use Doctrine\DBAL\Types\Type;
10+
use PHPUnit\Framework\TestCase;
11+
use Symfony\Component\DependencyInjection\ContainerBuilder;
12+
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
13+
14+
use function sprintf;
15+
16+
class RegisterDbalTypePassTest extends TestCase
17+
{
18+
public function testTaggedTypeAreAdded(): void
19+
{
20+
$container = new ContainerBuilder();
21+
$container->addCompilerPass(new RegisterDbalTypePass());
22+
23+
$container->setParameter('doctrine.dbal.connection_factory.types', []);
24+
25+
$container->register(BarType::class)
26+
->addTag('doctrine.dbal.type', ['type_name' => 'bar'])
27+
->addTag('container.excluded');
28+
29+
$container->compile();
30+
31+
self::assertSame(['bar' => ['class' => BarType::class]], $container->getParameter('doctrine.dbal.connection_factory.types'));
32+
}
33+
34+
public function testServiceIdMustBeUsedAsTypeNameIfNotDefined(): void
35+
{
36+
$container = new ContainerBuilder();
37+
$container->addCompilerPass(new RegisterDbalTypePass());
38+
39+
$container->setParameter('doctrine.dbal.connection_factory.types', []);
40+
41+
$container->register('doctrine.dbal.type.bar')
42+
->setClass(BarType::class)
43+
->addTag('doctrine.dbal.type')
44+
->addTag('container.excluded');
45+
46+
$container->compile();
47+
48+
self::assertSame(['doctrine.dbal.type.bar' => ['class' => BarType::class]], $container->getParameter('doctrine.dbal.connection_factory.types'));
49+
}
50+
51+
public function testTypeMustBeASubclassOfTheDbalBaseType(): void
52+
{
53+
$container = new ContainerBuilder();
54+
$container->addCompilerPass(new RegisterDbalTypePass());
55+
56+
$container->setParameter('doctrine.dbal.connection_factory.types', []);
57+
58+
$container->register(NotASubClassOfDbalBaseType::class)
59+
->addTag('doctrine.dbal.type', ['type_name' => 'invalid_type'])
60+
->addTag('container.excluded');
61+
62+
$this->expectException(InvalidArgumentException::class);
63+
$this->expectExceptionMessage(sprintf('The "%s" class must extends "%s".', NotASubClassOfDbalBaseType::class, Type::class));
64+
65+
$container->compile();
66+
}
67+
}
68+
69+
class BarType extends Type
70+
{
71+
/** @param array<string, mixed> $column */
72+
public function getSQLDeclaration(array $column, AbstractPlatform $platform): string
73+
{
74+
return 'bar';
75+
}
76+
}
77+
78+
class NotASubClassOfDbalBaseType
79+
{
80+
}

0 commit comments

Comments
 (0)