diff --git a/tests/WP_SQLite_Driver_Tests.php b/tests/WP_SQLite_Driver_Tests.php index a76d4836..7dc380dd 100644 --- a/tests/WP_SQLite_Driver_Tests.php +++ b/tests/WP_SQLite_Driver_Tests.php @@ -4202,7 +4202,7 @@ public function getReservedPrefixTestData(): array { * @dataProvider getInformationSchemaIsReadonlyTestData */ public function testInformationSchemaIsReadonly( string $query ): void { - $this->assertQuery( 'CREATE TABLE t1 (id INT)' ); + $this->assertQuery( 'CREATE TABLE tables (id INT)' ); $this->expectException( WP_SQLite_Driver_Exception::class ); $this->expectExceptionMessage( "Access denied for user 'sqlite'@'%' to database 'information_schema'" ); $this->assertQuery( $query ); @@ -4211,12 +4211,22 @@ public function testInformationSchemaIsReadonly( string $query ): void { public function getInformationSchemaIsReadonlyTestData(): array { return array( array( 'INSERT INTO information_schema.tables (table_name) VALUES ("t")' ), + array( 'REPLACE INTO information_schema.tables (table_name) VALUES ("t")' ), array( 'UPDATE information_schema.tables SET table_name = "new_t" WHERE table_name = "t"' ), + array( 'UPDATE information_schema.tables, information_schema.columns SET table_name = "new_t" WHERE table_name = "t"' ), array( 'DELETE FROM information_schema.tables WHERE table_name = "t"' ), + array( 'DELETE it FROM t, information_schema.tables it WHERE table_name = "t"' ), + array( 'TRUNCATE information_schema.tables' ), array( 'CREATE TABLE information_schema.new_table (id INT)' ), array( 'ALTER TABLE information_schema.tables ADD COLUMN new_column INT' ), array( 'DROP TABLE information_schema.tables' ), - array( 'TRUNCATE information_schema.tables' ), + array( 'LOCK TABLES information_schema.tables READ' ), + array( 'CREATE INDEX idx_name ON information_schema.tables (name)' ), + array( 'DROP INDEX `PRIMARY` ON information_schema.tables' ), + array( 'ANALYZE TABLE information_schema.tables' ), + array( 'CHECK TABLE information_schema.tables' ), + array( 'OPTIMIZE TABLE information_schema.tables' ), + array( 'REPAIR TABLE information_schema.tables' ), ); } @@ -4224,7 +4234,7 @@ public function getInformationSchemaIsReadonlyTestData(): array { * @dataProvider getInformationSchemaIsReadonlyWithUseTestData */ public function testInformationSchemaIsReadonlyWithUse( string $query ): void { - $this->assertQuery( 'CREATE TABLE t1 (id INT)' ); + $this->assertQuery( 'CREATE TABLE tables (id INT)' ); $this->expectException( WP_SQLite_Driver_Exception::class ); $this->expectExceptionMessage( "Access denied for user 'sqlite'@'%' to database 'information_schema'" ); $this->assertQuery( 'USE information_schema' ); @@ -4234,12 +4244,22 @@ public function testInformationSchemaIsReadonlyWithUse( string $query ): void { public function getInformationSchemaIsReadonlyWithUseTestData(): array { return array( array( 'INSERT INTO tables (table_name) VALUES ("t")' ), + array( 'REPLACE INTO tables (table_name) VALUES ("t")' ), array( 'UPDATE tables SET table_name = "new_t" WHERE table_name = "t"' ), + array( 'UPDATE tables, columns SET table_name = "new_t" WHERE table_name = "t"' ), array( 'DELETE FROM tables WHERE table_name = "t"' ), + array( 'DELETE it FROM t, tables it WHERE table_name = "t"' ), + array( 'TRUNCATE tables' ), array( 'CREATE TABLE new_table (id INT)' ), array( 'ALTER TABLE tables ADD COLUMN new_column INT' ), array( 'DROP TABLE tables' ), - array( 'TRUNCATE tables' ), + array( 'LOCK TABLES tables READ' ), + array( 'CREATE INDEX idx_name ON tables (name)' ), + array( 'DROP INDEX `PRIMARY` ON tables' ), + array( 'ANALYZE TABLE tables' ), + array( 'CHECK TABLE tables' ), + array( 'OPTIMIZE TABLE tables' ), + array( 'REPAIR TABLE tables' ), ); } @@ -9537,4 +9557,256 @@ public function testInsertIntoSetSyntaxInNonStrictMode(): void { $result ); } + + public function testFullyQualifiedTableName(): void { + // Ensure "information_schema.tables" is empty. + $this->assertQuery( 'DROP TABLE _options, _dates' ); + $result = $this->assertQuery( 'SELECT * FROM information_schema.tables' ); + $this->assertCount( 0, $result ); + + // Switch to the "information_schema" database. + $this->assertQuery( 'USE information_schema' ); + + // CREATE TABLE + $this->assertQuery( 'CREATE TABLE wp.t (id INT PRIMARY KEY)' ); + $result = $this->assertQuery( 'SHOW TABLES FROM wp' ); + $this->assertCount( 1, $result ); + + // INSERT + $this->assertQuery( 'INSERT INTO wp.t (id) VALUES (1)' ); + $result = $this->assertQuery( 'SELECT * FROM wp.t' ); + $this->assertEquals( array( (object) array( 'id' => '1' ) ), $result ); + + // SELECT + $result = $this->assertQuery( 'SELECT * FROM wp.t' ); + $this->assertEquals( array( (object) array( 'id' => '1' ) ), $result ); + + // UPDATE + $this->assertQuery( 'UPDATE wp.t SET id = 2' ); + $result = $this->assertQuery( 'SELECT * FROM wp.t' ); + $this->assertEquals( array( (object) array( 'id' => '2' ) ), $result ); + + // DELETE + $this->assertQuery( 'DELETE FROM wp.t' ); + $result = $this->assertQuery( 'SELECT * FROM wp.t' ); + $this->assertCount( 0, $result ); + + // TRUNCATE TABLE + $this->assertQuery( 'INSERT INTO wp.t (id) VALUES (1)' ); + $this->assertQuery( 'TRUNCATE TABLE wp.t' ); + $result = $this->assertQuery( 'SELECT * FROM wp.t' ); + $this->assertCount( 0, $result ); + + // SHOW CREATE TABLE + $result = $this->assertQuery( 'SHOW CREATE TABLE wp.t' ); + $this->assertEquals( + array( + (object) array( + 'Create Table' => implode( + "\n", + array( + 'CREATE TABLE `t` (', + ' `id` int NOT NULL,', + ' PRIMARY KEY (`id`)', + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', + ) + ), + ), + ), + $result + ); + + // SHOW COLUMNS + $result = $this->assertQuery( 'SHOW COLUMNS FROM wp.t' ); + $this->assertEquals( + array( + (object) array( + 'Field' => 'id', + 'Type' => 'int', + 'Null' => 'NO', + 'Key' => 'PRI', + 'Default' => null, + 'Extra' => '', + ), + ), + $result + ); + + // SHOW COLUMNS with both qualified table name and "FROM database" clause. + // In case both are present, the "FROM database" clause takes precedence. + $result = $this->assertQuery( 'SHOW COLUMNS FROM information_schema.t FROM wp' ); + $this->assertEquals( + array( + (object) array( + 'Field' => 'id', + 'Type' => 'int', + 'Null' => 'NO', + 'Key' => 'PRI', + 'Default' => null, + 'Extra' => '', + ), + ), + $result + ); + + // SHOW INDEXES + $result = $this->assertQuery( 'SHOW INDEXES FROM wp.t' ); + $this->assertCount( 1, $result ); + $this->assertEquals( 'PRIMARY', $result[0]->Key_name ); + + // SHOW INDEXES with both qualified table name and "FROM database" clause. + // In case both are present, the "FROM database" clause takes precedence. + $result = $this->assertQuery( 'SHOW INDEXES FROM information_schema.t FROM wp' ); + $this->assertCount( 1, $result ); + $this->assertEquals( 'PRIMARY', $result[0]->Key_name ); + + // DESCRIBE + $result = $this->assertQuery( 'DESCRIBE wp.t' ); + $this->assertCount( 1, $result ); + $this->assertEquals( 'id', $result[0]->Field ); + $this->assertEquals( 'int', $result[0]->Type ); + $this->assertEquals( 'NO', $result[0]->Null ); + $this->assertEquals( 'PRI', $result[0]->Key ); + $this->assertEquals( null, $result[0]->Default ); + $this->assertEquals( '', $result[0]->Extra ); + + // SHOW TABLES + $result = $this->assertQuery( 'SHOW TABLES FROM wp' ); + $this->assertCount( 1, $result ); + $this->assertEquals( 't', $result[0]->Tables_in_wp ); + + // SHOW TABLE STATUS + $result = $this->assertQuery( 'SHOW TABLE STATUS FROM wp' ); + $this->assertCount( 1, $result ); + $this->assertEquals( 't', $result[0]->Name ); + + // ALTER TABLE + $this->assertQuery( 'ALTER TABLE wp.t ADD COLUMN name VARCHAR(255)' ); + $result = $this->assertQuery( 'SHOW COLUMNS FROM wp.t' ); + $this->assertCount( 2, $result ); + + // CREATE INDEX + $this->assertQuery( 'CREATE INDEX idx_name ON wp.t (name)' ); + $result = $this->assertQuery( 'SHOW INDEXES FROM wp.t' ); + $this->assertCount( 2, $result ); + $this->assertEquals( 'idx_name', $result[1]->Key_name ); + + // DROP INDEX + $this->assertQuery( 'DROP INDEX idx_name ON wp.t' ); + $result = $this->assertQuery( 'SHOW INDEXES FROM wp.t' ); + $this->assertCount( 1, $result ); + $this->assertEquals( 'PRIMARY', $result[0]->Key_name ); + + // LOCK TABLE + $this->assertQuery( 'LOCK TABLES wp.t READ' ); + + // UNLOCK TABLE + $this->assertQuery( 'UNLOCK TABLES' ); + + // ANALYZE TABLE + $this->assertQuery( 'ANALYZE TABLE wp.t' ); + + // CHECK TABLE + $this->assertQuery( 'CHECK TABLE wp.t' ); + + // OPTIMIZE TABLE + $this->assertQuery( 'OPTIMIZE TABLE wp.t' ); + + // REPAIR TABLE + $this->assertQuery( 'REPAIR TABLE wp.t' ); + + // DROP TABLE + $this->assertQuery( 'DROP TABLE wp.t' ); + $result = $this->assertQuery( 'SHOW TABLES FROM wp' ); + $this->assertCount( 0, $result ); + } + + public function testWriteWithUsageOfInformationSchemaTables(): void { + // Ensure "information_schema.tables" is empty. + $this->assertQuery( 'DROP TABLE _options, _dates' ); + $result = $this->assertQuery( 'SELECT * FROM information_schema.tables' ); + $this->assertCount( 0, $result ); + + // Create a table. + $this->assertQuery( 'CREATE TABLE t (id INT, value VARCHAR(255))' ); + + // INSERT with SELECT from information schema. + $this->assertQuery( 'INSERT INTO t (id, value) SELECT 1, table_name FROM information_schema.tables' ); + $result = $this->assertQuery( 'SELECT * FROM t' ); + $this->assertCount( 1, $result ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 't', + ), + ), + $result + ); + + // INSERT with subselect from information schema. + $this->assertQuery( 'INSERT INTO t (id, value) SELECT 2, table_name FROM (SELECT table_name FROM information_schema.tables)' ); + $result = $this->assertQuery( 'SELECT * FROM t' ); + $this->assertCount( 2, $result ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 't', + ), + (object) array( + 'id' => '2', + 'value' => 't', + ), + ), + $result + ); + + // INSERT with JOIN on information schema. + $this->assertQuery( + 'INSERT INTO t (id, value) + SELECT 3, it.table_name + FROM information_schema.schemata s + JOIN information_schema.tables it ON s.schema_name = it.table_schema' + ); + $result = $this->assertQuery( 'SELECT * FROM t' ); + $this->assertCount( 3, $result ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => 't', + ), + (object) array( + 'id' => '2', + 'value' => 't', + ), + (object) array( + 'id' => '3', + 'value' => 't', + ), + ), + $result + ); + + // TODO: UPDATE with JOIN on information schema is not supported yet. + + // DELETE with JOIN on information schema. + $this->assertQuery( 'UPDATE t SET value = "other" WHERE id > 1' ); + $this->assertQuery( 'DELETE t FROM t JOIN information_schema.tables it ON t.value = it.table_name' ); + $result = $this->assertQuery( 'SELECT * FROM t' ); + $this->assertEquals( + array( + (object) array( + 'id' => '2', + 'value' => 'other', + ), + (object) array( + 'id' => '3', + 'value' => 'other', + ), + ), + $result + ); + } } diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php index ba0e4ede..00630d76 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-driver.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-driver.php @@ -1351,9 +1351,13 @@ private function execute_transaction_or_locking_statement( WP_Parser_Node $node // Check if the table(s) exists. $lock_items = $subnode->get_child_nodes( 'lockItem' ); foreach ( $lock_items as $lock_item ) { - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $lock_item->get_first_child_node( 'tableRef' ) ) - ); + $table_ref = $lock_item->get_first_child_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + try { /* * Attempt to query the table directly rather than checking @@ -1532,6 +1536,13 @@ private function execute_insert_or_replace_statement( WP_Parser_Node $node ): vo $is_token = $child instanceof WP_MySQL_Token; $is_node = $child instanceof WP_Parser_Node; + if ( $child instanceof WP_Parser_Node && 'tableRef' === $child->rule_name ) { + $database = $this->get_database_name( $child ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + } + // Skip the SET keyword in "INSERT INTO ... SET ..." syntax. if ( $is_token && WP_MySQL_Lexer::SET_SYMBOL === $child->id ) { continue; @@ -1625,6 +1636,21 @@ private function execute_update_statement( WP_Parser_Node $node ): void { $node->get_first_child_node( 'tableReferenceList' ) ); + /* + * Deny UPDATE for information schema tables. + * + * This basic approach is rather restrictive, as it blocks the usage + * of information schema tables anywhere in the UPDATE statement. + * + * TODO: Implement support for UPDATE statements like: + * UPDATE t, information_schema.columns c SET t.column = c.column ... + */ + foreach ( $table_alias_map as $alias => $data ) { + if ( 'information_schema' === strtolower( $data['database'] ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + } + // Determine whether the UPDATE statement modifies multiple tables. $update_list_node = $node->get_first_child_node( 'updateList' ); $update_target = null; @@ -1845,14 +1871,24 @@ private function execute_delete_statement( WP_Parser_Node $node ): void { $alias_map = array(); $table_ref_list = $node->get_first_child_node( 'tableReferenceList' ); foreach ( $table_ref_list->get_descendant_nodes( 'singleTable' ) as $single_table ) { - $alias = $this->unquote_sqlite_identifier( - $this->translate( $single_table->get_first_child_node( 'tableAlias' ) ) - ); - $ref = $this->unquote_sqlite_identifier( - $this->translate( $single_table->get_first_child_node( 'tableRef' ) ) - ); + $table_ref = $single_table->get_first_child_node( 'tableRef' ); + $alias_node = $single_table->get_first_child_node( 'tableAlias' ); + if ( $alias_node ) { + $alias = $this->unquote_sqlite_identifier( $this->translate( $alias_node ) ); + } else { + $alias = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + } + + // For an information schema table, check if is a DELETE target. + $database = $this->get_database_name( $table_ref ); + if ( + 'information_schema' === strtolower( $database ) + && in_array( $alias, $table_aliases, true ) + ) { + throw $this->new_access_denied_to_information_schema_exception(); + } - $alias_map[ $alias ] = $ref; + $alias_map[ $alias ] = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); } // 3. Compose the SELECT query to fetch ROWIDs to delete. @@ -1902,6 +1938,12 @@ private function execute_delete_statement( WP_Parser_Node $node ): void { // @TODO: Translate DELETE with JOIN to use a subquery. + $table_ref = $node->get_first_child_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + $query = $this->translate( $node ); $this->execute_sqlite_query( $query ); $this->set_result_from_affected_rows(); @@ -1935,9 +1977,13 @@ private function execute_create_table_statement( WP_Parser_Node $node ): void { } // Get table name. - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $subnode->get_first_child_node( 'tableName' ) ) - ); + $table_name_node = $subnode->get_first_child_node( 'tableName' ); + $database = $this->get_database_name( $table_name_node ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_name_node ) ); + + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } // Handle IF NOT EXISTS. if ( $subnode->has_child_node( 'ifNotExists' ) ) { @@ -1978,9 +2024,12 @@ private function execute_create_table_statement( WP_Parser_Node $node ): void { * @throws WP_SQLite_Driver_Exception When the query execution fails. */ private function execute_alter_table_statement( WP_Parser_Node $node ): void { - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $node->get_first_descendant_node( 'tableRef' ) ) - ); + $table_ref = $node->get_first_descendant_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); @@ -1994,7 +2043,7 @@ private function execute_alter_table_statement( WP_Parser_Node $node ): void { FROM %s WHERE table_schema = ? AND table_name = ?', $this->quote_sqlite_identifier( $columns_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $this->get_saved_db_name( $database ), $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); // Track column renames and removals. @@ -2063,6 +2112,11 @@ private function execute_drop_table_statement( WP_Parser_Node $node ): void { $table_is_temporary = $child_node->has_child_token( WP_MySQL_Lexer::TEMPORARY_SYMBOL ); $queries = array(); foreach ( $table_refs as $table_ref ) { + $database = $this->get_database_name( $table_ref ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + $parts = array(); foreach ( $child_node->get_children() as $child ) { $is_token = $child instanceof WP_MySQL_Token; @@ -2100,9 +2154,12 @@ private function execute_drop_table_statement( WP_Parser_Node $node ): void { * @throws WP_SQLite_Driver_Exception When the query execution fails. */ private function execute_truncate_table_statement( WP_Parser_Node $node ): void { - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $node->get_first_child_node( 'tableRef' ) ) - ); + $table_ref = $node->get_first_child_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } $this->execute_sqlite_query( sprintf( 'DELETE FROM %s', $this->quote_sqlite_identifier( $table_name ) ) @@ -2126,14 +2183,18 @@ private function execute_truncate_table_statement( WP_Parser_Node $node ): void * @throws WP_SQLite_Driver_Exception When the query execution fails. */ private function execute_create_index_statement( WP_Parser_Node $node ): void { - $this->information_schema_builder->record_create_index( $node ); - $create_index = $node->get_first_child_node( 'createIndex' ); $target = $create_index->get_first_child_node( 'createIndexTarget' ); + $table_ref = $target->get_first_child_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + + $this->information_schema_builder->record_create_index( $node ); - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $target->get_first_child_node( 'tableRef' ) ) - ); $index_name = $this->unquote_sqlite_identifier( $this->translate( $create_index->get_first_child_node( 'indexName' ) ) ); @@ -2180,12 +2241,16 @@ private function execute_create_index_statement( WP_Parser_Node $node ): void { * @throws WP_SQLite_Driver_Exception When the query execution fails. */ private function execute_drop_index_statement( WP_Parser_Node $node ): void { + $drop_index = $node->get_first_child_node( 'dropIndex' ); + $table_ref = $drop_index->get_first_child_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + $this->information_schema_builder->record_drop_index( $node ); - $drop_index = $node->get_first_child_node( 'dropIndex' ); - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $drop_index->get_first_child_node( 'tableRef' ) ) - ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); $index_name = $this->unquote_sqlite_identifier( $this->translate( $drop_index->get_first_child_node( 'indexRef' ) ) ); @@ -2234,9 +2299,18 @@ private function execute_show_statement( WP_Parser_Node $node ): void { return; case WP_MySQL_Lexer::CREATE_SYMBOL: if ( WP_MySQL_Lexer::TABLE_SYMBOL === $keyword2->id ) { - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $node->get_first_child_node( 'tableRef' ) ) - ); + $table_ref = $node->get_first_child_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + + // Refuse SHOW CREATE TABLE for information schema tables, + // as we don't have the table definitions at the moment. + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_driver_exception( + sprintf( "SHOW command denied to user 'sqlite'@'%%' for table '%s'", $table_name ), + '42000' + ); + } $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); @@ -2258,10 +2332,7 @@ private function execute_show_statement( WP_Parser_Node $node ): void { case WP_MySQL_Lexer::INDEX_SYMBOL: case WP_MySQL_Lexer::INDEXES_SYMBOL: case WP_MySQL_Lexer::KEYS_SYMBOL: - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $node->get_first_child_node( 'tableRef' ) ) - ); - $this->execute_show_index_statement( $table_name ); + $this->execute_show_index_statement( $node ); return; case WP_MySQL_Lexer::GRANTS_SYMBOL: $this->set_results_from_fetched_data( @@ -2353,10 +2424,21 @@ private function execute_show_databases_statement( WP_Parser_Node $node ): void /** * Translate and execute a MySQL SHOW INDEX statement in SQLite. * - * @param string $table_name The table name to show indexes for. + * @param WP_Parser_Node $node The "showStatement" AST node. */ - private function execute_show_index_statement( string $table_name ): void { - // TODO: FROM/IN (multiple) + private function execute_show_index_statement( WP_Parser_Node $node ): void { + $table_ref = $node->get_first_child_node( 'tableRef' ); + $in_db = $node->get_first_child_node( 'inDb' ); + + // Get database and table name. + if ( $in_db ) { + // FROM/IN database. + $database = $this->get_database_name( $in_db ); + } else { + $database = $this->get_database_name( $table_ref ); + } + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); + // TODO: WHERE $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); @@ -2410,7 +2492,7 @@ private function execute_show_index_statement( string $table_name ): void { ROWID, SEQ_IN_INDEX ", - array( $this->get_saved_db_name(), $table_name ) + array( $this->get_saved_db_name( $database ), $table_name ) )->fetchAll( PDO::FETCH_OBJ ); $this->set_results_from_fetched_data( $index_info ); @@ -2554,20 +2636,17 @@ private function execute_show_tables_statement( WP_Parser_Node $node ): void { */ private function execute_show_columns_statement( WP_Parser_Node $node ): void { // TODO: EXTENDED, FULL - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $node->get_first_child_node( 'tableRef' ) ) - ); + $table_ref = $node->get_first_child_node( 'tableRef' ); + $in_db = $node->get_first_child_node( 'inDb' ); - // FROM/IN database. - $in_db = $node->get_first_child_node( 'inDb' ); - if ( null === $in_db ) { - $database = $this->db_name; + // Get database and table name. + if ( $in_db ) { + // FROM/IN database. + $database = $this->get_database_name( $in_db ); } else { - $database = $this->unquote_sqlite_identifier( - $this->translate( $in_db->get_first_child_node( 'identifier' ) ) - ); + $database = $this->get_database_name( $table_ref ); } - + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); // Check if the table exists. @@ -2632,9 +2711,9 @@ private function execute_show_columns_statement( WP_Parser_Node $node ): void { * @throws WP_SQLite_Driver_Exception When the query execution fails. */ private function execute_describe_statement( WP_Parser_Node $node ): void { - $table_name = $this->unquote_sqlite_identifier( - $this->translate( $node->get_first_child_node( 'tableRef' ) ) - ); + $table_ref = $node->get_first_child_node( 'tableRef' ); + $database = $this->get_database_name( $table_ref ); + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); $table_is_temporary = $this->information_schema_builder->temporary_table_exists( $table_name ); @@ -2653,7 +2732,7 @@ private function execute_describe_statement( WP_Parser_Node $node ): void { AND table_name = ? ORDER BY ordinal_position ', - array( $this->get_saved_db_name(), $table_name ) + array( $this->get_saved_db_name( $database ), $table_name ) )->fetchAll( PDO::FETCH_OBJ ); $this->set_results_from_fetched_data( $column_info ); @@ -2669,10 +2748,9 @@ private function execute_use_statement( WP_Parser_Node $node ): void { $database_name = $this->unquote_sqlite_identifier( $this->translate( $node->get_first_child_node( 'identifier' ) ) ); + $database_name = strtolower( $database_name ); - if ( 'information_schema' === strtolower( $database_name ) ) { - $this->db_name = 'information_schema'; - } elseif ( $this->main_db_name === $database_name ) { + if ( $this->main_db_name === $database_name || 'information_schema' === $database_name ) { $this->db_name = $database_name; } else { throw $this->new_not_supported_exception( @@ -2925,6 +3003,11 @@ private function execute_administration_statement( WP_Parser_Node $node ): void $table_ref_list = $node->get_first_child_node( 'tableRefList' ); $results = array(); foreach ( $table_ref_list->get_child_nodes( 'tableRef' ) as $table_ref ) { + $database = $this->get_database_name( $table_ref ); + if ( 'information_schema' === strtolower( $database ) ) { + throw $this->new_access_denied_to_information_schema_exception(); + } + $table_name = $this->unquote_sqlite_identifier( $this->translate( $table_ref ) ); $quoted_table_name = $this->quote_sqlite_identifier( $table_name ); try { @@ -3465,24 +3548,6 @@ private function translate_qualified_identifier( } } - /* - * Make the 'information_schema' database read-only. - * - * This basic approach is rather restrictive, as it blocks the usage - * of information schema tables in all data-modifying statements. - * - * Some of these statements can be valid, when the schema is only read: - * DELETE t FROM t JOIN information_schema.columns c ON ... - * - * If needed, a more granular approach can be implemented in the future. - */ - if ( true === $is_information_schema && false === $this->is_readonly ) { - throw $this->new_driver_exception( - "Access denied for user 'sqlite'@'%' to database 'information_schema'", - '42000' - ); - } - // Database-level object name (table, view, procedure, trigger, etc.). if ( null !== $object_node ) { $parts[] = $this->translate( $object_node ); @@ -4041,11 +4106,6 @@ public function translate_select_item( WP_Parser_Node $node ): string { * @throws WP_SQLite_Driver_Exception When the translation fails. */ public function translate_table_ref( WP_Parser_Node $node ): string { - // Information schema is currently accessible only in read-only queries. - if ( ! $this->is_readonly ) { - return $this->translate_sequence( $node->get_children() ); - } - // The table reference is in "." or "
" format. $parts = $node->get_descendant_nodes( 'identifier' ); $table = array_pop( $parts ); @@ -4081,6 +4141,10 @@ public function translate_table_ref( WP_Parser_Node $node ): string { array( $sqlite_table_name ) )->fetchAll( PDO::FETCH_COLUMN ); + if ( count( $columns ) === 0 ) { + return $this->translate_sequence( $node->get_children() ); + } + // List all columns in the table, replacing columns targeting database // name columns with the configured database name. static $information_schema_db_column_map = array( @@ -4696,6 +4760,7 @@ private function create_select_item_disambiguation_map( WP_Parser_Node $select_i * The returned array maps table aliases to table names and additional data: * - key: table alias, or name if no alias is used * - value: an array of table data + * - database: the database name of the table (null for derived tables) * - table_name: the real name of the table (null for derived tables) * - table_expr: the table expression for a derived table (null for regular tables) * - join_expr: the join expression used for the table (null when no join is used) @@ -4735,11 +4800,13 @@ private function create_table_reference_map( WP_Parser_Node $node ): array { if ( 'singleTable' === $child->rule_name ) { // Extract data from the "singleTable" node. - $name = $this->translate( $child->get_first_child_node( 'tableRef' ) ); + $table_ref = $child->get_first_child_node( 'tableRef' ); + $name = $this->translate( $table_ref ); $alias_node = $child->get_first_child_node( 'tableAlias' ); $alias = $alias_node ? $this->translate( $alias_node->get_first_child_node( 'identifier' ) ) : null; $table_map[ $this->unquote_sqlite_identifier( $alias ?? $name ) ] = array( + 'database' => $this->get_database_name( $table_ref ), 'table_name' => $this->unquote_sqlite_identifier( $name ), 'table_expr' => null, 'join_expr' => $this->translate( $join_expr ), @@ -4751,6 +4818,7 @@ private function create_table_reference_map( WP_Parser_Node $node ): array { $alias = $alias_node ? $this->translate( $alias_node->get_first_child_node( 'identifier' ) ) : null; $table_map[ $this->unquote_sqlite_identifier( $alias ) ] = array( + 'database' => null, 'table_name' => null, 'table_expr' => $this->translate( $subquery ), 'join_expr' => $this->translate( $join_expr ), @@ -4848,6 +4916,31 @@ private function get_saved_db_name( ?string $db_name = null ): string { : $db_name; } + /** + * Get the database name from one of fully-qualified name AST nodes. + * + * @param WP_Parser_Node $node The AST node. One of "tableName", "tableRef", or "inDb". + * @return string The database name. + */ + private function get_database_name( WP_Parser_Node $node ): string { + if ( 'tableName' === $node->rule_name || 'tableRef' === $node->rule_name ) { + $parts = $node->get_descendant_nodes( 'identifier' ); + if ( count( $parts ) > 1 ) { + return $this->unquote_sqlite_identifier( $this->translate( $parts[0] ) ); + } else { + return $this->db_name; + } + } elseif ( 'inDb' === $node->rule_name ) { + return $this->unquote_sqlite_identifier( + $this->translate( $node->get_first_child_node( 'identifier' ) ) + ); + } + + throw $this->new_driver_exception( + sprintf( 'Could not get database name from node: %s', $node->rule_name ) + ); + } + /** * Generate a SQLite CREATE TABLE statement from information schema data. * @@ -4862,6 +4955,9 @@ private function get_sqlite_create_table_statement( string $table_name, ?string $new_table_name = null ): array { + // This method is always used with the main database. + $database = $this->get_saved_db_name( $this->main_db_name ); + // 1. Get table info. $tables_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'tables' ); $table_info = $this->execute_sqlite_query( @@ -4872,7 +4968,7 @@ private function get_sqlite_create_table_statement( AND table_schema = ? AND table_name = ? ", - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetch( PDO::FETCH_ASSOC ); if ( false === $table_info ) { @@ -4889,7 +4985,7 @@ private function get_sqlite_create_table_statement( 'SELECT * FROM %s WHERE table_schema = ? AND table_name = ? ORDER BY ordinal_position', $this->quote_sqlite_identifier( $columns_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); // 3. Get index info, grouped by index name. @@ -4912,7 +5008,7 @@ private function get_sqlite_create_table_statement( ", $this->quote_sqlite_identifier( $statistics_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); $grouped_constraints = array(); @@ -4930,7 +5026,7 @@ private function get_sqlite_create_table_statement( 'SELECT * FROM %s WHERE constraint_schema = ? AND table_name = ? ORDER BY constraint_name', $this->quote_sqlite_identifier( $referential_constraints_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); $key_column_usage_map = array(); @@ -4942,7 +5038,7 @@ private function get_sqlite_create_table_statement( 'SELECT * FROM %s WHERE table_schema = ? AND table_name = ? AND referenced_column_name IS NOT NULL', $this->quote_sqlite_identifier( $key_column_usage_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); $key_column_usage_map = array(); @@ -4974,7 +5070,7 @@ private function get_sqlite_create_table_statement( $this->quote_sqlite_identifier( $table_constraints_table ), $this->quote_sqlite_identifier( $check_constraints_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); // 6. Generate CREATE TABLE statement columns. @@ -5186,6 +5282,9 @@ function ( $column ) { * @return string The CREATE TABLE statement. */ private function get_mysql_create_table_statement( bool $table_is_temporary, string $table_name ): ?string { + // This method is always used with the main database. + $database = $this->get_saved_db_name( $this->main_db_name ); + // 1. Get table info. $tables_table = $this->information_schema_builder->get_table_name( $table_is_temporary, 'tables' ); $table_info = $this->execute_sqlite_query( @@ -5196,7 +5295,7 @@ private function get_mysql_create_table_statement( bool $table_is_temporary, str AND table_schema = ? AND table_name = ? ", - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetch( PDO::FETCH_ASSOC ); if ( false === $table_info ) { @@ -5216,7 +5315,7 @@ private function get_mysql_create_table_statement( bool $table_is_temporary, str ', $this->quote_sqlite_identifier( $columns_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); // 3. Get index info, grouped by index name. @@ -5239,7 +5338,7 @@ private function get_mysql_create_table_statement( bool $table_is_temporary, str ", $this->quote_sqlite_identifier( $statistics_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); $grouped_constraints = array(); @@ -5257,7 +5356,7 @@ private function get_mysql_create_table_statement( bool $table_is_temporary, str 'SELECT * FROM %s WHERE constraint_schema = ? AND table_name = ? ORDER BY constraint_name', $this->quote_sqlite_identifier( $referential_constraints_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); $key_column_usage_map = array(); @@ -5269,7 +5368,7 @@ private function get_mysql_create_table_statement( bool $table_is_temporary, str 'SELECT * FROM %s WHERE table_schema = ? AND table_name = ? AND referenced_column_name IS NOT NULL', $this->quote_sqlite_identifier( $key_column_usage_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); $key_column_usage_map = array(); @@ -5301,7 +5400,7 @@ private function get_mysql_create_table_statement( bool $table_is_temporary, str $this->quote_sqlite_identifier( $table_constraints_table ), $this->quote_sqlite_identifier( $check_constraints_table ) ), - array( $this->get_saved_db_name(), $table_name ) + array( $database, $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); // 6. Generate CREATE TABLE statement columns. @@ -5690,6 +5789,18 @@ private function new_not_supported_exception( string $cause ): WP_SQLite_Driver_ ); } + /** + * Create a new access denied exception for the information schema database. + * + * @return WP_SQLite_Driver_Exception + */ + private function new_access_denied_to_information_schema_exception(): WP_SQLite_Driver_Exception { + return $this->new_driver_exception( + "Access denied for user 'sqlite'@'%' to database 'information_schema'", + '42000' + ); + } + /** * Convert an information schema exception to a MySQL-like driver exception. * diff --git a/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php b/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php index cf864597..f07bf65f 100644 --- a/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php +++ b/wp-includes/sqlite-ast/class-wp-sqlite-information-schema-builder.php @@ -481,7 +481,8 @@ public function ensure_temporary_information_schema_tables(): void { * @param WP_Parser_Node $node The "createStatement" AST node with "createTable" child. */ public function record_create_table( WP_Parser_Node $node ): void { - $table_name = $this->get_value( $node->get_first_descendant_node( 'tableName' ) ); + $table_name_node = $node->get_first_descendant_node( 'tableName' ); + $table_name = $this->get_table_name_from_node( $table_name_node ); $table_engine = $this->get_table_engine( $node ); $table_row_format = 'MyISAM' === $table_engine ? 'Fixed' : 'Dynamic'; $table_collation = $this->get_table_collation( $node ); @@ -638,7 +639,8 @@ public function record_create_table( WP_Parser_Node $node ): void { * @param WP_Parser_Node $node The "alterStatement" AST node with "alterTable" child. */ public function record_alter_table( WP_Parser_Node $node ): void { - $table_name = $this->get_value( $node->get_first_descendant_node( 'tableRef' ) ); + $table_ref = $node->get_first_descendant_node( 'tableRef' ); + $table_name = $this->get_table_name_from_node( $table_ref ); $actions = $node->get_descendant_nodes( 'alterListItem' ); // Check if a temporary table with the given name exists. @@ -765,7 +767,7 @@ public function record_drop_table( WP_Parser_Node $node ): void { $table_refs = $child_node->get_first_child_node( 'tableRefList' )->get_child_nodes(); foreach ( $table_refs as $table_ref ) { - $table_name = $this->get_value( $table_ref ); + $table_name = $this->get_table_name_from_node( $table_ref ); $table_is_temporary = $has_temporary_keyword || $this->temporary_table_exists( $table_name ); $this->delete_values( @@ -809,7 +811,8 @@ public function record_drop_table( WP_Parser_Node $node ): void { public function record_create_index( WP_Parser_Node $node ): void { $create_index = $node->get_first_child_node( 'createIndex' ); $target = $create_index->get_first_child_node( 'createIndexTarget' ); - $table_name = $this->get_value( $target->get_first_child_node( 'tableRef' ) ); + $table_ref = $target->get_first_child_node( 'tableRef' ); + $table_name = $this->get_table_name_from_node( $table_ref ); $table_is_temporary = $this->temporary_table_exists( $table_name ); $this->record_add_index( $table_is_temporary, $table_name, $create_index ); @@ -822,7 +825,8 @@ public function record_create_index( WP_Parser_Node $node ): void { */ public function record_drop_index( WP_Parser_Node $node ): void { $drop_index = $node->get_first_child_node( 'dropIndex' ); - $table_name = $this->get_value( $drop_index->get_first_child_node( 'tableRef' ) ); + $table_ref = $drop_index->get_first_child_node( 'tableRef' ); + $table_name = $this->get_table_name_from_node( $table_ref ); $index_name = $this->get_value( $drop_index->get_first_child_node( 'indexRef' ) ); $table_is_temporary = $this->temporary_table_exists( $table_name ); $this->record_drop_index_data( $table_is_temporary, $table_name, $index_name ); @@ -1750,9 +1754,8 @@ private function extract_referential_constraint_data( WP_Parser_Node $node, stri } // Referenced table name. - $referenced_table = $references->get_first_child_node( 'tableRef' ); - $referenced_identifiers = $referenced_table->get_descendant_nodes( 'identifier' ); - $referenced_table_name = $this->get_value( end( $referenced_identifiers ) ); + $referenced_table = $references->get_first_child_node( 'tableRef' ); + $referenced_table_name = $this->get_table_name_from_node( $referenced_table ); // Referenced column names. $reference_parts = $references->get_first_child_node( 'identifierListWithParentheses' ) @@ -1843,7 +1846,7 @@ private function extract_key_column_usage_data( $referenced_table_schema = count( $referenced_identifiers ) > 1 ? $this->get_value( $referenced_identifiers[0] ) : self::SAVED_DATABASE_NAME; - $referenced_table_name = $this->get_value( end( $referenced_identifiers ) ); + $referenced_table_name = $this->get_table_name_from_node( $referenced_table ); $referenced_columns = $references->get_first_child_node( 'identifierListWithParentheses' ) ->get_first_child_node( 'identifierList' ) ->get_child_nodes( 'identifier' ); @@ -1964,6 +1967,23 @@ private function sync_column_key_info( bool $table_is_temporary, string $table_n ); } + /** + * Extract table name from one of fully-qualified name AST nodes. + * + * @param WP_Parser_Node $node The AST node. One of "tableName" or "tableRef". + * @return string The table name. + */ + private function get_table_name_from_node( WP_Parser_Node $node ): string { + if ( 'tableRef' === $node->rule_name || 'tableName' === $node->rule_name ) { + $parts = $node->get_descendant_nodes( 'identifier' ); + return $this->get_value( end( $parts ) ); + } + + throw new Exception( + sprintf( 'Could not get table name from node: %s', $node->rule_name ) + ); + } + /** * Extract table engine value from the "createStatement" AST node. *