diff --git a/tests/WP_SQLite_Translator_Tests.php b/tests/WP_SQLite_Translator_Tests.php index 4192740e..d22a9ec1 100644 --- a/tests/WP_SQLite_Translator_Tests.php +++ b/tests/WP_SQLite_Translator_Tests.php @@ -3056,6 +3056,94 @@ public function testSelectVariable( $variable_name ) { $this->assertQuery( "SELECT $variable_name;" ); } + public function testAlterColumnSetAndDropDefault() { + $this->assertQuery( + 'CREATE TABLE _tmp_table ( + name varchar(20) NOT NULL + );' + ); + $result = $this->assertQuery( 'DESCRIBE _tmp_table' ); + $this->assertEquals( + array( + (object) array( + 'Field' => 'name', + 'Type' => 'varchar(20)', + 'Null' => 'NO', + 'Key' => '', + 'Default' => '', + 'Extra' => '', + ), + ), + $result + ); + + // SET DEFAULT + $this->assertQuery( "ALTER TABLE _tmp_table ALTER COLUMN name SET DEFAULT 'abc'" ); + $result = $this->assertQuery( 'DESCRIBE _tmp_table' ); + $this->assertEquals( + array( + (object) array( + 'Field' => 'name', + 'Type' => 'varchar(20)', + 'Null' => 'NO', + 'Key' => '', + 'Default' => 'abc', + 'Extra' => '', + ), + ), + $result + ); + + // DROP DEFAULT + $this->assertQuery( 'ALTER TABLE _tmp_table ALTER COLUMN name DROP DEFAULT' ); + $result = $this->assertQuery( 'DESCRIBE _tmp_table' ); + $this->assertEquals( + array( + (object) array( + 'Field' => 'name', + 'Type' => 'varchar(20)', + 'Null' => 'NO', + 'Key' => '', + 'Default' => '', + 'Extra' => '', + ), + ), + $result + ); + + // multiple ALTER statements, with and without the COLUMN keyword + $this->assertQuery( "ALTER TABLE _tmp_table ADD COLUMN value varchar(255) DEFAULT 'aaa'" ); + $this->assertQuery( + "ALTER TABLE _tmp_table + ALTER name SET DEFAULT 'bbb', + ALTER COLUMN name DROP DEFAULT, + ALTER value DROP DEFAULT, + ALTER COLUMN name SET DEFAULT 'ccc'" + ); + $result = $this->assertQuery( 'DESCRIBE _tmp_table' ); + $this->assertEquals( + array( + (object) array( + 'Field' => 'name', + 'Type' => 'varchar(20)', + 'Null' => 'NO', + 'Key' => '', + 'Default' => '', + 'Extra' => '', + ), + (object) array( + 'Field' => 'value', + 'Type' => 'varchar(255)', + 'Null' => 'YES', + 'Key' => '', + 'Default' => 'aaa', + 'Extra' => '', + ), + ), + $result + ); + } + public static function mysqlVariablesToTest() { return array( // NOTE: This list was derived from the variables used by the UpdraftPlus plugin. diff --git a/wp-includes/sqlite/class-wp-sqlite-translator.php b/wp-includes/sqlite/class-wp-sqlite-translator.php index 06fd0529..572e009c 100644 --- a/wp-includes/sqlite/class-wp-sqlite-translator.php +++ b/wp-includes/sqlite/class-wp-sqlite-translator.php @@ -3063,126 +3063,14 @@ private function execute_alter() { $new_field->mysql_data_type ); - /* - * In SQLite, there is no direct equivalent to the CHANGE COLUMN - * statement from MySQL. We need to do a bit of work to emulate it. - * - * The idea is to: - * 1. Get the existing table schema. - * 2. Adjust the column definition. - * 3. Copy the data out of the old table. - * 4. Drop the old table to free up the indexes names. - * 5. Create a new table from the updated schema. - * 6. Copy the data from step 3 to the new table. - * 7. Drop the old table copy. - * 8. Restore any indexes that were dropped in step 4. - */ - - // 1. Get the existing table schema. - $old_schema = $this->get_sqlite_create_table( $this->table_name ); - $old_indexes = $this->get_keys( $this->table_name, false ); - - // 2. Adjust the column definition. - - // First, tokenize the old schema. - $tokens = ( new WP_SQLite_Lexer( $old_schema ) )->tokens; - $create_table = new WP_SQLite_Query_Rewriter( $tokens ); - - // Now, replace every reference to the old column name with the new column name. - while ( true ) { - $token = $create_table->consume(); - if ( ! $token ) { - break; - } - if ( WP_SQLite_Token::TYPE_STRING !== $token->type - || $from_name !== $this->normalize_column_name( $token->value ) ) { - continue; - } - - // We found the old column name, let's remove it. - $create_table->drop_last(); - - // If the next token is a data type, we're dealing with a column definition. - $is_column_definition = $create_table->peek()->matches( - WP_SQLite_Token::TYPE_KEYWORD, - WP_SQLite_Token::FLAG_KEYWORD_DATA_TYPE - ); - if ( $is_column_definition ) { - // Skip the old field definition. - $field_depth = $create_table->depth; - do { - $field_terminator = $create_table->skip(); - } while ( - ! $this->is_create_table_field_terminator( - $field_terminator, - $field_depth, - $create_table->depth - ) - ); - - // Add an updated field definition. - $definition = $this->make_sqlite_field_definition( $new_field ); - // Technically it's not a token, but it's fine to cheat a little bit. - $create_table->add( new WP_SQLite_Token( $definition, WP_SQLite_Token::TYPE_KEYWORD ) ); - // Restore the terminating "," or ")" token. - $create_table->add( $field_terminator ); - } else { - // 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->execute_change( + function ( $old_name ) use ( $from_name, $new_field ) { + if ( $from_name === $old_name ) { + return $this->make_sqlite_field_definition( $new_field ); + } } - } - - // 3. Copy the data out of the old table - $cache_table_name = "_tmp__{$this->table_name}_" . rand( 10000000, 99999999 ); - $this->execute_sqlite_query( - "CREATE TABLE `$cache_table_name` as SELECT * FROM `$this->table_name`" ); - // 4. Drop the old table to free up the indexes names - $this->execute_sqlite_query( "DROP TABLE `$this->table_name`" ); - - // 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" ); - - // 7. Drop the old table copy - $this->execute_sqlite_query( "DROP TABLE `$cache_table_name`" ); - - // 8. Restore any indexes that were dropped in step 4 - foreach ( $old_indexes as $row ) { - /* - * Skip indexes prefixed with sqlite_autoindex_ - * (these are automatically created by SQLite). - */ - if ( str_starts_with( $row['index']['name'], 'sqlite_autoindex_' ) ) { - continue; - } - - $columns = array(); - foreach ( $row['columns'] as $column ) { - $columns[] = ( $column['name'] === $from_name ) - ? '`' . $new_field->name . '`' - : '`' . $column['name'] . '`'; - } - - $unique = '1' === $row['index']['unique'] ? 'UNIQUE' : ''; - - /* - * Use IF NOT EXISTS to avoid collisions with indexes that were - * 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 ) . ')' - ); - } - if ( ',' === $alter_terminator->token ) { /* * If the terminator was a comma, @@ -3193,6 +3081,70 @@ private function execute_alter() { } // We're done. break; + } elseif ( 'ALTER' === $op_type ) { + $raw_from_name = 'COLUMN' === $op_subject ? $this->rewriter->skip()->token : $op_raw_subject; + $from_name = $this->normalize_column_name( $raw_from_name ); + + $set_or_drop_default = $this->rewriter->peek()->matches( + WP_SQLite_Token::TYPE_KEYWORD, + WP_SQLite_Token::FLAG_KEYWORD_RESERVED, + array( 'SET', 'DROP' ) + ) && $this->rewriter->peek_nth( 2 )->matches( + WP_SQLite_Token::TYPE_KEYWORD, + WP_SQLite_Token::FLAG_KEYWORD_RESERVED, + array( 'DEFAULT' ) + ); + + // Handle "CHANGE DROP DEFAULT" and "CHANGE SET DEFAULT ". + if ( $set_or_drop_default ) { + $this->execute_change( + function ( $old_name, $old_column ) use ( $from_name ) { + //$old_column->consume_all(); + //var_dump($old_column->get_updated_query());ob_flush(); + //$old_column->replace_all([]); + if ( $from_name !== $old_name ) { + return null; + } + + // 1. Drop "DEFAULT " from old column definition. + do { + $is_default = $old_column->peek()->matches( + WP_SQLite_Token::TYPE_KEYWORD, + WP_SQLite_Token::FLAG_KEYWORD_RESERVED, + array( 'DEFAULT' ) + ); + if ( $is_default ) { + $old_column->skip(); // DEFAULT + $old_column->skip(); // value + } else { + $old_column->consume(); + } + } while ( $old_column->peek() ); + + // 2. For SET, add new "DEFAULT " to column definition. + $keyword = $this->rewriter->consume(); + if ( 'SET' === $keyword->value ) { + $old_column->add( new WP_SQLite_Token( ' ', WP_SQLite_Token::TYPE_WHITESPACE ) ); + $old_column->add( $this->rewriter->consume() ); // DEFAULT + $old_column->add( new WP_SQLite_Token( ' ', WP_SQLite_Token::TYPE_WHITESPACE ) ); + $old_column->add( $this->rewriter->consume() ); // value + } + return $old_column->get_updated_query(); + } + ); + + if ( ',' === $this->rewriter->peek()->value ) { + /* + * If the terminator was a comma, + * we need to continue processing the rest of the ALTER query. + */ + $this->rewriter->consume(); + $comma = true; + continue; + } + // We're done. + break; + } } elseif ( 'ADD' === $op_type && $is_index_op ) { $key_name = $this->rewriter->consume()->value; $sqlite_index_type = $this->mysql_index_type_to_sqlite_type( $mysql_index_type ); @@ -3363,6 +3315,146 @@ private function execute_drop() { } } + /** + * Translates a CHANGE query. + * + * In SQLite, there is no direct equivalent to the CHANGE COLUMN + * statement from MySQL. We need to do a bit of work to emulate it. + * + * The idea is to: + * 1. Get the existing table schema. + * 2. Adjust the column definition. + * 3. Copy the data out of the old table. + * 4. Drop the old table to free up the indexes names. + * 5. Create a new table from the updated schema. + * 6. Copy the data from step 3 to the new table. + * 7. Drop the old table copy. + * 8. Restore any indexes that were dropped in step 4. + * + * @param callable(string, WP_SQLite_Query_Rewriter): string|null $update_column_callback + */ + private function execute_change( $update_column_callback ) { + // 1. Get the existing table schema. + $old_schema = $this->get_sqlite_create_table( $this->table_name ); + $old_indexes = $this->get_keys( $this->table_name, false ); + + // 2. Adjust the column definition. + + // First, tokenize the old schema. + $tokens = ( new WP_SQLite_Lexer( $old_schema ) )->tokens; + $create_table = new WP_SQLite_Query_Rewriter( $tokens ); + + // Now, replace every reference to the old column name with the new column name. + $renames = array(); + while ( true ) { + $token = $create_table->consume(); + if ( ! $token ) { + break; + } + if ( WP_SQLite_Token::TYPE_STRING !== $token->type ) { + continue; + } + + // We found the old column name, let's store it and remove it from the old schema. + $old_name_token = $create_table->drop_last(); + $old_name = $old_name_token->value; + + // If the next token is a data type, we're dealing with a column definition. + $is_column_definition = $create_table->peek()->matches( + WP_SQLite_Token::TYPE_KEYWORD, + WP_SQLite_Token::FLAG_KEYWORD_DATA_TYPE + ); + if ( $is_column_definition ) { + // Skip the old field definition in the old schema and store it separately. + $field_depth = $create_table->depth; + $old_field_tokens = array( $old_name_token ); + do { + $field_terminator = $create_table->skip(); + $old_field_tokens[] = new WP_SQLite_Token( ' ', WP_SQLite_Token::TYPE_WHITESPACE ); + $old_field_tokens[] = $field_terminator; + } while ( + ! $this->is_create_table_field_terminator( + $field_terminator, + $field_depth, + $create_table->depth + ) + ); + array_pop( $old_field_tokens ); // terminator + $old_field = new WP_SQLite_Query_Rewriter( $old_field_tokens ); + + // Add an updated field definition. + // 1) string = new column definition, + // 2) null = no change, use the old definition. + // (We could add "false" to implement DROP COLUMN in the future.) + $definition = $update_column_callback( $old_name, $old_field ); + if ( null === $definition ) { + $old_field->consume_all(); + $definition = $old_field->get_updated_query(); + } + + // Save new column name. + $new_name = ( new WP_SQLite_Lexer( $definition ) )->tokens[0] ?? $old_name; + $renames[ $old_name ] = $new_name->value; + + // Technically it's not a token, but it's fine to cheat a little bit. + $create_table->add( new WP_SQLite_Token( $definition, WP_SQLite_Token::TYPE_KEYWORD ) ); + // Restore the terminating "," or ")" token. + $create_table->add( $field_terminator ); + } else { + // Otherwise, just add the new name in place of the old name we dropped. + $create_table->add( + isset( $renames[ $old_name ] ) + ? new WP_SQLite_Token( '"' . $renames[ $old_name ] . '"', WP_SQLite_Token::TYPE_STRING ) + : $old_name_token + ); + } + } + + // 3. Copy the data out of the old table + $cache_table_name = "_tmp__{$this->table_name}_" . rand( 10000000, 99999999 ); + $this->execute_sqlite_query( + "CREATE TABLE `$cache_table_name` as SELECT * FROM `$this->table_name`" + ); + + // 4. Drop the old table to free up the indexes names + $this->execute_sqlite_query( "DROP TABLE `$this->table_name`" ); + + // 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" ); + + // 7. Drop the old table copy + $this->execute_sqlite_query( "DROP TABLE `$cache_table_name`" ); + + // 8. Restore any indexes that were dropped in step 4 + foreach ( $old_indexes as $row ) { + /* + * Skip indexes prefixed with sqlite_autoindex_ + * (these are automatically created by SQLite). + */ + if ( str_starts_with( $row['index']['name'], 'sqlite_autoindex_' ) ) { + continue; + } + + $columns = array(); + foreach ( $row['columns'] as $column ) { + $columns[] = '`' . ( $renames[ $column['name'] ] ?? $column['name'] ) . '`'; + } + + $unique = '1' === $row['index']['unique'] ? 'UNIQUE' : ''; + + /* + * Use IF NOT EXISTS to avoid collisions with indexes that were + * 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 ) . ')' + ); + } + } + /** * Translates a SHOW query. *