Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 159 additions & 8 deletions src/Schema/SqliteSchemaManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,10 @@
use function rtrim;
use function str_replace;
use function strcasecmp;
use function strlen;
use function strpos;
use function strtolower;
use function substr;
use function trim;
use function unlink;
use function usort;
Expand Down Expand Up @@ -317,15 +319,16 @@ protected function _getPortableTableColumnList($table, $database, $tableColumns)
}

// inspect column collation and comments
$createSql = $this->getCreateTableSQL($table);
$createSql = $this->getCreateTableSQL($table);
$columnCollations = $this->parseColumnCollationsFromSQL($createSql);

foreach ($list as $columnName => $column) {
$type = $column->getType();

if ($type instanceof StringType || $type instanceof TextType) {
$column->setPlatformOption(
'collation',
$this->parseColumnCollationFromSQL($columnName, $createSql) ?? 'BINARY',
$columnCollations[strtolower($column->getName())] ?? 'BINARY',
);
}

Expand Down Expand Up @@ -506,16 +509,164 @@ protected function _getPortableTableForeignKeyDefinition($tableForeignKey): Fore
);
}

private function parseColumnCollationFromSQL(string $column, string $sql): ?string
/** @return array<string, string> */
private function parseColumnCollationsFromSQL(string $sql): array
{
$pattern = '{' . $this->buildIdentifierPattern($column)
. '[^,(]+(?:\([^()]+\)[^,]*)?(?:(?:DEFAULT|CHECK)\s*(?:\(.*?\))?[^,]*)*COLLATE\s+["\']?([^\s,"\')]+)}is';
$collations = [];
$column = null;
$depth = -1;
$expectsCollation = false;

if (preg_match($pattern, $sql, $match) !== 1) {
return null;
foreach ($this->tokenizeSQL($sql) as [$token, $quoted]) {
if (! $quoted && $token === '(') {
$depth++;

continue;
}

if (! $quoted && $token === ')') {
if ($depth === 0) {
break;
}

$depth--;

continue;
}

if (! $quoted && $token === ',' && $depth === 0) {
$column = null;
$expectsCollation = false;

continue;
}

if ($depth !== 0) {
continue;
}

if ($column === null) {
$column = $token;

continue;
}

if ($expectsCollation) {
$collations[strtolower($column)] = $token;
$expectsCollation = false;

continue;
}

if ($quoted) {
continue;
}

if (strcasecmp($token, 'COLLATE') !== 0) {
continue;
}

$expectsCollation = true;
}

return $collations;
}

/** @return iterable<array{string, bool}> */
private function tokenizeSQL(string $sql): iterable
{
$length = strlen($sql);

for ($offset = 0; $offset < $length;) {
$character = $sql[$offset];

if (strpos("\"'`[", $character) !== false) {
yield [$this->parseQuotedToken($sql, $offset), true];

continue;
}

if ($character === '-' && $offset + 1 < $length && $sql[$offset + 1] === '-') {
$commentEnd = strpos($sql, "\n", $offset + 2);
$offset = $commentEnd === false ? $length : $commentEnd + 1;

continue;
}

if ($character === '/' && $offset + 1 < $length && $sql[$offset + 1] === '*') {
$commentEnd = strpos($sql, '*/', $offset + 2);
$offset = $commentEnd === false ? $length : $commentEnd + 2;

continue;
}

if (strpos('(),', $character) !== false) {
$offset++;

yield [$character, false];

continue;
}

if (trim($character) === '') {
$offset++;

continue;
}

$tokenStart = $offset;
while ($offset < $length) {
$character = $sql[$offset];
if (
trim($character) === ''
|| strpos("(),\"'`[", $character) !== false
|| ($character === '-' && $offset + 1 < $length && $sql[$offset + 1] === '-')
|| ($character === '/' && $offset + 1 < $length && $sql[$offset + 1] === '*')
) {
break;
}

$offset++;
}

yield [substr($sql, $tokenStart, $offset - $tokenStart), false];
}
}

private function parseQuotedToken(string $sql, int &$offset): string
{
$openingDelimiter = $sql[$offset];
$closingDelimiter = $openingDelimiter === '[' ? ']' : $openingDelimiter;
$token = '';
$length = strlen($sql);
$offset++;

while ($offset < $length) {
$character = $sql[$offset];
if ($character !== $closingDelimiter) {
$token .= $character;
$offset++;

continue;
}

if (
$openingDelimiter !== '['
&& $offset + 1 < $length
&& $sql[$offset + 1] === $closingDelimiter
) {
$token .= $closingDelimiter;
$offset += 2;

continue;
}

$offset++;

break;
}

return $match[1];
return $token;
}

private function parseTableCommentFromSQL(string $table, string $sql): ?string
Expand Down
99 changes: 99 additions & 0 deletions tests/Functional/Schema/SqliteSchemaManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
use function array_shift;
use function assert;
use function dirname;
use function implode;
use function sprintf;

class SqliteSchemaManagerTest extends SchemaManagerFunctionalTestCase
{
Expand Down Expand Up @@ -111,6 +113,103 @@ public function testColumnCollation(): void
self::assertEquals('NOCASE', $columns['bar']->getPlatformOption('collation'));
}

public function testColumnCollationIdentifierQuoting(): void
{
$this->dropTableIfExists('test_collation_quoting');
$this->connection->executeStatement(<<<'SQL'
CREATE TABLE test_collation_quoting (
backtick TEXT COLLATE `NOCASE`,
double_quote TEXT COLLATE "RTRIM",
bracket TEXT COLLATE [BINARY],
unquoted TEXT cOlLaTe nOcAsE,
`escaped``backtick` TEXT DEFAULT ('COLLATE "BINARY"')
CHECK (`escaped``backtick` <> '') COLLATE `RTRIM`,
"escaped""double" TEXT DEFAULT 'COLLATE BINARY'
CHECK ("escaped""double" COLLATE RTRIM <> '') COLLATE "NOCASE",
no_collation TEXT DEFAULT 'COLLATE NOCASE'
CHECK (no_collation COLLATE RTRIM <> '')
)
SQL);

$columns = $this->schemaManager->listTableColumns('test_collation_quoting');

self::assertSame('NOCASE', $columns['backtick']->getPlatformOption('collation'));
self::assertSame('RTRIM', $columns['double_quote']->getPlatformOption('collation'));
self::assertSame('BINARY', $columns['bracket']->getPlatformOption('collation'));
self::assertSame('nOcAsE', $columns['unquoted']->getPlatformOption('collation'));
self::assertSame('RTRIM', $columns['escaped`backtick']->getPlatformOption('collation'));
self::assertSame('NOCASE', $columns['escaped"double']->getPlatformOption('collation'));
self::assertSame('BINARY', $columns['no_collation']->getPlatformOption('collation'));
self::assertSame('COLLATE NOCASE', $columns['no_collation']->getDefault());
}

public function testColumnCollationOnQuotedReservedWordColumns(): void
{
$this->dropTableIfExists('test_collation_reserved_words');
$this->connection->executeStatement(<<<'SQL'
CREATE TABLE test_collation_reserved_words (
"DEFAULT" TEXT COLLATE NOCASE,
"CHECK" TEXT COLLATE RTRIM
)
SQL);

$columns = $this->schemaManager->listTableColumns('test_collation_reserved_words');

self::assertSame('NOCASE', $columns['"default"']->getPlatformOption('collation'));
self::assertSame('RTRIM', $columns['"check"']->getPlatformOption('collation'));
}

/** @dataProvider quotedCollationTokenProvider */
public function testDoesNotMisidentifyQuotedCollationToken(string $ddl): void
{
$this->dropTableIfExists('test_collation_token');
$this->connection->executeStatement($ddl);

$columns = $this->schemaManager->listTableColumns('test_collation_token');

self::assertSame('BINARY', $columns['value']->getPlatformOption('collation'));
}

/** @return iterable<string, array{string}> */
public static function quotedCollationTokenProvider(): iterable
{
$ddl = "CREATE TABLE test_collation_token (value TEXT DEFAULT 'COLLATE' NOT NULL)";

yield 'single-quoted default' => [$ddl];

$ddl = 'CREATE TABLE test_collation_token (value TEXT DEFAULT "COLLATE" NOT NULL)';

yield 'double-quoted default' => [$ddl];

$ddl = 'CREATE TABLE test_collation_token (value TEXT REFERENCES "COLLATE" (id) NOT NULL)';

yield 'quoted referenced table' => [$ddl];

$ddl = 'CREATE TABLE test_collation_token (value TEXT CONSTRAINT "COLLATE" NOT NULL)';

yield 'quoted constraint name' => [$ddl];
}

public function testColumnCollationsAreParsedForWideTable(): void
{
$this->dropTableIfExists('test_collation_count');
$definitions = [];
for ($index = 0; $index < 1000; $index++) {
$definitions[] = sprintf('column_%d TEXT COLLATE NOCASE', $index);
}

$this->connection->executeStatement(sprintf(
'CREATE TABLE test_collation_count (%s)',
implode(', ', $definitions),
));

$columns = $this->schemaManager->listTableColumns('test_collation_count');

self::assertCount(1000, $columns);
self::assertSame('NOCASE', $columns['column_0']->getPlatformOption('collation'));
self::assertSame('NOCASE', $columns['column_999']->getPlatformOption('collation'));
}

/**
* SQLite stores BINARY columns as BLOB
*/
Expand Down
43 changes: 41 additions & 2 deletions tests/Schema/SqliteSchemaManagerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
use PHPUnit\Framework\TestCase;
use ReflectionMethod;

use function implode;
use function sprintf;

use const PHP_VERSION_ID;

class SqliteSchemaManagerTest extends TestCase
Expand All @@ -19,12 +22,14 @@ public function testParseColumnCollation(?string $collation, string $column, str
$conn = $this->createMock(Connection::class);

$manager = new SqliteSchemaManager($conn, new SqlitePlatform());
$ref = new ReflectionMethod($manager, 'parseColumnCollationFromSQL');
$ref = new ReflectionMethod($manager, 'parseColumnCollationsFromSQL');
if (PHP_VERSION_ID < 80100) {
$ref->setAccessible(true);
}

self::assertSame($collation, $ref->invoke($manager, $column, $sql));
$collations = $ref->invoke($manager, $sql);
self::assertIsArray($collations);
self::assertSame($collation, $collations[$column] ?? null);
}

/** @return mixed[][] */
Expand All @@ -48,6 +53,16 @@ public static function getDataColumnCollation(): iterable
'CREATE TABLE "a" ("a" text DEFAULT (lower(ltrim(" a") || rtrim("a ")))'
. ' CHECK ("a") NOT NULL COLLATE NOCASE UNIQUE, "b" text COLLATE RTRIM)',
],
[
'RTRIM',
'a',
'CREATE TABLE "a" ("a" text COLLATE NOCASE COLLATE RTRIM)',
],
[
null,
'a',
'CREATE TABLE "a" ("a" "COLLATE" NOT NULL)',
],
[
null,
'a',
Expand Down Expand Up @@ -127,6 +142,30 @@ public static function getDataColumnCollation(): iterable
];
}

public function testParseColumnCollationsFromWideTable(): void
{
$definitions = [];
$expected = [];

for ($index = 0; $index < 1000; $index++) {
$column = sprintf('column_%d', $index);
$definitions[] = sprintf('%s TEXT COLLATE NOCASE', $column);
$expected[$column] = 'NOCASE';
}

$conn = $this->createMock(Connection::class);
$manager = new SqliteSchemaManager($conn, new SqlitePlatform());
$ref = new ReflectionMethod($manager, 'parseColumnCollationsFromSQL');
if (PHP_VERSION_ID < 80100) {
$ref->setAccessible(true);
}

self::assertSame(
$expected,
$ref->invoke($manager, sprintf('CREATE TABLE wide_table (%s)', implode(', ', $definitions))),
);
}

/** @dataProvider getDataColumnComment */
public function testParseColumnCommentFromSQL(?string $comment, string $column, string $sql): void
{
Expand Down