Browse Source

Merge remote-tracking branch 'origin/5.x' into 5.x

Mark Story 2 years ago
parent
commit
9c30190dd7

+ 115 - 19
src/Database/Schema/SqliteSchemaDialect.php

@@ -26,14 +26,6 @@ use Cake\Database\Exception\DatabaseException;
 class SqliteSchemaDialect extends SchemaDialect
 {
     /**
-     * Array containing the foreign keys constraints names
-     * Necessary for composite foreign keys to be handled
-     *
-     * @var array<string, mixed>
-     */
-    protected array $_constraintsIdMap = [];
-
-    /**
      * Whether there is any table in this connection to SQLite containing sequences.
      *
      * @var bool
@@ -269,6 +261,60 @@ class SqliteSchemaDialect extends SchemaDialect
     }
 
     /**
+     * Generates a regular expression to match identifiers that may or
+     * may not be quoted with any of the supported quotes.
+     *
+     * @param string $identifier The identifier to match.
+     * @return string
+     */
+    protected function possiblyQuotedIdentifierRegex(string $identifier): string
+    {
+        $identifiers = [];
+        $identifier = preg_quote($identifier, '/');
+
+        $hasTick = str_contains($identifier, '`');
+        $hasDoubleQuote = str_contains($identifier, '"');
+        $hasSingleQuote = str_contains($identifier, "'");
+
+        $identifiers[] = '\[' . $identifier . '\]';
+        $identifiers[] = '`' . ($hasTick ? str_replace('`', '``', $identifier) : $identifier) . '`';
+        $identifiers[] = '"' . ($hasDoubleQuote ? str_replace('"', '""', $identifier) : $identifier) . '"';
+        $identifiers[] = "'" . ($hasSingleQuote ? str_replace("'", "''", $identifier) : $identifier) . "'";
+
+        if (!$hasTick && !$hasDoubleQuote && !$hasSingleQuote) {
+            $identifiers[] = $identifier;
+        }
+
+        return implode('|', $identifiers);
+    }
+
+    /**
+     * Removes possible escape characters and surrounding quotes from
+     * identifiers.
+     *
+     * @param string $value The identifier to normalize.
+     * @return string
+     */
+    protected function normalizePossiblyQuotedIdentifier(string $value): string
+    {
+        $value = trim($value);
+
+        if (str_starts_with($value, '[') && str_ends_with($value, ']')) {
+            return mb_substr($value, 1, -1);
+        }
+
+        foreach (['`', "'", '"'] as $quote) {
+            if (str_starts_with($value, $quote) && str_ends_with($value, $quote)) {
+                $value = str_replace($quote . $quote, $quote, $value);
+
+                return mb_substr($value, 1, -1);
+            }
+        }
+
+        return $value;
+    }
+
+    /**
      * {@inheritDoc}
      *
      * Since SQLite does not have a way to get metadata about all indexes at once,
@@ -283,6 +329,11 @@ class SqliteSchemaDialect extends SchemaDialect
      */
     public function convertIndexDescription(TableSchema $schema, array $row): void
     {
+        // Skip auto-indexes created for non-ROWID primary keys.
+        if ($row['origin'] === 'pk') {
+            return;
+        }
+
         $sql = sprintf(
             'PRAGMA index_info(%s)',
             $this->_driver->quoteIdentifier($row['name'])
@@ -294,6 +345,36 @@ class SqliteSchemaDialect extends SchemaDialect
             $columns[] = $column['name'];
         }
         if ($row['unique']) {
+            if ($row['origin'] === 'u') {
+                // Try to obtain the actual constraint name for indexes that are
+                // created automatically for unique constraints.
+
+                $sql = sprintf(
+                    'SELECT sql FROM sqlite_master WHERE type = "table" AND tbl_name = %s',
+                    $this->_driver->quoteIdentifier($schema->name())
+                );
+                $statement = $this->_driver->prepare($sql);
+                $statement->execute();
+
+                $tableRow = $statement->fetchAssoc();
+                $tableSql = $tableRow['sql'] ??= null;
+
+                if ($tableSql) {
+                    $columnsPattern = implode(
+                        '\s*,\s*',
+                        array_map(
+                            fn ($column) => '(?:' . $this->possiblyQuotedIdentifierRegex($column) . ')',
+                            $columns
+                        )
+                    );
+
+                    $regex = "/CONSTRAINT\s*(['\"`\[ ].+?['\"`\] ])\s*UNIQUE\s*\(\s*(?:{$columnsPattern})\s*\)/i";
+                    if (preg_match($regex, $tableSql, $matches)) {
+                        $row['name'] = $this->normalizePossiblyQuotedIdentifier($matches[1]);
+                    }
+                }
+            }
+
             $schema->addConstraint($row['name'], [
                 'type' => TableSchema::CONSTRAINT_UNIQUE,
                 'columns' => $columns,
@@ -311,7 +392,10 @@ class SqliteSchemaDialect extends SchemaDialect
      */
     public function describeForeignKeySql(string $tableName, array $config): array
     {
-        $sql = sprintf('PRAGMA foreign_key_list(%s)', $this->_driver->quoteIdentifier($tableName));
+        $sql = sprintf(
+            'SELECT id FROM pragma_foreign_key_list(%s) GROUP BY id',
+            $this->_driver->quoteIdentifier($tableName)
+        );
 
         return [$sql, []];
     }
@@ -321,23 +405,35 @@ class SqliteSchemaDialect extends SchemaDialect
      */
     public function convertForeignKeyDescription(TableSchema $schema, array $row): void
     {
-        $name = $row['from'] . '_fk';
+        $sql = sprintf(
+            'SELECT * FROM pragma_foreign_key_list(%s) WHERE id = %d ORDER BY seq',
+            $this->_driver->quoteIdentifier($schema->name()),
+            $row['id']
+        );
+        $statement = $this->_driver->prepare($sql);
+        $statement->execute();
 
-        $update = $row['on_update'] ?? '';
-        $delete = $row['on_delete'] ?? '';
         $data = [
             'type' => TableSchema::CONSTRAINT_FOREIGN,
-            'columns' => [$row['from']],
-            'references' => [$row['table'], $row['to']],
-            'update' => $this->_convertOnClause($update),
-            'delete' => $this->_convertOnClause($delete),
+            'columns' => [],
+            'references' => [],
         ];
 
-        if (isset($this->_constraintsIdMap[$schema->name()][$row['id']])) {
-            $name = $this->_constraintsIdMap[$schema->name()][$row['id']];
+        $foreignKey = null;
+        foreach ($statement->fetchAll('assoc') as $foreignKey) {
+            $data['columns'][] = $foreignKey['from'];
+            $data['references'][] = $foreignKey['to'];
+        }
+
+        if (count($data['references']) === 1) {
+            $data['references'] = [$foreignKey['table'], $data['references'][0]];
         } else {
-            $this->_constraintsIdMap[$schema->name()][$row['id']] = $name;
+            $data['references'] = [$foreignKey['table'], $data['references']];
         }
+        $data['update'] = $this->_convertOnClause($foreignKey['on_update'] ?? '');
+        $data['delete'] = $this->_convertOnClause($foreignKey['on_delete'] ?? '');
+
+        $name = implode('_', $data['columns']) . '_' . $row['id'] . '_fk';
 
         $schema->addConstraint($name, $data);
     }

+ 177 - 6
tests/TestCase/Database/Schema/SqliteSchemaTest.php

@@ -261,6 +261,51 @@ SQL;
         $connection->execute('CREATE INDEX "created_idx" ON "schema_articles" ("created")');
         $connection->execute('CREATE UNIQUE INDEX "unique_id_idx" ON "schema_articles" ("unique_id")');
 
+        $table = <<<SQL
+CREATE TABLE schema_no_rowid_pk (
+id INT PRIMARY KEY
+);
+SQL;
+        $connection->execute($table);
+
+        $table = <<<SQL
+CREATE TABLE schema_unique_constraint_variations (
+id INTEGER PRIMARY KEY AUTOINCREMENT,
+no_quotes INTEGER,
+'single_''quotes' INTEGER,
+"double_""quotes" INTEGER,
+`tick_``quotes` INTEGER,
+[bracket_[quotes] INTEGER,
+foo INTEGER,
+bar INTEGER,
+baz INTEGER,
+zap INTEGER,
+CONSTRAINT no_quotes_idx UNIQUE (no_quotes)
+CONSTRAINT duplicate_idx UNIQUE (no_quotes)
+CONSTRAINT 'single_''quotes_idx' UNIQUE ('single_''quotes')
+CONSTRAINT "double_""quotes_idx" UNIQUE ("double_""quotes")
+CONSTRAINT `tick_``quotes_idx` UNIQUE (`tick_``quotes`)
+CONSTRAINT [bracket_[quotes_idx] UNIQUE ([bracket_[quotes])
+CONSTraint
+    a_cat_walked_over_my_keyboard_idx     UNIque
+        (    id  ,'foo',
+            	    "bar",     `baz`,
+    [zap])
+);
+SQL;
+        $connection->execute($table);
+
+        $table = <<<SQL
+CREATE TABLE schema_foreign_key_variations (
+id INTEGER PRIMARY KEY AUTOINCREMENT,
+author_id INT(11),
+author_name VARCHAR(50),
+CONSTRAINT author_fk FOREIGN KEY (author_id) REFERENCES schema_authors (id) ON UPDATE CASCADE ON DELETE RESTRICT
+CONSTRAINT multi_col_author_fk FOREIGN KEY (author_id, author_name) REFERENCES schema_authors (id, name) ON UPDATE CASCADE
+);
+SQL;
+        $connection->execute($table);
+
         $sql = <<<SQL
 CREATE TABLE schema_composite (
     "id" INTEGER NOT NULL,
@@ -455,12 +500,12 @@ SQL;
                 'columns' => ['id'],
                 'length' => [],
             ],
-            'sqlite_autoindex_schema_articles_1' => [
+            'title_idx' => [
                 'type' => 'unique',
                 'columns' => ['title', 'body'],
                 'length' => [],
             ],
-            'author_id_fk' => [
+            'author_id_0_fk' => [
                 'type' => 'foreign',
                 'columns' => ['author_id'],
                 'references' => ['schema_authors', 'id'],
@@ -479,12 +524,12 @@ SQL;
         $this->assertCount(4, $result->constraints());
         $this->assertEquals($expected['primary'], $result->getConstraint('primary'));
         $this->assertEquals(
-            $expected['sqlite_autoindex_schema_articles_1'],
-            $result->getConstraint('sqlite_autoindex_schema_articles_1')
+            $expected['title_idx'],
+            $result->getConstraint('title_idx')
         );
         $this->assertEquals(
-            $expected['author_id_fk'],
-            $result->getConstraint('author_id_fk')
+            $expected['author_id_0_fk'],
+            $result->getConstraint('author_id_0_fk')
         );
         $this->assertEquals($expected['unique_id_idx'], $result->getConstraint('unique_id_idx'));
 
@@ -495,6 +540,132 @@ SQL;
             'length' => [],
         ];
         $this->assertEquals($expected, $result->getIndex('created_idx'));
+
+        $schema = new SchemaCollection($connection);
+        $result = $schema->describe('schema_no_rowid_pk');
+        $this->assertInstanceOf('Cake\Database\Schema\TableSchema', $result);
+
+        $this->assertSame(['primary'], $result->constraints());
+
+        $schema = new SchemaCollection($connection);
+        $result = $schema->describe('schema_unique_constraint_variations');
+        $this->assertInstanceOf('Cake\Database\Schema\TableSchema', $result);
+
+        $expected = [
+            'primary' => [
+                'type' => 'primary',
+                'columns' => [
+                    'id',
+                ],
+                'length' => [],
+            ],
+            'a_cat_walked_over_my_keyboard_idx' => [
+                'type' => 'unique',
+                'columns' => [
+                    'id',
+                    'foo',
+                    'bar',
+                    'baz',
+                    'zap',
+                ],
+                'length' => [],
+            ],
+            'bracket_[quotes_idx' => [
+                'type' => 'unique',
+                'columns' => [
+                    'bracket_[quotes',
+                ],
+                'length' => [],
+            ],
+            'tick_`quotes_idx' => [
+                'type' => 'unique',
+                'columns' => [
+                    'tick_`quotes',
+                ],
+                'length' => [],
+            ],
+            'double_"quotes_idx' => [
+                'type' => 'unique',
+                'columns' => [
+                    'double_"quotes',
+                ],
+                'length' => [],
+            ],
+            "single_'quotes_idx" => [
+                'type' => 'unique',
+                'columns' => [
+                    "single_'quotes",
+                ],
+                'length' => [],
+            ],
+            'no_quotes_idx' => [
+                'type' => 'unique',
+                'columns' => [
+                    'no_quotes',
+                ],
+                'length' => [],
+            ],
+        ];
+        foreach ($expected as $name => $constraint) {
+            $this->assertSame($constraint, $result->getConstraint($name));
+        }
+        $this->assertCount(7, $result->constraints());
+
+        $this->assertEmpty($result->indexes());
+    }
+
+    /**
+     * Test describing a table with foreign keys
+     */
+    public function testDescribeTableForeignKeys(): void
+    {
+        $connection = ConnectionManager::get('test');
+        $this->_createTables($connection);
+
+        $schema = new SchemaCollection($connection);
+        $result = $schema->describe('schema_foreign_key_variations');
+        $this->assertInstanceOf('Cake\Database\Schema\TableSchema', $result);
+
+        $expected = [
+            'primary' => [
+                'type' => 'primary',
+                'columns' => [
+                    'id',
+                ],
+                'length' => [],
+            ],
+            'author_id_author_name_0_fk' => [
+                'type' => 'foreign',
+                'columns' => [
+                    'author_id',
+                    'author_name',
+                ],
+                'references' => [
+                    'schema_authors',
+                    ['id', 'name'],
+                ],
+                'update' => 'cascade',
+                'delete' => 'noAction',
+                'length' => [],
+            ],
+            'author_id_1_fk' => [
+                'type' => 'foreign',
+                'columns' => [
+                    'author_id',
+                ],
+                'references' => [
+                    'schema_authors',
+                    'id',
+                ],
+                'update' => 'cascade',
+                'delete' => 'restrict',
+                'length' => [],
+            ],
+        ];
+        foreach ($expected as $name => $constraint) {
+            $this->assertSame($constraint, $result->getConstraint($name));
+        }
+        $this->assertCount(3, $result->constraints());
     }
 
     /**

+ 17 - 4
tests/TestCase/Database/Schema/TableSchemaTest.php

@@ -17,6 +17,7 @@ declare(strict_types=1);
 namespace Cake\Test\TestCase\Database\Schema;
 
 use Cake\Database\Driver\Postgres;
+use Cake\Database\Driver\Sqlite;
 use Cake\Database\Exception\DatabaseException;
 use Cake\Database\Schema\TableSchema;
 use Cake\Database\TypeFactory;
@@ -510,7 +511,13 @@ class TableSchemaTest extends TestCase
     public function testConstraintForeignKey(): void
     {
         $table = $this->getTableLocator()->get('ArticlesTags');
-        $compositeConstraint = $table->getSchema()->getConstraint('tag_id_fk');
+
+        $name = 'tag_id_fk';
+        if ($table->getConnection()->getDriver() instanceof Sqlite) {
+            $name = 'tag_id_0_fk';
+        }
+
+        $compositeConstraint = $table->getSchema()->getConstraint($name);
         $expected = [
             'type' => 'foreign',
             'columns' => ['tag_id'],
@@ -521,7 +528,7 @@ class TableSchemaTest extends TestCase
         ];
         $this->assertEquals($expected, $compositeConstraint);
 
-        $expectedSubstring = 'CONSTRAINT <tag_id_fk> FOREIGN KEY \(<tag_id>\) REFERENCES <tags> \(<id>\)';
+        $expectedSubstring = "CONSTRAINT <{$name}> FOREIGN KEY \\(<tag_id>\\) REFERENCES <tags> \\(<id>\\)";
         $this->assertQuotedQuery($expectedSubstring, $table->getSchema()->createSql(ConnectionManager::get('test'))[0]);
     }
 
@@ -537,7 +544,13 @@ class TableSchemaTest extends TestCase
             $connection->getDriver() instanceof Postgres,
             'Constraints get dropped in postgres for some reason'
         );
-        $compositeConstraint = $table->getSchema()->getConstraint('product_category_fk');
+
+        $name = 'product_category_fk';
+        if ($table->getConnection()->getDriver() instanceof Sqlite) {
+            $name = 'product_category_product_id_0_fk';
+        }
+
+        $compositeConstraint = $table->getSchema()->getConstraint($name);
         $expected = [
             'type' => 'foreign',
             'columns' => [
@@ -554,7 +567,7 @@ class TableSchemaTest extends TestCase
         ];
         $this->assertEquals($expected, $compositeConstraint);
 
-        $expectedSubstring = 'CONSTRAINT <product_category_fk> FOREIGN KEY \(<product_category>, <product_id>\)' .
+        $expectedSubstring = "CONSTRAINT <{$name}> FOREIGN KEY \\(<product_category>, <product_id>\\)" .
             ' REFERENCES <products> \(<category>, <id>\)';
 
         $this->assertQuotedQuery($expectedSubstring, $table->getSchema()->createSql(ConnectionManager::get('test'))[0]);