diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php
index bef1bedeb..fd791509e 100644
--- a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php
+++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php
@@ -714,6 +714,17 @@ class WP_MySQL_On_SQLite extends PDO {
*/
private $in_transaction = false;
+ /**
+ * Whether the active transaction was started by the user, e.g., via the
+ * "START TRANSACTION" statement or the PDO "beginTransaction()" method.
+ *
+ * Internal transactions, such as the exclusive transaction used while
+ * configuring the database, are not user transactions.
+ *
+ * @var bool
+ */
+ private $user_transaction = false;
+
/**
* Names of active user savepoints, outermost first.
*
@@ -1172,14 +1183,52 @@ public function query( string $query, ?int $fetch_mode = null, ...$fetch_mode_ar
}
}
- if ( $wrap_in_transaction ) {
- $this->begin_wrapper_transaction();
+ /*
+ * MySQL implicitly commits an open transaction before DDL statements,
+ * so DDL cannot be rolled back together with the earlier statements
+ * of a transaction. CREATE TEMPORARY TABLE and DROP TEMPORARY TABLE
+ * are the exceptions. OPTIMIZE TABLE and REPAIR TABLE recreate the
+ * table and cannot be rolled back either, so they are treated as DDL.
+ *
+ * See: https://dev.mysql.com/doc/refman/8.4/en/implicit-commit.html
+ */
+ if (
+ null !== $statement_node
+ && $this->user_transaction
+ && $this->statement_causes_implicit_commit( $statement_node )
+ ) {
+ $this->commit_user_transaction();
}
- $this->execute_mysql_query( $ast );
+ /*
+ * A statement that recreates a table must not fire the foreign key
+ * actions of referencing tables when dropping the original table.
+ * Foreign keys can only be disabled outside a transaction, so they
+ * are disabled here, before the statement's wrapper transaction
+ * begins, and enabled again below, after it has ended.
+ *
+ * See: https://www.sqlite.org/lang_altertable.html#otheralter
+ */
+ $disables_foreign_keys = null !== $statement_node
+ && $this->statement_disables_foreign_keys( $statement_node );
+ if ( $disables_foreign_keys ) {
+ $this->execute_sqlite_query( 'PRAGMA foreign_keys = OFF' );
+ }
+
+ try {
+ if ( $wrap_in_transaction ) {
+ $this->begin_wrapper_transaction();
+ }
- if ( $wrap_in_transaction ) {
- $this->commit_wrapper_transaction();
+ $this->execute_mysql_query( $ast );
+
+ if ( $wrap_in_transaction ) {
+ $this->commit_wrapper_transaction();
+ }
+ } finally {
+ if ( $disables_foreign_keys ) {
+ $this->execute_sqlite_query( 'PRAGMA foreign_keys = ON' );
+ }
}
if ( null === $this->last_result_statement ) {
@@ -1405,8 +1454,9 @@ public function commit(): bool {
// Unlike a COMMIT statement, PDO::commit() doesn't roll back on failure,
// so the caller can still call PDO::rollBack(), as with PDO MySQL.
$this->connection->query( 'COMMIT' );
- $this->in_transaction = false;
- $this->savepoint_names = array();
+ $this->in_transaction = false;
+ $this->savepoint_names = array();
+ $this->user_transaction = false;
return true;
}
@@ -2152,8 +2202,9 @@ private function begin_user_transaction(): void {
* @see self::begin_wrapper_transaction()
*/
$this->connection->query( 'BEGIN IMMEDIATE' );
- $this->in_transaction = true;
- $this->savepoint_names = array();
+ $this->in_transaction = true;
+ $this->savepoint_names = array();
+ $this->user_transaction = true;
}
/**
@@ -2162,6 +2213,7 @@ private function begin_user_transaction(): void {
private function commit_user_transaction(): void {
// MySQL doesn't throw an error if there is no active transaction.
if ( ! $this->inTransaction() ) {
+ $this->user_transaction = false;
return;
}
try {
@@ -2175,8 +2227,9 @@ private function commit_user_transaction(): void {
}
throw $e;
}
- $this->in_transaction = false;
- $this->savepoint_names = array();
+ $this->in_transaction = false;
+ $this->savepoint_names = array();
+ $this->user_transaction = false;
}
/**
@@ -2190,8 +2243,9 @@ private function rollback_user_transaction(): void {
}
} finally {
// A ROLLBACK always ends the transaction, even when it fails.
- $this->in_transaction = false;
- $this->savepoint_names = array();
+ $this->in_transaction = false;
+ $this->savepoint_names = array();
+ $this->user_transaction = false;
}
}
@@ -3143,6 +3197,16 @@ private function execute_drop_table_statement( WP_Parser_Node $node ): void {
throw $this->new_access_denied_to_information_schema_exception();
}
+ // MySQL rejects dropping a table that is referenced by a foreign key
+ // constraint of another table. Otherwise, dropping the parent would
+ // silently delete or modify the rows of the referencing tables.
+ if ( ! $table_is_temporary ) {
+ $this->assert_table_not_referenced(
+ $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ),
+ 'drop'
+ );
+ }
+
$parts = array();
foreach ( $child_node->get_children() as $child ) {
$is_token = $child instanceof WP_MySQL_Token;
@@ -3187,6 +3251,12 @@ private function execute_truncate_table_statement( WP_Parser_Node $node ): void
throw $this->new_access_denied_to_information_schema_exception();
}
+ // Like MySQL, truncating a table that is referenced by a foreign key
+ // constraint of another table is rejected.
+ if ( ! $this->information_schema_builder->temporary_table_exists( $table_name ) ) {
+ $this->assert_table_not_referenced( $table_name, 'truncate' );
+ }
+
$this->execute_sqlite_query(
sprintf( 'DELETE FROM %s', $this->quote_sqlite_identifier( $table_name ) )
);
@@ -5772,6 +5842,132 @@ private function translate_table_ref( WP_Parser_Node $node ): string {
return $this->translate_sequence( $node->get_children() );
}
+ /**
+ * Whether the statement causes an implicit commit, like in MySQL.
+ *
+ * MySQL ends any open transaction before DDL statements, so DDL cannot be
+ * rolled back together with the earlier statements of a transaction. The
+ * TEMPORARY keyword is the exception: CREATE TEMPORARY TABLE and DROP
+ * TEMPORARY TABLE do not cause an implicit commit. OPTIMIZE TABLE and
+ * REPAIR TABLE recreate the table and cannot be rolled back either, so
+ * they are treated as DDL here as well.
+ *
+ * See: https://dev.mysql.com/doc/refman/8.4/en/implicit-commit.html
+ *
+ * @param WP_Parser_Node $statement_node The concrete statement AST node.
+ * @return bool Whether the statement causes an implicit commit.
+ */
+ private function statement_causes_implicit_commit( WP_Parser_Node $statement_node ): bool {
+ if ( 'tableAdministrationStatement' === $statement_node->rule_name ) {
+ $first_token = $statement_node->get_first_child_token();
+ return WP_MySQL_Lexer::OPTIMIZE_SYMBOL === $first_token->id
+ || WP_MySQL_Lexer::REPAIR_SYMBOL === $first_token->id;
+ }
+
+ if (
+ ! in_array(
+ $statement_node->rule_name,
+ array( 'createStatement', 'alterStatement', 'dropStatement', 'truncateTableStatement' ),
+ true
+ )
+ ) {
+ return false;
+ }
+
+ // CREATE TEMPORARY TABLE and DROP TEMPORARY TABLE do not commit.
+ $subtree = $statement_node->get_first_child_node();
+ return ! (
+ null !== $subtree
+ && in_array( $subtree->rule_name, array( 'createTable', 'dropTable' ), true )
+ && $subtree->has_child_token( WP_MySQL_Lexer::TEMPORARY_SYMBOL )
+ );
+ }
+
+ /**
+ * Whether the statement recreates a table and therefore must run with
+ * foreign keys disabled, so that dropping the original table does not
+ * fire the foreign key actions of the referencing tables.
+ *
+ * @param WP_Parser_Node $statement_node The concrete statement AST node.
+ * @return bool Whether the statement must run with foreign keys disabled.
+ */
+ private function statement_disables_foreign_keys( WP_Parser_Node $statement_node ): bool {
+ if ( 'alterStatement' === $statement_node->rule_name ) {
+ return true;
+ }
+
+ if ( 'tableAdministrationStatement' === $statement_node->rule_name ) {
+ $first_token = $statement_node->get_first_child_token();
+ return WP_MySQL_Lexer::OPTIMIZE_SYMBOL === $first_token->id
+ || WP_MySQL_Lexer::REPAIR_SYMBOL === $first_token->id;
+ }
+
+ // "DROP INDEX `PRIMARY` ON
" recreates the table without the
+ // primary key, as MySQL allows, while SQLite does not support it.
+ if ( 'dropStatement' === $statement_node->rule_name ) {
+ $subtree = $statement_node->get_first_child_node();
+ $index_ref = null !== $subtree && 'dropIndex' === $subtree->rule_name
+ ? $subtree->get_first_child_node( 'indexRef' )
+ : null;
+ if ( null === $index_ref ) {
+ return false;
+ }
+ $index_name = $this->unquote_sqlite_identifier( $this->translate( $index_ref ) );
+ return 'PRIMARY' === strtoupper( $index_name );
+ }
+
+ return false;
+ }
+
+ /**
+ * Reject dropping or truncating a table that is referenced by a foreign key
+ * constraint of another table, like MySQL does.
+ *
+ * MySQL fails "DROP TABLE" with error 3730 and "TRUNCATE TABLE" with error
+ * 1701 in this case, rather than silently deleting or modifying the rows of
+ * the referencing tables.
+ *
+ * @param string $table_name The unquoted name of the referenced table.
+ * @param string $statement The statement name, "drop" or "truncate".
+ * @throws WP_MySQL_On_SQLite_Exception When the table is referenced.
+ */
+ private function assert_table_not_referenced( string $table_name, string $statement ): void {
+ $tables = $this->execute_sqlite_query(
+ "SELECT name FROM main.sqlite_master WHERE type = 'table'"
+ )->fetchAll( PDO::FETCH_COLUMN );
+ foreach ( $tables as $child_table ) {
+ if ( 0 === strcasecmp( (string) $child_table, $table_name ) ) {
+ // A self-referencing foreign key does not block the statement.
+ continue;
+ }
+ $foreign_keys = $this->execute_sqlite_query(
+ sprintf(
+ 'PRAGMA foreign_key_list(%s)',
+ $this->quote_sqlite_identifier( (string) $child_table )
+ )
+ )->fetchAll( PDO::FETCH_ASSOC );
+ foreach ( $foreign_keys as $foreign_key ) {
+ if ( 0 === strcasecmp( (string) $foreign_key['table'], $table_name ) ) {
+ if ( 'truncate' === $statement ) {
+ throw $this->new_driver_exception(
+ sprintf(
+ 'Cannot truncate a table referenced in a foreign key constraint (`%s`)',
+ $child_table
+ )
+ );
+ }
+ throw $this->new_driver_exception(
+ sprintf(
+ "Cannot drop table '%s' referenced by a foreign key constraint on table '%s'.",
+ $table_name,
+ $child_table
+ )
+ );
+ }
+ }
+ }
+ }
+
/**
* Recreate an existing table using data in the information schema.
*
@@ -5830,9 +6026,9 @@ private function recreate_table_from_information_schema(
* https://www.sqlite.org/lang_altertable.html#making_other_kinds_of_table_schema_changes
*/
- // 1. If foreign key constraints are enabled, disable them.
- $pragma_foreign_keys = $this->execute_sqlite_query( 'PRAGMA foreign_keys' )->fetchColumn();
- $this->execute_sqlite_query( 'PRAGMA foreign_keys = OFF' );
+ // 1. Snapshot the foreign key violations that exist before the rebuild,
+ // so that new ones introduced by the rebuild can be detected below.
+ $foreign_key_violations_before = $this->execute_sqlite_query( 'PRAGMA foreign_key_check' )->fetchAll( PDO::FETCH_ASSOC );
// 2. Create a new table with the new schema.
$tmp_table_name = self::RESERVED_PREFIX . "tmp_{$table_name}_" . uniqid();
@@ -5895,10 +6091,27 @@ private function recreate_table_from_information_schema(
$this->execute_sqlite_query( $query );
}
- // 7. If foreign key constraints were enabled, verify and enable them.
- if ( 1 === (int) $pragma_foreign_keys ) {
- $this->execute_sqlite_query( 'PRAGMA foreign_key_check' );
- $this->execute_sqlite_query( 'PRAGMA foreign_keys = ON' );
+ // 7. Fail if the rebuild introduced new foreign key violations. The
+ // check runs inside the statement's wrapper transaction, so failing
+ // rolls the rebuild back. Pre-existing violations are tolerated,
+ // matching MySQL, which does not re-validate existing rows. Enabling
+ // foreign keys again is the caller's job, as the pragma is a no-op
+ // inside a transaction.
+ $foreign_key_violations = $this->execute_sqlite_query( 'PRAGMA foreign_key_check' )->fetchAll( PDO::FETCH_ASSOC );
+ $violation_keys_before = array();
+ foreach ( $foreign_key_violations_before as $violation ) {
+ $violation_keys_before[] = implode( "\x1f", $violation );
+ }
+ foreach ( $foreign_key_violations as $violation ) {
+ if ( ! in_array( implode( "\x1f", $violation ), $violation_keys_before, true ) ) {
+ throw $this->new_driver_exception(
+ sprintf(
+ 'Recreating the table introduced a foreign key constraint violation in table "%s", row "%s".',
+ $violation['table'],
+ $violation['rowid']
+ )
+ );
+ }
}
// @TODO: Triggers and views.
diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php
index 1b8d9137c..a47cdba3f 100644
--- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php
+++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Concurrency_Tests.php
@@ -69,7 +69,11 @@ public function testWriteQueryOpensWriteTransaction( string $query ): void {
$driver->query( $query );
- $this->assertSame( 'BEGIN IMMEDIATE', $driver->get_last_sqlite_queries()[0]['sql'] );
+ // Statements that recreate a table disable foreign keys before opening
+ // the write transaction.
+ $queries = array_column( $driver->get_last_sqlite_queries(), 'sql' );
+ $begin_index = 'PRAGMA foreign_keys = OFF' === $queries[0] ? 1 : 0;
+ $this->assertSame( 'BEGIN IMMEDIATE', $queries[ $begin_index ] );
}
public function testWriteQueryAfterSavepointOpensWriteTransaction(): void {
diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php
index 388d7d291..1db285ecf 100644
--- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php
+++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php
@@ -1027,21 +1027,40 @@ public function test_rollback_statement_as_first_transaction_on_connection(): vo
$this->driver->exec( 'ROLLBACK' );
$this->assertFalse( $this->driver->inTransaction() );
- // Statements with empty result sets keep working after the rollback.
+ // A DDL statement implicitly commits the transaction, like in MySQL,
+ // so a following ROLLBACK has nothing left to roll back.
$this->driver->exec( 'START TRANSACTION' );
$this->driver->exec( 'CREATE TABLE t (id INT)' );
+ $this->assertFalse( $this->driver->inTransaction() );
$this->driver->exec( 'ROLLBACK' );
- $this->assertSame( 0, $this->driver->exec( 'CREATE TABLE t (id INT)' ) );
+
+ // The DDL statement was committed.
+ $this->driver->query( 'SELECT COUNT(*) FROM t' );
+
+ // Statements with empty result sets keep working after the rollback.
+ $this->assertSame( 0, $this->driver->exec( 'CREATE TABLE u (id INT)' ) );
}
public function test_rollback_method_as_first_transaction_on_connection(): void {
// Roll back the first transaction on the connection.
$this->driver->beginTransaction();
- $this->driver->exec( 'CREATE TABLE t (id INT)' );
$this->assertTrue( $this->driver->rollBack() );
+ $this->assertFalse( $this->driver->inTransaction() );
+
+ // A DDL statement implicitly commits the transaction, like in MySQL,
+ // so there is no active transaction left to roll back.
+ $this->driver->beginTransaction();
+ $this->driver->exec( 'CREATE TABLE t (id INT)' );
+ $this->assertFalse( $this->driver->inTransaction() );
+ try {
+ $this->driver->rollBack();
+ $this->fail( 'The rollback was expected to fail.' );
+ } catch ( WP_MySQL_On_SQLite_Exception $e ) {
+ $this->assertSame( 'There is no active transaction', $e->getMessage() );
+ }
// Statements with empty result sets keep working after the rollback.
- $this->assertSame( 0, $this->driver->exec( 'CREATE TABLE t (id INT)' ) );
+ $this->assertSame( 0, $this->driver->exec( 'CREATE TABLE u (id INT)' ) );
}
public function test_failed_query_keeps_transaction_active(): void {
diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Query_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Query_Tests.php
index 15e4fb447..d86a4368f 100644
--- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Query_Tests.php
+++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Query_Tests.php
@@ -126,7 +126,10 @@ public function setUp(): void {
}
$this->engine->query( $query );
}
- $this->engine->commit();
+ // A DDL statement implicitly commits the transaction, like in MySQL.
+ if ( $this->engine->inTransaction() ) {
+ $this->engine->commit();
+ }
} catch ( Throwable $e ) {
$message = sprintf(
'Error occurred while creating tables or indexes...
Query was: %s
',
diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php
index 4f87a3a6f..b9a3fe45a 100644
--- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php
+++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php
@@ -8110,6 +8110,190 @@ public function testConfigurationNormalizesStoredTemporalZeroDefaultsOfReference
$this->assertEquals( array( (object) array( 'id' => '1' ) ), $this->last_result );
}
+ public function testAlterTableWithCascadingForeignKeyPreservesChildRows(): void {
+ $this->assertQuery( 'CREATE TABLE parent (id INT PRIMARY KEY)' );
+ $this->assertQuery( 'CREATE TABLE child (id INT, pid INT, FOREIGN KEY (pid) REFERENCES parent (id) ON DELETE CASCADE)' );
+ $this->assertQuery( 'INSERT INTO parent (id) VALUES (1), (2)' );
+ $this->assertQuery( 'INSERT INTO child (id, pid) VALUES (10, 1), (20, 2)' );
+
+ $this->assertQuery( 'ALTER TABLE parent ADD COLUMN name TEXT' );
+
+ // Recreating the parent table must not trigger the foreign key actions.
+ $this->assertQuery( 'SELECT id, pid FROM child ORDER BY id' );
+ $this->assertEquals(
+ array(
+ (object) array(
+ 'id' => '10',
+ 'pid' => '1',
+ ),
+ (object) array(
+ 'id' => '20',
+ 'pid' => '2',
+ ),
+ ),
+ $this->last_result
+ );
+
+ // Foreign keys are enforced again after the rebuild.
+ $this->assertQuery( 'CREATE TABLE no_action (id INT, pid INT, FOREIGN KEY (pid) REFERENCES parent (id))' );
+ $this->assertQuery( 'INSERT INTO no_action (id, pid) VALUES (1, 1)' );
+ $this->assertQueryError(
+ 'DELETE FROM parent WHERE id = 1',
+ 'SQLSTATE[23000]: Integrity constraint violation: 19 FOREIGN KEY constraint failed'
+ );
+ }
+
+ public function testAlterTableWithPopulatedChildAndNoActionForeignKeySucceeds(): void {
+ $this->assertQuery( 'CREATE TABLE parent (id INT PRIMARY KEY)' );
+ $this->assertQuery( 'CREATE TABLE child (id INT, pid INT, FOREIGN KEY (pid) REFERENCES parent (id))' );
+ $this->assertQuery( 'INSERT INTO parent (id) VALUES (1), (2)' );
+ $this->assertQuery( 'INSERT INTO child (id, pid) VALUES (10, 1)' );
+
+ $this->assertQuery( 'ALTER TABLE parent ADD COLUMN name TEXT' );
+
+ $this->assertQuery( 'SELECT COUNT(*) AS count FROM child' );
+ $this->assertSame( '1', $this->last_result[0]->count );
+ }
+
+ public function testAlterTablePreservesChildRowsWithSetNullForeignKey(): void {
+ $this->assertQuery( 'CREATE TABLE parent (id INT PRIMARY KEY)' );
+ $this->assertQuery( 'CREATE TABLE child (id INT, pid INT, FOREIGN KEY (pid) REFERENCES parent (id) ON DELETE SET NULL)' );
+ $this->assertQuery( 'INSERT INTO parent (id) VALUES (1), (2)' );
+ $this->assertQuery( 'INSERT INTO child (id, pid) VALUES (10, 1), (20, 2)' );
+
+ $this->assertQuery( 'ALTER TABLE parent ADD COLUMN name TEXT' );
+
+ $this->assertQuery( 'SELECT id, pid FROM child ORDER BY id' );
+ $this->assertEquals(
+ array(
+ (object) array(
+ 'id' => '10',
+ 'pid' => '1',
+ ),
+ (object) array(
+ 'id' => '20',
+ 'pid' => '2',
+ ),
+ ),
+ $this->last_result
+ );
+ }
+
+ public function testAlterTableToleratesPreexistingForeignKeyViolations(): void {
+ $this->assertQuery( 'CREATE TABLE parent (id INT PRIMARY KEY)' );
+ $this->assertQuery( 'CREATE TABLE child (id INT, pid INT, FOREIGN KEY (pid) REFERENCES parent (id))' );
+ $this->assertQuery( 'INSERT INTO parent (id) VALUES (1)' );
+ $this->assertQuery( 'INSERT INTO child (id, pid) VALUES (10, 1)' );
+
+ // Insert an orphan row with foreign keys disabled, as an older driver
+ // version that didn't enforce foreign keys may have left behind.
+ $this->sqlite->exec( 'PRAGMA foreign_keys = OFF' );
+ $this->sqlite->exec( 'INSERT INTO child (id, pid) VALUES (20, 99)' );
+ $this->sqlite->exec( 'PRAGMA foreign_keys = ON' );
+
+ // The pre-existing violation is tolerated, matching MySQL, which does
+ // not re-validate existing rows when rebuilding a table.
+ $this->assertQuery( 'ALTER TABLE parent ADD COLUMN name TEXT' );
+
+ $this->assertQuery( 'SELECT COUNT(*) AS count FROM child' );
+ $this->assertSame( '2', $this->last_result[0]->count );
+ }
+
+ public function testDdlStatementImplicitlyCommitsTransaction(): void {
+ $this->assertQuery( 'CREATE TABLE t (id INT)' );
+ $this->assertQuery( 'INSERT INTO t (id) VALUES (1)' );
+
+ $this->assertQuery( 'START TRANSACTION' );
+ $this->assertQuery( 'INSERT INTO t (id) VALUES (2)' );
+ $this->assertQuery( 'CREATE TABLE u (id INT)' );
+
+ // Like in MySQL, the DDL statement committed the transaction.
+ $this->assertFalse( $this->engine->inTransaction() );
+
+ $this->assertQuery( 'ROLLBACK' );
+
+ $this->assertQuery( 'SELECT COUNT(*) AS count FROM t' );
+ $this->assertSame( '2', $this->last_result[0]->count );
+ $this->assertQuery( 'SELECT COUNT(*) AS count FROM u' );
+ $this->assertSame( '0', $this->last_result[0]->count );
+ }
+
+ public function testCreateTemporaryTableDoesNotCommitTransaction(): void {
+ $this->assertQuery( 'CREATE TABLE t (id INT)' );
+
+ $this->assertQuery( 'START TRANSACTION' );
+ $this->assertQuery( 'INSERT INTO t (id) VALUES (1)' );
+ $this->assertQuery( 'CREATE TEMPORARY TABLE tmp_t (id INT)' );
+ $this->assertQuery( 'DROP TEMPORARY TABLE tmp_t' );
+
+ // The transaction is still open, like in MySQL.
+ $this->assertTrue( $this->engine->inTransaction() );
+
+ $this->assertQuery( 'ROLLBACK' );
+
+ $this->assertQuery( 'SELECT COUNT(*) AS count FROM t' );
+ $this->assertSame( '0', $this->last_result[0]->count );
+ }
+
+ public function testDropTableReferencedByForeignKeyIsRejected(): void {
+ $this->assertQuery( 'CREATE TABLE parent (id INT PRIMARY KEY)' );
+ $this->assertQuery( 'CREATE TABLE child (id INT, pid INT, FOREIGN KEY (pid) REFERENCES parent (id) ON DELETE CASCADE)' );
+ $this->assertQuery( 'INSERT INTO parent (id) VALUES (1), (2)' );
+ $this->assertQuery( 'INSERT INTO child (id, pid) VALUES (10, 1), (20, 2)' );
+
+ $this->assertQueryError(
+ 'DROP TABLE parent',
+ "Cannot drop table 'parent' referenced by a foreign key constraint on table 'child'."
+ );
+
+ // Dropping the child in the same statement does not help either, as in MySQL.
+ $this->assertQueryError(
+ 'DROP TABLE child, parent',
+ "Cannot drop table 'parent' referenced by a foreign key constraint on table 'child'."
+ );
+
+ // Nothing was dropped.
+ $this->assertQuery( 'SELECT COUNT(*) AS count FROM parent' );
+ $this->assertSame( '2', $this->last_result[0]->count );
+ $this->assertQuery( 'SELECT COUNT(*) AS count FROM child' );
+ $this->assertSame( '2', $this->last_result[0]->count );
+
+ // Dropping the referencing table first makes the parent droppable.
+ $this->assertQuery( 'DROP TABLE child' );
+ $this->assertQuery( 'DROP TABLE parent' );
+ }
+
+ public function testTruncateTableReferencedByForeignKeyIsRejected(): void {
+ $this->assertQuery( 'CREATE TABLE parent (id INT PRIMARY KEY)' );
+ $this->assertQuery( 'CREATE TABLE child (id INT, pid INT, FOREIGN KEY (pid) REFERENCES parent (id))' );
+ $this->assertQuery( 'INSERT INTO parent (id) VALUES (1), (2)' );
+ $this->assertQuery( 'INSERT INTO child (id, pid) VALUES (10, 1), (20, 2)' );
+
+ $this->assertQueryError(
+ 'TRUNCATE TABLE parent',
+ 'Cannot truncate a table referenced in a foreign key constraint (`child`)'
+ );
+
+ // Nothing was truncated.
+ $this->assertQuery( 'SELECT COUNT(*) AS count FROM parent' );
+ $this->assertSame( '2', $this->last_result[0]->count );
+ $this->assertQuery( 'SELECT COUNT(*) AS count FROM child' );
+ $this->assertSame( '2', $this->last_result[0]->count );
+
+ $this->assertQuery( 'TRUNCATE TABLE child' );
+
+ // An empty referencing table still blocks truncating the parent, as
+ // MySQL rejects the statement because of the constraint itself.
+ $this->assertQueryError(
+ 'TRUNCATE TABLE parent',
+ 'Cannot truncate a table referenced in a foreign key constraint (`child`)'
+ );
+
+ // Dropping the referencing table makes the parent truncatable.
+ $this->assertQuery( 'DROP TABLE child' );
+ $this->assertQuery( 'TRUNCATE TABLE parent' );
+ }
+
public function testConfigurationNormalizesTemporalZeroDefaultsOfReconstructedTable(): void {
// A table created by the legacy SQLite driver, with no information schema data.
$this->sqlite->exec(
diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php
index f6c253d2d..889be3aae 100644
--- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php
+++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php
@@ -907,8 +907,8 @@ public function testAlterTableAddColumn( bool $stringify_fetches ): void {
$this->driver->query( 'CREATE TABLE t (id INT)' );
$this->assertQuery(
array(
- 'PRAGMA foreign_keys',
'PRAGMA foreign_keys = OFF',
+ 'PRAGMA foreign_key_check',
'CREATE TABLE `` ( `id` INTEGER, `a` INTEGER )' . $this->strict_suffix,
'INSERT INTO `` (`rowid`, `id`) SELECT `rowid`, `id` FROM `t`',
"SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'",
@@ -947,8 +947,8 @@ public function testAlterTableAddColumnWithNotNull(): void {
$this->driver->query( 'CREATE TABLE t (id INT)' );
$this->assertQuery(
array(
- 'PRAGMA foreign_keys',
'PRAGMA foreign_keys = OFF',
+ 'PRAGMA foreign_key_check',
'CREATE TABLE `` ( `id` INTEGER, `a` INTEGER NOT NULL )' . $this->strict_suffix,
'INSERT INTO `` (`rowid`, `id`) SELECT `rowid`, `id` FROM `t`',
"SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'",
@@ -980,8 +980,8 @@ public function testAlterTableAddColumnWithDefault(): void {
$this->driver->query( 'CREATE TABLE t (id INT)' );
$this->assertQuery(
array(
- 'PRAGMA foreign_keys',
'PRAGMA foreign_keys = OFF',
+ 'PRAGMA foreign_key_check',
'CREATE TABLE `` ( `id` INTEGER, `a` INTEGER DEFAULT \'0\' )' . $this->strict_suffix,
'INSERT INTO `` (`rowid`, `id`) SELECT `rowid`, `id` FROM `t`',
"SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'",
@@ -1013,8 +1013,8 @@ public function testAlterTableAddColumnWithNotNullAndDefault(): void {
$this->driver->query( 'CREATE TABLE t (id INT)' );
$this->assertQuery(
array(
- 'PRAGMA foreign_keys',
'PRAGMA foreign_keys = OFF',
+ 'PRAGMA foreign_key_check',
'CREATE TABLE `` ( `id` INTEGER, `a` INTEGER NOT NULL DEFAULT \'0\' )' . $this->strict_suffix,
'INSERT INTO `` (`rowid`, `id`) SELECT `rowid`, `id` FROM `t`',
"SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'",
@@ -1046,8 +1046,8 @@ public function testAlterTableAddMultipleColumns(): void {
$this->driver->query( 'CREATE TABLE t (id INT)' );
$this->assertQuery(
array(
- 'PRAGMA foreign_keys',
'PRAGMA foreign_keys = OFF',
+ 'PRAGMA foreign_key_check',
'CREATE TABLE `` ( `id` INTEGER, `a` INTEGER, `b` TEXT COLLATE NOCASE, `c` INTEGER )' . $this->strict_suffix,
'INSERT INTO `` (`rowid`, `id`) SELECT `rowid`, `id` FROM `t`',
"SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'",
@@ -1085,8 +1085,8 @@ public function testAlterTableDropColumn(): void {
$this->driver->query( 'CREATE TABLE t (id INT, a TEXT)' );
$this->assertQuery(
array(
- 'PRAGMA foreign_keys',
'PRAGMA foreign_keys = OFF',
+ 'PRAGMA foreign_key_check',
'CREATE TABLE `` ( `id` INTEGER )' . $this->strict_suffix,
'INSERT INTO `` (`rowid`, `id`) SELECT `rowid`, `id` FROM `t`',
"SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'",
@@ -1119,8 +1119,8 @@ public function testAlterTableDropMultipleColumns(): void {
$this->driver->query( 'CREATE TABLE t (id INT, a INT, b TEXT)' );
$this->assertQuery(
array(
- 'PRAGMA foreign_keys',
'PRAGMA foreign_keys = OFF',
+ 'PRAGMA foreign_key_check',
'CREATE TABLE `` ( `id` INTEGER )' . $this->strict_suffix,
'INSERT INTO `` (`rowid`, `id`) SELECT `rowid`, `id` FROM `t`',
"SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'",
@@ -1159,8 +1159,8 @@ public function testAlterTableAddAndDropColumns(): void {
$this->driver->query( 'CREATE TABLE t (a INT)' );
$this->assertQuery(
array(
- 'PRAGMA foreign_keys',
'PRAGMA foreign_keys = OFF',
+ 'PRAGMA foreign_key_check',
'CREATE TABLE `` ( `b` INTEGER )' . $this->strict_suffix,
'INSERT INTO `` (`rowid`) SELECT `rowid` FROM `t`',
"SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'",
@@ -1197,8 +1197,8 @@ public function testAlterTableDropAndAddSingleColumn(): void {
$this->driver->query( 'CREATE TABLE t (a INT)' );
$this->assertQuery(
array(
- 'PRAGMA foreign_keys',
'PRAGMA foreign_keys = OFF',
+ 'PRAGMA foreign_key_check',
'CREATE TABLE `` ( `a` INTEGER )' . $this->strict_suffix,
'INSERT INTO `` (`rowid`) SELECT `rowid` FROM `t`',
"SELECT 1 FROM main.sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'",
@@ -2244,10 +2244,15 @@ private function assertQuery( $expected, string $query ): void {
$executed_queries = array_column( $this->driver->get_last_sqlite_queries(), 'sql' );
- // Remove BEGIN and COMMIT/ROLLBACK queries.
- if ( count( $executed_queries ) > 2 ) {
- $executed_queries = array_values( array_slice( $executed_queries, 1, -1, true ) );
- }
+ // Remove wrapper transaction queries.
+ $executed_queries = array_values(
+ array_filter(
+ $executed_queries,
+ function ( $query ) {
+ return ! in_array( $query, array( 'BEGIN', 'BEGIN IMMEDIATE', 'COMMIT', 'ROLLBACK' ), true );
+ }
+ )
+ );
// Remove temporary table existence checks.
$executed_queries = array_values(
diff --git a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php
index 59a9c9acd..49f407ae2 100644
--- a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php
+++ b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/install-functions.php
@@ -53,9 +53,14 @@ function sqlite_make_db_sqlite() {
$translator->query( $query );
}
- $translator->commit();
+ // DDL statements implicitly commit the transaction, like in MySQL.
+ if ( $translator->inTransaction() ) {
+ $translator->commit();
+ }
} catch ( PDOException $err ) {
- $translator->rollBack();
+ if ( $translator->inTransaction() ) {
+ $translator->rollBack();
+ }
$message = sprintf(
'Error occurred while creating tables or indexes...
Query was: %s
',
var_export( $query, true )