Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improve MySQL charset and collation comparison #5523

Merged
merged 1 commit into from Jul 22, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/Platforms/MySQL/CharsetMetadataProvider.php
@@ -0,0 +1,13 @@
<?php

declare(strict_types=1);

namespace Doctrine\DBAL\Platforms\MySQL;

/**
* @internal
*/
interface CharsetMetadataProvider
{
public function getDefaultCharsetCollation(string $charset): ?string;
}
@@ -0,0 +1,31 @@
<?php

declare(strict_types=1);

namespace Doctrine\DBAL\Platforms\MySQL\CharsetMetadataProvider;

use Doctrine\DBAL\Platforms\MySQL\CharsetMetadataProvider;

use function array_key_exists;

/**
* @internal
*/
final class CachingCharsetMetadataProvider implements CharsetMetadataProvider
{
/** @var array<string,?string> */
private array $cache = [];

public function __construct(private CharsetMetadataProvider $charsetMetadataProvider)
{
}

public function getDefaultCharsetCollation(string $charset): ?string
{
if (array_key_exists($charset, $this->cache)) {
return $this->cache[$charset];
}

return $this->cache[$charset] = $this->charsetMetadataProvider->getDefaultCharsetCollation($charset);
}
}
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace Doctrine\DBAL\Platforms\MySQL\CharsetMetadataProvider;

use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Platforms\MySQL\CharsetMetadataProvider;

/**
* @internal
*/
final class ConnectionCharsetMetadataProvider implements CharsetMetadataProvider
{
public function __construct(private Connection $connection)
{
}

/**
* @throws Exception
*/
public function getDefaultCharsetCollation(string $charset): ?string
{
$collation = $this->connection->fetchOne(
<<<'SQL'
SELECT DEFAULT_COLLATE_NAME
FROM information_schema.CHARACTER_SETS
WHERE CHARACTER_SET_NAME = ?;
SQL
,
[$charset]
);

if ($collation !== false) {
return $collation;
}

return null;
}
}
53 changes: 36 additions & 17 deletions src/Platforms/MySQL/Comparator.php
Expand Up @@ -10,7 +10,6 @@
use Doctrine\DBAL\Schema\TableDiff;

use function array_diff_assoc;
use function array_intersect_key;

