diff --git a/features/db-import.feature b/features/db-import.feature index d520a61e..e27c4d58 100644 --- a/features/db-import.feature +++ b/features/db-import.feature @@ -69,6 +69,34 @@ Feature: Import a WordPress database Success: Imported from 'wp_cli_test.sql'. """ + @require-mysql-or-mariadb + Scenario: Database import falls back to wpdb when mysql binary is unavailable + Given a WP install + And a fake-bin/mysql file: + """ + #!/bin/sh + exit 127 + """ + And a fake-bin/mariadb file: + """ + #!/bin/sh + exit 127 + """ + + When I run `wp db export wp_cli_test.sql` + Then the wp_cli_test.sql file should exist + + When I run `chmod +x fake-bin/mysql fake-bin/mariadb` + And I try `env PATH={RUN_DIR}/fake-bin:$PATH wp db import wp_cli_test.sql --debug` + Then STDOUT should be: + """ + Success: Imported from 'wp_cli_test.sql'. + """ + And STDERR should contain: + """ + MySQL/MariaDB binary not available, falling back to wpdb for import. + """ + # SQLite doesn't support the --dbuser flag. @require-mysql-or-mariadb Scenario: Import from database name path by default with passed-in dbuser/dbpass diff --git a/features/db-query.feature b/features/db-query.feature index 07b5a262..8f9722fc 100644 --- a/features/db-query.feature +++ b/features/db-query.feature @@ -119,3 +119,29 @@ Feature: Query the database with WordPress' MySQL config """ ANSI """ + + @require-mysql-or-mariadb + Scenario: Database querying falls back to wpdb when mysql binary is unavailable + Given a WP install + And a fake-bin/mysql file: + """ + #!/bin/sh + exit 127 + """ + And a fake-bin/mariadb file: + """ + #!/bin/sh + exit 127 + """ + + When I run `chmod +x fake-bin/mysql fake-bin/mariadb` + And I try `env PATH={RUN_DIR}/fake-bin:$PATH wp db query "SELECT COUNT(ID) FROM wp_users;" --debug` + Then STDOUT should be: + """ + COUNT(ID) + 1 + """ + And STDERR should contain: + """ + MySQL/MariaDB binary not available, falling back to wpdb. + """ diff --git a/features/db.feature b/features/db.feature index 7a58c3a2..f4eb571f 100644 --- a/features/db.feature +++ b/features/db.feature @@ -383,8 +383,58 @@ Feature: Perform database operations Query succeeded. Rows affected: 1 """ - @require-sqlite @skip-windows + @require-mysql-or-mariadb + Scenario: Database drop falls back to wpdb when mysql binary is unavailable + Given a WP install + And a fake-bin/mysql file: + """ + #!/bin/sh + exit 127 + """ + And a fake-bin/mariadb file: + """ + #!/bin/sh + exit 127 + """ + + When I run `chmod +x fake-bin/mysql fake-bin/mariadb` + And I try `env PATH={RUN_DIR}/fake-bin:$PATH wp db drop --yes --debug` + Then STDOUT should contain: + """ + Success: Database dropped. + """ + And STDERR should contain: + """ + Query via mysqli: + """ + + @require-mysql-or-mariadb + Scenario: Database reset falls back to wpdb when mysql binary is unavailable + Given a WP install + And a fake-bin/mysql file: + """ + #!/bin/sh + exit 127 + """ + And a fake-bin/mariadb file: + """ + #!/bin/sh + exit 127 + """ + + When I run `chmod +x fake-bin/mysql fake-bin/mariadb` + And I try `env PATH={RUN_DIR}/fake-bin:$PATH wp db reset --yes --debug` + Then STDOUT should contain: + """ + Success: Database reset. + """ + And STDERR should contain: + """ + Query via mysqli: + """ + # Skipped on Windows due to persistent file locking issues when run via Behat. + @require-sqlite @skip-windows Scenario: SQLite DB CRUD operations Given a WP install And a session_yes file: diff --git a/src/DB_Command.php b/src/DB_Command.php index dfa24586..a597c5b1 100644 --- a/src/DB_Command.php +++ b/src/DB_Command.php @@ -592,6 +592,24 @@ public function query( $args, $assoc_args ) { return; } + if ( ! $this->is_mysql_binary_available() ) { + // Get the query from args or STDIN. + $query = ''; + if ( ! empty( $args ) ) { + $query = $args[0]; + } else { + $query = stream_get_contents( STDIN ); + } + + if ( empty( $query ) ) { + WP_CLI::error( 'No query specified.' ); + } + + WP_CLI::debug( 'MySQL/MariaDB binary not available, falling back to wpdb.', 'db' ); + $this->wpdb_query( $query, $assoc_args ); + return; + } + $command = sprintf( '%s%s --no-auto-rehash', Utils\get_mysql_binary_path(), @@ -921,6 +939,29 @@ public function import( $args, $assoc_args ) { return; } + if ( ! $this->is_mysql_binary_available() ) { + if ( '-' === $result_file ) { + $sql_content = stream_get_contents( STDIN ); + if ( false === $sql_content ) { + WP_CLI::error( 'Failed to read from STDIN.' ); + } + $result_file = 'STDIN'; + } else { + if ( ! is_readable( $result_file ) ) { + WP_CLI::error( sprintf( 'Import file missing or not readable: %s', $result_file ) ); + } + $sql_content = file_get_contents( $result_file ); + if ( false === $sql_content ) { + WP_CLI::error( sprintf( 'Could not read import file: %s', $result_file ) ); + } + } + + WP_CLI::debug( 'MySQL/MariaDB binary not available, falling back to wpdb for import.', 'db' ); + $this->wpdb_import( (string) $sql_content, $assoc_args ); + WP_CLI::success( sprintf( "Imported from '%s'.", $result_file ) ); + return; + } + // Process options to MySQL. $mysql_args = array_merge( [ 'database' => DB_NAME ], @@ -1916,6 +1957,27 @@ private static function get_create_query() { return $create_query; } + /** + * Run a single DDL query (e.g. DROP/CREATE DATABASE) using PHP's mysqli extension. + * + * Used as a fallback when the mysql/mariadb CLI binary is not available. + * Creates a fresh connection per call so that DDL operations like + * DROP DATABASE followed by CREATE DATABASE work correctly. + * + * @param string $query SQL query to execute. + */ + protected function run_query_via_mysqli( $query ) { + // No database selected — DDL operations like DROP/CREATE DATABASE work at the server level. + $conn = $this->get_db_connection( false ); + + if ( ! $conn->query( $query ) ) { + $error = $conn->error; + $conn->close(); + WP_CLI::error( sprintf( 'Query failed: %s', $error ) ); + } + $conn->close(); + } + /** * Run a single query via the 'mysql' binary. * @@ -1926,6 +1988,12 @@ private static function get_create_query() { * @param array $assoc_args Optional. Associative array of arguments. */ protected function run_query( $query, $assoc_args = [] ) { + if ( ! $this->is_mysql_binary_available() ) { + WP_CLI::debug( "Query via mysqli: {$query}", 'db' ); + $this->run_query_via_mysqli( $query ); + return; + } + // Ensure that the SQL mode is compatible with WPDB. $query = $this->get_sql_mode_query( $assoc_args ) . $query; @@ -2390,4 +2458,276 @@ protected function get_current_sql_modes( $assoc_args ) { return $modes; } + + /** + * Check if the mysql or mariadb binary is available. + * + * @return bool True if the binary is available, false otherwise. + */ + protected function is_mysql_binary_available() { + static $available = null; + + if ( null === $available ) { + $result = \WP_CLI\Process::create( Utils\get_mysql_binary_path() . ' --version', null, null )->run(); + $available = 0 === $result->return_code; + } + + return $available; + } + + /** + * Open a mysqli connection using the WordPress database credentials. + * + * Used as a fallback when the mysql/mariadb binary is not available. + * Parses DB_HOST the same way WordPress does (host:port, host:/socket, :/socket). + * + * @param bool $select_db Whether to select DB_NAME on connect. Pass false for + * server-level DDL (DROP/CREATE DATABASE). + * @return mysqli Connected mysqli instance. Calls WP_CLI::error() on failure. + */ + protected function get_db_connection( $select_db = true ) { + $db_host = DB_HOST; + $port = null; + $socket = null; + + if ( ':' === substr( $db_host, 0, 1 ) ) { + $socket = substr( $db_host, 1 ); + $db_host = 'localhost'; + } else { + $parts = explode( ':', $db_host, 2 ); + $db_host = $parts[0]; + if ( isset( $parts[1] ) ) { + $port_or_socket = trim( $parts[1] ); + if ( '' !== $port_or_socket && '/' === $port_or_socket[0] ) { + $socket = $port_or_socket; + } elseif ( '' !== $port_or_socket ) { + $port = (int) $port_or_socket; + } + } + } + + $port_value = null !== $port ? $port : 0; + $socket_value = null !== $socket ? $socket : ''; + $database = $select_db ? DB_NAME : ''; + + // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__mysqli -- direct mysqli required as fallback when mysql binary is unavailable. + $conn = new mysqli( $db_host, DB_USER, DB_PASSWORD, $database, $port_value, $socket_value ); + + if ( $conn->connect_errno ) { + WP_CLI::error( sprintf( 'Failed to connect to the database: %s', $conn->connect_error ) ); + } + + return $conn; + } + + /** + * Execute a query against the database using mysqli. + * + * Used as a fallback when the mysql/mariadb binary is not available. + * Outputs results in the same tab-separated format as the mysql binary. + * + * @param string $query SQL query to execute. + * @param array $assoc_args Associative arguments. + */ + protected function wpdb_query( $query, $assoc_args = [] ) { + $conn = $this->get_db_connection(); + + $skip_column_names = Utils\get_flag_value( $assoc_args, 'skip-column-names', false ); + $is_row_modifying_query = (bool) preg_match( '/\b(UPDATE|DELETE|INSERT|REPLACE(?!\s*\()|LOAD DATA)\b/i', $query ); + + if ( $is_row_modifying_query ) { + if ( ! $conn->query( $query ) ) { + $error = $conn->error; + $conn->close(); + WP_CLI::error( "Query failed: {$error}" ); + } + $affected_rows = $conn->affected_rows; + $conn->close(); + WP_CLI::success( "Query succeeded. Rows affected: {$affected_rows}" ); + } else { + $result = $conn->query( $query ); + if ( ! $result ) { + $error = $conn->error; + $conn->close(); + WP_CLI::error( "Query failed: {$error}" ); + } + + if ( $result instanceof mysqli_result ) { + $fields = $result->fetch_fields(); + $headers = $fields ? array_column( $fields, 'name' ) : []; + if ( ! $skip_column_names && ! empty( $headers ) ) { + WP_CLI::line( implode( "\t", $headers ) ); + } + $all_rows = $result->fetch_all( MYSQLI_NUM ); + foreach ( $all_rows as $row ) { + WP_CLI::line( + implode( + "\t", + array_map( + static function ( $v ) { + return null === $v ? 'NULL' : (string) $v; + }, + $row + ) + ) + ); + } + $result->free(); + } + + $conn->close(); + } + } + + /** + * Import SQL content into the database using mysqli. + * + * Used as a fallback when the mysql/mariadb binary is not available. + * + * @param string $sql_content SQL content to import. + * @param array $assoc_args Associative arguments. + */ + protected function wpdb_import( $sql_content, $assoc_args = [] ) { + $conn = $this->get_db_connection(); + + $skip_optimization = Utils\get_flag_value( $assoc_args, 'skip-optimization', false ); + + if ( ! $skip_optimization ) { + $conn->query( 'SET autocommit = 0' ); + $conn->query( 'SET unique_checks = 0' ); + $conn->query( 'SET foreign_key_checks = 0' ); + } + + $statements = $this->split_sql_statements( $sql_content ); + + foreach ( $statements as $statement ) { + $statement = trim( $statement ); + if ( '' === $statement ) { + continue; + } + if ( ! $conn->query( $statement ) ) { + $error = $conn->error; + if ( ! $skip_optimization ) { + $conn->query( 'ROLLBACK' ); + } + $conn->close(); + WP_CLI::error( "Import failed: {$error}" ); + } + } + + if ( ! $skip_optimization ) { + $conn->query( 'COMMIT' ); + $conn->query( 'SET autocommit = 1' ); + $conn->query( 'SET unique_checks = 1' ); + $conn->query( 'SET foreign_key_checks = 1' ); + } + + $conn->close(); + } + + /** + * Split a SQL string into individual statements. + * + * Handles single-quoted strings, double-quoted strings, and comments + * so that semicolons inside them are not treated as statement delimiters. + * + * @param string $sql SQL string to split. + * @return string[] Array of individual SQL statements. + */ + private function split_sql_statements( $sql ) { + $statements = []; + $current = ''; + $in_single_quote = false; + $in_double_quote = false; + $in_comment = false; + $in_line_comment = false; + $in_conditional_comment = false; + $length = strlen( $sql ); + + for ( $i = 0; $i < $length; $i++ ) { + $char = $sql[ $i ]; + $next = ( $i + 1 < $length ) ? $sql[ $i + 1 ] : ''; + + if ( $in_line_comment ) { + if ( "\n" === $char ) { + $in_line_comment = false; + } + continue; + } + + if ( $in_comment ) { + if ( '*' === $char && '/' === $next ) { + $in_comment = false; + ++$i; + } + continue; + } + + // Handle end of MySQL conditional comment (/*!...*/): resume normal parsing. + if ( $in_conditional_comment && ! $in_single_quote && ! $in_double_quote ) { + if ( '*' === $char && '/' === $next ) { + $in_conditional_comment = false; + ++$i; + continue; + } + // Fall through: treat content as regular SQL (handled below). + } + + if ( '/' === $char && '*' === $next && ! $in_single_quote && ! $in_double_quote ) { + $char_after_asterisk = ( $i + 2 < $length ) ? $sql[ $i + 2 ] : ''; + if ( '!' === $char_after_asterisk ) { + // MySQL conditional comment (/*!...*/): execute its SQL content. + $in_conditional_comment = true; + $i += 2; // skip past /*!; $i now points at ! + // Advance past optional version digits (e.g. "40101" in /*!40101 SET ... */). + // Loop checks the NEXT character so $i ends at the last digit. + while ( $i + 1 < $length && ctype_digit( $sql[ $i + 1 ] ) ) { + ++$i; + } + // Skip one space following the version digits, if present. + if ( $i + 1 < $length && ' ' === $sql[ $i + 1 ] ) { + ++$i; + } + // The for-loop's own ++$i then lands on the first SQL char. + } else { + $in_comment = true; + ++$i; + } + continue; + } + + if ( '-' === $char && '-' === $next && ! $in_single_quote && ! $in_double_quote ) { + $in_line_comment = true; + continue; + } + + // Handle backslash escaping inside quoted strings (e.g. \' or \"). + if ( '\\' === $char && ( $in_single_quote || $in_double_quote ) ) { + $current .= $char; + if ( $i + 1 < $length ) { + $current .= $sql[ ++$i ]; + } + continue; + } + + if ( "'" === $char && ! $in_double_quote ) { + $in_single_quote = ! $in_single_quote; + } elseif ( '"' === $char && ! $in_single_quote ) { + $in_double_quote = ! $in_double_quote; + } + + if ( ';' === $char && ! $in_single_quote && ! $in_double_quote ) { + $statements[] = $current; + $current = ''; + } else { + $current .= $char; + } + } + + if ( '' !== trim( $current ) ) { + $statements[] = $current; + } + + return $statements; + } }