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 )