diff --git a/core/lib/Drupal/Core/Database/Driver/mysql/Schema.php b/core/lib/Drupal/Core/Database/Driver/mysql/Schema.php index 1911d6a..8b8420b 100644 --- a/core/lib/Drupal/Core/Database/Driver/mysql/Schema.php +++ b/core/lib/Drupal/Core/Database/Driver/mysql/Schema.php @@ -275,6 +275,16 @@ protected function createKeysSql($spec) { if (!empty($spec['primary key'])) { $keys[] = 'PRIMARY KEY (' . $this->createKeySql($spec['primary key']) . ')'; } + if (!empty($spec['foreign keys'])) { + foreach ($spec['foreign keys'] as $key => $fields) { + if (!empty($fields['on update']) && !empty($fields['on delete'])) { + $fk = $this->createForeignKeySql($key, $fields); + if ($fk) { + $keys[] = $fk; + } + } + } + } if (!empty($spec['unique keys'])) { foreach ($spec['unique keys'] as $key => $fields) { $keys[] = 'UNIQUE KEY `' . $key . '` (' . $this->createKeySql($fields) . ')'; @@ -366,6 +376,19 @@ protected function createKeySql($fields) { return implode(', ', $return); } + /** + * @return string|null + * The SQL definition of the foreign key or NULL if the + * definition is invalid. + */ + protected function createForeignKeySql($key_name, $fields) { + $operations = ['no action', 'restrict', 'cascade', 'set null', 'set default']; + if (isset($fields['on update']) && isset($fields['on delete']) && in_array($fields['on update'], $operations) && in_array($fields['on delete'], $operations)) { + return 'CONSTRAINT `' . $key_name . '` FOREIGN KEY (`' . implode('`, `', array_keys($fields['columns'])) . '`) REFERENCES {' . $fields['table'] . '} (`' . implode('`, `', $fields['columns']) . '`) ON DELETE ' . strtoupper($fields['on delete']) . ' ON UPDATE ' . strtoupper($fields['on update']); + } + return NULL; + } + public function renameTable($table, $new_name) { if (!$this->tableExists($table)) { throw new SchemaObjectDoesNotExistException(t("Cannot rename @table to @table_new: table @table doesn't exist.", ['@table' => $table, '@table_new' => $new_name])); @@ -464,6 +487,11 @@ public function indexExists($table, $name) { return isset($row['Key_name']); } + public function fkExists($table, $name) { + $row = $this->connection->query('SELECT CONSTRAINT_NAME FROM information_schema.key_column_usage WHERE TABLE_NAME = ' . $this->connection->quote('{' . $table . '}') . ' AND CONSTRAINT_NAME = ' . $this->connection->quote($name) . ' AND REFERENCED_TABLE_NAME IS NOT NULL')->fetchAssoc(); + return isset($row['CONSTRAINT_NAME']); + } + public function addPrimaryKey($table, $fields) { if (!$this->tableExists($table)) { throw new SchemaObjectDoesNotExistException(t("Cannot add primary key to table @table: table doesn't exist.", ['@table' => $table])); @@ -484,6 +512,31 @@ public function dropPrimaryKey($table) { return TRUE; } + public function addForeignKey($table, $name, $fields) { + if (!$this->tableExists($table)) { + throw new SchemaObjectDoesNotExistException(t("Cannot add foreign key @name to table @table: table doesn't exist.", ['@table' => $table, '@name' => $name])); + } + if ($this->fkExists($table, $name)) { + throw new SchemaObjectExistsException(t("Cannot add foreign key @name to table @table: foreign key already exists.", ['@table' => $table, '@name' => $name])); + } + + $fk = $this->createForeignKeySql($name, $fields); + if (!$fk) { + throw new SchemaException(t("Cannot add foreign key @name to table @table: invalid foreign key definition.", ['@table' => $table, '@name' => $name])); + } + + $this->connection->query('ALTER TABLE {' . $table . '} ADD ' . $fk); + } + + public function dropForeignKey($table, $name) { + if (!$this->fkExists($table, $name)) { + return FALSE; + } + + $this->connection->query('ALTER TABLE {' . $table . '} DROP FOREIGN KEY `' . $name . '`'); + return TRUE; + } + public function addUniqueKey($table, $name, $fields) { if (!$this->tableExists($table)) { throw new SchemaObjectDoesNotExistException(t("Cannot add unique key @name to table @table: table doesn't exist.", ['@table' => $table, '@name' => $name])); diff --git a/core/lib/Drupal/Core/Database/Driver/pgsql/Schema.php b/core/lib/Drupal/Core/Database/Driver/pgsql/Schema.php index 4586d01..fa44bea 100644 --- a/core/lib/Drupal/Core/Database/Driver/pgsql/Schema.php +++ b/core/lib/Drupal/Core/Database/Driver/pgsql/Schema.php @@ -253,6 +253,17 @@ protected function createTableSql($name, $table) { if (isset($table['primary key']) && is_array($table['primary key'])) { $sql_keys[] = 'CONSTRAINT ' . $this->ensureIdentifiersLength($name, '', 'pkey') . ' PRIMARY KEY (' . $this->createPrimaryKeySql($table['primary key']) . ')'; } + + if (isset($table['foreign keys']) && is_array($table['foreign keys'])) { + foreach ($table['foreign keys'] as $key_name => $key) { + if (!empty($key['on update']) && !empty($key['on delete'])) { + $fk = $this->createForeignKeySql($name, $key_name, $key); + if ($fk) { + $sql_keys[] = $fk; + } + } + } + } if (isset($table['unique keys']) && is_array($table['unique keys'])) { foreach ($table['unique keys'] as $key_name => $key) { $sql_keys[] = 'CONSTRAINT ' . $this->ensureIdentifiersLength($name, $key_name, 'key') . ' UNIQUE (' . implode(', ', $key) . ')'; @@ -443,6 +454,19 @@ protected function _createKeySql($fields) { } /** + * @return string|null + * The SQL definition of the foreign key or NULL if the + * definition is invalid. + */ + protected function createForeignKeySql($name, $key_name, $fields) { + $operations = ['no action', 'restrict', 'cascade', 'set null', 'set default']; + if (isset($fields['on update']) && isset($fields['on delete']) && in_array($fields['on update'], $operations) && in_array($fields['on delete'], $operations)) { + return 'CONSTRAINT "' . $this->ensureIdentifiersLength($name, $key_name, 'fkey') . '" FOREIGN KEY (' . implode(', ', array_keys($fields['columns'])) . ') REFERENCES {' . $fields['table'] . '} (' . implode(', ', $fields['columns']) . ') ON UPDATE ' . $fields['on update'] . ' ON DELETE ' . $fields['on delete']; + } + return NULL; + } + + /** * Create the SQL expression for primary keys. * * Postgresql does not support key length. It does support fillfactor, but @@ -530,7 +554,7 @@ public function dropTable($table) { return FALSE; } - $this->connection->query('DROP TABLE {' . $table . '}'); + $this->connection->query('DROP TABLE {' . $table . '} CASCADE'); $this->resetTableInformation($table); return TRUE; } @@ -673,6 +697,28 @@ public function dropPrimaryKey($table) { return TRUE; } + public function addForeignKey($table, $name, $fields) { + if (!$this->tableExists($table)) { + throw new SchemaObjectDoesNotExistException(t("Cannot add foreign key @name to table @table: table doesn't exist.", ['@table' => $table, '@name' => $name])); + } + if ($this->constraintExists($table, $name . '__fkey')) { + throw new SchemaObjectExistsException(t("Cannot add foreign key @name to table @table: foreign key already exists.", ['@table' => $table, '@name' => $name])); + } + $fk = $this->createForeignKeySql($table, $name, $fields); + if (!$fk) { + throw new SchemaException(t("Cannot add foreign key @name to table @table: invalid foreign key definition.", ['@table' => $table, '@name' => $name])); + } + $this->connection->query('ALTER TABLE {' . $table . '} ADD ' . $fk); + } + + public function dropForeignKey($table, $name) { + if (!$this->constraintExists($table, $name . '__fkey')) { + return FALSE; + } + $this->connection->query('ALTER TABLE {' . $table . '} DROP CONSTRAINT "' . $this->ensureIdentifiersLength($table, $name, 'fkey') . '"'); + return TRUE; + } + public function addUniqueKey($table, $name, $fields) { if (!$this->tableExists($table)) { throw new SchemaObjectDoesNotExistException(t("Cannot add unique key @name to table @table: table doesn't exist.", ['@table' => $table, '@name' => $name])); @@ -839,6 +885,13 @@ protected function _createKeys($table, $new_keys) { if (isset($new_keys['primary key'])) { $this->addPrimaryKey($table, $new_keys['primary key']); } + if (isset($new_keys['foreign keys'])) { + foreach ($new_keys['foreign keys'] as $name => $fields) { + if (!empty($fields['on update']) && !empty($fields['on delete'])) { + $this->addForeignKey($table, $name, $fields); + } + } + } if (isset($new_keys['unique keys'])) { foreach ($new_keys['unique keys'] as $name => $fields) { $this->addUniqueKey($table, $name, $fields); diff --git a/core/modules/simpletest/simpletest.module b/core/modules/simpletest/simpletest.module index 7d72aa4..811d412 100644 --- a/core/modules/simpletest/simpletest.module +++ b/core/modules/simpletest/simpletest.module @@ -663,6 +663,14 @@ function simpletest_clean_environment() { * Removes prefixed tables from the database from crashed tests. */ function simpletest_clean_database() { + // Use transactional DDL if available. + $txn = Database::getConnection()->startTransaction(); + // MySQL does not have a transactional DDL so we must disable foreign keys to + // delete tables that have fk relationships between them. + $is_mysql = Database::getConnection()->databaseType() === 'mysql'; + if ($is_mysql) { + Database::getConnection()->query('SET foreign_key_checks = 0'); + } $tables = db_find_tables('test%'); $count = 0; foreach ($tables as $table) { @@ -673,6 +681,10 @@ function simpletest_clean_database() { $count++; } } + if ($is_mysql) { + Database::getConnection()->query('SET foreign_key_checks = 1'); + } + unset($txn); if ($count > 0) { drupal_set_message(\Drupal::translation()->formatPlural($count, 'Removed 1 leftover table.', 'Removed @count leftover tables.')); diff --git a/core/tests/Drupal/KernelTests/Core/Database/SchemaTest.php b/core/tests/Drupal/KernelTests/Core/Database/SchemaTest.php index e239098..6dc3ff0 100644 --- a/core/tests/Drupal/KernelTests/Core/Database/SchemaTest.php +++ b/core/tests/Drupal/KernelTests/Core/Database/SchemaTest.php @@ -166,6 +166,18 @@ public function testSchema() { $this->assertPrimaryKeyColumns('test_table', ['test_serial', 'test_composite_primary_key']); // Test renaming of keys and constraints. + + // Table to be target of foreign key. + $table_specification = [ + 'fields' => [ + 'id' => [ + 'type' => 'serial', + ], + ], + 'primary key' => ['id'], + ]; + db_create_table('fk_test_table', $table_specification); + db_drop_table('test_table'); $table_specification = [ 'fields' => [ @@ -179,6 +191,20 @@ public function testSchema() { ], ], 'primary key' => ['id'], + 'foreign keys' => [ + 'test_fk_create' => [ + 'table' => 'fk_test_table', + 'columns' => ['test_field' => 'id'], + 'on update' => 'cascade', + 'on delete' => 'set null', + ], + // The following should not create anything in the database because + // 'on update' and 'on delete' are not set. + 'test_fk_no_create' => [ + 'table' => 'no_table', + 'columns' => ['test_field' => 'id'], + ], + ], 'unique keys' => [ 'test_field' => ['test_field'], ], @@ -207,6 +233,12 @@ public function testSchema() { $this->assertIdentical($primary_key_exists, TRUE, 'Primary key created.'); $this->assertIdentical($unique_key_exists, TRUE, 'Unique key created.'); + // Test for existing foreign keys. + $foreign_key_exists = $this->fkExists('test_table', 'test_fk_create'); + $this->assertIdentical(isset($foreign_key_exists) ? $foreign_key_exists : TRUE, TRUE, 'Foreign key test_fk_create created.'); + $foreign_key_exists = $this->fkExists('test_table', 'test_fk_no_create'); + $this->assertIdentical(isset($foreign_key_exists) ? $foreign_key_exists : FALSE, FALSE, 'Foreign key test_fk_no_create not created.'); + db_rename_table('test_table', 'test_table2'); // Test for renamed primary and unique keys. @@ -236,6 +268,16 @@ public function testSchema() { $this->assertEqual($sequence_name, current($info->sequences), 'Sequence was renamed.'); } + // Test creating foreign keys. + Database::getConnection()->schema()->addForeignKey('test_table2', 'test_fk_add_del', $table_specification['foreign keys']['test_fk_create']); + $foreign_key_exists = $this->fkExists('test_table2', 'test_fk_add_del'); + $this->assertIdentical(isset($foreign_key_exists) ? $foreign_key_exists : TRUE, TRUE, 'Foreign key was added.'); + + // Test deleting foreign keys. + Database::getConnection()->schema()->dropForeignKey('test_table2', 'test_fk_add_del'); + $foreign_key_exists = $this->fkExists('test_table2', 'test_fk_add_del'); + $this->assertIdentical(isset($foreign_key_exists) ? $foreign_key_exists : FALSE, FALSE, 'Foreign key was deleted.'); + // Use database specific data type and ensure that table is created. $table_specification = [ 'description' => 'Schema table description.', @@ -258,6 +300,30 @@ public function testSchema() { } /** + * Return whether a foreign key exists. + * + * @param string $table + * The name of the table. + * @psram string $name + * The name of the key. + * + * @return bool|null + * Whther or not the foreign key exists or NULL if the driver does not + * support foreign keys. + */ + private function fkExists($table, $name) { + switch (Database::getConnection()->databaseType()) { + case 'pgsql': + return Database::getConnection()->schema()->constraintExists($table, $name . '__fkey'); + case 'sqlite': + // SQLite does not create foreign keys. + return NULL; + default: + return Database::getConnection()->schema()->fkExists($table, $name); + } + } + + /** * Tests that indexes on string fields are limited to 191 characters on MySQL. * * @see \Drupal\Core\Database\Driver\mysql\Schema::getNormalizedIndexes() diff --git a/core/tests/Drupal/KernelTests/KernelTestBase.php b/core/tests/Drupal/KernelTests/KernelTestBase.php index 5621d4a..eb3691e 100644 --- a/core/tests/Drupal/KernelTests/KernelTestBase.php +++ b/core/tests/Drupal/KernelTests/KernelTestBase.php @@ -691,11 +691,24 @@ protected function tearDown() { $test_prefix = $test_connection_info['default']['prefix']['default']; if ($original_prefix != $test_prefix) { $tables = Database::getConnection()->schema()->findTables('%'); + // Use transactional DDL if available. + $txn = Database::getConnection()->startTransaction(); + // MySQL does not have a transactional DDL so we must + // disable foreign keys to delete tables that have + // fk relationships between them. + $is_mysql = Database::getConnection()->databaseType() === 'mysql'; + if ($is_mysql) { + Database::getConnection()->query('SET foreign_key_checks = 0'); + } foreach ($tables as $table) { if (Database::getConnection()->schema()->dropTable($table)) { unset($tables[$table]); } } + if ($is_mysql) { + Database::getConnection()->query('SET foreign_key_checks = 1'); + } + unset($txn); } // Free up memory: Own properties.