diff --git a/src/Driver/AbstractMySQLDriver.php b/src/Driver/AbstractMySQLDriver.php index 5d33e04e4ad..4610bde3bd8 100644 --- a/src/Driver/AbstractMySQLDriver.php +++ b/src/Driver/AbstractMySQLDriver.php @@ -12,6 +12,7 @@ use Doctrine\DBAL\Platforms\MariaDB1052Platform; use Doctrine\DBAL\Platforms\MariaDB1060Platform; use Doctrine\DBAL\Platforms\MariaDBPlatform; +use Doctrine\DBAL\Platforms\MySQL8013Platform; use Doctrine\DBAL\Platforms\MySQL80Platform; use Doctrine\DBAL\Platforms\MySQL84Platform; use Doctrine\DBAL\Platforms\MySQLPlatform; @@ -58,6 +59,10 @@ public function getDatabasePlatform(ServerVersionProvider $versionProvider): Abs return new MySQL84Platform(); } + if (version_compare($version, '8.0.13', '>=')) { + return new MySQL8013Platform(); + } + if (version_compare($version, '8.0.0', '>=')) { return new MySQL80Platform(); } diff --git a/src/Platforms/AbstractMySQLPlatform.php b/src/Platforms/AbstractMySQLPlatform.php index 770d86341ef..4c72aca26dc 100644 --- a/src/Platforms/AbstractMySQLPlatform.php +++ b/src/Platforms/AbstractMySQLPlatform.php @@ -43,6 +43,11 @@ abstract class AbstractMySQLPlatform extends AbstractPlatform final public const LENGTH_LIMIT_BLOB = 65535; final public const LENGTH_LIMIT_MEDIUMBLOB = 16777215; + public function getColumnNameForIndexFetch(): string + { + return 'COLUMN_NAME'; + } + protected function doModifyLimitQuery(string $query, ?int $limit, int $offset): string { if ($limit !== null) { diff --git a/src/Platforms/AbstractPlatform.php b/src/Platforms/AbstractPlatform.php index b9c5fa2d19d..d2128186c56 100644 --- a/src/Platforms/AbstractPlatform.php +++ b/src/Platforms/AbstractPlatform.php @@ -55,8 +55,10 @@ use function str_contains; use function str_replace; use function strlen; +use function strrpos; use function strtolower; use function strtoupper; +use function substr; /** * Base class for all DatabasePlatforms. The DatabasePlatforms are the central @@ -794,6 +796,16 @@ private function buildCreateTableSQL(Table $table, bool $createForeignKeys): arr $options['primary'] = []; foreach ($table->getIndexes() as $index) { + if ($index->isFunctional() && ! $this->supportsFunctionalIndex()) { + throw new InvalidArgumentException(sprintf( + 'Index "%s" on table "%s" contains a functional part, ' . + 'but platform "%s" does not support functional indexes.', + $index->getName(), + $table->getName(), + get_debug_type($this), + )); + } + if (! $index->isPrimary()) { $options['indexes'][$index->getQuotedName($this)] = $index; @@ -1081,6 +1093,16 @@ public function getCreateIndexSQL(Index $index, string $table): string )); } + if ($index->isFunctional() && ! $this->supportsFunctionalIndex()) { + throw new InvalidArgumentException(sprintf( + 'Index "%s" on table "%s" contains a functional part, ' . + 'but platform "%s" does not support functional indexes.', + $name, + $table, + get_debug_type($this), + )); + } + if ($index->isPrimary()) { return $this->getCreatePrimaryKeySQL($index, $table); } @@ -1533,6 +1555,15 @@ public function getIndexDeclarationSQL(Index $index): string throw new InvalidArgumentException('Incomplete definition. "columns" required.'); } + if ($index->isFunctional() && ! $this->supportsFunctionalIndex()) { + throw new InvalidArgumentException(sprintf( + 'Index "%s" contains a functional part, ' . + 'but platform "%s" does not support functional indexes.', + $index->getName(), + get_debug_type($this), + )); + } + return $this->getCreateIndexSQLFlags($index) . 'INDEX ' . $index->getQuotedName($this) . ' (' . implode(', ', $index->getQuotedColumns($this)) . ')' . $this->getPartialIndexSQL($index); } @@ -1973,6 +2004,14 @@ public function supportsColumnCollation(): bool return false; } + /** + * A flag that indicates whether the platform supports functional indexes. + */ + public function supportsFunctionalIndex(): bool + { + return false; + } + /** * Gets the format string, as accepted by the date() function, that describes * the format of a stored datetime value of this platform. diff --git a/src/Platforms/MySQL8013Platform.php b/src/Platforms/MySQL8013Platform.php new file mode 100644 index 00000000000..2b2feaf0428 --- /dev/null +++ b/src/Platforms/MySQL8013Platform.php @@ -0,0 +1,24 @@ +isFunctional() && ! $this->supportsFunctionalIndex()) { + throw new InvalidArgumentException(sprintf( + 'Index "%s" on table "%s" contains a functional part, ' . + 'but platform "%s" does not support functional indexes.', + $name, + $table, + get_debug_type($this), + )); + } + if ($index->isPrimary()) { return $this->getCreatePrimaryKeySQL($index, $table); } diff --git a/src/Schema/Index.php b/src/Schema/Index.php index 175565483d1..9803f9c7676 100644 --- a/src/Schema/Index.php +++ b/src/Schema/Index.php @@ -12,6 +12,8 @@ use function array_search; use function array_shift; use function count; +use function str_ends_with; +use function str_starts_with; use function strtolower; class Index extends AbstractAsset @@ -27,6 +29,8 @@ class Index extends AbstractAsset protected bool $_isPrimary = false; + protected bool $_isFunctional = false; + /** * Platform specific flags for indexes. * @@ -58,6 +62,10 @@ public function __construct( foreach ($columns as $column) { $this->_addColumn($column); + + $this->_isFunctional = $this->_isFunctional === true + ? $this->_isFunctional + : self::isFunctionalIndex($column); } foreach ($flags as $flag) { @@ -101,10 +109,14 @@ public function getQuotedColumns(AbstractPlatform $platform): array foreach ($this->_columns as $column) { $length = array_shift($subParts); - $quotedColumn = $column->getQuotedName($platform); + if ($this->isFunctional()) { + $quotedColumn = $column->getName(); + } else { + $quotedColumn = $column->getQuotedName($platform); - if ($length !== null) { - $quotedColumn .= '(' . $length . ')'; + if ($length !== null) { + $quotedColumn .= '(' . $length . ')'; + } } $columns[] = $quotedColumn; @@ -137,6 +149,11 @@ public function isPrimary(): bool return $this->_isPrimary; } + public function isFunctional(): bool + { + return $this->_isFunctional; + } + public function hasColumnAtPosition(string $name, int $pos = 0): bool { $name = $this->trimQuotes(strtolower($name)); @@ -283,6 +300,11 @@ public function getOptions(): array return $this->options; } + public static function isFunctionalIndex(string $name): bool + { + return str_starts_with($name, '(') && str_ends_with($name, ')'); + } + /** * Return whether the two indexes have the same partial index */ diff --git a/src/Schema/MySQLSchemaManager.php b/src/Schema/MySQLSchemaManager.php index fa042d653ce..40833e6314d 100644 --- a/src/Schema/MySQLSchemaManager.php +++ b/src/Schema/MySQLSchemaManager.php @@ -390,10 +390,12 @@ protected function selectIndexColumns(string $databaseName, ?string $tableName = $sql .= ' TABLE_NAME,'; } - $sql .= <<<'SQL' + $columnName = $this->getColumnNameForIndexFetch(); + + $sql .= <<defaultTableOptions; } + + /** + * EXPRESSION + * + * MySQL 8.0.13 and higher supports functional key parts (see Functional Key Parts), which affects both + * the COLUMN_NAME and EXPRESSION columns: + * For a nonfunctional key part, COLUMN_NAME indicates the column indexed by the key part and EXPRESSION is NULL. + * For a functional key part, COLUMN_NAME column is NULL and EXPRESSION indicates the expression for the key part. + */ + private function getColumnNameForIndexFetch(): string + { + return $this->platform->getColumnNameForIndexFetch() . ' as Column_Name'; + } } diff --git a/src/Schema/PostgreSQLSchemaManager.php b/src/Schema/PostgreSQLSchemaManager.php index 9af16c9986a..f4ebfd82ab4 100644 --- a/src/Schema/PostgreSQLSchemaManager.php +++ b/src/Schema/PostgreSQLSchemaManager.php @@ -19,8 +19,8 @@ use function implode; use function in_array; use function is_string; +use function json_decode; use function preg_match; -use function sprintf; use function str_contains; use function str_replace; use function strtolower; @@ -161,30 +161,16 @@ protected function _getPortableTableIndexesList(array $tableIndexes, string $tab { $buffer = []; foreach ($tableIndexes as $row) { - $colNumbers = array_map('intval', explode(' ', $row['indkey'])); - $columnNameSql = sprintf( - 'SELECT attnum, attname FROM pg_attribute WHERE attrelid=%d AND attnum IN (%s) ORDER BY attnum ASC', - $row['indrelid'], - implode(' ,', $colNumbers), - ); - - $indexColumns = $this->connection->fetchAllAssociative($columnNameSql); - - // required for getting the order of the columns right. - foreach ($colNumbers as $colNum) { - foreach ($indexColumns as $colRow) { - if ($colNum !== $colRow['attnum']) { - continue; - } - - $buffer[] = [ - 'key_name' => $row['relname'], - 'column_name' => trim($colRow['attname']), - 'non_unique' => ! $row['indisunique'], - 'primary' => $row['indisprimary'], - 'where' => $row['where'], - ]; - } + $indexColumns = json_decode($row['index_columns'], true, flags: JSON_THROW_ON_ERROR); + + foreach ($indexColumns as $colRow) { + $buffer[] = [ + 'key_name' => $row['relname'], + 'column_name' => trim($colRow), + 'non_unique' => ! $row['indisunique'], + 'primary' => $row['indisprimary'], + 'where' => $row['where'], + ]; } } @@ -468,34 +454,53 @@ protected function selectTableColumns(string $databaseName, ?string $tableName = protected function selectIndexColumns(string $databaseName, ?string $tableName = null): Result { - $sql = 'SELECT'; - + $tableNameSql = ''; if ($tableName === null) { - $sql .= ' tc.relname AS table_name, tn.nspname AS schema_name,'; + $tableNameSql = <<<'SQL' + tc.relname AS table_name, + tn.nspname AS schema_name, + SQL; } - $sql .= <<<'SQL' - quote_ident(ic.relname) AS relname, - i.indisunique, - i.indisprimary, - i.indkey, - i.indrelid, - pg_get_expr(indpred, indrelid) AS "where" - FROM pg_index i - JOIN pg_class AS tc ON tc.oid = i.indrelid - JOIN pg_namespace tn ON tn.oid = tc.relnamespace - JOIN pg_class AS ic ON ic.oid = i.indexrelid - WHERE ic.oid IN ( - SELECT indexrelid - FROM pg_index i, pg_class c, pg_namespace n -SQL; - - $conditions = array_merge([ - 'c.oid = i.indrelid', - 'c.relnamespace = n.oid', - ], $this->buildQueryConditions($tableName)); + $whereConditions = array_merge( + [ + 'c.oid = i.indrelid', + 'c.relnamespace = n.oid', + ], + $this->buildQueryConditions($tableName), + ); - $sql .= ' WHERE ' . implode(' AND ', $conditions) . ')'; + $whereSql = implode(' AND ', $whereConditions); + + $sql = <<connection->executeQuery($sql); } diff --git a/src/Schema/Table.php b/src/Schema/Table.php index cc7f04d2c49..d34b66ab169 100644 --- a/src/Schema/Table.php +++ b/src/Schema/Table.php @@ -743,7 +743,7 @@ private function _createIndex( } foreach ($columns as $columnName) { - if (! $this->hasColumn($columnName)) { + if (! $this->hasColumn($columnName) && ! Index::isFunctionalIndex($columnName)) { throw ColumnDoesNotExist::new($columnName, $this->_name); } } diff --git a/tests/Driver/VersionAwarePlatformDriverTest.php b/tests/Driver/VersionAwarePlatformDriverTest.php index 3996e4691d1..11478a0c58c 100644 --- a/tests/Driver/VersionAwarePlatformDriverTest.php +++ b/tests/Driver/VersionAwarePlatformDriverTest.php @@ -10,6 +10,7 @@ use Doctrine\DBAL\Platforms\MariaDB1052Platform; use Doctrine\DBAL\Platforms\MariaDB1060Platform; use Doctrine\DBAL\Platforms\MariaDBPlatform; +use Doctrine\DBAL\Platforms\MySQL8013Platform; use Doctrine\DBAL\Platforms\MySQL80Platform; use Doctrine\DBAL\Platforms\MySQL84Platform; use Doctrine\DBAL\Platforms\MySQLPlatform; @@ -37,6 +38,8 @@ public static function mySQLVersionProvider(): array return [ ['5.7.0', MySQLPlatform::class], ['8.0.11', MySQL80Platform::class], + ['8.0.13', MySQL8013Platform::class], + ['8.0.14', MySQL8013Platform::class], ['8.4.0', MySQL84Platform::class], ['5.5.40-MariaDB-1~wheezy', MariaDBPlatform::class], ['5.5.5-MariaDB-10.2.8+maria~xenial-log', MariaDBPlatform::class], diff --git a/tests/Functional/Platform/FunctionalIndexTest.php b/tests/Functional/Platform/FunctionalIndexTest.php new file mode 100644 index 00000000000..c142d8d2905 --- /dev/null +++ b/tests/Functional/Platform/FunctionalIndexTest.php @@ -0,0 +1,69 @@ +connection->getDatabasePlatform(); + if (! $platform->supportsFunctionalIndex()) { + self::markTestSkipped('Platform does not support functional indexes.'); + } + + $tableName = 'some_table'; + + $table = new Table($tableName); + $table->addColumn('column1', Types::INTEGER, ['notnull' => false]); + $table->addColumn('column2', Types::INTEGER, ['notnull' => false]); + $table->addIndex(['column1', 'column2', '(column2 IS NOT NULL)'], 'func_idx'); + $this->dropAndCreateTable($table); + + $this->connection->insert($tableName, ['column1' => 1]); + + $tablesFromList = $this->connection->createSchemaManager()->listTables(); + + $tables = array_filter($tablesFromList, static fn (Table $table): bool => $table->getName() === $tableName); + $someTable = array_pop($tables); + + self::assertInstanceOf(Table::class, $someTable); + self::assertEquals($tableName, $someTable->getName()); + + $index = $someTable->getIndex('func_idx'); + + self::assertTrue($index->isFunctional()); + + if (TestUtil::isDriverOneOf('pdo_pgsql', 'pgsql')) { + self::assertEquals(['column1', 'column2', '(column2 IS NOT NULL)'], $index->getColumns()); + } else { + self::assertEquals(['column1', 'column2', '((`column2` is not null))'], $index->getColumns()); + } + } + + public function testPlatformException(): void + { + $platform = $this->connection->getDatabasePlatform(); + if ($platform->supportsFunctionalIndex()) { + self::markTestSkipped('Skipping, platform supports functional indexes.'); + } + + $table = new Table('some_table'); + $table->addColumn('column1', Types::INTEGER, ['notnull' => false]); + $table->addColumn('column2', Types::INTEGER, ['notnull' => false]); + $table->addIndex(['column1', 'column2', '(column2 IS NOT NULL)'], 'func_idx'); + + $this->expectException(InvalidArgumentException::class); + $this->dropAndCreateTable($table); + } +} diff --git a/tests/Functional/Schema/MySQLSchemaManagerTest.php b/tests/Functional/Schema/MySQLSchemaManagerTest.php index 2b17b6a25be..5502e1af8fc 100644 --- a/tests/Functional/Schema/MySQLSchemaManagerTest.php +++ b/tests/Functional/Schema/MySQLSchemaManagerTest.php @@ -6,6 +6,7 @@ use DateTime; use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Exception; use Doctrine\DBAL\Exception\DatabaseRequired; use Doctrine\DBAL\Platforms\AbstractMySQLPlatform; use Doctrine\DBAL\Platforms\AbstractPlatform; @@ -250,6 +251,37 @@ public function testColumnCharsetChange(): void ); } + public function testCreateTableWithFunctionalIndex(): void + { + $tableName = 'table_with_functional_index'; + + if (! $this->connection->getDatabasePlatform()->supportsFunctionalIndex()) { + $this->expectException(Exception::class); + } + + $table = new Table($tableName); + $table->addColumn('col_string', Types::STRING) + ->setLength(100) + ->setNotnull(true) + ->setPlatformOption('charset', 'utf8'); + + $table->addIndex(['(LENGTH(col_string))'], 'length_index'); + + $this->dropAndCreateTable($table); + + if (! ($this->connection->getDatabasePlatform()->supportsFunctionalIndex())) { + return; + } + + $schema = $this->schemaManager->introspectTable($tableName); + self::assertArrayHasKey('length_index', $schema->getIndexes()); + self::assertTrue($schema->getIndexes()['length_index']->isFunctional()); + self::assertEquals( + '(length(`col_string`))', + $schema->getIndexes()['length_index']->getColumns()[0], + ); + } + public function testColumnCollation(): void { $table = new Table('test_collation'); diff --git a/tests/Schema/Platforms/MySQL8013SchemaTest.php b/tests/Schema/Platforms/MySQL8013SchemaTest.php new file mode 100644 index 00000000000..fe50f64aca6 --- /dev/null +++ b/tests/Schema/Platforms/MySQL8013SchemaTest.php @@ -0,0 +1,59 @@ +platformMysql8013 = new MySQL8013Platform(); + $this->platformMysql = new MySQL80Platform(); + } + + public function testGenerateFunctionalIndex(): void + { + $table = new Table('test'); + $table->addColumn('foo_id', 'integer'); + $table->addIndex(['foo_id', '(CAST(bar AS CHAR(10)))'], 'idx_foo_id'); + + $sqls = []; + foreach ($table->getIndexes() as $index) { + $sqls[] = $this->platformMysql8013->getCreateIndexSQL( + $index, + $table->getQuotedName($this->platformMysql8013), + ); + } + + self::assertEquals( + ['CREATE INDEX idx_foo_id ON test (foo_id, (CAST(bar AS CHAR(10))))'], + $sqls, + ); + } + + public function testGenerateFunctionalIndexWithError(): void + { + $table = new Table('test'); + $table->addColumn('foo_id', 'integer'); + $table->addIndex(['foo_id', '(CAST(bar AS CHAR(10)))'], 'idx_foo_id'); + + foreach ($table->getIndexes() as $index) { + $this->expectException(Exception::class); + + $this->platformMysql->getCreateIndexSQL( + $index, + $table->getQuotedName($this->platformMysql), + ); + } + } +}