/**
* Compares schemas in the context of MySQL platform.
Expand All @@ -21,32 +20,54 @@
*/
class Comparator extends BaseComparator
{
private CharsetMetadataProvider $charsetMetadataProvider;

private CollationMetadataProvider $collationMetadataProvider;

private DefaultTableOptions $defaultTableOptions;

/**
* @internal The comparator can be only instantiated by a schema manager.
*/
public function __construct(AbstractMySQLPlatform $platform, CollationMetadataProvider $collationMetadataProvider)
{
public function __construct(
AbstractMySQLPlatform $platform,
CharsetMetadataProvider $charsetMetadataProvider,
CollationMetadataProvider $collationMetadataProvider,
DefaultTableOptions $defaultTableOptions
) {
parent::__construct($platform);

$this->charsetMetadataProvider = $charsetMetadataProvider;
$this->collationMetadataProvider = $collationMetadataProvider;
$this->defaultTableOptions = $defaultTableOptions;
}

public function diffTable(Table $fromTable, Table $toTable): ?TableDiff
{
return parent::diffTable(
$this->normalizeColumns($fromTable),
$this->normalizeColumns($toTable)
$this->normalizeTable($fromTable),
$this->normalizeTable($toTable)
);
}

private function normalizeColumns(Table $table): Table
private function normalizeTable(Table $table): Table
{
$tableOptions = array_intersect_key($table->getOptions(), [
'charset' => null,
'collation' => null,
]);
$charset = $table->getOption('charset');
$collation = $table->getOption('collation');

if ($charset === null && $collation !== null) {
$charset = $this->collationMetadataProvider->getCollationCharset($collation);
} elseif ($charset !== null && $collation === null) {
$collation = $this->charsetMetadataProvider->getDefaultCharsetCollation($charset);
} elseif ($charset === null && $collation === null) {
$charset = $this->defaultTableOptions->getCharset();
$collation = $this->defaultTableOptions->getCollation();
}

$tableOptions = [
'charset' => $charset,
'collation' => $collation,
];

$table = clone $table;

Expand All @@ -69,16 +90,14 @@ private function normalizeColumns(Table $table): Table
/**
* @param array<string,string> $options
*
* @return array<string,string>
* @return array<string,string|null>
*/
private function normalizeOptions(array $options): array
{
if (isset($options['collation']) && ! isset($options['charset'])) {
$charset = $this->collationMetadataProvider->getCollationCharset($options['collation']);

if ($charset !== null) {
$options['charset'] = $charset;
}
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']);
}

return $options;
Expand Down
25 changes: 25 additions & 0 deletions src/Platforms/MySQL/DefaultTableOptions.php
@@ -0,0 +1,25 @@
<?php

declare(strict_types=1);

namespace Doctrine\DBAL\Platforms\MySQL;

/**
* @internal
*/
final class DefaultTableOptions
{
public function __construct(private string $charset, private string $collation)
{
}

public function getCharset(): string
{
return $this->charset;
}

public function getCollation(): string
{
return $this->collation;
}
}
33 changes: 32 additions & 1 deletion src/Schema/MySQLSchemaManager.php
Expand Up @@ -4,11 +4,15 @@

namespace Doctrine\DBAL\Schema;

use Doctrine\DBAL\Exception;
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use Doctrine\DBAL\Platforms\MariaDBPlatform;
use Doctrine\DBAL\Platforms\MySQL;
use Doctrine\DBAL\Platforms\MySQL\CharsetMetadataProvider\CachingCharsetMetadataProvider;
use Doctrine\DBAL\Platforms\MySQL\CharsetMetadataProvider\ConnectionCharsetMetadataProvider;
use Doctrine\DBAL\Platforms\MySQL\CollationMetadataProvider\CachingCollationMetadataProvider;
use Doctrine\DBAL\Platforms\MySQL\CollationMetadataProvider\ConnectionCollationMetadataProvider;
use Doctrine\DBAL\Platforms\MySQL\DefaultTableOptions;
use Doctrine\DBAL\Result;
use Doctrine\DBAL\Types\Type;

Expand Down Expand Up @@ -52,6 +56,8 @@ class MySQLSchemaManager extends AbstractSchemaManager
"''" => "'",
];

private ?DefaultTableOptions $defaultTableOptions = null;

/**
* {@inheritdoc}
*/
Expand Down Expand Up @@ -315,13 +321,20 @@ protected function _getPortableTableForeignKeyDefinition(array $tableForeignKey)
);
}

/**
* @throws Exception
*/
public function createComparator(): Comparator
{
return new MySQL\Comparator(
$this->_platform,
new CachingCharsetMetadataProvider(
new ConnectionCharsetMetadataProvider($this->_conn)
),
new CachingCollationMetadataProvider(
new ConnectionCollationMetadataProvider($this->_conn)
)
),
$this->getDefaultTableOptions()
);
}

Expand Down Expand Up @@ -510,4 +523,22 @@ private function parseCreateOptions(?string $string): array

return $options;
}

/**
* @throws Exception
*/
private function getDefaultTableOptions(): DefaultTableOptions
{
if ($this->defaultTableOptions === null) {
$row = $this->_conn->fetchNumeric(
'SELECT @@character_set_database, @@collation_database',
);

assert($row !== false);

$this->defaultTableOptions = new DefaultTableOptions(...$row);
}

return $this->defaultTableOptions;
}
}
2 changes: 1 addition & 1 deletion src/Schema/Table.php
Expand Up @@ -598,7 +598,7 @@ public function hasOption(string $name): bool

