diff --git a/features/db-import.feature b/features/db-import.feature index d520a61e..e0e92ca6 100644 --- a/features/db-import.feature +++ b/features/db-import.feature @@ -247,3 +247,114 @@ Feature: Import a WordPress database """ 🍣 """ + + # SQLite does not use the MySQL client and has no concept of SQL modes. + @require-mysql-or-mariadb + Scenario: `wp db import` adapts the SQL mode via --init-command by default + Given a WP install + + When I run `wp db export wp_cli_test.sql` + Then the wp_cli_test.sql file should exist + + # The WordPress-compatibility mode adaptation runs on the same import + # connection via --init-command, so it is visible in the debug output and no + # separate mode probe runs (which is what used to break with custom connection + # options). + When I try `wp db import wp_cli_test.sql --debug` + Then the return code should be 0 + And STDERR should contain: + """ + SET SESSION sql_mode + """ + And STDERR should not contain: + """ + Failed to get current SQL modes + """ + + @require-mysql-or-mariadb + Scenario: `wp db import --skip-sql-mode-compat` imports under the server's own SQL modes + Given a WP install + + When I run `wp db export wp_cli_test.sql` + Then the wp_cli_test.sql file should exist + + When I try `wp db import wp_cli_test.sql --skip-sql-mode-compat --debug` + Then the return code should be 0 + And STDERR should not contain: + """ + SET SESSION sql_mode + """ + + # Regression test for the WordPress-compatibility behavior. WordPress schema + # declares datetime columns as `DEFAULT '0000-00-00 00:00:00'`, so real dumps + # carry zero-date values. On servers whose default SQL mode includes + # NO_ZERO_DATE/STRICT_TRANS_TABLES (MySQL 5.7+/8.0), a raw dump without its own + # SQL_MODE header would fail to import with "Invalid default value". `wp db + # import` must strip those modes for the session so the import succeeds. + @require-mysql-or-mariadb + Scenario: `wp db import` loads a dump containing legacy zero-date values + Given a WP install + And a zerodate.sql file: + """ + CREATE TABLE `wp_cli_zerodate` ( + `id` int NOT NULL, + `d` datetime NOT NULL DEFAULT '0000-00-00 00:00:00' + ); + INSERT INTO `wp_cli_zerodate` (`id`, `d`) VALUES (1, '0000-00-00 00:00:00'); + """ + + When I run `wp db import zerodate.sql` + Then STDOUT should contain: + """ + Success: Imported from 'zerodate.sql'. + """ + + When I run `wp db query 'SELECT COUNT(*) FROM wp_cli_zerodate;' --skip-column-names` + Then STDOUT should contain: + """ + 1 + """ + + # Regression test for https://github.com/wp-cli/db-command/issues/171 + # A dump streamed from STDIN must get the same WordPress SQL-mode compatibility + # as a file import. This is now handled via --init-command, which applies on the + # STDIN connection too (the previous prepend only covered file imports). + @require-mysql-or-mariadb + Scenario: `wp db import -` from STDIN loads a dump containing legacy zero-date values + Given a WP install + And a zerodate_stdin.sql file: + """ + CREATE TABLE wp_cli_zerodate_stdin (id int NOT NULL, d datetime NOT NULL DEFAULT '0000-00-00 00:00:00'); + INSERT INTO wp_cli_zerodate_stdin (id, d) VALUES (1, '0000-00-00 00:00:00'); + """ + + When I run `wp db import - < zerodate_stdin.sql` + Then STDOUT should contain: + """ + Success: Imported from 'STDIN'. + """ + + When I run `wp db query 'SELECT COUNT(*) FROM wp_cli_zerodate_stdin;' --skip-column-names` + Then STDOUT should contain: + """ + 1 + """ + + # The compatibility statement must compose with a caller-supplied --init-command + # rather than replace it. Both are sent as a single multi-statement + # --init-command (compatibility statement first, caller's second), so the + # caller's own init command still runs and the zero-date import still succeeds. + @require-mysql-or-mariadb + Scenario: `wp db import` keeps SQL-mode compatibility when the caller sets --init-command + Given a WP install + And a zerodate_compose.sql file: + """ + CREATE TABLE wp_cli_zd_compose (id int NOT NULL, d datetime NOT NULL DEFAULT '0000-00-00 00:00:00'); + INSERT INTO wp_cli_zd_compose (id, d) VALUES (1, '0000-00-00 00:00:00'); + """ + + When I run `wp db import zerodate_compose.sql --init-command="SET @x = 1"` + Then STDOUT should contain: + """ + Success: Imported from 'zerodate_compose.sql'. + """ diff --git a/features/db-query.feature b/features/db-query.feature index cc0306cb..641292ce 100644 --- a/features/db-query.feature +++ b/features/db-query.feature @@ -91,33 +91,51 @@ Feature: Query the database with WordPress' MySQL config When I try `wp db query --no-defaults --debug` Then STDERR should match #Debug \(db\): Running shell command: /([^/]+/)+(mysql|mariadb) --no-defaults --no-auto-rehash# - Scenario: SQL modes do not include any of the modes incompatible with WordPress + # `wp db query` adapts the session SQL mode to be WordPress-compatible the same + # way `wp db import` does: via --init-command on the query's own connection, with + # no separate mode probe. This keeps statements against WordPress's zero-date + # schema (e.g. `ALTER TABLE wp_blogs ...`, `CREATE TABLE ... AS SELECT` from a + # WordPress table) working on servers whose default SQL mode is strict. + @require-mysql-or-mariadb + Scenario: `wp db query` adapts the SQL mode by default without a separate mode probe Given a WP install - When I try `wp db query 'SELECT @@SESSION.sql_mode;' --debug` - Then STDOUT should not contain: - """ - NO_ZERO_DATE - """ - And STDOUT should not contain: - """ - ONLY_FULL_GROUP_BY - """ - And STDOUT should not contain: + When I try `wp db query 'SELECT 1;' --debug` + Then the return code should be 0 + And STDERR should contain: """ - STRICT_TRANS_TABLES + SET SESSION sql_mode """ - And STDOUT should not contain: + And STDERR should not contain: """ - STRICT_ALL_TABLES + Failed to get current SQL modes """ - And STDOUT should not contain: + + @require-mysql-or-mariadb + Scenario: `wp db query --skip-sql-mode-compat` runs under the server's own SQL modes + Given a WP install + + When I try `wp db query 'SELECT 1;' --skip-sql-mode-compat --debug` + Then the return code should be 0 + And STDERR should not contain: """ - TRADITIONAL + SET SESSION sql_mode """ - And STDOUT should not contain: + + # Regression test for https://github.com/wp-cli/db-command/issues/311 + # Passing connection options alongside an inline query used to fail, because the + # old SQL-mode probe opened a *second* connection that ignored those very options + # (custom --host, --defaults, SSL/TLS, sockets, ...) and then aborted the whole + # command with "Failed to get current SQL modes". The probe is gone -- the + # compatibility mode is now applied via --init-command on the query's own + # connection -- so the inline query runs directly under the given options. + Scenario: `wp db query` with an inline query and connection options does not trigger a failing mode probe + Given a WP install + + When I try `wp db query 'SELECT 1;' --defaults --debug` + Then STDERR should not contain: """ - ANSI + Failed to get current SQL modes """ # Regression test for https://github.com/wp-cli/db-command/issues/309 diff --git a/src/DB_Command.php b/src/DB_Command.php index db5a8442..123122ac 100644 --- a/src/DB_Command.php +++ b/src/DB_Command.php @@ -513,6 +513,13 @@ public function cli( $_, $assoc_args ) { * [--defaults] * : Loads the environment's MySQL option files. Default behavior is to skip loading them to avoid failures due to misconfiguration. * + * [--skip-sql-mode-compat] + * : Run the query under the server's own SQL modes instead of adapting them to + * be WordPress-compatible. By default, the incompatible modes that WordPress + * disables in `wpdb` (such as `NO_ZERO_DATE` and `STRICT_TRANS_TABLES`) are + * stripped for the session, so statements against WordPress's zero-date schema + * behave the same as they do in WordPress itself. + * * ## EXAMPLES * * # Execute a query stored in a file @@ -606,10 +613,13 @@ public function query( $args, $assoc_args ) { $assoc_args['execute'] = $args[0]; } - if ( isset( $assoc_args['execute'] ) ) { - // Ensure that the SQL mode is compatible with WPDB. - $assoc_args['execute'] = $this->get_sql_mode_query( $assoc_args ) . $assoc_args['execute']; - } + // Adapt the session SQL mode to be WordPress-compatible via --init-command, + // so it runs on connect before the query -- exactly like `wp db import` and + // WordPress Core's own wpdb -- and composes with any caller-provided + // --init-command. No separate probe connection is needed. Pass + // --skip-sql-mode-compat to run under the server's own SQL modes instead. + $this->apply_sql_mode_compat_init_command( $assoc_args, $assoc_args ); + unset( $assoc_args['skip-sql-mode-compat'] ); $is_row_modifying_query = isset( $assoc_args['execute'] ) && preg_match( '/\b(UPDATE|DELETE|INSERT|REPLACE(?!\s*\()|LOAD DATA)\b/i', $assoc_args['execute'] ); @@ -898,6 +908,9 @@ private function command_supports_option( $command, $option ) { * [--skip-optimization] * : When using an SQL file, do not include speed optimization such as disabling auto-commit and key checks. * + * [--skip-sql-mode-compat] + * : Do not adapt the session SQL mode for WordPress compatibility. By default, `wp db import` strips the SQL modes that WordPress Core disables (such as `STRICT_TRANS_TABLES` and `NO_ZERO_DATE`) so that dumps containing legacy values like `0000-00-00` import cleanly. Pass this flag to import under the server's own SQL modes instead. + * * [--defaults] * : Loads the environment's MySQL option files. Default behavior is to skip loading them to avoid failures due to misconfiguration. * @@ -928,6 +941,12 @@ public function import( $args, $assoc_args ) { self::get_mysql_args( $assoc_args ) ); + // Adapt the session SQL mode to be WordPress-compatible via --init-command, + // so it runs on connect before any SQL is read. This covers both file and + // STDIN imports, composes with any caller-provided --init-command, and needs + // no separate probe connection. + $this->apply_sql_mode_compat_init_command( $mysql_args, $assoc_args ); + if ( '-' !== $result_file ) { if ( ! is_readable( $result_file ) ) { WP_CLI::error( sprintf( 'Import file missing or not readable: %s', $result_file ) ); @@ -937,8 +956,6 @@ public function import( $args, $assoc_args ) { ? 'SOURCE %s;' : 'SET autocommit = 0; SET unique_checks = 0; SET foreign_key_checks = 0; SOURCE %s; COMMIT;'; - $query = $this->get_sql_mode_query( $assoc_args ) . $query; - $mysql_args['execute'] = sprintf( $query, $result_file ); } else { $result_file = 'STDIN'; @@ -1926,9 +1943,6 @@ private static function get_create_query() { * @param array $assoc_args Optional. Associative array of arguments. */ protected function run_query( $query, $assoc_args = [] ) { - // Ensure that the SQL mode is compatible with WPDB. - $query = $this->get_sql_mode_query( $assoc_args ) . $query; - WP_CLI::debug( "Query: {$query}", 'db' ); $mysql_args = array_merge( @@ -1936,6 +1950,11 @@ protected function run_query( $query, $assoc_args = [] ) { self::get_mysql_args( $assoc_args ) ); + // Adapt the session SQL mode to be WordPress-compatible via --init-command, + // so it runs on connect before the query, composes with any caller-provided + // --init-command, and needs no separate probe connection. + $this->apply_sql_mode_compat_init_command( $mysql_args, $assoc_args ); + self::run( sprintf( '%s%s --no-auto-rehash', @@ -2304,103 +2323,74 @@ protected function get_defaults_flag_string( &$assoc_args ) { } /** - * Get the query to change the current SQL mode, and ensure its WordPress compatibility. + * Get the statement that strips the SQL modes incompatible with WordPress + * from the current session. * - * If no modes are passed, it will ensure the current MySQL server modes are - * compatible. + * The statement is applied on the same connection as the import or query via + * the MySQL client's `--init-command`, so it runs on connect before any SQL is + * read. Imports and queries therefore behave like WordPress Core (which + * disables these modes in `wpdb`), including when a dump is streamed from + * STDIN. Unlike the previous implementation, no separate connection is opened + * to first discover the current modes, so it works regardless of the + * connection options in play (custom `--host`, `--defaults`, SSL/TLS, + * sockets, ...). * - * Copied and adapted from WordPress Core code. + * Returns an empty string when the `--skip-sql-mode-compat` flag is set. * - * @see https://github.com/WordPress/wordpress-develop/blob/5.4.0/src/wp-includes/wp-db.php#L817-L880 + * @see https://github.com/WordPress/wordpress-develop/blob/5.4.0/src/wp-includes/wp-db.php#L559-L572 * * @param array $assoc_args The associative argument array passed to the command. - * @param array $modes Optional. A list of SQL modes to set. - * @return string Query string to use for setting the SQL modes to a - * compatible state. + * @return string SQL statement that sets a WordPress-compatible SQL mode, or an + * empty string. */ - protected function get_sql_mode_query( $assoc_args, $modes = [] ) { - if ( empty( $modes ) ) { - $modes = $this->get_current_sql_modes( $assoc_args ); - } - - $modes = array_change_key_case( $modes, CASE_UPPER ); - - $is_mode_adaptation_needed = false; - foreach ( $modes as $i => $mode ) { - if ( in_array( $mode, $this->sql_incompatible_modes, true ) ) { - unset( $modes[ $i ] ); - $is_mode_adaptation_needed = true; - } - } - - if ( ! $is_mode_adaptation_needed ) { - WP_CLI::debug( - sprintf( - 'SQL modes look fine: %s', - json_encode( $modes ) - ) - ); + protected function get_sql_mode_compat_statement( $assoc_args = [] ) { + if ( Utils\get_flag_value( $assoc_args, 'skip-sql-mode-compat', false ) ) { return ''; } - WP_CLI::debug( - sprintf( - 'SQL mode adaptation is needed: %s => %s', - json_encode( $this->get_current_sql_modes( $assoc_args ) ), - json_encode( $modes ) - ) - ); - - $modes_str = implode( ',', $modes ); + // Strip the incompatible modes from the session mode list. Each mode is + // wrapped in commas so that it only matches as a whole token and never as + // a substring -- stripping `ANSI` must not turn `ANSI_QUOTES` into + // `_QUOTES`, for example. + $expression = "CONCAT( ',', @@SESSION.sql_mode, ',' )"; + foreach ( $this->sql_incompatible_modes as $mode ) { + $expression = sprintf( "REPLACE( %s, ',%s,', ',' )", $expression, $mode ); + } - return "SET SESSION sql_mode='{$modes_str}';"; + return sprintf( "SET SESSION sql_mode = TRIM( BOTH ',' FROM %s )", $expression ); } /** - * Get the list of current SQL modes. + * Applies the WordPress SQL-mode compatibility statement to the MySQL client + * arguments via `--init-command`, so it runs on connect before any SQL is read. * + * If the caller already supplied their own `--init-command` (via the CLI or an + * option file), the compatibility statement is composed with it as a single + * multi-statement `--init-command` value -- the compatibility statement first, + * the caller's command second -- rather than dropping either one. A caller that + * re-sets `sql_mode` in their own command therefore still wins (last statement + * takes effect), while the compatibility statement is guaranteed to run. + * + * Running both statements from a single `--init-command` value works across + * every targeted client (mysql 5.6/5.7/8.0/8.4 and mariadb). The newer + * `--init-command-add` option is only available on mysql 8.4+, so it is not + * used here. + * + * Does nothing when compatibility is disabled via `--skip-sql-mode-compat`. + * + * @param array $mysql_args MySQL client arguments, passed by reference. * @param array $assoc_args The associative argument array passed to the command. - * @return string[] Array of SQL modes. */ - protected function get_current_sql_modes( $assoc_args ) { - static $modes = null; - - // Make sure the provided arguments don't interfere with the expected - // output here. - $args = []; - - if ( null === $modes ) { - $modes = []; - - list( $stdout, $stderr, $exit_code ) = self::run( - sprintf( - '%s%s --no-auto-rehash --batch --skip-column-names', - Utils\get_mysql_binary_path(), - $this->get_defaults_flag_string( $assoc_args ) - ), - array_merge( $args, [ 'execute' => 'SELECT @@SESSION.sql_mode' ] ), - false - ); - - if ( $exit_code ) { - WP_CLI::error( - 'Failed to get current SQL modes.' - . ( ! empty( $stderr ) ? " Reason: {$stderr}" : '' ), - $exit_code - ); - } - - if ( ! empty( $stdout ) ) { - $lines = preg_split( "/\r\n|\n|\r|,/", $stdout ); - $modes = array_filter( - array_map( - 'trim', - $lines ? $lines : [] - ) - ); - } + protected function apply_sql_mode_compat_init_command( &$mysql_args, $assoc_args ) { + $sql_mode_compat = $this->get_sql_mode_compat_statement( $assoc_args ); + if ( '' === $sql_mode_compat ) { + return; } - return $modes; + if ( isset( $mysql_args['init-command'] ) && '' !== trim( (string) $mysql_args['init-command'] ) ) { + $mysql_args['init-command'] = $sql_mode_compat . '; ' . $mysql_args['init-command']; + } else { + $mysql_args['init-command'] = $sql_mode_compat; + } } }