diff --git a/tests/WP_SQLite_Translator_Tests.php b/tests/WP_SQLite_Translator_Tests.php index e53e139e..1130c9b1 100644 --- a/tests/WP_SQLite_Translator_Tests.php +++ b/tests/WP_SQLite_Translator_Tests.php @@ -1170,14 +1170,14 @@ public function testColumnWithOnUpdate() { 'name' => '___tmp_table_created_at_on_update__', 'tbl_name' => '_tmp_table', 'rootpage' => '0', - 'sql' => "CREATE TRIGGER \"___tmp_table_created_at_on_update__\"\n\t\t\tAFTER UPDATE ON \"_tmp_table\"\n\t\t\tFOR EACH ROW\n\t\t\tBEGIN\n\t\t\t UPDATE \"_tmp_table\" SET \"created_at\" = CURRENT_TIMESTAMP WHERE rowid = NEW.rowid;\n\t\t\tEND", + 'sql' => "CREATE TRIGGER `___tmp_table_created_at_on_update__`\n\t\t\tAFTER UPDATE ON `_tmp_table`\n\t\t\tFOR EACH ROW\n\t\t\tBEGIN\n\t\t\t UPDATE `_tmp_table` SET `created_at` = CURRENT_TIMESTAMP WHERE rowid = NEW.rowid;\n\t\t\tEND", ), (object) array( 'type' => 'trigger', 'name' => '___tmp_table_updated_at_on_update__', 'tbl_name' => '_tmp_table', 'rootpage' => '0', - 'sql' => "CREATE TRIGGER \"___tmp_table_updated_at_on_update__\"\n\t\t\tAFTER UPDATE ON \"_tmp_table\"\n\t\t\tFOR EACH ROW\n\t\t\tBEGIN\n\t\t\t UPDATE \"_tmp_table\" SET \"updated_at\" = CURRENT_TIMESTAMP WHERE rowid = NEW.rowid;\n\t\t\tEND", + 'sql' => "CREATE TRIGGER `___tmp_table_updated_at_on_update__`\n\t\t\tAFTER UPDATE ON `_tmp_table`\n\t\t\tFOR EACH ROW\n\t\t\tBEGIN\n\t\t\t UPDATE `_tmp_table` SET `updated_at` = CURRENT_TIMESTAMP WHERE rowid = NEW.rowid;\n\t\t\tEND", ), ), $results diff --git a/wp-includes/sqlite/class-wp-sqlite-translator.php b/wp-includes/sqlite/class-wp-sqlite-translator.php index 0c0dce6a..15352b85 100644 --- a/wp-includes/sqlite/class-wp-sqlite-translator.php +++ b/wp-includes/sqlite/class-wp-sqlite-translator.php @@ -916,12 +916,12 @@ private function execute_create_table() { } if ( count( $table->primary_key ) > 1 ) { - $definitions[] = 'PRIMARY KEY ("' . implode( '", "', $table->primary_key ) . '")'; + $definitions[] = 'PRIMARY KEY (' . implode( ', ', array_map( array( $this, 'quote_identifier' ), $table->primary_key ) ) . ')'; } $create_query = ( $table->create_table . - '"' . $table->name . '" (' . "\n" . + $this->quote_identifier( $table->name ) . ' (' . "\n" . implode( ",\n", $definitions ) . ')' ); @@ -940,7 +940,7 @@ private function execute_create_table() { } $index_name = $this->generate_index_name( $table->name, $constraint->name ); $this->execute_sqlite_query( - "CREATE $unique INDEX $if_not_exists \"$index_name\" ON \"{$table->name}\" (\"" . implode( '", "', $constraint->columns ) . '")' + 'CREATE ' . $unique . 'INDEX ' . $if_not_exists . ' ' . $this->quote_identifier( $index_name ) . ' ON ' . $this->quote_identifier( $table->name ) . ' (' . implode( ', ', array_map( array( $this, 'quote_identifier' ), $constraint->columns ) ) . ')' ); $this->update_data_type_cache( $table->name, @@ -1208,7 +1208,7 @@ private function parse_mysql_create_table_field() { * @return string */ private function make_sqlite_field_definition( $field ) { - $definition = '"' . $field->name . '" ' . $field->sqlite_data_type; + $definition = $this->quote_identifier( $field->name ) . ' ' . $field->sqlite_data_type; if ( $field->auto_increment ) { $definition .= ' PRIMARY KEY AUTOINCREMENT'; } elseif ( $field->primary_key ) { @@ -1382,8 +1382,9 @@ private function execute_delete() { // @TODO: Actually rewrite the query instead of using a hardcoded workaround. if ( str_contains( $updated_query, ' JOIN ' ) ) { $table_prefix = isset( $GLOBALS['table_prefix'] ) ? $GLOBALS['table_prefix'] : 'wp_'; + $quoted_table = $this->quote_identifier( $table_prefix . 'options' ); $this->execute_sqlite_query( - "DELETE FROM {$table_prefix}options WHERE option_id IN (SELECT MIN(option_id) FROM {$table_prefix}options GROUP BY option_name HAVING COUNT(*) > 1)" + "DELETE FROM $quoted_table WHERE option_id IN (SELECT MIN(option_id) FROM $quoted_table GROUP BY option_name HAVING COUNT(*) > 1)" ); $this->set_result_from_affected_rows(); return; @@ -1417,7 +1418,7 @@ private function execute_delete() { // SELECT to fetch the IDs of the rows to delete, then delete them // using a separate DELETE query. - $this->table_name = $rewriter->skip()->value; + $this->table_name = $this->normalize_column_name( $rewriter->skip()->value ); $rewriter->add( new WP_SQLite_Token( 'SELECT', WP_SQLite_Token::TYPE_KEYWORD, WP_SQLite_Token::FLAG_KEYWORD_RESERVED ) ); /* @@ -1433,7 +1434,7 @@ private function execute_delete() { for ( $i = $index + 1; $i < $rewriter->max; $i++ ) { // Assume the table name is the first token after FROM. if ( ! $rewriter->input_tokens[ $i ]->is_semantically_void() ) { - $this->table_name = $rewriter->input_tokens[ $i ]->value; + $this->table_name = $this->normalize_column_name( $rewriter->input_tokens[ $i ]->value ); break; } } @@ -1445,7 +1446,7 @@ private function execute_delete() { * Now, let's figure out the primary key name. * This assumes that all listed table names are the same. */ - $q = $this->execute_sqlite_query( 'SELECT l.name FROM pragma_table_info("' . $this->table_name . '") as l WHERE l.pk = 1;' ); + $q = $this->execute_sqlite_query( 'SELECT l.name FROM pragma_table_info(' . $this->pdo->quote( $this->table_name ) . ') as l WHERE l.pk = 1;' ); $pk_name = $q->fetch()['name']; /* @@ -1468,7 +1469,7 @@ private function execute_delete() { $rewriter->add_many( array( new WP_SQLite_Token( '.', WP_SQLite_Token::TYPE_OPERATOR, WP_SQLite_Token::FLAG_OPERATOR_SQL ), - new WP_SQLite_Token( $pk_name, WP_SQLite_Token::TYPE_KEYWORD, WP_SQLite_Token::FLAG_KEYWORD_KEY ), + new WP_SQLite_Token( $this->quote_identifier( $pk_name ), WP_SQLite_Token::TYPE_KEYWORD, WP_SQLite_Token::FLAG_KEYWORD_KEY ), new WP_SQLite_Token( ' ', WP_SQLite_Token::TYPE_WHITESPACE ), new WP_SQLite_Token( 'AS', WP_SQLite_Token::TYPE_KEYWORD, WP_SQLite_Token::FLAG_KEYWORD_RESERVED ), new WP_SQLite_Token( ' ', WP_SQLite_Token::TYPE_WHITESPACE ), @@ -1491,10 +1492,12 @@ private function execute_delete() { $ids_to_delete[] = $id['id_1']; } - $query = ( + $quoted_table = $this->quote_identifier( $this->table_name ); + $quoted_pk = $this->quote_identifier( $pk_name ); + $query = ( count( $ids_to_delete ) - ? "DELETE FROM {$this->table_name} WHERE {$pk_name} IN (" . implode( ',', $ids_to_delete ) . ')' - : "DELETE FROM {$this->table_name} WHERE 0=1" + ? "DELETE FROM {$quoted_table} WHERE {$quoted_pk} IN (" . implode( ',', $ids_to_delete ) . ')' + : "DELETE FROM {$quoted_table} WHERE 0=1" ); $this->execute_sqlite_query( $query ); $this->set_result_from_affected_rows( @@ -1765,12 +1768,12 @@ private function describe( $table_name ) { ELSE 'PRI' END ) as `Key` - FROM pragma_table_info(\"$table_name\") p - LEFT JOIN " . self::DATA_TYPES_CACHE_TABLE . " d - ON d.`table` = \"$table_name\" + FROM pragma_table_info(" . $this->pdo->quote( $table_name ) . ') p + LEFT JOIN ' . self::DATA_TYPES_CACHE_TABLE . ' d + ON d.`table` = ' . $this->pdo->quote( $table_name ) . ' AND d.`column_or_index` = p.`name` ; - " + ' ) ->fetchAll( $this->pdo_fetch_mode ); } @@ -1891,7 +1894,7 @@ private function preface_where_clause_with_a_subquery() { new WP_SQLite_Token( ' ', WP_SQLite_Token::TYPE_WHITESPACE ), new WP_SQLite_Token( 'FROM', WP_SQLite_Token::TYPE_KEYWORD, WP_SQLite_Token::FLAG_KEYWORD_RESERVED ), new WP_SQLite_Token( ' ', WP_SQLite_Token::TYPE_WHITESPACE ), - new WP_SQLite_Token( $this->table_name, WP_SQLite_Token::TYPE_KEYWORD, WP_SQLite_Token::FLAG_KEYWORD_RESERVED ), + new WP_SQLite_Token( $this->quote_identifier( $this->table_name ), WP_SQLite_Token::TYPE_KEYWORD, WP_SQLite_Token::FLAG_KEYWORD_KEY ), new WP_SQLite_Token( ' ', WP_SQLite_Token::TYPE_WHITESPACE ), ) ); @@ -2955,7 +2958,7 @@ private function translate_on_duplicate_key( $table_name ) { $max = count( $conflict_columns ); $i = 0; foreach ( $conflict_columns as $conflict_column ) { - $this->rewriter->add( new WP_SQLite_Token( '"' . $conflict_column . '"', WP_SQLite_Token::TYPE_KEYWORD, WP_SQLite_Token::FLAG_KEYWORD_KEY ) ); + $this->rewriter->add( new WP_SQLite_Token( $this->quote_identifier( $conflict_column ), WP_SQLite_Token::TYPE_KEYWORD, WP_SQLite_Token::FLAG_KEYWORD_KEY ) ); if ( ++$i < $max ) { $this->rewriter->add( new WP_SQLite_Token( ',', WP_SQLite_Token::TYPE_OPERATOR ) ); $this->rewriter->add( new WP_SQLite_Token( ' ', WP_SQLite_Token::TYPE_WHITESPACE ) ); @@ -2993,12 +2996,12 @@ private function get_primary_keys( $table_name ) { * @return array */ private function get_keys( $table_name, $only_unique = false ) { - $query = $this->execute_sqlite_query( 'SELECT * FROM pragma_index_list("' . $table_name . '") as l;' ); + $query = $this->execute_sqlite_query( 'SELECT * FROM pragma_index_list(' . $this->pdo->quote( $table_name ) . ') as l;' ); $indices = $query->fetchAll(); $results = array(); foreach ( $indices as $index ) { if ( ! $only_unique || '1' === $index['unique'] ) { - $query = $this->execute_sqlite_query( 'SELECT * FROM pragma_index_info("' . $index['name'] . '") as l;' ); + $query = $this->execute_sqlite_query( 'SELECT * FROM pragma_index_info(' . $this->pdo->quote( $index['name'] ) . ') as l;' ); $results[] = array( 'index' => $index, 'columns' => $query->fetchAll(), @@ -3049,7 +3052,7 @@ private function execute_alter() { new WP_SQLite_Token( ' ', WP_SQLite_Token::TYPE_WHITESPACE ), new WP_SQLite_Token( 'TABLE', WP_SQLite_Token::TYPE_KEYWORD ), new WP_SQLite_Token( ' ', WP_SQLite_Token::TYPE_WHITESPACE ), - new WP_SQLite_Token( $this->table_name, WP_SQLite_Token::TYPE_KEYWORD ), + new WP_SQLite_Token( $this->quote_identifier( $this->table_name ), WP_SQLite_Token::TYPE_KEYWORD, WP_SQLite_Token::FLAG_KEYWORD_KEY ), ) ); $op_type = strtoupper( $this->rewriter->consume()->token ?? '' ); @@ -3153,7 +3156,7 @@ private function execute_alter() { // Drop ON UPDATE trigger by the old column name. $on_update_trigger_name = $this->get_column_on_update_current_timestamp_trigger_name( $this->table_name, $from_name ); - $this->execute_sqlite_query( "DROP TRIGGER IF EXISTS \"$on_update_trigger_name\"" ); + $this->execute_sqlite_query( 'DROP TRIGGER IF EXISTS ' . $this->quote_identifier( $on_update_trigger_name ) ); /* * In SQLite, there is no direct equivalent to the CHANGE COLUMN @@ -3186,7 +3189,7 @@ private function execute_alter() { if ( ! $token ) { break; } - if ( WP_SQLite_Token::TYPE_STRING !== $token->type + if ( ( WP_SQLite_Token::TYPE_STRING !== $token->type && WP_SQLite_Token::TYPE_SYMBOL !== $token->type ) || $from_name !== $this->normalize_column_name( $token->value ) ) { continue; } @@ -3222,30 +3225,33 @@ private function execute_alter() { // Otherwise, just add the new name in place of the old name we dropped. $create_table->add( new WP_SQLite_Token( - "`$new_field->name`", - WP_SQLite_Token::TYPE_KEYWORD + $this->quote_identifier( $new_field->name ), + WP_SQLite_Token::TYPE_KEYWORD, + WP_SQLite_Token::FLAG_KEYWORD_KEY ) ); } } // 3. Copy the data out of the old table - $cache_table_name = "_tmp__{$this->table_name}_" . rand( 10000000, 99999999 ); + $cache_table_name = "_tmp__{$this->table_name}_" . rand( 10000000, 99999999 ); + $quoted_cache_table = $this->quote_identifier( $cache_table_name ); + $quoted_table = $this->quote_identifier( $this->table_name ); $this->execute_sqlite_query( - "CREATE TABLE `$cache_table_name` as SELECT * FROM `$this->table_name`" + "CREATE TABLE $quoted_cache_table as SELECT * FROM $quoted_table" ); // 4. Drop the old table to free up the indexes names - $this->execute_sqlite_query( "DROP TABLE `$this->table_name`" ); + $this->execute_sqlite_query( "DROP TABLE $quoted_table" ); // 5. Create a new table from the updated schema $this->execute_sqlite_query( $create_table->get_updated_query() ); // 6. Copy the data from step 3 to the new table - $this->execute_sqlite_query( "INSERT INTO {$this->table_name} SELECT * FROM $cache_table_name" ); + $this->execute_sqlite_query( "INSERT INTO $quoted_table SELECT * FROM $quoted_cache_table" ); // 7. Drop the old table copy - $this->execute_sqlite_query( "DROP TABLE `$cache_table_name`" ); + $this->execute_sqlite_query( "DROP TABLE $quoted_cache_table" ); // 8. Restore any indexes that were dropped in step 4 foreach ( $old_indexes as $row ) { @@ -3260,8 +3266,8 @@ private function execute_alter() { $columns = array(); foreach ( $row['columns'] as $column ) { $columns[] = ( $column['name'] === $from_name ) - ? '`' . $new_field->name . '`' - : '`' . $column['name'] . '`'; + ? $this->quote_identifier( $new_field->name ) + : $this->quote_identifier( $column['name'] ); } $unique = '1' === $row['index']['unique'] ? 'UNIQUE' : ''; @@ -3271,7 +3277,7 @@ private function execute_alter() { * a part of the CREATE TABLE statement */ $this->execute_sqlite_query( - "CREATE $unique INDEX IF NOT EXISTS `{$row['index']['name']}` ON $this->table_name (" . implode( ', ', $columns ) . ')' + "CREATE $unique INDEX IF NOT EXISTS " . $this->quote_identifier( $row['index']['name'] ) . " ON $quoted_table (" . implode( ', ', $columns ) . ')' ); } @@ -3300,11 +3306,11 @@ private function execute_alter() { new WP_SQLite_Token( ' ', WP_SQLite_Token::TYPE_WHITESPACE ), new WP_SQLite_Token( $sqlite_index_type, WP_SQLite_Token::TYPE_KEYWORD, WP_SQLite_Token::FLAG_KEYWORD_RESERVED ), new WP_SQLite_Token( ' ', WP_SQLite_Token::TYPE_WHITESPACE ), - new WP_SQLite_Token( "\"$sqlite_index_name\"", WP_SQLite_Token::TYPE_KEYWORD, WP_SQLite_Token::FLAG_KEYWORD_KEY ), + new WP_SQLite_Token( $this->quote_identifier( $sqlite_index_name ), WP_SQLite_Token::TYPE_KEYWORD, WP_SQLite_Token::FLAG_KEYWORD_KEY ), new WP_SQLite_Token( ' ', WP_SQLite_Token::TYPE_WHITESPACE ), new WP_SQLite_Token( 'ON', WP_SQLite_Token::TYPE_KEYWORD, WP_SQLite_Token::FLAG_KEYWORD_RESERVED ), new WP_SQLite_Token( ' ', WP_SQLite_Token::TYPE_WHITESPACE ), - new WP_SQLite_Token( "\"$this->table_name\"", WP_SQLite_Token::TYPE_STRING, WP_SQLite_Token::FLAG_STRING_DOUBLE_QUOTES ), + new WP_SQLite_Token( $this->quote_identifier( $this->table_name ), WP_SQLite_Token::TYPE_KEYWORD, WP_SQLite_Token::FLAG_KEYWORD_KEY ), new WP_SQLite_Token( ' ', WP_SQLite_Token::TYPE_WHITESPACE ), new WP_SQLite_Token( '(', WP_SQLite_Token::TYPE_OPERATOR ), ) @@ -3332,8 +3338,8 @@ private function execute_alter() { } // $token is field name. if ( ! $token->matches( WP_SQLite_Token::TYPE_OPERATOR ) ) { - $token->token = '`' . $this->normalize_column_name( $token->token ) . '`'; - $token->value = '`' . $this->normalize_column_name( $token->token ) . '`'; + $token->token = $this->quote_identifier( $this->normalize_column_name( $token->token ) ); + $token->value = $token->token; } /* @@ -3358,7 +3364,7 @@ private function execute_alter() { new WP_SQLite_Token( ' ', WP_SQLite_Token::TYPE_WHITESPACE ), new WP_SQLite_Token( 'INDEX', WP_SQLite_Token::TYPE_KEYWORD, WP_SQLite_Token::FLAG_KEYWORD_RESERVED ), new WP_SQLite_Token( ' ', WP_SQLite_Token::TYPE_WHITESPACE ), - new WP_SQLite_Token( "\"{$this->table_name}__$key_name\"", WP_SQLite_Token::TYPE_KEYWORD, WP_SQLite_Token::FLAG_KEYWORD_KEY ), + new WP_SQLite_Token( $this->quote_identifier( $this->table_name . '__' . $key_name ), WP_SQLite_Token::TYPE_KEYWORD, WP_SQLite_Token::FLAG_KEYWORD_KEY ), ) ); } else { @@ -3618,7 +3624,7 @@ private function execute_show() { $tables = $this->strip_sqlite_system_tables( $stmt->fetchAll( $this->pdo_fetch_mode ) ); foreach ( $tables as $table ) { $table_name = $table->Name; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase - $stmt = $this->execute_sqlite_query( "SELECT COUNT(1) as `Rows` FROM $table_name" ); + $stmt = $this->execute_sqlite_query( 'SELECT COUNT(1) as `Rows` FROM ' . $this->quote_identifier( $table_name ) ); $rows = $stmt->fetchall( $this->pdo_fetch_mode ); $table->Rows = $rows[0]->Rows; // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase } @@ -3710,7 +3716,7 @@ private function generate_create_statement() { * @return stdClass[] */ protected function get_table_columns( $table_name ) { - return $this->execute_sqlite_query( "PRAGMA table_info(\"$table_name\");" ) + return $this->execute_sqlite_query( 'PRAGMA table_info(' . $this->pdo->quote( $table_name ) . ');' ) ->fetchAll( $this->pdo_fetch_mode ); } @@ -3729,7 +3735,7 @@ protected function get_column_definitions( $table_name, $columns ) { $mysql_type = $this->get_cached_mysql_data_type( $table_name, $column->name ); $is_auto_incr = $auto_increment_column && strtolower( $auto_increment_column ) === strtolower( $column->name ); $definition = array(); - $definition[] = '`' . $column->name . '`'; + $definition[] = $this->quote_identifier( $column->name ); $definition[] = $mysql_type ?? $column->name; if ( '1' === $column->notnull ) { @@ -3787,7 +3793,7 @@ private function get_key_definitions( $table_name, $columns ) { // Remove the prefix from the index name if there is any. We use __ as a separator. $index_name = explode( '__', $key['index']['name'], 2 )[1] ?? $key['index']['name']; - $key_definition[] = sprintf( '`%s`', $index_name ); + $key_definition[] = $this->quote_identifier( $index_name ); $cols = array_map( function ( $column ) use ( $table_name, $key_length_limit ) { @@ -3807,9 +3813,9 @@ function ( $column ) use ( $table_name, $key_length_limit ) { str_ends_with( $data_type, 'blob' ) || str_starts_with( $data_type, 'var' ) ) { - return sprintf( '`%s`(%s)', $column['name'], $data_length ); + return $this->quote_identifier( $column['name'] ) . '(' . $data_length . ')'; } - return sprintf( '`%s`', $column['name'] ); + return $this->quote_identifier( $column['name'] ); }, $key['columns'] ); @@ -3842,7 +3848,7 @@ function ( $a, $b ) { foreach ( $columns as $column ) { if ( '0' !== $column->pk ) { - $primary_keys[] = sprintf( '`%s`', $column->name ); + $primary_keys[] = $this->quote_identifier( $column->name ); } } @@ -3860,7 +3866,7 @@ function ( $a, $b ) { */ private function get_autoincrement_column( $table_name ) { preg_match( - '/"([^"]+)"\s+integer\s+primary\s+key\s+autoincrement/i', + '/[`"]([^`"]+)[`"]\s+integer\s+primary\s+key\s+autoincrement/i', $this->get_sqlite_create_table( $table_name ), $matches ); @@ -4050,6 +4056,21 @@ private function normalize_column_name( $column_name ) { return trim( $column_name, '`\'"' ); } + /** + * Quotes an identifier for safe use in SQLite queries. + * + * Wraps the identifier in backticks and escapes any internal backticks + * by doubling them. This ensures identifiers with special characters + * are properly escaped in the target SQLite query context. + * + * @param string $identifier The unquoted identifier. + * + * @return string The properly quoted identifier. + */ + private function quote_identifier( $identifier ) { + return '`' . str_replace( '`', '``', $identifier ) . '`'; + } + /** * Normalizes an index type. * @@ -4487,12 +4508,15 @@ private function add_column_on_update_current_timestamp( $table, $column ) { // The trigger wouldn't work for virtual and "WITHOUT ROWID" tables, // but currently that can't happen as we're not creating such tables. // See: https://www.sqlite.org/rowidtable.html + $quoted_trigger = $this->quote_identifier( $trigger_name ); + $quoted_table = $this->quote_identifier( $table ); + $quoted_column = $this->quote_identifier( $column ); $this->execute_sqlite_query( - "CREATE TRIGGER \"$trigger_name\" - AFTER UPDATE ON \"$table\" + "CREATE TRIGGER $quoted_trigger + AFTER UPDATE ON $quoted_table FOR EACH ROW BEGIN - UPDATE \"$table\" SET \"$column\" = CURRENT_TIMESTAMP WHERE rowid = NEW.rowid; + UPDATE $quoted_table SET $quoted_column = CURRENT_TIMESTAMP WHERE rowid = NEW.rowid; END" ); }