diff --git a/composer.json b/composer.json index 5dc9e2e8fc3..40722350a43 100644 --- a/composer.json +++ b/composer.json @@ -47,7 +47,8 @@ "slevomat/coding-standard": "8.27.1", "squizlabs/php_codesniffer": "4.0.1", "symfony/cache": "^6.3.8|^7.0|^8.0", - "symfony/console": "^5.4|^6.3|^7.0|^8.0" + "symfony/console": "^5.4|^6.3|^7.0|^8.0", + "symfony/service-contracts": "^2|^3" }, "suggest": { "symfony/console": "For helpful console commands such as SQL execution and import of files." diff --git a/src/Configuration.php b/src/Configuration.php index 109e6bf07e3..9afa4557656 100644 --- a/src/Configuration.php +++ b/src/Configuration.php @@ -7,6 +7,8 @@ use Doctrine\DBAL\Driver\Middleware; use Doctrine\DBAL\Exception\InvalidArgumentException; use Doctrine\DBAL\Schema\SchemaManagerFactory; +use Doctrine\DBAL\Types\Type; +use Doctrine\DBAL\Types\TypeRegistry; use Psr\Cache\CacheItemPoolInterface; /** @@ -36,6 +38,8 @@ class Configuration private ?SchemaManagerFactory $schemaManagerFactory = null; + private ?TypeRegistry $typeRegistry = null; + public function __construct() { $this->schemaAssetsFilter = static function (): bool { @@ -134,6 +138,19 @@ public function setSchemaManagerFactory(SchemaManagerFactory $schemaManagerFacto return $this; } + public function getTypeRegistry(): TypeRegistry + { + return $this->typeRegistry ??= Type::getTypeRegistry(); + } + + /** @return $this */ + public function setTypeRegistry(TypeRegistry $typeRegistry): self + { + $this->typeRegistry = $typeRegistry; + + return $this; + } + public function getDisableTypeComments(): bool { return true; diff --git a/src/Connection.php b/src/Connection.php index cd501c3ba16..4cf9e2ed70c 100644 --- a/src/Connection.php +++ b/src/Connection.php @@ -194,6 +194,7 @@ public function getDatabasePlatform(): AbstractPlatform } $this->platform = $this->driver->getDatabasePlatform($versionProvider); + $this->platform->setConfiguration($this->_config); } return $this->platform; @@ -1278,7 +1279,8 @@ public function isRollbackOnly(): bool */ public function convertToDatabaseValue(mixed $value, string $type): mixed { - return Type::getType($type)->convertToDatabaseValue($value, $this->getDatabasePlatform()); + return $this->_config->getTypeRegistry()->get($type) + ->convertToDatabaseValue($value, $this->getDatabasePlatform()); } /** @@ -1294,7 +1296,7 @@ public function convertToDatabaseValue(mixed $value, string $type): mixed */ public function convertToPHPValue(mixed $value, string $type): mixed { - return Type::getType($type)->convertToPHPValue($value, $this->getDatabasePlatform()); + return $this->_config->getTypeRegistry()->get($type)->convertToPHPValue($value, $this->getDatabasePlatform()); } /** @@ -1360,7 +1362,7 @@ private function bindParameters(DriverStatement $stmt, array $params, array $typ private function getBindingInfo(mixed $value, string|ParameterType|Type $type): array { if (is_string($type)) { - $type = Type::getType($type); + $type = $this->_config->getTypeRegistry()->get($type); } if ($type instanceof Type) { diff --git a/src/Platforms/AbstractPlatform.php b/src/Platforms/AbstractPlatform.php index f29f8fef703..698f815089c 100644 --- a/src/Platforms/AbstractPlatform.php +++ b/src/Platforms/AbstractPlatform.php @@ -4,6 +4,7 @@ namespace Doctrine\DBAL\Platforms; +use Doctrine\DBAL\Configuration; use Doctrine\DBAL\Connection; use Doctrine\DBAL\Exception; use Doctrine\DBAL\Exception\InvalidArgumentException; @@ -107,6 +108,8 @@ abstract class AbstractPlatform */ private ?UnquotedIdentifierFolding $unquotedIdentifierFolding = null; + private ?Configuration $configuration = null; + public function __construct(?UnquotedIdentifierFolding $unquotedIdentifierFolding = null) { if ($unquotedIdentifierFolding === null) { @@ -121,6 +124,17 @@ public function __construct(?UnquotedIdentifierFolding $unquotedIdentifierFoldin $this->unquotedIdentifierFolding = $unquotedIdentifierFolding ?? UnquotedIdentifierFolding::UPPER; } + /** @internal Called by Connection after platform creation. */ + public function setConfiguration(Configuration $configuration): void + { + $this->configuration = $configuration; + } + + private function getTypeRegistry(): Types\TypeRegistry + { + return $this->configuration?->getTypeRegistry() ?? Type::getTypeRegistry(); + } + /** * Returns the SQL snippet that declares a boolean column. * @@ -171,8 +185,8 @@ private function initializeAllDoctrineTypeMappings(): void { $this->initializeDoctrineTypeMappings(); - foreach (Type::getTypesMap() as $typeName => $className) { - foreach (Type::getType($typeName)->getMappedDatabaseTypes($this) as $dbType) { + foreach ($this->getTypeRegistry()->getMap() as $typeName => $type) { + foreach ($type->getMappedDatabaseTypes($this) as $dbType) { $dbType = strtolower($dbType); $this->doctrineTypeMapping[$dbType] = $typeName; } @@ -397,7 +411,7 @@ public function registerDoctrineTypeMapping(string $dbType, string $doctrineType $this->initializeAllDoctrineTypeMappings(); } - if (! Types\Type::hasType($doctrineType)) { + if (! $this->getTypeRegistry()->has($doctrineType)) { throw TypeNotFound::new($doctrineType); } diff --git a/src/Platforms/Db2/Db2MetadataProvider.php b/src/Platforms/Db2/Db2MetadataProvider.php index 39a3779c144..5b00dd1e77e 100644 --- a/src/Platforms/Db2/Db2MetadataProvider.php +++ b/src/Platforms/Db2/Db2MetadataProvider.php @@ -193,7 +193,7 @@ private function createTableColumn(array $row): TableColumnMetadataRow } $editor - ->setTypeName($type) + ->setType($this->connection->getConfiguration()->getTypeRegistry()->get($type)) ->setNotNull($nulls === 'N') ->setDefaultValue($this->parseDefaultExpression($default)) ->setAutoincrement($generated === 'D'); diff --git a/src/Platforms/MySQL/MySQLMetadataProvider.php b/src/Platforms/MySQL/MySQLMetadataProvider.php index 10e50734b1d..b9dcfbeac50 100644 --- a/src/Platforms/MySQL/MySQLMetadataProvider.php +++ b/src/Platforms/MySQL/MySQLMetadataProvider.php @@ -214,8 +214,10 @@ private function createTableColumn(array $row): TableColumnMetadataRow $editor = Column::editor() ->setQuotedName($columnName) - ->setTypeName( - $this->platform->getDoctrineTypeMapping($dbType), + ->setType( + $this->connection->getConfiguration()->getTypeRegistry()->get( + $this->platform->getDoctrineTypeMapping($dbType), + ), ); if (str_contains($columnType, 'unsigned')) { diff --git a/src/Platforms/Oracle/OracleMetadataProvider.php b/src/Platforms/Oracle/OracleMetadataProvider.php index eea15812335..0022b4011f7 100644 --- a/src/Platforms/Oracle/OracleMetadataProvider.php +++ b/src/Platforms/Oracle/OracleMetadataProvider.php @@ -229,7 +229,7 @@ private function createTableColumn(array $row): TableColumnMetadataRow } $editor - ->setTypeName($type) + ->setType($this->connection->getConfiguration()->getTypeRegistry()->get($type)) ->setPrecision($precision) ->setScale($scale) ->setNotNull($nullable === 'N') diff --git a/src/Platforms/PostgreSQL/PostgreSQLMetadataProvider.php b/src/Platforms/PostgreSQL/PostgreSQLMetadataProvider.php index 6b6e91a1928..8e6e5ca3389 100644 --- a/src/Platforms/PostgreSQL/PostgreSQLMetadataProvider.php +++ b/src/Platforms/PostgreSQL/PostgreSQLMetadataProvider.php @@ -243,8 +243,10 @@ private function createTableColumn(array $row): TableColumnMetadataRow $completeType = $domainCompleteType; } - $editor->setTypeName( - $this->platform->getDoctrineTypeMapping($typeName), + $editor->setType( + $this->connection->getConfiguration()->getTypeRegistry()->get( + $this->platform->getDoctrineTypeMapping($typeName), + ), ); switch ($typeName) { diff --git a/src/Platforms/SQLServer/SQLServerMetadataProvider.php b/src/Platforms/SQLServer/SQLServerMetadataProvider.php index 1835e6a5ed1..218c589079b 100644 --- a/src/Platforms/SQLServer/SQLServerMetadataProvider.php +++ b/src/Platforms/SQLServer/SQLServerMetadataProvider.php @@ -221,8 +221,10 @@ private function createTableColumn(array $row): TableColumnMetadataRow $editor = Column::editor() ->setQuotedName($columnName) - ->setTypeName( - $this->platform->getDoctrineTypeMapping($dbType), + ->setType( + $this->connection->getConfiguration()->getTypeRegistry()->get( + $this->platform->getDoctrineTypeMapping($dbType), + ), ) ->setNotNull(! $isNullable) ->setAutoincrement((bool) $isIdentity); diff --git a/src/Platforms/SQLite/SQLiteMetadataProvider.php b/src/Platforms/SQLite/SQLiteMetadataProvider.php index 04ed05421d2..95a43a1088c 100644 --- a/src/Platforms/SQLite/SQLiteMetadataProvider.php +++ b/src/Platforms/SQLite/SQLiteMetadataProvider.php @@ -174,7 +174,7 @@ private function createTableColumn(array $row, array $sqlByTableName): TableColu $typeName = $this->platform->getDoctrineTypeMapping($dbType); - $editor->setTypeName($typeName); + $editor->setType($this->connection->getConfiguration()->getTypeRegistry()->get($typeName)); if ($dbType === 'char') { $editor->setFixed(true); diff --git a/src/Schema/AbstractSchemaManager.php b/src/Schema/AbstractSchemaManager.php index c65c3e526c1..0e405645cf3 100644 --- a/src/Schema/AbstractSchemaManager.php +++ b/src/Schema/AbstractSchemaManager.php @@ -16,6 +16,7 @@ use Doctrine\DBAL\Schema\Name\Parsers; use Doctrine\DBAL\Schema\Name\UnqualifiedName; use Doctrine\DBAL\Types\Exception\TypesException; +use Doctrine\DBAL\Types\Type; use Doctrine\Deprecations\Deprecation; use Throwable; @@ -264,6 +265,8 @@ public function listTables(): array $this->_getPortableTableForeignKeysList($foreignKeyColumnsByTable[$tableName] ?? []), $tableOptionsByTable[$tableName] ?? [], $configuration, + null, + $this->connection->getConfiguration()->getTypeRegistry(), ); } @@ -494,6 +497,9 @@ public function introspectTable(string $name): Table [], $this->listTableForeignKeys($name), $this->getTableOptions($name), + null, + null, + $this->connection->getConfiguration()->getTypeRegistry(), ); } @@ -1267,6 +1273,12 @@ protected function _getPortableSequenceDefinition(array $sequence): Sequence throw NotSupported::new(__METHOD__); } + /** @throws TypesException */ + final protected function getType(string $typeName): Type + { + return $this->connection->getConfiguration()->getTypeRegistry()->get($typeName); + } + /** * Independent of the database the keys of the column list result are lowercased. * @@ -1444,6 +1456,7 @@ public function createSchemaConfig(): SchemaConfig } $schemaConfig->setDefaultTableOptions($params['defaultTableOptions']); + $schemaConfig->setTypeRegistry($this->connection->getConfiguration()->getTypeRegistry()); return $schemaConfig; } diff --git a/src/Schema/ColumnDiff.php b/src/Schema/ColumnDiff.php index 6c86e921646..76fe4fe8524 100644 --- a/src/Schema/ColumnDiff.php +++ b/src/Schema/ColumnDiff.php @@ -54,6 +54,13 @@ public function hasNameChanged(): bool public function hasTypeChanged(): bool { + // TODO: This comparison by class is insufficient now that types can be distinct services sharing the same + // class (e.g. two differently configured instances of the same Type subclass). It also produces false + // negatives for built-in aliases that share a class: json and json_object both map to JsonType::class, + // so switching between them is not detected as a change. + // The fix is to compare Type instances by identity (===) once both the introspected schema and the + // target schema are guaranteed to resolve types from the same TypeRegistry, so the flyweight invariant + // (one instance per registered name) holds across both sides of the diff. return $this->newColumn->getType()::class !== $this->oldColumn->getType()::class; } diff --git a/src/Schema/DB2SchemaManager.php b/src/Schema/DB2SchemaManager.php index 5f7b29358ad..bee05f24a1e 100644 --- a/src/Schema/DB2SchemaManager.php +++ b/src/Schema/DB2SchemaManager.php @@ -6,7 +6,6 @@ use Doctrine\DBAL\Platforms\DB2Platform; use Doctrine\DBAL\Result; -use Doctrine\DBAL\Types\Type; use Doctrine\DBAL\Types\Types; use function array_change_key_case; @@ -98,7 +97,7 @@ protected function _getPortableTableColumnDefinition(array $tableColumn): Column $options['precision'] = $precision; } - return new Column($tableColumn['colname'], Type::getType($type), $options); + return new Column($tableColumn['colname'], $this->getType($type), $options); } /** diff --git a/src/Schema/MySQLSchemaManager.php b/src/Schema/MySQLSchemaManager.php index c8489389ba4..b74ec884915 100644 --- a/src/Schema/MySQLSchemaManager.php +++ b/src/Schema/MySQLSchemaManager.php @@ -17,7 +17,6 @@ use Doctrine\DBAL\Schema\DefaultExpression\CurrentDate; use Doctrine\DBAL\Schema\DefaultExpression\CurrentTime; use Doctrine\DBAL\Schema\DefaultExpression\CurrentTimestamp; -use Doctrine\DBAL\Types\Type; use function array_change_key_case; use function array_map; @@ -214,7 +213,7 @@ protected function _getPortableTableColumnDefinition(array $tableColumn): Column $options['comment'] = $tableColumn['comment']; } - $column = new Column($tableColumn['field'], Type::getType($type), $options); + $column = new Column($tableColumn['field'], $this->getType($type), $options); $column->setPlatformOption('charset', $tableColumn['characterset']); $column->setPlatformOption('collation', $tableColumn['collation']); diff --git a/src/Schema/OracleSchemaManager.php b/src/Schema/OracleSchemaManager.php index 47d87400f19..f218138bbe8 100644 --- a/src/Schema/OracleSchemaManager.php +++ b/src/Schema/OracleSchemaManager.php @@ -8,7 +8,6 @@ use Doctrine\DBAL\Exception\DatabaseObjectNotFoundException; use Doctrine\DBAL\Platforms\OraclePlatform; use Doctrine\DBAL\Result; -use Doctrine\DBAL\Types\Type; use function array_change_key_case; use function array_key_exists; @@ -184,7 +183,7 @@ protected function _getPortableTableColumnDefinition(array $tableColumn): Column $options['comment'] = $tableColumn['comments']; } - return new Column($this->getQuotedIdentifierName($tableColumn['column_name']), Type::getType($type), $options); + return new Column($this->getQuotedIdentifierName($tableColumn['column_name']), $this->getType($type), $options); } /** diff --git a/src/Schema/PostgreSQLSchemaManager.php b/src/Schema/PostgreSQLSchemaManager.php index 10072969f0e..458ed0d7926 100644 --- a/src/Schema/PostgreSQLSchemaManager.php +++ b/src/Schema/PostgreSQLSchemaManager.php @@ -8,7 +8,6 @@ use Doctrine\DBAL\Platforms\PostgreSQLPlatform; use Doctrine\DBAL\Result; use Doctrine\DBAL\Types\JsonType; -use Doctrine\DBAL\Types\Type; use function array_change_key_case; use function array_map; @@ -274,7 +273,7 @@ protected function _getPortableTableColumnDefinition(array $tableColumn): Column $options['comment'] = $tableColumn['comment']; } - $column = new Column($tableColumn['field'], Type::getType($type), $options); + $column = new Column($tableColumn['field'], $this->getType($type), $options); if (! empty($tableColumn['collation'])) { $column->setPlatformOption('collation', $tableColumn['collation']); diff --git a/src/Schema/SQLServerSchemaManager.php b/src/Schema/SQLServerSchemaManager.php index 86c1c6585bd..85e315f507e 100644 --- a/src/Schema/SQLServerSchemaManager.php +++ b/src/Schema/SQLServerSchemaManager.php @@ -9,7 +9,6 @@ use Doctrine\DBAL\Platforms\SQLServerPlatform; use Doctrine\DBAL\Result; use Doctrine\DBAL\Schema\DefaultExpression\CurrentTimestamp; -use Doctrine\DBAL\Types\Type; use function array_change_key_case; use function assert; @@ -132,7 +131,7 @@ protected function _getPortableTableColumnDefinition(array $tableColumn): Column $options['length'] = $length; } - $column = new Column($tableColumn['name'], Type::getType($type), $options); + $column = new Column($tableColumn['name'], $this->getType($type), $options); if ($tableColumn['default'] !== null) { $default = $this->parseDefaultExpression($tableColumn['default']); diff --git a/src/Schema/SQLiteSchemaManager.php b/src/Schema/SQLiteSchemaManager.php index 8a8cba834ba..66f2d0f7677 100644 --- a/src/Schema/SQLiteSchemaManager.php +++ b/src/Schema/SQLiteSchemaManager.php @@ -8,7 +8,6 @@ use Doctrine\DBAL\Platforms\SQLite; use Doctrine\DBAL\Platforms\SQLitePlatform; use Doctrine\DBAL\Result; -use Doctrine\DBAL\Types\Type; use Doctrine\DBAL\Types\Types; use Doctrine\Deprecations\Deprecation; @@ -127,7 +126,7 @@ protected function _getPortableTableColumnDefinition(array $tableColumn): Column 'scale' => $scale, ]; - $column = new Column($tableColumn['name'], Type::getType($type), $options); + $column = new Column($tableColumn['name'], $this->getType($type), $options); if ($type === Types::STRING || $type === Types::TEXT) { $column->setPlatformOption('collation', $tableColumn['collation'] ?? 'BINARY'); diff --git a/src/Schema/Schema.php b/src/Schema/Schema.php index f92ee0f7d39..4b776d058d4 100644 --- a/src/Schema/Schema.php +++ b/src/Schema/Schema.php @@ -391,7 +391,17 @@ public function createNamespace(string $name): self */ public function createTable(string $name): Table { - $table = new Table($name, [], [], [], [], [], $this->_schemaConfig->toTableConfiguration()); + $table = new Table( + $name, + [], + [], + [], + [], + [], + $this->_schemaConfig->toTableConfiguration(), + null, + $this->_schemaConfig->getTypeRegistry(), + ); $this->_addTable($table); foreach ($this->_schemaConfig->getDefaultTableOptions() as $option => $value) { diff --git a/src/Schema/SchemaConfig.php b/src/Schema/SchemaConfig.php index 557ed1e700f..9aa6af6f667 100644 --- a/src/Schema/SchemaConfig.php +++ b/src/Schema/SchemaConfig.php @@ -4,11 +4,14 @@ namespace Doctrine\DBAL\Schema; +use Doctrine\DBAL\Types\TypeRegistry; + /** * Configuration for a Schema. */ class SchemaConfig { + private ?TypeRegistry $typeRegistry = null; /** @var positive-int */ protected int $maxIdentifierLength = 63; @@ -67,6 +70,16 @@ public function setDefaultTableOptions(array $defaultTableOptions): void $this->defaultTableOptions = $defaultTableOptions; } + public function getTypeRegistry(): ?TypeRegistry + { + return $this->typeRegistry; + } + + public function setTypeRegistry(TypeRegistry $typeRegistry): void + { + $this->typeRegistry = $typeRegistry; + } + public function toTableConfiguration(): TableConfiguration { return new TableConfiguration($this->maxIdentifierLength); diff --git a/src/Schema/Table.php b/src/Schema/Table.php index e41fcc6a5f1..4e260619482 100644 --- a/src/Schema/Table.php +++ b/src/Schema/Table.php @@ -20,6 +20,7 @@ use Doctrine\DBAL\Schema\Name\UnqualifiedName; use Doctrine\DBAL\Types\Exception\TypesException; use Doctrine\DBAL\Types\Type; +use Doctrine\DBAL\Types\TypeRegistry; use Doctrine\Deprecations\Deprecation; use LogicException; @@ -100,6 +101,7 @@ public function __construct( array $options = [], ?TableConfiguration $configuration = null, ?PrimaryKeyConstraint $primaryKeyConstraint = null, + private ?TypeRegistry $typeRegistry = null, ) { if ($name === '') { throw InvalidTableName::new($name); @@ -386,7 +388,9 @@ public function columnsAreIndexed(array $columnNames): bool */ public function addColumn(string $name, string $typeName, array $options = []): Column { - $column = new Column($name, Type::getType($typeName), $options); + $type = $this->typeRegistry?->get($typeName) ?? Type::getType($typeName); + + $column = new Column($name, $type, $options); $this->_addColumn($column); diff --git a/src/Statement.php b/src/Statement.php index 9b4a3b43bca..b017251280b 100644 --- a/src/Statement.php +++ b/src/Statement.php @@ -79,7 +79,7 @@ public function bindValue( $this->types[$param] = $type; if (is_string($type)) { - $type = Type::getType($type); + $type = $this->conn->getConfiguration()->getTypeRegistry()->get($type); } if ($type instanceof Type) { diff --git a/src/Types/Exception/UnknownColumnType.php b/src/Types/Exception/UnknownColumnType.php index 5397f808b71..6a31e0b46a1 100644 --- a/src/Types/Exception/UnknownColumnType.php +++ b/src/Types/Exception/UnknownColumnType.php @@ -5,12 +5,13 @@ namespace Doctrine\DBAL\Types\Exception; use Exception; +use Throwable; use function sprintf; final class UnknownColumnType extends Exception implements TypesException { - public static function new(string $name): self + public static function new(string $name, Throwable|null $previous = null): self { return new self( sprintf( @@ -23,6 +24,8 @@ public static function new(string $name): self . 'have a problem with the cache or forgot some mapping information.', $name, ), + 0, + $previous, ); } } diff --git a/src/Types/Type.php b/src/Types/Type.php index fb480ee4ebd..783fe02a0a6 100644 --- a/src/Types/Type.php +++ b/src/Types/Type.php @@ -21,41 +21,6 @@ */ abstract class Type { - /** - * The map of supported doctrine mapping types. - */ - private const BUILTIN_TYPES_MAP = [ - Types::ASCII_STRING => AsciiStringType::class, - Types::BIGINT => BigIntType::class, - Types::BINARY => BinaryType::class, - Types::BLOB => BlobType::class, - Types::BOOLEAN => BooleanType::class, - Types::DATE_MUTABLE => DateType::class, - Types::DATE_IMMUTABLE => DateImmutableType::class, - Types::DATEINTERVAL => DateIntervalType::class, - Types::DATETIME_MUTABLE => DateTimeType::class, - Types::DATETIME_IMMUTABLE => DateTimeImmutableType::class, - Types::DATETIMETZ_MUTABLE => DateTimeTzType::class, - Types::DATETIMETZ_IMMUTABLE => DateTimeTzImmutableType::class, - Types::DECIMAL => DecimalType::class, - Types::NUMBER => NumberType::class, - Types::ENUM => EnumType::class, - Types::FLOAT => FloatType::class, - Types::GUID => GuidType::class, - Types::INTEGER => IntegerType::class, - Types::JSON => JsonType::class, - Types::JSON_OBJECT => JsonType::class, - Types::JSONB => JsonbType::class, - Types::JSONB_OBJECT => JsonbType::class, - Types::SIMPLE_ARRAY => SimpleArrayType::class, - Types::SMALLFLOAT => SmallFloatType::class, - Types::SMALLINT => SmallIntType::class, - Types::STRING => StringType::class, - Types::TEXT => TextType::class, - Types::TIME_MUTABLE => TimeType::class, - Types::TIME_IMMUTABLE => TimeImmutableType::class, - ]; - private static ?TypeRegistry $typeRegistry = null; /** @@ -98,21 +63,10 @@ public function convertToPHPValue(mixed $value, AbstractPlatform $platform): mix */ abstract public function getSQLDeclaration(array $column, AbstractPlatform $platform): string; - /** @throws TypesException */ final public static function getTypeRegistry(): TypeRegistry { - return self::$typeRegistry ??= self::createTypeRegistry(); - } - - /** @throws TypesException */ - private static function createTypeRegistry(): TypeRegistry - { - return new TypeRegistry( - array_map( - static fn ($class) => new $class(), - self::BUILTIN_TYPES_MAP, - ), - ); + // @phpstan-ignore missingType.checkedException + return self::$typeRegistry ??= new TypeRegistry(); } /** diff --git a/src/Types/TypeRegistry.php b/src/Types/TypeRegistry.php index fce4ab0b271..a99b28fd660 100644 --- a/src/Types/TypeRegistry.php +++ b/src/Types/TypeRegistry.php @@ -4,37 +4,124 @@ namespace Doctrine\DBAL\Types; -use Doctrine\DBAL\Exception; use Doctrine\DBAL\Types\Exception\TypeAlreadyRegistered; use Doctrine\DBAL\Types\Exception\TypeNotFound; use Doctrine\DBAL\Types\Exception\TypeNotRegistered; use Doctrine\DBAL\Types\Exception\TypesAlreadyExists; use Doctrine\DBAL\Types\Exception\TypesException; use Doctrine\DBAL\Types\Exception\UnknownColumnType; +use InvalidArgumentException; +use Psr\Container\ContainerExceptionInterface; +use Psr\Container\ContainerInterface; +use Symfony\Contracts\Service\ServiceProviderInterface; +use WeakMap; -use function spl_object_id; +use function array_fill_keys; +use function array_keys; +use function get_debug_type; +use function sprintf; /** * The type registry is responsible for holding a map of all known DBAL types. */ final class TypeRegistry { + /** Map of type names and their corresponding class names. */ + private const BUILTIN_TYPES_MAP = [ + Types::ASCII_STRING => AsciiStringType::class, + Types::BIGINT => BigIntType::class, + Types::BINARY => BinaryType::class, + Types::BLOB => BlobType::class, + Types::BOOLEAN => BooleanType::class, + Types::DATE_MUTABLE => DateType::class, + Types::DATE_IMMUTABLE => DateImmutableType::class, + Types::DATEINTERVAL => DateIntervalType::class, + Types::DATETIME_MUTABLE => DateTimeType::class, + Types::DATETIME_IMMUTABLE => DateTimeImmutableType::class, + Types::DATETIMETZ_MUTABLE => DateTimeTzType::class, + Types::DATETIMETZ_IMMUTABLE => DateTimeTzImmutableType::class, + Types::DECIMAL => DecimalType::class, + Types::NUMBER => NumberType::class, + Types::ENUM => EnumType::class, + Types::FLOAT => FloatType::class, + Types::GUID => GuidType::class, + Types::INTEGER => IntegerType::class, + Types::JSON => JsonType::class, + Types::JSON_OBJECT => JsonType::class, + Types::JSONB => JsonbType::class, + Types::JSONB_OBJECT => JsonbType::class, + Types::SIMPLE_ARRAY => SimpleArrayType::class, + Types::SMALLFLOAT => SmallFloatType::class, + Types::SMALLINT => SmallIntType::class, + Types::STRING => StringType::class, + Types::TEXT => TextType::class, + Types::TIME_MUTABLE => TimeType::class, + Types::TIME_IMMUTABLE => TimeImmutableType::class, + ]; + /** @var array Map of type names and their corresponding flyweight objects. */ - private array $instances; - /** @var array */ - private array $instancesReverseIndex; + private array $instances = []; /** - * @param array $instances + * Lazy factories for types not yet instantiated. + * Values are either a class-string (built-in) or a ContainerInterface (container type). * + * @var array|ContainerInterface> + */ + private array $factories = []; + + /** @var WeakMap */ + private WeakMap $instancesReverseIndex; + + /** + * Creates a registry pre-populated with all built-in types. Additional types passed via + * {@param $instances} are registered on top; if a name matches a built-in type it is + * overridden rather than re-registered. + * + * A {@see ServiceProviderInterface} can be passed instead of an array to lazy-load type + * instances from a service container. Types are resolved on first access and cached. + * + * @param array|ServiceProviderInterface $instances + * + * @throws TypeAlreadyRegistered * @throws TypesException */ - public function __construct(array $instances = []) + public function __construct(array|ServiceProviderInterface $instances = []) { - $this->instances = []; - $this->instancesReverseIndex = []; - foreach ($instances as $name => $type) { - $this->register($name, $type); + $this->instancesReverseIndex = new WeakMap(); + + if ($instances instanceof ServiceProviderInterface) { + $this->factories = array_fill_keys(array_keys($instances->getProvidedServices()), $instances); + } else { + foreach ($instances as $name => $instance) { + if ($instance instanceof ContainerInterface) { + $this->factories[$name] = $instance; + continue; + } + + if (! $instance instanceof Type) { + throw new InvalidArgumentException(sprintf( + 'Unexpected value for type "%s", got "%s".', + $name, + get_debug_type($instance), + )); + } + + if (isset($this->instancesReverseIndex[$instance])) { + throw TypeAlreadyRegistered::new($instance); + } + + $this->instances[$name] = $instance; + $this->instancesReverseIndex[$instance] = $name; + } + } + + foreach (self::BUILTIN_TYPES_MAP as $name => $class) { + if (isset($this->instances[$name]) || isset($this->factories[$name])) { + continue; + } + + $this->factories[$name] = $class; } } @@ -46,10 +133,39 @@ public function __construct(array $instances = []) public function get(string $name): Type { $type = $this->instances[$name] ?? null; - if ($type === null) { - throw UnknownColumnType::new($name); + if ($type !== null) { + return $type; } + $factory = $this->factories[$name] ?? null; + if ($factory === null) { + throw TypeNotFound::new($name); + } + + if ($factory instanceof ContainerInterface) { + try { + $type = $factory->get($name); + } catch (ContainerExceptionInterface $exception) { + unset($this->factories[$name]); + if (! $factory->has($name)) { + throw UnknownColumnType::new($name, $exception); + } + + // @phpstan-ignore missingType.checkedException + throw $exception; + } + } else { + $type = new $factory(); + } + + if (isset($this->instancesReverseIndex[$type])) { + throw TypeAlreadyRegistered::new($type); + } + + unset($this->factories[$name]); + $this->instances[$name] = $type; + $this->instancesReverseIndex[$type] = $name; + return $type; } @@ -74,7 +190,7 @@ public function lookupName(Type $type): string */ public function has(string $name): bool { - return isset($this->instances[$name]); + return isset($this->instances[$name]) || isset($this->factories[$name]); } /** @@ -84,7 +200,7 @@ public function has(string $name): bool */ public function register(string $name, Type $type): void { - if (isset($this->instances[$name])) { + if (isset($this->instances[$name]) || isset($this->factories[$name])) { throw TypesAlreadyExists::new($name); } @@ -92,29 +208,43 @@ public function register(string $name, Type $type): void throw TypeAlreadyRegistered::new($type); } - $this->instances[$name] = $type; - $this->instancesReverseIndex[spl_object_id($type)] = $name; + $this->instances[$name] = $type; + $this->instancesReverseIndex[$type] = $name; } /** * Overrides an already defined type to use a different implementation. * - * @throws Exception + * @throws TypeNotFound + * @throws TypeAlreadyRegistered */ public function override(string $name, Type $type): void { $origType = $this->instances[$name] ?? null; if ($origType === null) { - throw TypeNotFound::new($name); + if (! isset($this->factories[$name])) { + throw TypeNotFound::new($name); + } + + // Type is not yet instantiated — replace factory with the new instance directly + if (($this->findTypeName($type) ?? $name) !== $name) { + throw TypeAlreadyRegistered::new($type); + } + + unset($this->factories[$name]); + $this->instances[$name] = $type; + $this->instancesReverseIndex[$type] = $name; + + return; } if (($this->findTypeName($type) ?? $name) !== $name) { throw TypeAlreadyRegistered::new($type); } - unset($this->instancesReverseIndex[spl_object_id($origType)]); - $this->instances[$name] = $type; - $this->instancesReverseIndex[spl_object_id($type)] = $name; + unset($this->instancesReverseIndex[$origType]); + $this->instances[$name] = $type; + $this->instancesReverseIndex[$type] = $name; } /** @@ -123,14 +253,21 @@ public function override(string $name, Type $type): void * @internal * * @return array + * + * @throws TypesException */ public function getMap(): array { + // Ensure all types are loaded before returning the map + foreach ($this->factories as $name => $factory) { + $this->get($name); + } + return $this->instances; } private function findTypeName(Type $type): ?string { - return $this->instancesReverseIndex[spl_object_id($type)] ?? null; + return $this->instancesReverseIndex[$type] ?? null; } } diff --git a/tests/ConfigurationTest.php b/tests/ConfigurationTest.php index 5c3c835f8bb..8ad88ae7a25 100644 --- a/tests/ConfigurationTest.php +++ b/tests/ConfigurationTest.php @@ -5,6 +5,8 @@ namespace Doctrine\DBAL\Tests; use Doctrine\DBAL\Configuration; +use Doctrine\DBAL\Types\Type; +use Doctrine\DBAL\Types\TypeRegistry; use PHPUnit\Framework\TestCase; /** @@ -39,4 +41,22 @@ public function testSetsDefaultConnectionAutoCommitMode(): void self::assertFalse($this->config->getAutoCommit()); } + + public function testGetTypeRegistryReturnsGlobalRegistryByDefault(): void + { + self::assertSame(Type::getTypeRegistry(), $this->config->getTypeRegistry()); + } + + public function testSetTypeRegistryReplacesRegistry(): void + { + $registry = new TypeRegistry(); + $this->config->setTypeRegistry($registry); + + self::assertSame($registry, $this->config->getTypeRegistry()); + } + + public function testSetTypeRegistryReturnsSelf(): void + { + self::assertSame($this->config, $this->config->setTypeRegistry(new TypeRegistry())); + } } diff --git a/tests/Schema/TableTest.php b/tests/Schema/TableTest.php index 5346f6aa2b8..10c296eea88 100644 --- a/tests/Schema/TableTest.php +++ b/tests/Schema/TableTest.php @@ -22,6 +22,7 @@ use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Schema\UniqueConstraint; use Doctrine\DBAL\Types\Type; +use Doctrine\DBAL\Types\TypeRegistry; use Doctrine\DBAL\Types\Types; use Doctrine\Deprecations\PHPUnit\VerifyDeprecations; use LogicException; @@ -1786,4 +1787,15 @@ public function testOverwritingForeignKeyConstraint(): void $this->expectDeprecationWithIdentifier('https://github.com/doctrine/dbal/pull/7125'); $table->addForeignKeyConstraint('baz', ['id'], ['id']); } + + public function testAddColumnUsesConfigurationTypeRegistry(): void + { + $customType = Type::getType(Types::INTEGER); + $registry = new TypeRegistry([Types::INTEGER => $customType]); + + $table = new Table('foo', [], [], [], [], [], null, null, $registry); + $column = $table->addColumn('id', Types::INTEGER); + + self::assertSame($customType, $column->getType()); + } } diff --git a/tests/Types/TypeRegistryTest.php b/tests/Types/TypeRegistryTest.php index b1e55ec63b0..a56e6c3fedd 100644 --- a/tests/Types/TypeRegistryTest.php +++ b/tests/Types/TypeRegistryTest.php @@ -10,8 +10,17 @@ use Doctrine\DBAL\Types\Exception\TypeNotRegistered; use Doctrine\DBAL\Types\StringType; use Doctrine\DBAL\Types\TextType; +use Doctrine\DBAL\Types\Type; use Doctrine\DBAL\Types\TypeRegistry; +use Doctrine\DBAL\Types\Types; use PHPUnit\Framework\TestCase; +use ReflectionClass; +use Symfony\Contracts\Service\ServiceProviderInterface; + +use function array_map; +use function count; +use function interface_exists; +use function sprintf; class TypeRegistryTest extends TestCase { @@ -149,10 +158,172 @@ public function testGetMap(): void { $registeredTypes = $this->registry->getMap(); - self::assertCount(2, $registeredTypes); + // Built-in types plus the two registered in setUp() + self::assertGreaterThan(2, count($registeredTypes)); self::assertArrayHasKey(self::TEST_TYPE_NAME, $registeredTypes); self::assertArrayHasKey(self::OTHER_TEST_TYPE_NAME, $registeredTypes); self::assertSame($this->testType, $registeredTypes[self::TEST_TYPE_NAME]); self::assertSame($this->otherTestType, $registeredTypes[self::OTHER_TEST_TYPE_NAME]); } + + private function requireServiceProvider(): void + { + if (interface_exists(ServiceProviderInterface::class)) { + return; + } + + self::markTestSkipped('symfony/service-contracts is not installed.'); + } + + /** @param array $types */ + private function createServiceProvider(array $types): ServiceProviderInterface + { + return new class ($types) implements ServiceProviderInterface { + /** @param array $types */ + public function __construct(private array $types) + { + } + + public function get(string $id): Type + { + return $this->types[$id]; + } + + public function has(string $id): bool + { + return isset($this->types[$id]); + } + + /** @return array */ + public function getProvidedServices(): array + { + return array_map(static fn (Type $t): string => $t::class, $this->types); + } + }; + } + + public function testServiceProviderIsLazy(): void + { + $this->requireServiceProvider(); + + $type = new BlobType(); + $provider = new class ($type) implements ServiceProviderInterface { + public int $resolved = 0; + + public function __construct(private Type $type) + { + } + + public function get(string $id): Type + { + ++$this->resolved; + + return $this->type; + } + + public function has(string $id): bool + { + return $id === 'custom'; + } + + /** @return array */ + public function getProvidedServices(): array + { + return ['custom' => BlobType::class]; + } + }; + + $registry = new TypeRegistry($provider); + + self::assertSame(0, $provider->resolved, 'Provider must not be called during construction.'); + self::assertTrue($registry->has('custom')); + self::assertSame(0, $provider->resolved, 'Provider must not be called by has().'); + + $registry->get('custom'); + self::assertSame(1, $provider->resolved, 'Provider must be called exactly once on first get().'); + + $registry->get('custom'); + self::assertSame(1, $provider->resolved, 'Provider must not be called again after the instance is cached.'); + } + + public function testServiceProviderLookupName(): void + { + $this->requireServiceProvider(); + + $type = new BlobType(); + $registry = new TypeRegistry($this->createServiceProvider(['custom' => $type])); + + self::assertSame('custom', $registry->lookupName($registry->get('custom'))); + } + + public function testServiceProviderBuiltinTypesStillAvailable(): void + { + $this->requireServiceProvider(); + + $registry = new TypeRegistry($this->createServiceProvider([])); + + self::assertTrue($registry->has(Types::STRING)); + self::assertInstanceOf(StringType::class, $registry->get(Types::STRING)); + } + + public function testServiceProviderCanOverrideBuiltinType(): void + { + $this->requireServiceProvider(); + + $custom = new BlobType(); + $registry = new TypeRegistry($this->createServiceProvider([Types::STRING => $custom])); + + self::assertSame($custom, $registry->get(Types::STRING)); + } + + public function testArrayInstancesOverrideBuiltinTypes(): void + { + $custom = new BlobType(); + $registry = new TypeRegistry([Types::STRING => $custom]); + + self::assertSame($custom, $registry->get(Types::STRING)); + } + + public function testServiceProviderGetMapResolvesAllTypes(): void + { + $this->requireServiceProvider(); + + $type = new BlobType(); + $registry = new TypeRegistry($this->createServiceProvider(['custom' => $type])); + + $map = $registry->getMap(); + + self::assertArrayHasKey('custom', $map); + self::assertSame($type, $map['custom']); + self::assertArrayHasKey(Types::STRING, $map); + } + + public function testServiceProviderUnknownTypeThrows(): void + { + $this->requireServiceProvider(); + + $registry = new TypeRegistry($this->createServiceProvider([])); + + $this->expectException(Exception::class); + $registry->get('unknown'); + } + + public function testBuiltinTypesAvailableByDefault(): void + { + Type::getTypeRegistry()->register(__FUNCTION__, new class extends StringType { + }); + $registry = new TypeRegistry(); + + // Types from the singleton registry are not registered in a new instance + self::assertFalse($registry->has(__FUNCTION__)); + + // Check that all the constants from Types are registered by default + $constants = (new ReflectionClass(Types::class))->getConstants(); + foreach ($constants as $typeName) { + self::assertTrue( + $registry->has($typeName), + sprintf('Built-in type "%s" is not registered by default.', $typeName), + ); + } + } }