diff --git a/features/db-export.feature b/features/db-export.feature index 932e12ed..11354f75 100644 --- a/features/db-export.feature +++ b/features/db-export.feature @@ -43,6 +43,7 @@ Feature: Export a WordPress database -- Dump completed on """ + @require-mysql Scenario: Export database with mysql defaults to STDOUT Given a WP install @@ -52,6 +53,7 @@ Feature: Export a WordPress database -- Dump completed on """ + @require-mysql Scenario: Export database with mysql --no-defaults to STDOUT Given a WP install @@ -61,6 +63,7 @@ Feature: Export a WordPress database -- Dump completed on """ + @require-mysql Scenario: Export database with passed-in options Given a WP install @@ -78,6 +81,7 @@ Feature: Export a WordPress database """ And STDOUT should be empty + @require-mysql Scenario: MySQL defaults are available as appropriate with --defaults flag Given a WP install diff --git a/features/db-import.feature b/features/db-import.feature index af32c1be..b06344cb 100644 --- a/features/db-import.feature +++ b/features/db-import.feature @@ -12,6 +12,7 @@ Feature: Import a WordPress database Success: Imported from 'wp_cli_test.sql'. """ + @require-mysql Scenario: Import from database name path by default with mysql defaults Given a WP install @@ -24,6 +25,7 @@ Feature: Import a WordPress database Success: Imported from 'wp_cli_test.sql'. """ + @require-mysql Scenario: Import from database name path by default with --no-defaults Given a WP install @@ -36,15 +38,19 @@ Feature: Import a WordPress database Success: Imported from 'wp_cli_test.sql'. """ - Scenario: Import from STDIN - Given a WP install + Scenario: Import from STDIN + Given a WP install - When I run `wp db import -` - Then STDOUT should be: - """ - Success: Imported from 'STDIN'. - """ + When I run `wp db export wp_cli_test.sql` + Then the wp_cli_test.sql file should exist + + When I run `cat wp_cli_test.sql | wp db import -` + Then STDOUT should be: + """ + Success: Imported from 'STDIN'. + """ + @require-mysql Scenario: Import from database name path by default and skip speed optimization Given a WP install @@ -56,7 +62,7 @@ Feature: Import a WordPress database """ Success: Imported from 'wp_cli_test.sql'. """ - + @require-mysql Scenario: Import from database name path by default with passed-in dbuser/dbpass Given a WP install @@ -91,6 +97,7 @@ Feature: Import a WordPress database Success: Imported from 'debug.sql'. """ + @require-mysql Scenario: Help runs properly at various points of a functional WP install Given an empty directory @@ -128,6 +135,7 @@ Feature: Import a WordPress database """ wp db import """ + @require-mysql Scenario: MySQL defaults are available as appropriate with --defaults flag Given a WP install @@ -152,7 +160,7 @@ Feature: Import a WordPress database Debug (db): Running shell command: /usr/bin/env mysql --no-defaults --no-auto-rehash """ - @require-wp-4.2 + @require-wp-4.2 @require-mysql Scenario: Import db that has emoji in post Given a WP install diff --git a/features/db-query.feature b/features/db-query.feature index 627ef534..f3d6bec9 100644 --- a/features/db-query.feature +++ b/features/db-query.feature @@ -75,23 +75,23 @@ Feature: Query the database with WordPress' MySQL config Scenario: MySQL defaults are available as appropriate with --defaults flag Given a WP install - When I try `wp db query --defaults --debug` + When I try `"select 1" | wp db query --defaults --debug` Then STDERR should contain: """ Debug (db): Running shell command: /usr/bin/env mysql --no-auto-rehash """ - When I try `wp db query --debug` + When I try `"select 1" | wp db query --debug` Then STDERR should contain: """ Debug (db): Running shell command: /usr/bin/env mysql --no-defaults --no-auto-rehash """ - When I try `wp db query --no-defaults --debug` + When I try `"select 1" | wp db query --no-defaults --debug` Then STDERR should contain: """ Debug (db): Running shell command: /usr/bin/env mysql --no-defaults --no-auto-rehash - """ + """ Scenario: SQL modes do not include any of the modes incompatible with WordPress Given a WP install diff --git a/features/db.feature b/features/db.feature index a6c32e10..0bdad0ba 100644 --- a/features/db.feature +++ b/features/db.feature @@ -272,7 +272,7 @@ Feature: Perform database operations When I run `cat wp-config.php` Then STDOUT should contain: """ - define( 'DB_CHARSET', '' ); + define( 'DB_CHARSET', 'utf8' ); """ When I run `wp db create` diff --git a/src/DB_Command.php b/src/DB_Command.php index 17d1c00e..617a1456 100644 --- a/src/DB_Command.php +++ b/src/DB_Command.php @@ -1,5 +1,7 @@ run( $result_file, $assoc_args ); + return; + } + if ( ! $stdout ) { $assoc_args['result-file'] = $result_file; } @@ -762,6 +772,13 @@ public function import( $args, $assoc_args ) { $result_file = sprintf( '%s.sql', DB_NAME ); } + // Check if SQLite is enabled and use it if it is. + if ( Import::get_sqlite_plugin_version() ) { + $importer = new Import(); + $importer->run( $result_file, $assoc_args ); + return; + } + // Process options to MySQL. $mysql_args = array_merge( [ 'database' => DB_NAME ], @@ -842,7 +859,6 @@ public function import( $args, $assoc_args ) { * # Export only tables for a single site * $ wp db export --tables=$(wp db tables --url=sub.example.com --format=csv) * Success: Exported to wordpress_dbase.sql - * * @when after_wp_load */ public function tables( $args, $assoc_args ) { diff --git a/src/SQLite/Base.php b/src/SQLite/Base.php new file mode 100644 index 00000000..0df48832 --- /dev/null +++ b/src/SQLite/Base.php @@ -0,0 +1,115 @@ +unsupported_arguments ) ) ) { + WP_CLI::error( + sprintf( + 'The following arguments are not supported by SQLite exports: %s', + implode( ', ', $this->unsupported_arguments ) + ) + ); + } + } +} diff --git a/src/SQLite/Export.php b/src/SQLite/Export.php new file mode 100644 index 00000000..78302c20 --- /dev/null +++ b/src/SQLite/Export.php @@ -0,0 +1,282 @@ +load_dependencies(); + $this->translator = new WP_SQLite_Translator(); + } + + /** + * Run the export command. + * + * @param string $result_file The file to write the exported data to. + * @param array $args The arguments passed to the command. + * + * @return void + * @throws Exception + */ + public function run( $result_file, $args ) { + $this->args = $args; + $this->check_arguments( $args ); + + $handle = $this->open_output_stream( $result_file ); + + $this->write_sql_statements( $handle ); + $this->close_output_stream( $handle ); + + $this->display_result_message( $result_file ); + } + + /** + * Get output stream for the export. + * + * @param $result_file + * + * @return false|resource + * @throws WP_CLI\ExitException + */ + protected function open_output_stream( $result_file ) { + $this->is_stdout = '-' === $result_file; + $handle = $this->is_stdout ? fopen( 'php://stdout', 'w' ) : fopen( $result_file, 'w' ); + if ( ! $handle ) { + WP_CLI::error( "Unable to open file: $result_file" ); + } + return $handle; + } + + /** + * Close the output stream. + * + * @param $handle + * + * @return void + * @throws WP_CLI\ExitException + */ + protected function close_output_stream( $handle ) { + if ( ! fclose( $handle ) ) { + WP_CLI::error( 'Error closing output stream.' ); + } + } + + /** + * Write SQL statements to the output stream. + * + * @param resource $handle The output stream. + * + * @return void + * @throws Exception + */ + protected function write_sql_statements( $handle ) { + $include_tables = $this->get_include_tables(); + $exclude_tables = $this->get_exclude_tables(); + foreach ( $this->translator->query( 'SHOW TABLES' ) as $table ) { + // Skip tables that are not in the include_tables list if the list is defined + if ( ! empty( $include_tables ) && ! in_array( $table->name, $include_tables, true ) ) { + continue; + } + + // Skip tables that are in the exclude_tables list + if ( in_array( $table->name, $exclude_tables, true ) ) { + continue; + } + + $this->write_create_table_statement( $handle, $table->name ); + $this->write_insert_statements( $handle, $table->name ); + } + + fwrite( $handle, sprintf( '-- Dump completed on %s', gmdate( 'c' ) ) ); + } + + /** + * Write the create statement for a table to the output stream. + * + * @param resource $handle + * @param string $table_name + * + * @throws Exception + */ + protected function write_create_table_statement( $handle, $table_name ) { + $comment = $this->get_dump_comment( sprintf( 'Table structure for table `%s`', $table_name ) ); + fwrite( $handle, $comment . PHP_EOL . PHP_EOL ); + fwrite( $handle, sprintf( 'DROP TABLE IF EXISTS `%s`;', $table_name ) . PHP_EOL ); + fwrite( $handle, $this->get_create_statement( $table_name ) . PHP_EOL ); + } + + /** + * Write the insert statements for a table to the output stream. + * + * @param $handle + * @param $table_name + * + * @return void + */ + protected function write_insert_statements( $handle, $table_name ) { + + if ( ! $this->table_has_records( $table_name ) ) { + return; + } + + $comment = $this->get_dump_comment( sprintf( 'Dumping data for table `%s`', $table_name ) ); + fwrite( $handle, $comment . PHP_EOL . PHP_EOL ); + foreach ( $this->get_insert_statements( $table_name ) as $insert_statement ) { + fwrite( $handle, $insert_statement . PHP_EOL ); + } + + fwrite( $handle, PHP_EOL ); + } + + /** + * Get the CREATE TABLE statement for a table. + * + * @param string $table_name + * + * @return mixed + * @throws Exception + */ + protected function get_create_statement( $table_name ) { + $create = $this->translator->query( 'SHOW CREATE TABLE ' . $table_name ); + return $create[0]->{'Create Table'} . "\n"; + } + + /** + * Get the INSERT statements for a table. + * + * @param string $table_name + * + * @return \Generator + */ + protected function get_insert_statements( $table_name ) { + $pdo = $this->translator->get_pdo(); + $stmt = $pdo->prepare( 'SELECT * FROM ' . $table_name ); + $stmt->execute(); + // phpcs:ignore + while ( $row = $stmt->fetch( PDO::FETCH_ASSOC, PDO::FETCH_ORI_NEXT ) ) { + yield sprintf( 'INSERT INTO `%1s` VALUES (%2s);', $table_name, $this->escape_values( $pdo, $row ) ); + } + } + + /** + * Get the tables to exclude from the export. + * + * @return array|false|string[] + */ + protected function get_exclude_tables() { + $exclude_tables = isset( $this->args['exclude_tables'] ) ? explode( ',', $this->args['exclude_tables'] ) : []; + return array_merge( + $exclude_tables, + [ + '_mysql_data_types_cache', + 'sqlite_master', + 'sqlite_sequence', + ] + ); + } + + protected function display_result_message( $result_file ) { + if ( $this->is_stdout ) { + return; + } + + if ( isset( $this->args['porcelain'] ) ) { + WP_CLI::line( $result_file ); + } else { + WP_CLI::success( 'Export complete. File written to ' . $result_file ); + } + } + + /** + * Get the tables to include in the export. + * + * @return array|false|string[] + */ + protected function get_include_tables() { + return isset( $this->args['tables'] ) ? explode( ',', $this->args['tables'] ) : []; + } + + /** + * Escape values for insert statement + * + * @param PDO $pdo + * @param $values + * + * @return string + */ + protected function escape_values( PDO $pdo, $values ) { + // Get a mysql PDO instance + $escaped_values = []; + foreach ( $values as $value ) { + if ( is_null( $value ) ) { + $escaped_values[] = 'NULL'; + } elseif ( is_numeric( $value ) ) { + $escaped_values[] = $value; + } else { + // Quote the values and escape encode the newlines so the insert statement appears on a single line. + $escaped_values[] = $this->escape_string( $value ); + } + } + return implode( ',', $escaped_values ); + } + + /** + * Escapes a string for use in an insert statement. + * + * @param $value + * + * @return string + */ + protected function escape_string( $value ) { + $pdo = $this->translator->get_pdo(); + return addcslashes( $pdo->quote( $value ), "\\\n" ); + } + + /** + * Get a comment for the dump. + * + * @param $comment + * + * @return string + */ + protected function get_dump_comment( $comment ) { + return implode( + "\n", + array( '--', sprintf( '-- %s', $comment ), '--' ) + ); + } + + /** + * Check if the given table has records. + * + * @param string $table_name + * + * @return bool + */ + protected function table_has_records( $table_name ) { + $pdo = $this->translator->get_pdo(); + $stmt = $pdo->prepare( 'SELECT COUNT(*) FROM ' . $table_name ); + $stmt->execute(); + return $stmt->fetchColumn() > 0; + } +} diff --git a/src/SQLite/Import.php b/src/SQLite/Import.php new file mode 100644 index 00000000..33e26f73 --- /dev/null +++ b/src/SQLite/Import.php @@ -0,0 +1,139 @@ +load_dependencies(); + $this->translator = new WP_SQLite_Translator(); + } + + /** + * Execute the import command for SQLite. + * + * @param string $sql_file_path The path to the SQL dump file. + * @param array $args The arguments passed to the command. + * + * @return void + */ + public function run( $sql_file_path, $args ) { + $this->args = $args; + $this->check_arguments( $args ); + + $is_stdin = '-' === $sql_file_path; + $import_file = $is_stdin ? 'php://stdin' : $sql_file_path; + + $this->execute_statements( $import_file ); + + $imported_from = $is_stdin ? 'STDIN' : $sql_file_path; + WP_CLI::success( sprintf( "Imported from '%s'.", $imported_from ) ); + } + + /** + * Execute SQL statements from an SQL dump file. + * + * @param $import_file + * + * @return void + * @throws Exception + */ + protected function execute_statements( $import_file ) { + foreach ( $this->parse_statements( $import_file ) as $statement ) { + $result = $this->translator->query( $statement ); + if ( false === $result ) { + WP_CLI::warning( 'Could not execute statement: ' . $statement ); + } + } + } + + /** + * Parse SQL statements from an SQL dump file. + * @param string $sql_file_path The path to the SQL dump file. + * + * @return Generator A generator that yields SQL statements. + */ + public function parse_statements( $sql_file_path ) { + + $handle = fopen( $sql_file_path, 'r' ); + + if ( ! $handle ) { + WP_CLI::error( "Unable to open file: $sql_file_path" ); + } + + $single_quotes = 0; + $double_quotes = 0; + $in_comment = false; + $buffer = ''; + + // phpcs:ignore + while ( ( $line = fgets( $handle ) ) !== false ) { + $line = trim( $line ); + + // Skip empty lines and comments + if ( empty( $line ) || strpos( $line, '--' ) === 0 || strpos( $line, '#' ) === 0 ) { + continue; + } + + // Handle multi-line comments + if ( ! $in_comment && strpos( $line, '/*' ) === 0 ) { + $in_comment = true; + } + if ( $in_comment ) { + if ( strpos( $line, '*/' ) !== false ) { + $in_comment = false; + } + continue; + } + + $strlen = strlen( $line ); + for ( $i = 0; $i < $strlen; $i++ ) { + $ch = $line[ $i ]; + + // Handle escaped characters + if ( $i > 0 && '\\' === $line[ $i - 1 ] ) { + $buffer .= $ch; + continue; + } + + // Handle quotes + if ( "'" === $ch && 0 === $double_quotes ) { + $single_quotes = 1 - $single_quotes; + } + if ( '"' === $ch && 0 === $single_quotes ) { + $double_quotes = 1 - $double_quotes; + } + + // Process statement end + if ( ';' === $ch && 0 === $single_quotes && 0 === $double_quotes ) { + yield trim( $buffer ); + $buffer = ''; + } else { + $buffer .= $ch; + } + } + } + + // Handle any remaining buffer content + if ( ! empty( $buffer ) ) { + yield trim( $buffer ); + } + + fclose( $handle ); + } +}