diff --git a/features/db-import.feature b/features/db-import.feature index 8438d06b..0af8c19d 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 run `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 48a50af5..4e736563 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 493b99c1..634c3f09 100644 --- a/features/db.feature +++ b/features/db.feature @@ -383,6 +383,56 @@ Feature: Perform database operations Query succeeded. Rows affected: 1 """ + @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 wpdb: + """ + + @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 wpdb: + """ + @require-sqlite Scenario: SQLite DB CRUD operations Given a WP install diff --git a/src/DB_Command.php b/src/DB_Command.php index e0d7ad47..8a78a3aa 100644 --- a/src/DB_Command.php +++ b/src/DB_Command.php @@ -591,6 +591,25 @@ 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->maybe_load_wpdb(); + $this->wpdb_query( $query, $assoc_args ); + return; + } + $command = sprintf( '/usr/bin/env %s%s --no-auto-rehash', $this->get_mysql_command(), @@ -910,6 +929,30 @@ 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->maybe_load_wpdb(); + $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 ], @@ -1900,6 +1943,24 @@ 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 wpdb: {$query}", 'db' ); + $this->maybe_load_wpdb(); + global $wpdb; + + if ( ! isset( $wpdb ) ) { + WP_CLI::error( 'WordPress database (wpdb) is not available. Please install MySQL or MariaDB client tools.' ); + } + + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $result = $wpdb->query( $query ); + if ( false === $result ) { + // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags + WP_CLI::error( 'Query failed: ' . strip_tags( $wpdb->last_error ) ); + } + return; + } + // Ensure that the SQL mode is compatible with WPDB. $query = $this->get_sql_mode_query( $assoc_args ) . $query; @@ -2348,4 +2409,248 @@ protected function get_current_sql_modes( $assoc_args ) { private function get_mysql_command() { return 'mariadb' === Utils\get_db_type() ? 'mariadb' : 'mysql'; } + + /** + * 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 ) { + $binary = $this->get_mysql_command(); + $result = \WP_CLI\Process::create( '/usr/bin/env ' . escapeshellarg( $binary ) . ' --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 ) ) { + 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' ); + } + + // Load required WordPress files if not already loaded. + if ( ! function_exists( 'add_action' ) ) { + $required_files = [ + ABSPATH . WPINC . '/load.php', + ABSPATH . WPINC . '/compat.php', + ABSPATH . WPINC . '/plugin.php', + // Defines `wp_debug_backtrace_summary()` as used by wpdb. + ABSPATH . WPINC . '/functions.php', + ABSPATH . WPINC . '/class-wpdb.php', + ]; + + 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' ) ) { + // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited + $wpdb = new wpdb( DB_USER, DB_PASSWORD, DB_NAME, DB_HOST ); + if ( isset( $GLOBALS['table_prefix'] ) && is_string( $GLOBALS['table_prefix'] ) ) { + $wpdb->set_prefix( $GLOBALS['table_prefix'] ); + } + } + } + + /** + * Execute a query against the database using wpdb. + * + * Used as a fallback when the mysql/mariadb binary is not available. + * + * @param string $query SQL query to execute. + * @param array $assoc_args Associative arguments. + */ + protected function wpdb_query( $query, $assoc_args = [] ) { + 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 = 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] ); + $this->display_query_results( $headers, $results, $skip_column_names ); + } + } + + /** + * 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 = [] ) { + 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_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 ) { + if ( ! $skip_optimization ) { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( 'ROLLBACK' ); + } + // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags + WP_CLI::error( 'Import failed: ' . strip_tags( $wpdb->last_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' ); + } + } + + /** + * 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; + $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; + } + + if ( '/' === $char && '*' === $next && ! $in_single_quote && ! $in_double_quote ) { + $in_comment = true; + ++$i; + continue; + } + + if ( '-' === $char && '-' === $next && ! $in_single_quote && ! $in_double_quote ) { + $in_line_comment = true; + 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; + } }