diff --git a/README.md b/README.md index 38a83303..dfed369e 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ wp db create [--dbuser=] [--dbpass=] [--defaults] Runs `CREATE_DATABASE` SQL statement using `DB_HOST`, `DB_NAME`, `DB_USER` and `DB_PASSWORD` database credentials specified in -wp-config.php. +wp-config.php. Requires MySQL/MariaDB client binaries to be available. **OPTIONS** @@ -111,7 +111,7 @@ wp db drop [--dbuser=] [--dbpass=] [--yes] [--defaults] Runs `DROP_DATABASE` SQL statement using `DB_HOST`, `DB_NAME`, `DB_USER` and `DB_PASSWORD` database credentials specified in -wp-config.php. +wp-config.php. Requires MySQL/MariaDB client binaries to be available. **OPTIONS** @@ -144,7 +144,7 @@ wp db reset [--dbuser=] [--dbpass=] [--yes] [--defaults] Runs `DROP_DATABASE` and `CREATE_DATABASE` SQL statements using `DB_HOST`, `DB_NAME`, `DB_USER` and `DB_PASSWORD` database credentials -specified in wp-config.php. +specified in wp-config.php. Requires MySQL/MariaDB client binaries to be available. **OPTIONS** @@ -177,7 +177,7 @@ wp db check [--dbuser=] [--dbpass=] [--=] [--default Runs `mysqlcheck` utility with `--check` using `DB_HOST`, `DB_NAME`, `DB_USER` and `DB_PASSWORD` database credentials -specified in wp-config.php. +specified in wp-config.php. Requires `mysqlcheck` or `mariadb-check` client binary. [See docs](http://dev.mysql.com/doc/refman/5.7/en/check-table.html) for more details on the `CHECK TABLE` statement. @@ -216,7 +216,7 @@ wp db optimize [--dbuser=] [--dbpass=] [--=] [--defa Runs `mysqlcheck` utility with `--optimize=true` using `DB_HOST`, `DB_NAME`, `DB_USER` and `DB_PASSWORD` database credentials -specified in wp-config.php. +specified in wp-config.php. Requires `mysqlcheck` or `mariadb-check` client binary. [See docs](http://dev.mysql.com/doc/refman/5.7/en/optimize-table.html) for more details on the `OPTIMIZE TABLE` statement. @@ -269,7 +269,7 @@ wp db repair [--dbuser=] [--dbpass=] [--=] [--defaul Runs `mysqlcheck` utility with `--repair=true` using `DB_HOST`, `DB_NAME`, `DB_USER` and `DB_PASSWORD` database credentials -specified in wp-config.php. +specified in wp-config.php. Requires `mysqlcheck` or `mariadb-check` client binary. [See docs](http://dev.mysql.com/doc/refman/5.7/en/repair-table.html) for more details on the `REPAIR TABLE` statement. @@ -297,7 +297,7 @@ more details on the `REPAIR TABLE` statement. ### wp db cli -Opens a MySQL console using credentials from wp-config.php +Opens a MySQL console using credentials from wp-config.php. ~~~ wp db cli [--database=] [--default-character-set=] [--dbuser=] [--dbpass=] [--=] [--defaults] @@ -305,6 +305,8 @@ wp db cli [--database=] [--default-character-set=] [--d **Alias:** `connect` +Requires `mysql` or `mariadb` client binary to be available. + **OPTIONS** [--database=] @@ -342,7 +344,9 @@ wp db query [] [--dbuser=] [--dbpass=] [--=] [- ~~~ Executes an arbitrary SQL query using `DB_HOST`, `DB_NAME`, `DB_USER` - and `DB_PASSWORD` database credentials specified in wp-config.php. +and `DB_PASSWORD` database credentials specified in wp-config.php. +If MySQL/MariaDB client binaries are not available, falls back to +executing queries via WordPress's `wpdb`. Use the `--skip-column-names` MySQL argument to exclude the headers from a SELECT query. Pipe the output to remove the ASCII table @@ -445,6 +449,7 @@ wp db export [] [--dbuser=] [--dbpass=] [--=] Runs `mysqldump` utility using `DB_HOST`, `DB_NAME`, `DB_USER` and `DB_PASSWORD` database credentials specified in wp-config.php. Accepts any valid [`mysqldump` flags](https://dev.mysql.com/doc/en/mysqldump.html#mysqldump-option-summary). +Requires `mysqldump` or `mariadb-dump` client binary to be available. **OPTIONS** @@ -536,7 +541,8 @@ wp db import [] [--dbuser=] [--dbpass=] [--=] Runs SQL queries using `DB_HOST`, `DB_NAME`, `DB_USER` and `DB_PASSWORD` database credentials specified in wp-config.php. This does not create database by itself and only performs whatever tasks are -defined in the SQL. +defined in the SQL. If MySQL/MariaDB client binaries are not available, +falls back to importing via WordPress's `wpdb`. **OPTIONS** diff --git a/features/db-import.feature b/features/db-import.feature index a650517d..e1b27200 100644 --- a/features/db-import.feature +++ b/features/db-import.feature @@ -69,6 +69,118 @@ 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. + """ + + @require-mysql-or-mariadb + Scenario: Database import falls back to wpdb and handles zero-date column defaults + Given a WP install + And a fake-bin/mysql file: + """ + #!/bin/sh + exit 127 + """ + And a fake-bin/mariadb file: + """ + #!/bin/sh + exit 127 + """ + And a zero_date.sql file: + """ + CREATE TABLE IF NOT EXISTS wp_zero_date_test ( + id INT AUTO_INCREMENT PRIMARY KEY, + created_at DATETIME NOT NULL DEFAULT '0000-00-00 00:00:00' + ); + """ + + When I run `chmod +x fake-bin/mysql fake-bin/mariadb` + And I try `env PATH={RUN_DIR}/fake-bin:$PATH wp db import zero_date.sql --debug` + Then STDOUT should be: + """ + Success: Imported from 'zero_date.sql'. + """ + + @require-mysql-or-mariadb + Scenario: Database import falls back to wpdb and handles DELIMITER blocks with procedures + Given a WP install + And a fake-bin/mysql file: + """ + #!/bin/sh + exit 127 + """ + And a fake-bin/mariadb file: + """ + #!/bin/sh + exit 127 + """ + And a procedure.sql file: + """ + DELIMITER // + CREATE PROCEDURE wp_test_procedure() + BEGIN + SELECT 1; + END// + DELIMITER ; + """ + + When I run `chmod +x fake-bin/mysql fake-bin/mariadb` + And I try `env PATH={RUN_DIR}/fake-bin:$PATH wp db import procedure.sql --debug` + Then STDOUT should be: + """ + Success: Imported from 'procedure.sql'. + """ + + @require-mysql-or-mariadb + Scenario: Database import falls back to wpdb and handles malformed empty DELIMITER lines + Given a WP install + And a fake-bin/mysql file: + """ + #!/bin/sh + exit 127 + """ + And a fake-bin/mariadb file: + """ + #!/bin/sh + exit 127 + """ + And a malformed_delimiter.sql file: + """ + DELIMITER + SELECT 1; + """ + + When I run `chmod +x fake-bin/mysql fake-bin/mariadb` + And I try `env PATH={RUN_DIR}/fake-bin:$PATH wp db import malformed_delimiter.sql --debug` + Then STDOUT should be: + """ + Success: Imported from 'malformed_delimiter.sql'. + """ + # 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..d63f218a 100644 --- a/features/db-query.feature +++ b/features/db-query.feature @@ -155,3 +155,56 @@ 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. + """ + + @require-mysql-or-mariadb + Scenario: Database querying falls back to wpdb for multi-statement queries 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 1 as num; SELECT 2 as num;" --debug` + Then STDOUT should contain: + """ + num + 1 + """ + And STDOUT should contain: + """ + num + 2 + """ 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..57269041 100644 --- a/src/DB_Command.php +++ b/src/DB_Command.php @@ -69,7 +69,7 @@ class DB_Command extends WP_CLI_Command { * * Runs `CREATE_DATABASE` SQL statement using `DB_HOST`, `DB_NAME`, * `DB_USER` and `DB_PASSWORD` database credentials specified in - * wp-config.php. + * wp-config.php. Requires MySQL/MariaDB client binaries to be available. * * ## OPTIONS * @@ -105,7 +105,7 @@ public function create( $_, $assoc_args ) { * * Runs `DROP_DATABASE` SQL statement using `DB_HOST`, `DB_NAME`, * `DB_USER` and `DB_PASSWORD` database credentials specified in - * wp-config.php. + * wp-config.php. Requires MySQL/MariaDB client binaries to be available. * * ## OPTIONS * @@ -148,7 +148,7 @@ public function drop( $_, $assoc_args ) { * * Runs `DROP_DATABASE` and `CREATE_DATABASE` SQL statements using * `DB_HOST`, `DB_NAME`, `DB_USER` and `DB_PASSWORD` database credentials - * specified in wp-config.php. + * specified in wp-config.php. Requires MySQL/MariaDB client binaries to be available. * * ## OPTIONS * @@ -251,7 +251,7 @@ public function clean( $_, $assoc_args ) { * * Runs `mysqlcheck` utility with `--check` using `DB_HOST`, * `DB_NAME`, `DB_USER` and `DB_PASSWORD` database credentials - * specified in wp-config.php. + * specified in wp-config.php. Requires `mysqlcheck` or `mariadb-check` client binary. * * [See docs](http://dev.mysql.com/doc/refman/5.7/en/check-table.html) * for more details on the `CHECK TABLE` statement. @@ -286,9 +286,14 @@ public function check( $_, $assoc_args ) { return; } + $check_cmd = Utils\get_sql_check_command(); + if ( '' === $check_cmd ) { + WP_CLI::error( 'mysqlcheck or mariadb-check binary is not available. Please install MySQL or MariaDB client tools.' ); + } + $command = sprintf( '/usr/bin/env %s%s %s', - Utils\get_sql_check_command(), + $check_cmd, $this->get_defaults_flag_string( $assoc_args ), '%s' ); @@ -315,7 +320,7 @@ public function check( $_, $assoc_args ) { * * Runs `mysqlcheck` utility with `--optimize=true` using `DB_HOST`, * `DB_NAME`, `DB_USER` and `DB_PASSWORD` database credentials - * specified in wp-config.php. + * specified in wp-config.php. Requires `mysqlcheck` or `mariadb-check` client binary. * * [See docs](http://dev.mysql.com/doc/refman/5.7/en/optimize-table.html) * for more details on the `OPTIMIZE TABLE` statement. @@ -347,9 +352,14 @@ public function optimize( $_, $assoc_args ) { return; } + $check_cmd = Utils\get_sql_check_command(); + if ( '' === $check_cmd ) { + WP_CLI::error( 'mysqlcheck or mariadb-check binary is not available. Please install MySQL or MariaDB client tools.' ); + } + $command = sprintf( '/usr/bin/env %s%s %s', - Utils\get_sql_check_command(), + $check_cmd, $this->get_defaults_flag_string( $assoc_args ), '%s' ); @@ -376,7 +386,7 @@ public function optimize( $_, $assoc_args ) { * * Runs `mysqlcheck` utility with `--repair=true` using `DB_HOST`, * `DB_NAME`, `DB_USER` and `DB_PASSWORD` database credentials - * specified in wp-config.php. + * specified in wp-config.php. Requires `mysqlcheck` or `mariadb-check` client binary. * * [See docs](http://dev.mysql.com/doc/refman/5.7/en/repair-table.html) for * more details on the `REPAIR TABLE` statement. @@ -408,9 +418,14 @@ public function repair( $_, $assoc_args ) { return; } + $check_cmd = Utils\get_sql_check_command(); + if ( '' === $check_cmd ) { + WP_CLI::error( 'mysqlcheck or mariadb-check binary is not available. Please install MySQL or MariaDB client tools.' ); + } + $command = sprintf( '/usr/bin/env %s%s %s', - Utils\get_sql_check_command(), + $check_cmd, $this->get_defaults_flag_string( $assoc_args ), '%s' ); @@ -433,7 +448,8 @@ public function repair( $_, $assoc_args ) { } /** - * Opens a MySQL console using credentials from wp-config.php + * Opens a MySQL console using credentials from wp-config.php. + * Requires `mysql` or `mariadb` client binary to be available. * * ## OPTIONS * @@ -471,6 +487,10 @@ public function cli( $_, $assoc_args ) { return; } + if ( ! $this->is_mysql_binary_available() ) { + WP_CLI::error( 'MySQL/MariaDB client binary is not available. Please install MySQL or MariaDB client tools.' ); + } + $command = sprintf( '%s%s --no-auto-rehash', Utils\get_mysql_binary_path(), @@ -490,7 +510,9 @@ public function cli( $_, $assoc_args ) { * Executes a SQL query against the database. * * Executes an arbitrary SQL query using `DB_HOST`, `DB_NAME`, `DB_USER` - * and `DB_PASSWORD` database credentials specified in wp-config.php. + * and `DB_PASSWORD` database credentials specified in wp-config.php. + * If MySQL/MariaDB client binaries are not available, falls back to + * executing queries via WordPress's `wpdb`. * * Use the `--skip-column-names` MySQL argument to exclude the headers * from a SELECT query. Pipe the output to remove the ASCII table @@ -599,6 +621,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(), @@ -647,6 +687,7 @@ public function query( $args, $assoc_args ) { * * Runs `mysqldump` utility using `DB_HOST`, `DB_NAME`, `DB_USER` and * `DB_PASSWORD` database credentials specified in wp-config.php. Accepts any valid [`mysqldump` flags](https://dev.mysql.com/doc/en/mysqldump.html#mysqldump-option-summary). + * Requires `mysqldump` or `mariadb-dump` client binary to be available. * * ## OPTIONS * @@ -756,7 +797,12 @@ public function export( $args, $assoc_args ) { $assoc_args['result-file'] = $result_file; } - $mysqldump_binary = Utils\force_env_on_nix_systems( Utils\get_sql_dump_command() ); + $dump_cmd = Utils\get_sql_dump_command(); + if ( '' === $dump_cmd ) { + WP_CLI::error( 'mysqldump or mariadb-dump binary is not available. Please install MySQL or MariaDB client tools.' ); + } + + $mysqldump_binary = Utils\force_env_on_nix_systems( $dump_cmd ); $support_column_statistics = $this->command_supports_option( $mysqldump_binary, 'column-statistics' ); @@ -889,7 +935,8 @@ private function command_supports_option( $command, $option ) { * Runs SQL queries using `DB_HOST`, `DB_NAME`, `DB_USER` and * `DB_PASSWORD` database credentials specified in wp-config.php. This * does not create database by itself and only performs whatever tasks are - * defined in the SQL. + * defined in the SQL. If MySQL/MariaDB client binaries are not available, + * falls back to importing via WordPress's `wpdb`. * * ## OPTIONS * @@ -934,6 +981,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 +2463,420 @@ 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 ) { + $path = Utils\get_mysql_binary_path(); + if ( '' === $path ) { + $available = false; + } else { + $result = \WP_CLI\Process::create( escapeshellarg( $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). + * + * @param array $assoc_args Optional. Associative arguments containing db credentials. + */ + protected function maybe_load_wpdb( $assoc_args = [] ) { + global $wpdb; + + $default_user = defined( 'DB_USER' ) ? DB_USER : ''; + $default_pass = defined( 'DB_PASSWORD' ) ? DB_PASSWORD : ''; + $default_name = defined( 'DB_NAME' ) ? DB_NAME : ''; + $default_host = defined( 'DB_HOST' ) ? DB_HOST : ''; + + $db_user = Utils\get_flag_value( $assoc_args, 'dbuser', Utils\get_flag_value( $assoc_args, 'user', $default_user ) ); + $db_pass = Utils\get_flag_value( $assoc_args, 'dbpass', Utils\get_flag_value( $assoc_args, 'password', Utils\get_flag_value( $assoc_args, 'pass', $default_pass ) ) ); + $db_name = Utils\get_flag_value( $assoc_args, 'dbname', Utils\get_flag_value( $assoc_args, 'database', $default_name ) ); + $db_host = Utils\get_flag_value( $assoc_args, 'dbhost', Utils\get_flag_value( $assoc_args, 'host', $default_host ) ); + + $has_custom_credentials = ( + ( $default_user !== $db_user ) || + ( $default_pass !== $db_pass ) || + ( $default_name !== $db_name ) || + ( $default_host !== $db_host ) + ); + + if ( isset( $wpdb ) && $wpdb instanceof wpdb && ! $has_custom_credentials ) { + 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 . '/functions.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() && ! $has_custom_credentials ) { + require_once $db_dropin_path; + } + + // If $wpdb is still not set or custom credentials were supplied, create a new instance using the DB credentials. + if ( ( ! isset( $GLOBALS['wpdb'] ) || ! ( $GLOBALS['wpdb'] instanceof wpdb ) || $has_custom_credentials ) && 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_pass, $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( $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.' ); + } + + $suppress = $wpdb->suppress_errors( true ); + + $sql_mode_compat = $this->get_sql_mode_compat_statement( $assoc_args ); + if ( '' !== $sql_mode_compat ) { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( $sql_mode_compat ); + } + + $skip_column_names = Utils\get_flag_value( $assoc_args, 'skip-column-names', false ); + $statements = $this->split_sql_statements( $query ); + + if ( empty( $statements ) ) { + $wpdb->suppress_errors( $suppress ); + return; + } + + foreach ( $statements as $statement ) { + $statement = trim( $statement ); + if ( '' === $statement ) { + continue; + } + + $is_row_modifying_query = (bool) preg_match( '/\b(UPDATE|DELETE|INSERT|REPLACE(?!\s*\()|LOAD DATA|CREATE|DROP|ALTER|TRUNCATE)\b/i', $statement ); + + if ( $is_row_modifying_query ) { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $affected_rows = $wpdb->query( $statement ); + if ( false === $affected_rows ) { + $error = $wpdb->last_error; + $wpdb->suppress_errors( $suppress ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags + WP_CLI::error( 'Query failed: ' . strip_tags( $error ) ); + } + $is_dml = (bool) preg_match( '/\b(UPDATE|DELETE|INSERT|REPLACE(?!\s*\()|LOAD DATA)\b/i', $statement ); + if ( $is_dml ) { + WP_CLI::success( "Query succeeded. Rows affected: {$affected_rows}" ); + } + } else { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $results = $wpdb->get_results( $statement, ARRAY_A ); + + if ( $wpdb->last_error ) { + $error = $wpdb->last_error; + $wpdb->suppress_errors( $suppress ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags + WP_CLI::error( 'Query failed: ' . strip_tags( $error ) ); + } + + if ( empty( $results ) ) { + continue; + } + + $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 ) + ) + ) + ); + } + } + } + + $wpdb->suppress_errors( $suppress ); + } + + /** + * 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( $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.' ); + } + + $suppress = $wpdb->suppress_errors( true ); + + $sql_mode_compat = $this->get_sql_mode_compat_statement( $assoc_args ); + if ( '' !== $sql_mode_compat ) { + // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared + $wpdb->query( $sql_mode_compat ); + } + + $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 + $commit_result = $wpdb->query( 'COMMIT' ); + if ( false === $commit_result ) { + $error = $wpdb->last_error; + $wpdb->suppress_errors( $suppress ); + // phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags + WP_CLI::error( 'Import failed to commit: ' . strip_tags( $error ) ); + } + // 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, comments, + * and DELIMITER directives 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; + $delimiter = ';'; + $length = strlen( $sql ); + + for ( $i = 0; $i < $length; $i++ ) { + $char = $sql[ $i ]; + $next = ( $i + 1 < $length ) ? $sql[ $i + 1 ] : ''; + + // Check for DELIMITER directive at line boundary / statement start + if ( ! $in_single_quote && ! $in_double_quote && ! $in_comment && ! $in_line_comment && ! $in_conditional_comment ) { + $trimmed_current = trim( $current ); + if ( '' === $trimmed_current && 0 === stripos( substr( $sql, $i, 9 ), 'DELIMITER' ) ) { + $char_after = ( $i + 9 < $length ) ? $sql[ $i + 9 ] : "\n"; + if ( ' ' === $char_after || "\t" === $char_after || "\r" === $char_after || "\n" === $char_after ) { + $line_end = strpos( $sql, "\n", $i ); + if ( false === $line_end ) { + $line_end = $length; + } + $delimiter_line = trim( substr( $sql, $i, $line_end - $i ) ); + $new_delimiter = trim( substr( $delimiter_line, 9 ) ); + if ( '' !== $new_delimiter ) { + $delimiter = $new_delimiter; + } + $current = ''; + $i = $line_end; + continue; + } + } + } + + 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. + } + + 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 ! + while ( $i + 1 < $length && ctype_digit( $sql[ $i + 1 ] ) ) { + ++$i; + } + if ( $i + 1 < $length && ' ' === $sql[ $i + 1 ] ) { + ++$i; + } + } else { + $in_comment = true; + ++$i; + } + continue; + } + + if ( '-' === $char && '-' === $next && ! $in_single_quote && ! $in_double_quote ) { + $char_after = ( $i + 2 < $length ) ? $sql[ $i + 2 ] : "\n"; + if ( ' ' === $char_after || "\t" === $char_after || "\r" === $char_after || "\n" === $char_after ) { + $in_line_comment = true; + continue; + } + } + + if ( '#' === $char && ! $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; + } + + $delim_len = strlen( $delimiter ); + if ( ! $in_single_quote && ! $in_double_quote && substr( $sql, $i, $delim_len ) === $delimiter ) { + $trimmed = trim( $current ); + if ( '' !== $trimmed ) { + $statements[] = $trimmed; + } + $current = ''; + $i += $delim_len - 1; + } else { + $current .= $char; + } + } + + $trimmed = trim( $current ); + if ( '' !== $trimmed ) { + $statements[] = $trimmed; + } + + return $statements; + } }