public function getOption(string $name): mixed
{
return $this->_options[$name];
return $this->_options[$name] ?? null;
}

/**
Expand Down
34 changes: 31 additions & 3 deletions tests/Functional/Schema/MySQL/ComparatorTest.php
Expand Up @@ -136,13 +136,20 @@ public function testChangeColumnCollation(): void
ComparatorTestUtils::assertDiffNotEmpty($this->connection, $this->comparator, $table);
}

public function testImplicitColumnCharset(): void
/**
* @param array<string,string> $tableOptions
* @param array<string,string> $columnOptions
*
* @dataProvider tableAndColumnOptionsProvider
*/
public function testTableAndColumnOptions(array $tableOptions, array $columnOptions): void
{
$table = new Table('comparator_test');
$table = new Table('comparator_test', [], [], [], [], $tableOptions);
$table->addColumn('name', Types::STRING, [
'length' => 32,
'platformOptions' => ['collation' => 'ascii_general_ci'],
'platformOptions' => $columnOptions,
]);

$this->dropAndCreateTable($table);

self::assertNull(ComparatorTestUtils::diffFromActualToDesiredTable(
Expand All @@ -158,6 +165,27 @@ public function testImplicitColumnCharset(): void
));
}

/**
* @return iterable<string,array{array<string,string>,array<string,string>}>
*/
public static function tableAndColumnOptionsProvider(): iterable
{
yield "Column collation explicitly set to its table's default" => [
[],
['collation' => 'utf8mb4_unicode_ci'],
];

yield "Column charset implicitly set to a value matching its table's charset" => [
['charset' => 'utf8mb4'],
['collation' => 'utf8mb4_unicode_ci'],
];

yield "Column collation reset to the collation's default matching its table's charset" => [
['collation' => 'utf8mb4_unicode_ci'],
['charset' => 'utf8mb4'],
];
}

/**
* @return array{Table,Column}
*
Expand Down
21 changes: 9 additions & 12 deletions tests/Functional/Schema/MySQLSchemaManagerTest.php
Expand Up @@ -10,7 +10,6 @@
use Doctrine\DBAL\Platforms\AbstractMySQLPlatform;
use Doctrine\DBAL\Platforms\AbstractPlatform;
use Doctrine\DBAL\Platforms\MariaDBPlatform;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\DBAL\Schema\Table;
use Doctrine\DBAL\Tests\Functional\Schema\MySQL\PointType;
use Doctrine\DBAL\Tests\TestUtil;
Expand Down Expand Up @@ -231,23 +230,21 @@ public function testColumnCharsetChange(): void
->setLength(100)
->setNotnull(true)
->setPlatformOption('charset', 'utf8');
$this->dropAndCreateTable($table);

$diffTable = clone $table;
$diffTable->getColumn('col_string')->setPlatformOption('charset', 'ascii');

$fromSchema = new Schema([$table]);
$toSchema = new Schema([$diffTable]);

$diff = $this->schemaManager->createComparator()
->compareSchemas($fromSchema, $toSchema)
->toSql(
$this->connection->getDatabasePlatform()
);
->diffTable($table, $diffTable);
self::assertNotNull($diff);
$this->schemaManager->alterTable($diff);

self::assertContains(
'ALTER TABLE test_column_charset_change CHANGE col_string'
. ' col_string VARCHAR(100) CHARACTER SET ascii NOT NULL',
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This patch affects the exact DDL used to alter the table: since now the column definition being compared always has an explicit charset and collation, it leaks into the resulting DDL (COLLATION ascii_general_ci is appended).

This is not a bug, since this is the default collation for the ascii charset according to the information schema.

Keeping this DDL unchanged would require additional juggling of column options. At the same time, a functional test shouldn't make assertions against SQL.

$diff
self::assertEquals(
'ascii',
$this->schemaManager->listTableDetails('test_column_charset_change')
->getColumn('col_string')
->getPlatformOption('charset')
);
}

Expand Down