diff --git a/features/db-import.feature b/features/db-import.feature index e0e92ca6..165ff4e0 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 try `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 641292ce..581eb2da 100644 --- a/features/db-query.feature +++ b/features/db-query.feature @@ -155,3 +155,29 @@ Feature: Query the database with WordPress' MySQL config """ skip-ssl-verify-server-cert """ + + @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 2fcbe899..45651d44 100644 --- a/features/db.feature +++ b/features/db.feature @@ -387,8 +387,8 @@ Feature: Perform database operations Query succeeded. Rows affected: 1 """ - @require-sqlite @skip-windows # 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 123122ac..f2cb7387 100644 --- a/src/DB_Command.php +++ b/src/DB_Command.php @@ -599,6 +599,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(), @@ -934,6 +952,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 ], @@ -2393,4 +2434,319 @@ protected function apply_sql_mode_compat_init_command( &$mysql_args, $assoc_args $mysql_args['init-command'] = $sql_mode_compat; } } + + /** + * 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; + } + + /** + * Load WordPress's wpdb if not already available. + * + * Loads the minimal required WordPress files to make $wpdb available, + * including any db.php drop-in (e.g., HyperDB or other custom drivers). + */ + protected function maybe_load_wpdb() { + global $wpdb; + + if ( isset( $wpdb ) && $wpdb instanceof wpdb ) { + return; + } + + if ( ! defined( 'WPINC' ) ) { + // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound + define( 'WPINC', 'wp-includes' ); + } + + if ( ! defined( 'WP_CONTENT_DIR' ) ) { + define( 'WP_CONTENT_DIR', ABSPATH . 'wp-content' ); + } + + $wpdb_file = file_exists( ABSPATH . WPINC . '/class-wpdb.php' ) + ? ABSPATH . WPINC . '/class-wpdb.php' + : ABSPATH . WPINC . '/wp-db.php'; + + // Load prerequisite WordPress files if not already loaded. + $required_files = [ + ABSPATH . WPINC . '/load.php', + ABSPATH . WPINC . '/compat.php', + ABSPATH . WPINC . '/plugin.php', + ABSPATH . WPINC . '/class-wp-error.php', + $wpdb_file, + ]; + + foreach ( $required_files as $required_file ) { + if ( file_exists( $required_file ) ) { + require_once $required_file; + } + } + + // Load db.php drop-in if it exists (e.g., HyperDB or other custom drivers). + $db_dropin_path = WP_CONTENT_DIR . '/db.php'; + if ( file_exists( $db_dropin_path ) && ! $this->is_sqlite() ) { + require_once $db_dropin_path; + } + + // If $wpdb is still not set (e.g. no drop-in), create a new instance using the DB credentials from wp-config.php. + if ( ! isset( $GLOBALS['wpdb'] ) && class_exists( 'wpdb' ) ) { + $table_prefix = isset( $GLOBALS['table_prefix'] ) && is_string( $GLOBALS['table_prefix'] ) ? $GLOBALS['table_prefix'] : 'wp_'; + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wpdb = new wpdb( DB_USER, DB_PASSWORD, DB_NAME, DB_HOST ); + $wpdb->set_prefix( $table_prefix ); + } + } + + /** + * Execute a query against the database using wpdb. + * + * 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 = [] ) { + $this->maybe_load_wpdb(); + global $wpdb; + + if ( ! isset( $wpdb ) || ! ( $wpdb instanceof wpdb ) ) { + WP_CLI::error( 'WordPress database (wpdb) is not available. Please install MySQL or MariaDB client tools.' ); + } + + $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 ) { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $affected_rows = $wpdb->query( $query ); + if ( false === $affected_rows ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags + WP_CLI::error( 'Query failed: ' . strip_tags( $wpdb->last_error ) ); + } + WP_CLI::success( "Query succeeded. Rows affected: {$affected_rows}" ); + } else { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $results = $wpdb->get_results( $query, ARRAY_A ); + + if ( $wpdb->last_error ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags + WP_CLI::error( 'Query failed: ' . strip_tags( $wpdb->last_error ) ); + } + + if ( empty( $results ) ) { + return; + } + + $headers = array_keys( $results[0] ); + if ( ! $skip_column_names && ! empty( $headers ) ) { + WP_CLI::line( implode( "\t", $headers ) ); + } + foreach ( $results as $row ) { + WP_CLI::line( + implode( + "\t", + array_map( + static function ( $v ) { + if ( null === $v ) { + return 'NULL'; + } + if ( is_scalar( $v ) ) { + return (string) $v; + } + return ''; + }, + array_values( $row ) + ) + ) + ); + } + } + } + + /** + * Import SQL content into the database using wpdb. + * + * 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 = [] ) { + $this->maybe_load_wpdb(); + global $wpdb; + + if ( ! isset( $wpdb ) || ! ( $wpdb instanceof wpdb ) ) { + WP_CLI::error( 'WordPress database (wpdb) is not available. Please install MySQL or MariaDB client tools.' ); + } + + $suppress = $wpdb->suppress_errors( true ); + + $skip_optimization = Utils\get_flag_value( $assoc_args, 'skip-optimization', false ); + + if ( ! $skip_optimization ) { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( 'SET autocommit = 0' ); + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( 'SET unique_checks = 0' ); + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( 'SET foreign_key_checks = 0' ); + } + + $statements = $this->split_sql_statements( $sql_content ); + + foreach ( $statements as $statement ) { + $statement = trim( $statement ); + if ( '' === $statement ) { + continue; + } + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $result = $wpdb->query( $statement ); + if ( false === $result ) { + $error = $wpdb->last_error; + + // Ignore privilege/permission errors for SET statements (e.g. SQL_LOG_BIN, GTID) in dumps when user lacks SUPER admin rights. + if ( 0 === stripos( ltrim( $statement ), 'SET ' ) && ( false !== strpos( $error, 'Access denied' ) || false !== strpos( $error, 'privilege' ) ) ) { + continue; + } + + if ( ! $skip_optimization ) { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( 'ROLLBACK' ); + } + $wpdb->suppress_errors( $suppress ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags + WP_CLI::error( 'Import failed: ' . strip_tags( $error ) ); + } + } + + if ( ! $skip_optimization ) { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( 'COMMIT' ); + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( 'SET autocommit = 1' ); + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( 'SET unique_checks = 1' ); + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( 'SET foreign_key_checks = 1' ); + } + + $wpdb->suppress_errors( $suppress ); + } + + /** + * 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; + } }