diff --git a/src/Platforms/AbstractMySQLPlatform.php b/src/Platforms/AbstractMySQLPlatform.php index 55cebe7ae9f..1ebb320c28e 100644 --- a/src/Platforms/AbstractMySQLPlatform.php +++ b/src/Platforms/AbstractMySQLPlatform.php @@ -856,4 +856,14 @@ public function fetchTableOptionsByTable(bool $includeTableName): string return $sql . ' WHERE ' . implode(' AND ', $conditions); } + + /** + * INFORMATION_SCHEMA.CHARACTER_SETS will contain either 'utf8' or 'utf8mb3' but not both. + */ + public function informationSchemaUsesUtf8mb3(Connection $connection): bool + { + $sql = "SELECT character_set_name FROM INFORMATION_SCHEMA.CHARACTER_SETS WHERE character_set_name = 'utf8mb3'"; + + return $connection->fetchOne($sql) === 'utf8mb3'; + } } diff --git a/src/Platforms/MySQL/CharsetMetadataProvider.php b/src/Platforms/MySQL/CharsetMetadataProvider.php index 665543e0d0a..896a2a9dc08 100644 --- a/src/Platforms/MySQL/CharsetMetadataProvider.php +++ b/src/Platforms/MySQL/CharsetMetadataProvider.php @@ -7,5 +7,7 @@ /** @internal */ interface CharsetMetadataProvider { + public function normalizeCharset(string $charset): string; + public function getDefaultCharsetCollation(string $charset): ?string; } diff --git a/src/Platforms/MySQL/CharsetMetadataProvider/CachingCharsetMetadataProvider.php b/src/Platforms/MySQL/CharsetMetadataProvider/CachingCharsetMetadataProvider.php index dadc8419d54..76e2cb7ec81 100644 --- a/src/Platforms/MySQL/CharsetMetadataProvider/CachingCharsetMetadataProvider.php +++ b/src/Platforms/MySQL/CharsetMetadataProvider/CachingCharsetMetadataProvider.php @@ -18,6 +18,11 @@ public function __construct(private readonly CharsetMetadataProvider $charsetMet { } + public function normalizeCharset(string $charset): string + { + return $this->charsetMetadataProvider->normalizeCharset($charset); + } + public function getDefaultCharsetCollation(string $charset): ?string { if (array_key_exists($charset, $this->cache)) { diff --git a/src/Platforms/MySQL/CharsetMetadataProvider/ConnectionCharsetMetadataProvider.php b/src/Platforms/MySQL/CharsetMetadataProvider/ConnectionCharsetMetadataProvider.php index 65b63df8fe6..61152e8aa42 100644 --- a/src/Platforms/MySQL/CharsetMetadataProvider/ConnectionCharsetMetadataProvider.php +++ b/src/Platforms/MySQL/CharsetMetadataProvider/ConnectionCharsetMetadataProvider.php @@ -11,13 +11,28 @@ /** @internal */ final class ConnectionCharsetMetadataProvider implements CharsetMetadataProvider { - public function __construct(private readonly Connection $connection) + public function __construct(private readonly Connection $connection, private bool $useUtf8mb3) { } + public function normalizeCharset(string $charset): string + { + if ($this->useUtf8mb3 && $charset === 'utf8') { + return 'utf8mb3'; + } + + if (! $this->useUtf8mb3 && $charset === 'utf8mb3') { + return 'utf8'; + } + + return $charset; + } + /** @throws Exception */ public function getDefaultCharsetCollation(string $charset): ?string { + $charset = $this->normalizeCharset($charset); + $collation = $this->connection->fetchOne( <<<'SQL' SELECT DEFAULT_COLLATE_NAME diff --git a/src/Platforms/MySQL/CollationMetadataProvider.php b/src/Platforms/MySQL/CollationMetadataProvider.php index d52ca74a2b2..62d8bda8e59 100644 --- a/src/Platforms/MySQL/CollationMetadataProvider.php +++ b/src/Platforms/MySQL/CollationMetadataProvider.php @@ -7,5 +7,7 @@ /** @internal */ interface CollationMetadataProvider { + public function normalizeCollation(string $collation): string; + public function getCollationCharset(string $collation): ?string; } diff --git a/src/Platforms/MySQL/CollationMetadataProvider/CachingCollationMetadataProvider.php b/src/Platforms/MySQL/CollationMetadataProvider/CachingCollationMetadataProvider.php index 0c99aa31366..9dd6a416524 100644 --- a/src/Platforms/MySQL/CollationMetadataProvider/CachingCollationMetadataProvider.php +++ b/src/Platforms/MySQL/CollationMetadataProvider/CachingCollationMetadataProvider.php @@ -18,6 +18,11 @@ public function __construct(private readonly CollationMetadataProvider $collatio { } + public function normalizeCollation(string $collation): string + { + return $this->collationMetadataProvider->normalizeCollation($collation); + } + public function getCollationCharset(string $collation): ?string { if (array_key_exists($collation, $this->cache)) { diff --git a/src/Platforms/MySQL/CollationMetadataProvider/ConnectionCollationMetadataProvider.php b/src/Platforms/MySQL/CollationMetadataProvider/ConnectionCollationMetadataProvider.php index fcd9995621f..3ee04645a8f 100644 --- a/src/Platforms/MySQL/CollationMetadataProvider/ConnectionCollationMetadataProvider.php +++ b/src/Platforms/MySQL/CollationMetadataProvider/ConnectionCollationMetadataProvider.php @@ -8,16 +8,34 @@ use Doctrine\DBAL\Exception; use Doctrine\DBAL\Platforms\MySQL\CollationMetadataProvider; +use function str_starts_with; +use function substr; + /** @internal */ final class ConnectionCollationMetadataProvider implements CollationMetadataProvider { - public function __construct(private readonly Connection $connection) + public function __construct(private readonly Connection $connection, private bool $useUtf8mb3) { } + public function normalizeCollation(string $collation): string + { + if ($this->useUtf8mb3 && str_starts_with($collation, 'utf8_')) { + return 'utf8mb3' . substr($collation, 4); + } + + if (! $this->useUtf8mb3 && str_starts_with($collation, 'utf8mb3_')) { + return 'utf8' . substr($collation, 7); + } + + return $collation; + } + /** @throws Exception */ public function getCollationCharset(string $collation): ?string { + $collation = $this->normalizeCollation($collation); + $charset = $this->connection->fetchOne( <<<'SQL' SELECT CHARACTER_SET_NAME diff --git a/src/Platforms/MySQL/Comparator.php b/src/Platforms/MySQL/Comparator.php index ebe025dc2a9..ebe4d9e23dd 100644 --- a/src/Platforms/MySQL/Comparator.php +++ b/src/Platforms/MySQL/Comparator.php @@ -82,10 +82,20 @@ private function normalizeTable(Table $table): Table */ private function normalizeOptions(array $options): array { - if (isset($options['charset']) && ! isset($options['collation'])) { - $options['collation'] = $this->charsetMetadataProvider->getDefaultCharsetCollation($options['charset']); - } elseif (isset($options['collation']) && ! isset($options['charset'])) { - $options['charset'] = $this->collationMetadataProvider->getCollationCharset($options['collation']); + if (isset($options['charset'])) { + $options['charset'] = $this->charsetMetadataProvider->normalizeCharset($options['charset']); + + if (! isset($options['collation'])) { + $options['collation'] = $this->charsetMetadataProvider->getDefaultCharsetCollation($options['charset']); + } + } + + if (isset($options['collation'])) { + $options['collation'] = $this->collationMetadataProvider->normalizeCollation($options['collation']); + + if (! isset($options['charset'])) { + $options['charset'] = $this->collationMetadataProvider->getCollationCharset($options['collation']); + } } return $options; diff --git a/src/Schema/MySQLSchemaManager.php b/src/Schema/MySQLSchemaManager.php index 1c0915905fa..896c2b17a08 100644 --- a/src/Schema/MySQLSchemaManager.php +++ b/src/Schema/MySQLSchemaManager.php @@ -316,13 +316,15 @@ protected function _getPortableTableForeignKeyDefinition(array $tableForeignKey) /** @throws Exception */ public function createComparator(): Comparator { + $useUtf8mb3 = $this->platform->informationSchemaUsesUtf8mb3($this->connection); + return new MySQL\Comparator( $this->platform, new CachingCharsetMetadataProvider( - new ConnectionCharsetMetadataProvider($this->connection), + new ConnectionCharsetMetadataProvider($this->connection, $useUtf8mb3), ), new CachingCollationMetadataProvider( - new ConnectionCollationMetadataProvider($this->connection), + new ConnectionCollationMetadataProvider($this->connection, $useUtf8mb3), ), $this->getDefaultTableOptions(), ); diff --git a/tests/Functional/Driver/DBAL6146Test.php b/tests/Functional/Driver/DBAL6146Test.php new file mode 100644 index 00000000000..2d73a06595e --- /dev/null +++ b/tests/Functional/Driver/DBAL6146Test.php @@ -0,0 +1,85 @@ +,array}> */ + public static function equivalentCharsetAndCollationProvider(): iterable + { + yield [[], []]; + yield [['charset' => 'utf8'], ['charset' => 'utf8']]; + yield [['charset' => 'utf8'], ['charset' => 'utf8mb3']]; + yield [['charset' => 'utf8mb3'], ['charset' => 'utf8']]; + yield [['charset' => 'utf8mb3'], ['charset' => 'utf8mb3']]; + yield [['collation' => 'utf8_unicode_ci'], ['collation' => 'utf8_unicode_ci']]; + yield [['collation' => 'utf8_unicode_ci'], ['collation' => 'utf8mb3_unicode_ci']]; + yield [['collation' => 'utf8mb3_unicode_ci'], ['collation' => 'utf8mb3_unicode_ci']]; + yield [['collation' => 'utf8mb3_unicode_ci'], ['collation' => 'utf8_unicode_ci']]; + yield [ + ['charset' => 'utf8', 'collation' => 'utf8_unicode_ci'], + ['charset' => 'utf8', 'collation' => 'utf8_unicode_ci'], + ]; + + yield [ + ['charset' => 'utf8', 'collation' => 'utf8_unicode_ci'], + ['charset' => 'utf8mb3', 'collation' => 'utf8mb3_unicode_ci'], + ]; + + yield [ + ['charset' => 'utf8mb3', 'collation' => 'utf8mb3_unicode_ci'], + ['charset' => 'utf8', 'collation' => 'utf8_unicode_ci'], + ]; + + yield [ + ['charset' => 'utf8mb3', 'collation' => 'utf8mb3_unicode_ci'], + ['charset' => 'utf8mb3', 'collation' => 'utf8mb3_unicode_ci'], + ]; + } + + /** + * @param array $options1 + * @param array $options2 + */ + #[DataProvider('equivalentCharsetAndCollationProvider')] + public function testThereAreNoRedundantAlterTableStatements(array $options1, array $options2): void + { + $column1 = new Column('bar', new StringType(), ['length' => 32, 'platformOptions' => $options1]); + $table1 = new Table(name: 'foo6146', columns: [$column1]); + + $column2 = new Column('bar', new StringType(), ['length' => 32, 'platformOptions' => $options2]); + $table2 = new Table(name: 'foo6146', columns: [$column2]); + + $this->dropAndCreateTable($table1); + + $schemaManager = $this->connection->createSchemaManager(); + $oldSchema = $schemaManager->introspectSchema(); + $newSchema = new Schema([$table2]); + $comparator = $schemaManager->createComparator(); + $schemaDiff = $comparator->compareSchemas($oldSchema, $newSchema); + $alteredTables = $schemaDiff->getAlteredTables(); + + self::assertEmpty($alteredTables); + } +} diff --git a/tests/Platforms/MySQL/CollationMetadataProvider/ConnectionCharsetMetadataProviderTest.php b/tests/Platforms/MySQL/CollationMetadataProvider/ConnectionCharsetMetadataProviderTest.php new file mode 100644 index 00000000000..005e196de2f --- /dev/null +++ b/tests/Platforms/MySQL/CollationMetadataProvider/ConnectionCharsetMetadataProviderTest.php @@ -0,0 +1,29 @@ +createMock(Connection::class); + + $utf8Provider = new ConnectionCharsetMetadataProvider($connection, false); + + self::assertSame('utf8', $utf8Provider->normalizeCharset('utf8')); + self::assertSame('utf8', $utf8Provider->normalizeCharset('utf8mb3')); + self::assertSame('foobar', $utf8Provider->normalizeCharset('foobar')); + + $utf8mb3Provider = new ConnectionCharsetMetadataProvider($connection, true); + + self::assertSame('utf8mb3', $utf8mb3Provider->normalizeCharset('utf8')); + self::assertSame('utf8mb3', $utf8mb3Provider->normalizeCharset('utf8mb3')); + self::assertSame('foobar', $utf8mb3Provider->normalizeCharset('foobar')); + } +} diff --git a/tests/Platforms/MySQL/CollationMetadataProvider/ConnectionCollationMetadataProviderTest.php b/tests/Platforms/MySQL/CollationMetadataProvider/ConnectionCollationMetadataProviderTest.php new file mode 100644 index 00000000000..966e9a870c3 --- /dev/null +++ b/tests/Platforms/MySQL/CollationMetadataProvider/ConnectionCollationMetadataProviderTest.php @@ -0,0 +1,29 @@ +createMock(Connection::class); + + $utf8Provider = new ConnectionCollationMetadataProvider($connection, false); + + self::assertSame('utf8_unicode_ci', $utf8Provider->normalizeCollation('utf8_unicode_ci')); + self::assertSame('utf8_unicode_ci', $utf8Provider->normalizeCollation('utf8mb3_unicode_ci')); + self::assertSame('foobar_unicode_ci', $utf8Provider->normalizeCollation('foobar_unicode_ci')); + + $utf8mb3Provider = new ConnectionCollationMetadataProvider($connection, true); + + self::assertSame('utf8mb3_unicode_ci', $utf8mb3Provider->normalizeCollation('utf8_unicode_ci')); + self::assertSame('utf8mb3_unicode_ci', $utf8mb3Provider->normalizeCollation('utf8mb3_unicode_ci')); + self::assertSame('foobar_unicode_ci', $utf8mb3Provider->normalizeCollation('foobar_unicode_ci')); + } +} diff --git a/tests/Platforms/MySQL/ComparatorTest.php b/tests/Platforms/MySQL/ComparatorTest.php index d2d8a25ad21..857af67fc9a 100644 --- a/tests/Platforms/MySQL/ComparatorTest.php +++ b/tests/Platforms/MySQL/ComparatorTest.php @@ -4,12 +4,19 @@ namespace Doctrine\DBAL\Tests\Platforms\MySQL; +use Doctrine\DBAL\Connection; use Doctrine\DBAL\Platforms\MySQL\CharsetMetadataProvider; +use Doctrine\DBAL\Platforms\MySQL\CharsetMetadataProvider\ConnectionCharsetMetadataProvider; use Doctrine\DBAL\Platforms\MySQL\CollationMetadataProvider; +use Doctrine\DBAL\Platforms\MySQL\CollationMetadataProvider\ConnectionCollationMetadataProvider; use Doctrine\DBAL\Platforms\MySQL\Comparator; use Doctrine\DBAL\Platforms\MySQL\DefaultTableOptions; use Doctrine\DBAL\Platforms\MySQLPlatform; +use Doctrine\DBAL\Schema\Column; +use Doctrine\DBAL\Schema\Table; use Doctrine\DBAL\Tests\Schema\AbstractComparatorTestCase; +use Doctrine\DBAL\Types\StringType; +use PHPUnit\Framework\Attributes\DataProvider; class ComparatorTest extends AbstractComparatorTestCase { @@ -22,4 +29,81 @@ protected function setUp(): void new DefaultTableOptions('utf8mb4', 'utf8mb4_general_ci'), ); } + + #[DataProvider('utf8AndUtf8mb3MismatchesProvider')] + public function testUtf8AndUtf8mb3Mismatches(bool $useUtf8mb3, string $defaultCharset): void + { + $connection = $this->createMock(Connection::class); + $platform = new MySQLPlatform(); + + $utf8Comparator = new Comparator( + $platform, + new ConnectionCharsetMetadataProvider($connection, $useUtf8mb3), + new ConnectionCollationMetadataProvider($connection, $useUtf8mb3), + new DefaultTableOptions($defaultCharset, $defaultCharset . '_unicode_ci'), + ); + + $table1 = new Table('t1', [ + new Column('c', new StringType(), [ + 'length' => 8, + 'platformOptions' => ['collation' => 'utf8_unicode_ci'], + ]), + ]); + $table2 = new Table('t2', [ + new Column('c', new StringType(), [ + 'length' => 8, + 'platformOptions' => ['collation' => 'utf8mb3_unicode_ci'], + ]), + ]); + $table3 = new Table('t3', [ + new Column('c', new StringType(), [ + 'length' => 8, + 'platformOptions' => ['collation' => 'utf8mb4_unicode_ci'], + ]), + ]); + + self::assertEmpty($utf8Comparator->compareTables($table1, $table2)->getChangedColumns()); + self::assertEmpty($utf8Comparator->compareTables($table2, $table1)->getChangedColumns()); + self::assertNotEmpty($utf8Comparator->compareTables($table1, $table3)->getChangedColumns()); + self::assertNotEmpty($utf8Comparator->compareTables($table3, $table1)->getChangedColumns()); + self::assertNotEmpty($utf8Comparator->compareTables($table2, $table3)->getChangedColumns()); + self::assertNotEmpty($utf8Comparator->compareTables($table3, $table2)->getChangedColumns()); + + $table4 = new Table('t4', [ + new Column('c', new StringType(), [ + 'length' => 8, + 'platformOptions' => ['charset' => 'utf8'], + ]), + ]); + $table5 = new Table('t5', [ + new Column('c', new StringType(), [ + 'length' => 8, + 'platformOptions' => ['charset' => 'utf8mb3'], + ]), + ]); + $table6 = new Table('t6', [ + new Column('c', new StringType(), [ + 'length' => 8, + 'platformOptions' => ['charset' => 'utf8mb4'], + ]), + ]); + + self::assertEmpty($utf8Comparator->compareTables($table4, $table5)->getChangedColumns()); + self::assertEmpty($utf8Comparator->compareTables($table5, $table4)->getChangedColumns()); + self::assertNotEmpty($utf8Comparator->compareTables($table4, $table6)->getChangedColumns()); + self::assertNotEmpty($utf8Comparator->compareTables($table6, $table4)->getChangedColumns()); + self::assertNotEmpty($utf8Comparator->compareTables($table5, $table6)->getChangedColumns()); + self::assertNotEmpty($utf8Comparator->compareTables($table6, $table5)->getChangedColumns()); + } + + /** @return iterable */ + public static function utf8AndUtf8mb3MismatchesProvider(): iterable + { + yield [false, 'utf8']; + yield [true, 'utf8']; + yield [false, 'utf8mb3']; + yield [true, 'utf8mb3']; + yield [false, 'utf8mb4']; + yield [true, 'utf8mb4']; + } } diff --git a/tests/Platforms/MySQL/MariaDBJsonComparatorTest.php b/tests/Platforms/MySQL/MariaDBJsonComparatorTest.php index ef9c99d24a4..1b96f51907d 100644 --- a/tests/Platforms/MySQL/MariaDBJsonComparatorTest.php +++ b/tests/Platforms/MySQL/MariaDBJsonComparatorTest.php @@ -28,12 +28,22 @@ protected function setUp(): void $this->comparator = new Comparator( new MariaDBPlatform(), new class implements CharsetMetadataProvider { + public function normalizeCharset(string $charset): string + { + return $charset; + } + public function getDefaultCharsetCollation(string $charset): ?string { return null; } }, new class implements CollationMetadataProvider { + public function normalizeCollation(string $collation): string + { + return $collation; + } + public function getCollationCharset(string $collation): ?string { return null;