From b417c2ee8d918593992624592fa15cc356ebb2be Mon Sep 17 00:00:00 2001
From: x-rush <78290977+x-rush@users.noreply.github.com>
Date: Fri, 6 Nov 2026 02:55:06 +0800
Subject: [PATCH 1/2] Match MySQL foreign key and transaction semantics for DDL
statements
ALTER TABLE on a parent table fired the foreign key actions of referencing
tables while dropping the original one during the rebuild: with ON DELETE
CASCADE the child rows were deleted, with ON DELETE SET NULL they lost their
references, and with any other action the statement failed. The rebuild
disabled foreign keys as the SQLite procedure requires, but the pragma is a
no-op inside a transaction, and the statement always ran inside the wrapper
transaction.
Statements that recreate a table (ALTER TABLE, OPTIMIZE TABLE, REPAIR TABLE,
and DROP INDEX `PRIMARY`) now disable foreign keys outside the wrapper
transaction and enable them again after it ends. Like MySQL, DDL statements
now also implicitly commit an open user transaction, so DDL cannot be rolled
back together with the earlier statements of a transaction; CREATE TEMPORARY
TABLE and DROP TEMPORARY TABLE are the exceptions. Internal transactions,
such as the exclusive configuration transaction, are not affected.
The rebuild now fails when it introduces new foreign key violations, detected
by comparing PRAGMA foreign_key_check before and after the rebuild.
Pre-existing violations are tolerated, matching MySQL, which does not
re-validate existing rows when rebuilding a table.
Dropping or truncating a table that is referenced by a foreign key constraint
of another table is now rejected, like MySQL rejects it with errors 3730 and
1701, rather than silently deleting or modifying the rows of the referencing
tables.
Fixes #528.
---
.../src/sqlite/class-wp-mysql-on-sqlite.php | 253 ++++++++++++++++--
.../WP_MySQL_On_SQLite_Concurrency_Tests.php | 6 +-
.../WP_MySQL_On_SQLite_PDO_API_Tests.php | 27 +-
.../tests/WP_MySQL_On_SQLite_Query_Tests.php | 5 +-
.../tests/WP_MySQL_On_SQLite_Tests.php | 184 +++++++++++++
.../WP_MySQL_On_SQLite_Translation_Tests.php | 31 ++-
6 files changed, 467 insertions(+), 39 deletions(-)
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(
From dde8047f437e425a9a7cf7bed4e4e46edfb3f90c Mon Sep 17 00:00:00 2001
From: x-rush <78290977+x-rush@users.noreply.github.com>
Date: Fri, 6 Nov 2026 07:53:23 +0800
Subject: [PATCH 2/2] Tolerate implicit DDL commits in the SQLite install
routine
sqlite_make_db_sqlite() wraps the table creation queries in a transaction
and commits it. With DDL statements now implicitly committing the user
transaction, like in MySQL, the first CREATE TABLE ends the transaction and
the explicit commit failed with "There is no active transaction", breaking
the WordPress install.
Commit and roll back only while a transaction is still open.
---
.../wp-includes/sqlite/install-functions.php | 9 +++++++--
1 file changed, 7 insertions(+), 2 deletions(-)
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 )