From f0d82f2cd3104677e7d0e40672bc62e6e66b7458 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Fri, 23 Oct 2026 20:30:39 +0200 Subject: [PATCH] Roll back failed table rebuilds Wrap each table administration operation in a savepoint before converting errors into result rows. Preserve successful table rebuilds and surrounding user transactions when another table fails. Fixes https://github.com/WordPress/sqlite-database-integration/issues/549 --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 6 + .../WP_MySQL_On_SQLite_Metadata_Tests.php | 151 +++++++++++++++++- 2 files changed, 151 insertions(+), 6 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 eee3f050b..57d710f76 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 @@ -4107,6 +4107,7 @@ private function execute_set_user_variable_statement( private function execute_administration_statement( WP_Parser_Node $node ): void { $first_token = $node->get_first_child_token(); $table_ref_list = $node->get_first_child_node( 'tableRefList' ); + $savepoint_name = $this->get_internal_savepoint_name( 'table_administration' ); $results = array(); foreach ( $table_ref_list->get_child_nodes( 'tableRef' ) as $table_ref ) { $database = $this->get_database_name( $table_ref ); @@ -4116,6 +4117,9 @@ private function execute_administration_statement( WP_Parser_Node $node ): void $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); $quoted_table_name = $this->quote_sqlite_identifier( $table_name ); + + // Each table must be atomic because failures are reported as result rows. + $this->execute_sqlite_query( sprintf( 'SAVEPOINT %s', $savepoint_name ) ); try { switch ( $first_token->id ) { case WP_MySQL_Lexer::ANALYZE_SYMBOL: @@ -4153,6 +4157,7 @@ private function execute_administration_statement( WP_Parser_Node $node ): void ); } } catch ( PDOException $e ) { + $this->execute_sqlite_query( sprintf( 'ROLLBACK TO SAVEPOINT %s', $savepoint_name ) ); if ( '42S02' === $e->getCode() && isset( $e->errorInfo[2] ) ) { $errors = array( $e->errorInfo[2] ); } elseif ( @@ -4165,6 +4170,7 @@ private function execute_administration_statement( WP_Parser_Node $node ): void $errors = array( $e->getMessage() ); } } + $this->execute_sqlite_query( sprintf( 'RELEASE SAVEPOINT %s', $savepoint_name ) ); $operation = strtolower( $first_token->get_value() ); foreach ( $errors as $error ) { diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Metadata_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Metadata_Tests.php index 1366cb840..db7f71d0f 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Metadata_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Metadata_Tests.php @@ -474,31 +474,40 @@ public function testOptimizeTable() { ); } - public function testOptimizeTableWithFailedRecreation() { - $this->assertQuery( 'CREATE TABLE t (id INT, name TEXT)' ); + /** + * @dataProvider data_rebuild_operations_and_table_types + */ + public function testAdministrationWithFailedRecreation( string $operation, bool $temporary ): void { + $this->assertQuery( 'CREATE ' . ( $temporary ? 'TEMPORARY ' : '' ) . 'TABLE t (id INT, name TEXT)' ); + $this->assertQuery( "INSERT INTO t VALUES (1, 'a'), (2, 'b')" ); // Make recreating the table fail, as its information schema names a column that doesn't exist. - $this->sqlite->exec( "UPDATE _wp_sqlite_mysql_information_schema_columns SET column_name = 'title' WHERE table_name = 't' AND column_name = 'name'" ); + $columns_table = $temporary ? '_wp_sqlite_mysql_information_schema_tmp_columns' : '_wp_sqlite_mysql_information_schema_columns'; + $this->sqlite->exec( "UPDATE $columns_table SET column_name = 'title' WHERE table_name = 't' AND column_name = 'name'" ); + $schema = $this->getSqliteSchema(); + $data = $this->sqlite->query( 'SELECT rowid, * FROM t ORDER BY rowid' )->fetchAll( PDO::FETCH_ASSOC ); // The error from SQLite is reported, not a missing table. - $result = $this->assertQuery( 'OPTIMIZE TABLE t' ); + $result = $this->assertQuery( "$operation TABLE t" ); $this->assertEquals( array( (object) array( 'Table' => 'wp.t', - 'Op' => 'optimize', + 'Op' => strtolower( $operation ), 'Msg_type' => 'Error', 'Msg_text' => 'SQLSTATE[HY000]: General error: 1 no such column: title', ), (object) array( 'Table' => 'wp.t', - 'Op' => 'optimize', + 'Op' => strtolower( $operation ), 'Msg_type' => 'status', 'Msg_text' => 'Operation failed', ), ), $result ); + $this->assertSame( $schema, $this->getSqliteSchema() ); + $this->assertSame( $data, $this->sqlite->query( 'SELECT rowid, * FROM t ORDER BY rowid' )->fetchAll( PDO::FETCH_ASSOC ) ); } public function testRepairTable() { @@ -586,6 +595,127 @@ public function testRepairTable() { ); } + /** + * @dataProvider data_rebuild_operations + */ + public function testAdministrationRollsBackFailedDrop( string $operation ): void { + $this->assertQuery( 'CREATE TABLE parent (id INT PRIMARY KEY, name TEXT)' ); + $this->assertQuery( 'CREATE TABLE child (id INT PRIMARY KEY, parent_id INT, FOREIGN KEY (parent_id) REFERENCES parent (id))' ); + $this->assertQuery( "INSERT INTO parent VALUES (1, 'a'), (2, 'b')" ); + $this->assertQuery( 'INSERT INTO child VALUES (1, 1)' ); + + $schema = $this->getSqliteSchema(); + $parent = $this->sqlite->query( 'SELECT * FROM parent ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ); + $child = $this->sqlite->query( 'SELECT * FROM child ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ); + + // Repeated failures must not leave copies of the parent table behind. + for ( $i = 0; $i < 2; ++$i ) { + $result = $this->assertQuery( "$operation TABLE parent" ); + $this->assertCount( 2, $result ); + $this->assertStringContainsString( 'FOREIGN KEY constraint failed', $result[0]->Msg_text ); + $this->assertSame( 'Operation failed', $result[1]->Msg_text ); + $this->assertSame( $schema, $this->getSqliteSchema() ); + $this->assertSame( $parent, $this->sqlite->query( 'SELECT * FROM parent ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) ); + $this->assertSame( $child, $this->sqlite->query( 'SELECT * FROM child ORDER BY id' )->fetchAll( PDO::FETCH_ASSOC ) ); + } + } + + /** + * @dataProvider data_rebuild_operations_and_table_types + */ + public function testAdministrationRollsBackFailedIndexRecreation( string $operation, bool $temporary ): void { + $this->assertQuery( + 'CREATE ' . ( $temporary ? 'TEMPORARY ' : '' ) . 'TABLE t (id INT AUTO_INCREMENT PRIMARY KEY, name TEXT, UNIQUE KEY u (name), KEY k (id)) AUTO_INCREMENT = 100' + ); + + // These values fit the existing binary index but conflict when its NOCASE definition is restored. + $this->sqlite->exec( 'DROP INDEX t__u' ); + $this->sqlite->exec( 'CREATE UNIQUE INDEX t__u ON t (name COLLATE BINARY)' ); + $this->sqlite->exec( "INSERT INTO t VALUES (1, 'a'), (2, 'A')" ); + $schema = $this->getSqliteSchema(); + $data = $this->sqlite->query( 'SELECT rowid, * FROM t ORDER BY rowid' )->fetchAll( PDO::FETCH_ASSOC ); + $sequence_table = $temporary ? 'temp.sqlite_sequence' : 'main.sqlite_sequence'; + $sequence = $this->sqlite->query( "SELECT seq FROM $sequence_table WHERE name = 't'" )->fetchColumn(); + + $result = $this->assertQuery( "$operation TABLE t" ); + $this->assertCount( 2, $result ); + $this->assertStringContainsString( 'UNIQUE constraint failed', $result[0]->Msg_text ); + $this->assertSame( 'Operation failed', $result[1]->Msg_text ); + $this->assertSame( $schema, $this->getSqliteSchema() ); + $this->assertSame( $data, $this->sqlite->query( 'SELECT rowid, * FROM t ORDER BY rowid' )->fetchAll( PDO::FETCH_ASSOC ) ); + $this->assertSame( $sequence, $this->sqlite->query( "SELECT seq FROM $sequence_table WHERE name = 't'" )->fetchColumn() ); + } + + /** + * @dataProvider data_rebuild_operations_and_transactions + */ + public function testAdministrationKeepsSuccessfulRebuilds( string $operation, bool $in_transaction ): void { + $this->assertQuery( 'CREATE TABLE good_before (id INT, KEY k (id))' ); + $this->assertQuery( 'CREATE TABLE bad (id INT)' ); + $this->assertQuery( 'CREATE TABLE good_after (id INT, KEY k (id))' ); + $this->assertQuery( 'INSERT INTO bad VALUES (NULL)' ); + $this->sqlite->exec( "UPDATE _wp_sqlite_mysql_information_schema_columns SET is_nullable = 'NO' WHERE table_name = 'bad'" ); + + // Rebuilding the good tables must restore their missing indexes. + $this->sqlite->exec( 'DROP INDEX good_before__k' ); + $this->sqlite->exec( 'DROP INDEX good_after__k' ); + $schema = $this->getSqliteSchema(); + if ( $in_transaction ) { + $this->assertQuery( 'START TRANSACTION' ); + $this->assertQuery( 'INSERT INTO good_before VALUES (1)' ); + $this->assertQuery( 'SAVEPOINT caller' ); + $this->assertQuery( 'INSERT INTO good_before VALUES (2)' ); + } + + $result = $this->assertQuery( "$operation TABLE good_before, bad, good_after" ); + $this->assertSame( array( 'wp.good_before', 'wp.bad', 'wp.bad', 'wp.good_after' ), array_column( $result, 'Table' ) ); + $this->assertSame( array( 'status', 'Error', 'status', 'status' ), array_column( $result, 'Msg_type' ) ); + $this->assertSame( 'OK', $result[0]->Msg_text ); + $this->assertStringContainsString( 'NOT NULL constraint failed', $result[1]->Msg_text ); + $this->assertSame( 'Operation failed', $result[2]->Msg_text ); + $this->assertSame( 'OK', $result[3]->Msg_text ); + $this->assertSame( + array( 'good_after__k', 'good_before__k' ), + $this->sqlite->query( "SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('good_before__k', 'good_after__k') ORDER BY name" )->fetchAll( PDO::FETCH_COLUMN ) + ); + $this->assertSame( array( null ), $this->sqlite->query( 'SELECT id FROM bad' )->fetchAll( PDO::FETCH_COLUMN ) ); + $this->assertSame( array(), $this->sqlite->query( "SELECT name FROM sqlite_master WHERE name GLOB '_wp_sqlite_tmp_*'" )->fetchAll( PDO::FETCH_COLUMN ) ); + $this->assertSame( $in_transaction, $this->engine->inTransaction() ); + + if ( $in_transaction ) { + $this->assertQuery( 'ROLLBACK TO SAVEPOINT caller' ); + $this->assertEquals( array( (object) array( 'id' => '1' ) ), $this->assertQuery( 'SELECT id FROM good_before' ) ); + $this->assertSame( $schema, $this->getSqliteSchema() ); + $this->assertQuery( 'COMMIT' ); + $this->assertEquals( array( (object) array( 'id' => '1' ) ), $this->assertQuery( 'SELECT id FROM good_before' ) ); + } + } + + public function data_rebuild_operations(): array { + return array( + 'optimize' => array( 'OPTIMIZE' ), + 'repair' => array( 'REPAIR' ), + ); + } + + public function data_rebuild_operations_and_table_types(): array { + return array( + 'optimize permanent' => array( 'OPTIMIZE', false ), + 'optimize temporary' => array( 'OPTIMIZE', true ), + 'repair permanent' => array( 'REPAIR', false ), + 'repair temporary' => array( 'REPAIR', true ), + ); + } + + public function data_rebuild_operations_and_transactions(): array { + return array( + 'optimize autocommit' => array( 'OPTIMIZE', false ), + 'optimize transaction' => array( 'OPTIMIZE', true ), + 'repair autocommit' => array( 'REPAIR', false ), + 'repair transaction' => array( 'REPAIR', true ), + ); + } + public function testShowCollation(): void { // Simple. $this->assertQuery( 'SHOW COLLATION' ); @@ -2713,4 +2843,13 @@ public function testInformationSchemaAlterTableDropCheckConstraint(): void { $result = $this->assertQuery( 'SELECT * FROM information_schema.check_constraints' ); $this->assertCount( 0, $result ); } + + private function getSqliteSchema(): array { + return $this->sqlite->query( + "SELECT 'main' AS db, type, name, tbl_name, sql FROM sqlite_master + UNION ALL + SELECT 'temp' AS db, type, name, tbl_name, sql FROM sqlite_temp_master + ORDER BY db, type, name" + )->fetchAll( PDO::FETCH_ASSOC ); + } }