diff --git a/wp/wp-content/plugins/sqlite-database-integration/activate.php b/wp/wp-content/plugins/sqlite-database-integration/activate.php index 1001914a2..5dc21d2b6 100644 --- a/wp/wp-content/plugins/sqlite-database-integration/activate.php +++ b/wp/wp-content/plugins/sqlite-database-integration/activate.php @@ -14,10 +14,15 @@ * @param string $plugin The plugin basename. */ function sqlite_plugin_activation_redirect( $plugin ) { - if ( plugin_basename( SQLITE_MAIN_FILE ) === $plugin ) { - if ( wp_safe_redirect( admin_url( 'options-general.php?page=sqlite-integration' ) ) ) { - exit; - } + if ( + plugin_basename( SQLITE_MAIN_FILE ) !== $plugin + || ! current_user_can( sqlite_plugin_get_manage_capability() ) + ) { + return; + } + + if ( wp_safe_redirect( sqlite_plugin_get_admin_page_url() ) ) { + exit; } } add_action( 'activated_plugin', 'sqlite_plugin_activation_redirect' ); @@ -25,36 +30,47 @@ function sqlite_plugin_activation_redirect( $plugin ) { /** * Check the URL to ensure we're on the plugin page, * the user has clicked the button to install SQLite, - * and the nonce is valid. + * the user has permission to manage the database drop-in, and the nonce is valid. * If the above conditions are met, run the sqlite_plugin_copy_db_file() function, * and redirect to the install screen. * * @since 1.0.0 */ function sqlite_activation() { - global $current_screen; - if ( isset( $current_screen->base ) && 'settings_page_sqlite-integration' === $current_screen->base ) { + if ( + ! isset( $_GET['page'], $_GET['confirm-install'] ) + || 'sqlite-integration' !== $_GET['page'] + ) { return; } - if ( isset( $_GET['confirm-install'] ) && wp_verify_nonce( $_GET['_wpnonce'], 'sqlite-install' ) ) { - // Handle upgrading from the performance-lab plugin. - if ( isset( $_GET['upgrade-from-pl'] ) ) { - global $wp_filesystem; - require_once ABSPATH . '/wp-admin/includes/file.php'; - // Delete the previous db.php file. - $wp_filesystem->delete( WP_CONTENT_DIR . '/db.php' ); - // Deactivate the performance-lab SQLite module. - $pl_option_name = defined( 'PERFLAB_MODULES_SETTING' ) ? PERFLAB_MODULES_SETTING : 'perflab_modules_settings'; - $pl_option = get_option( $pl_option_name, array() ); - unset( $pl_option['database/sqlite'] ); - update_option( $pl_option_name, $pl_option ); - } - sqlite_plugin_copy_db_file(); - // WordPress will automatically redirect to the install screen here. - wp_redirect( admin_url() ); - exit; + if ( ! current_user_can( sqlite_plugin_get_manage_capability() ) ) { + wp_die( + esc_html__( 'Sorry, you are not allowed to install the SQLite database drop-in.', 'sqlite-database-integration' ), + 403 + ); + } + + check_admin_referer( 'sqlite-install' ); + + // Handle upgrading from the performance-lab plugin. + if ( isset( $_GET['upgrade-from-pl'] ) ) { + global $wp_filesystem; + require_once ABSPATH . '/wp-admin/includes/file.php'; + // Delete the previous db.php file. + $wp_filesystem->delete( WP_CONTENT_DIR . '/db.php' ); + // Deactivate the performance-lab SQLite module. + $pl_option_name = defined( 'PERFLAB_MODULES_SETTING' ) ? PERFLAB_MODULES_SETTING : 'perflab_modules_settings'; + $pl_option = get_option( $pl_option_name, array() ); + unset( $pl_option['database/sqlite'] ); + update_option( $pl_option_name, $pl_option ); } + + sqlite_plugin_copy_db_file(); + + // WordPress will automatically redirect to the install screen here. + wp_redirect( admin_url() ); + exit; } add_action( 'admin_init', 'sqlite_activation' ); diff --git a/wp/wp-content/plugins/sqlite-database-integration/admin-notices.php b/wp/wp-content/plugins/sqlite-database-integration/admin-notices.php index a455cc86c..06e2b6fac 100644 --- a/wp/wp-content/plugins/sqlite-database-integration/admin-notices.php +++ b/wp/wp-content/plugins/sqlite-database-integration/admin-notices.php @@ -12,6 +12,9 @@ * When the plugin gets merged in wp-core, this is not to be ported. */ function sqlite_plugin_admin_notice() { + if ( ! current_user_can( sqlite_plugin_get_manage_capability() ) ) { + return; + } // Don't print notices in the plugin's admin screen. global $current_screen; @@ -66,11 +69,11 @@ function sqlite_plugin_admin_notice() { /* translators: 1: db.php drop-in path, 2: Admin URL to deactivate the module */ __( 'The SQLite Integration plugin is active, but the %1$s file is missing. Please deactivate the plugin and re-activate it to try again.', 'sqlite-database-integration' ), '' . esc_html( basename( WP_CONTENT_DIR ) ) . '/db.php', - esc_url( admin_url( 'plugins.php' ) ) + esc_url( self_admin_url( 'plugins.php' ) ) ) ); } -add_action( 'admin_notices', 'sqlite_plugin_admin_notice' ); // Add the admin notices. +add_action( is_multisite() ? 'network_admin_notices' : 'admin_notices', 'sqlite_plugin_admin_notice' ); // Add the admin notices. // Remove the PL-plugin admin notices for SQLite. remove_action( 'admin_notices', 'perflab_sqlite_plugin_admin_notice' ); diff --git a/wp/wp-content/plugins/sqlite-database-integration/admin-page.php b/wp/wp-content/plugins/sqlite-database-integration/admin-page.php index 828156267..0d8c81f13 100644 --- a/wp/wp-content/plugins/sqlite-database-integration/admin-page.php +++ b/wp/wp-content/plugins/sqlite-database-integration/admin-page.php @@ -12,20 +12,30 @@ * @since 1.0.0 */ function sqlite_add_admin_menu() { - add_options_page( + $parent_slug = is_multisite() ? 'settings.php' : 'options-general.php'; + + add_submenu_page( + $parent_slug, __( 'SQLite integration', 'sqlite-database-integration' ), __( 'SQLite integration', 'sqlite-database-integration' ), - 'manage_options', + sqlite_plugin_get_manage_capability(), 'sqlite-integration', 'sqlite_integration_admin_screen' ); } -add_action( 'admin_menu', 'sqlite_add_admin_menu' ); +add_action( is_multisite() ? 'network_admin_menu' : 'admin_menu', 'sqlite_add_admin_menu' ); /** * The admin page contents. */ function sqlite_integration_admin_screen() { + if ( ! current_user_can( sqlite_plugin_get_manage_capability() ) ) { + wp_die( + esc_html__( 'Sorry, you are not allowed to access the SQLite integration settings.', 'sqlite-database-integration' ), + 403 + ); + } + $db_dropin_path = WP_CONTENT_DIR . '/db.php'; /* @@ -56,7 +66,7 @@ function sqlite_integration_admin_screen() { printf( /* translators: 1: Admin URL to deactivate the module, 2: db.php drop-in path, */ __( 'The SQLite drop-in is enabled. To disable it and get back to your previous, MySQL database, you can deactivate the plugin. Alternatively, you can manually delete the %2$s file from your server.', 'sqlite-database-integration' ), - esc_url( admin_url( 'plugins.php' ) ), + esc_url( self_admin_url( 'plugins.php' ) ), '' . esc_html( basename( WP_CONTENT_DIR ) ) . '/db.php' ); ?> @@ -78,7 +88,16 @@ function sqlite_integration_admin_screen() { ?>

- + '1', + 'upgrade-from-pl' => '1', + ), + sqlite_plugin_get_admin_page_url() + ); + ?> +

-
+ 'sqlite-db-integration', 'parent' => 'top-secondary', 'title' => $title, - 'href' => esc_url( admin_url( 'options-general.php?page=sqlite-integration' ) ), + 'href' => esc_url( sqlite_plugin_get_admin_page_url() ), 'meta' => false, ); $admin_bar->add_node( $args ); } add_action( 'admin_bar_menu', 'sqlite_plugin_adminbar_item', 999 ); + +/** + * Get the SQLite integration admin page URL. + * + * @access private + * + * @return string Admin page URL. + */ +function sqlite_plugin_get_admin_page_url() { + if ( is_multisite() ) { + return network_admin_url( 'settings.php?page=sqlite-integration' ); + } + return admin_url( 'options-general.php?page=sqlite-integration' ); +} diff --git a/wp/wp-content/plugins/sqlite-database-integration/capabilities.php b/wp/wp-content/plugins/sqlite-database-integration/capabilities.php new file mode 100644 index 000000000..deab5108c --- /dev/null +++ b/wp/wp-content/plugins/sqlite-database-integration/capabilities.php @@ -0,0 +1,80 @@ +set_prefix( $table_prefix ); - // Get the perflab options, remove the database/sqlite module and update the option. - $row = $wpdb_mysql->get_row( $wpdb_mysql->prepare( "SELECT option_value FROM $wpdb_mysql->options WHERE option_name = %s LIMIT 1", 'active_plugins' ) ); - if ( is_object( $row ) ) { - $value = maybe_unserialize( $row->option_value ); - if ( is_array( $value ) ) { - $value_flipped = array_flip( $value ); - $items = array_reverse( explode( DIRECTORY_SEPARATOR, SQLITE_MAIN_FILE ) ); - $item = $items[1] . DIRECTORY_SEPARATOR . $items[0]; - unset( $value_flipped[ $item ] ); - $value = array_flip( $value_flipped ); - $wpdb_mysql->update( $wpdb_mysql->options, array( 'option_value' => maybe_serialize( $value ) ), array( 'option_name' => 'active_plugins' ) ); - } - } + sqlite_plugin_deactivate_in_mysql( $wpdb_mysql, $network_deactivating ); }, PHP_INT_MAX ); @@ -66,3 +62,52 @@ function () { wp_cache_flush(); } register_deactivation_hook( SQLITE_MAIN_FILE, 'sqlite_plugin_remove_db_file' ); // Remove db.php file on plugin deactivation. + +/** + * Deactivate the plugin in the original MySQL database. + * + * @access private + * + * @param wpdb $wpdb_mysql MySQL database connection. + * @param bool $network_deactivating Whether the plugin is being deactivated network-wide. + */ +function sqlite_plugin_deactivate_in_mysql( $wpdb_mysql, $network_deactivating ) { + if ( $network_deactivating ) { + $network_id = get_current_network_id(); + $row = $wpdb_mysql->get_row( $wpdb_mysql->prepare( "SELECT meta_value AS active_plugins FROM $wpdb_mysql->sitemeta WHERE site_id = %d AND meta_key = %s LIMIT 1", $network_id, 'active_sitewide_plugins' ) ); + $table = $wpdb_mysql->sitemeta; + $value_column = 'meta_value'; + $where = array( + 'site_id' => $network_id, + 'meta_key' => 'active_sitewide_plugins', + ); + } else { + $row = $wpdb_mysql->get_row( $wpdb_mysql->prepare( "SELECT option_value AS active_plugins FROM $wpdb_mysql->options WHERE option_name = %s LIMIT 1", 'active_plugins' ) ); + $table = $wpdb_mysql->options; + $value_column = 'option_value'; + $where = array( 'option_name' => 'active_plugins' ); + } + + if ( ! is_object( $row ) ) { + return; + } + + $active_plugins = maybe_unserialize( $row->active_plugins ); + if ( ! is_array( $active_plugins ) ) { + return; + } + + $item = plugin_basename( SQLITE_MAIN_FILE ); + if ( $network_deactivating ) { + $key = $item; + } else { + $key = array_search( $item, $active_plugins, true ); + } + + if ( false === $key || ! array_key_exists( $key, $active_plugins ) ) { + return; + } + + unset( $active_plugins[ $key ] ); + $wpdb_mysql->update( $table, array( $value_column => maybe_serialize( $active_plugins ) ), $where ); +} diff --git a/wp/wp-content/plugins/sqlite-database-integration/load.php b/wp/wp-content/plugins/sqlite-database-integration/load.php index fe8fbd5d5..ba8e6f5d8 100644 --- a/wp/wp-content/plugins/sqlite-database-integration/load.php +++ b/wp/wp-content/plugins/sqlite-database-integration/load.php @@ -3,8 +3,9 @@ * Plugin Name: SQLite Database Integration * Description: SQLite database driver drop-in. * Author: The WordPress Team - * Version: 3.0.0-rc.7 + * Version: 3.0.0-rc.8 * Requires PHP: 7.2 + * Network: true * Textdomain: sqlite-database-integration * * This feature plugin allows WordPress to use SQLite instead of MySQL as its database. @@ -20,6 +21,7 @@ define( 'SQLITE_MAIN_FILE', __FILE__ ); +require_once __DIR__ . '/capabilities.php'; require_once __DIR__ . '/admin-page.php'; require_once __DIR__ . '/activate.php'; require_once __DIR__ . '/deactivate.php'; diff --git a/wp/wp-content/plugins/sqlite-database-integration/readme.txt b/wp/wp-content/plugins/sqlite-database-integration/readme.txt index d19a6521c..ae7a6fe95 100644 --- a/wp/wp-content/plugins/sqlite-database-integration/readme.txt +++ b/wp/wp-content/plugins/sqlite-database-integration/readme.txt @@ -4,7 +4,7 @@ Contributors: wordpressdotorg, aristath, janjakes, zieladam, berislav.grgic Requires at least: 6.4 Tested up to: 7.0 Requires PHP: 7.2 -Stable tag: 3.0.0-rc.7 +Stable tag: 3.0.0-rc.8 License: GPLv2 or later License URI: https://www.gnu.org/licenses/gpl-2.0.html Tags: performance, database @@ -44,6 +44,17 @@ with SQLite syntax and behavior. == Changelog == += 3.0.0-rc.8 = + +* Preserve aliases in `UPDATE JOIN` translation ([#462](https://github.com/WordPress/sqlite-database-integration/pull/462)) +* Restrict SQLite installation permissions ([#464](https://github.com/WordPress/sqlite-database-integration/pull/464)) +* Improve string escaping ([#466](https://github.com/WordPress/sqlite-database-integration/pull/466)) +* Stabilize WordPress E2E tests ([#459](https://github.com/WordPress/sqlite-database-integration/pull/459)) +* Address WordPress PHPUnit test fails: charset detection, length validation etc ([#331](https://github.com/WordPress/sqlite-database-integration/pull/331)) +* Add Unicode support to the user-defined `REVERSE()` function ([#453](https://github.com/WordPress/sqlite-database-integration/pull/453)) +* Simplify the MySQL-on-SQLite driver API ([#449](https://github.com/WordPress/sqlite-database-integration/pull/449)) +* Add WordPress 7.0 compatibility ([#450](https://github.com/WordPress/sqlite-database-integration/pull/450)) + = 3.0.0-rc.7 = * Preserve configured SQLite journal mode in driver wrapper ([#447](https://github.com/WordPress/sqlite-database-integration/pull/447)) diff --git a/wp/wp-content/plugins/sqlite-database-integration/wp-includes/database/sqlite/class-wp-mysql-on-sqlite.php b/wp/wp-content/plugins/sqlite-database-integration/wp-includes/database/sqlite/class-wp-mysql-on-sqlite.php index c8f20e27a..7e06150b2 100644 --- a/wp/wp-content/plugins/sqlite-database-integration/wp-includes/database/sqlite/class-wp-mysql-on-sqlite.php +++ b/wp/wp-content/plugins/sqlite-database-integration/wp-includes/database/sqlite/class-wp-mysql-on-sqlite.php @@ -1009,6 +1009,75 @@ public function exec( $query ) { return $stmt->rowCount(); } + /** + * PDO API: Quote a string for use in a MySQL query. + * + * @param string $string The string to quote. + * @param int $type The PDO parameter type. + * @return string The quoted string. + */ + #[ReturnTypeWillChange] + // phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.stringFound + public function quote( $string, $type = PDO::PARAM_STR ) { + // Mirror PDO\MySQL::quote() value validation. + if ( + is_array( $string ) + || is_resource( $string ) + || ( is_object( $string ) && ! method_exists( $string, '__toString' ) ) + ) { + $given_type = is_object( $string ) ? get_class( $string ) : gettype( $string ); + throw new TypeError( + sprintf( + 'WP_MySQL_On_SQLite::quote(): Argument #1 ($string) must be of type string, %s given', + $given_type + ) + ); + } + $string = (string) $string; + + // Handle binary and national character prefixes. + $prefix = ''; + if ( PDO::PARAM_LOB === ( $type & PDO::PARAM_LOB ) ) { + $prefix = '_binary'; + } elseif ( + PDO::PARAM_STR_NATL === ( $type & PDO::PARAM_STR_NATL ) + && PDO::PARAM_STR_CHAR !== ( $type & PDO::PARAM_STR_CHAR ) + ) { + $prefix = 'N'; + } + + /* + * PDO uses mysqlnd by default and can alternatively use libmysqlclient. + * This escaped character mapping matches the escaping of both drivers. + * Their malformed multibyte sequence handling is not needed for UTF-8. + * + * @see https://github.com/php/php-src/blob/dd6e76cce27aaa0ed9f7520648ed1081dfb6af36/ext/mysqlnd/mysqlnd_charset.c#L905 + * @see https://github.com/mysql/mysql-server/blob/dc86e412f18b36ce271f791026714e8caa0ec919/mysys/charset.cc#L413 + * + * We can't use "addcslashes()" here, because it has an unusual handling + * of the ASCII NULL and Control+Z characters, escaping them to "\000" + * and "\032" instead of "\0" and "\Z", respectively. + * + * It is important to use "strtr()" and not "str_replace()", because + * "str_replace()" applies replacements one after another, modifying + * intermediate changes rather than just the original string: + * + * - str_replace( [ 'a', 'b' ], [ 'b', 'c' ], 'ab' ); // 'cc' (bad) + * - strtr( 'ab', [ 'a' => 'b', 'b' => 'c' ] ); // 'bc' (good) + */ + $backslash = chr( 92 ); + $replacements = array( + chr( 0 ) => $backslash . '0', // An ASCII NULL character (\0). + chr( 10 ) => $backslash . 'n', // A newline (linefeed) character (\n). + chr( 13 ) => $backslash . 'r', // A carriage return character (\r). + $backslash => $backslash . $backslash, // A backslash character (\). + "'" => $backslash . "'", // A single quote character ('). + '"' => $backslash . '"', // A double quote character ("). + chr( 26 ) => $backslash . 'Z', // An ASCII 26 (Control+Z) character. + ); + return $prefix . "'" . strtr( $string, $replacements ) . "'"; + } + /** * PDO API: Begin a transaction. * @@ -2147,10 +2216,24 @@ private function execute_update_statement( WP_Parser_Node $node ): void { )->fetchAll( PDO::FETCH_COLUMN ); } - $matched_tables = array_merge( $matched_temporary_tables, $matched_persistent_tables ); - $updates_multiple_tables = count( $matched_tables ) > 1; - if ( 1 === count( $matched_tables ) ) { - $table_or_alias = $matched_tables[0]; + $matched_tables = array_merge( $matched_temporary_tables, $matched_persistent_tables ); + $matched_aliases = array(); + foreach ( $table_alias_map as $alias => $data ) { + // Derived tables do not have a table name. + if ( null === $data['table_name'] ) { + continue; + } + + foreach ( $matched_tables as $matched_table ) { + if ( 0 === strcasecmp( $data['table_name'], $matched_table ) ) { + $matched_aliases[] = $alias; + break; + } + } + } + $updates_multiple_tables = count( $matched_aliases ) > 1; + if ( 1 === count( $matched_aliases ) ) { + $table_or_alias = $matched_aliases[0]; } else { break; } @@ -3524,7 +3607,14 @@ private function execute_set_system_variable_statement( if ( WP_MySQL_Lexer::SESSION_SYMBOL === $type ) { if ( 'sql_mode' === $name ) { - $modes = explode( ',', strtoupper( $value ) ); + // MySQL ignores trailing ASCII spaces in SQL mode names. + $modes = explode( ',', strtoupper( $value ) ); + foreach ( $modes as $i => $mode ) { + $modes[ $i ] = rtrim( $mode, ' ' ); + } + if ( in_array( 'NO_BACKSLASH_ESCAPES', $modes, true ) ) { + throw $this->new_not_supported_exception( "SQL mode 'NO_BACKSLASH_ESCAPES'" ); + } $this->active_sql_modes = $modes; } else { $this->session_system_variables[ $name ] = $value; diff --git a/wp/wp-content/plugins/sqlite-database-integration/wp-includes/database/sqlite/class-wp-sqlite-connection.php b/wp/wp-content/plugins/sqlite-database-integration/wp-includes/database/sqlite/class-wp-sqlite-connection.php index 170ba01c8..2b85f078d 100644 --- a/wp/wp-content/plugins/sqlite-database-integration/wp-includes/database/sqlite/class-wp-sqlite-connection.php +++ b/wp/wp-content/plugins/sqlite-database-integration/wp-includes/database/sqlite/class-wp-sqlite-connection.php @@ -57,9 +57,9 @@ class WP_SQLite_Connection { /** * A query logger callback. * - * @var callable(string, array): void + * @var (callable(string, array): void)|null */ - private $query_logger; + private $query_logger = null; /** * Constructor. @@ -274,11 +274,11 @@ public function get_pdo(): PDO { } /** - * Set a logger for the queries. + * Set or clear a logger for SQLite queries. * - * @param callable(string, array): void $logger A query logger callback. + * @param (callable(string, array): void)|null $logger A query logger callback, or null to clear it. */ - public function set_query_logger( callable $logger ): void { + public function set_query_logger( ?callable $logger ): void { $this->query_logger = $logger; } } diff --git a/wp/wp-content/plugins/sqlite-database-integration/wp-includes/database/version.php b/wp/wp-content/plugins/sqlite-database-integration/wp-includes/database/version.php index bb54d64dc..e436dd0e7 100644 --- a/wp/wp-content/plugins/sqlite-database-integration/wp-includes/database/version.php +++ b/wp/wp-content/plugins/sqlite-database-integration/wp-includes/database/version.php @@ -5,4 +5,4 @@ * * This constant needs to be updated on plugin release! */ -define( 'SQLITE_DRIVER_VERSION', '3.0.0-rc.7' ); +define( 'SQLITE_DRIVER_VERSION', '3.0.0-rc.8' ); diff --git a/wp/wp-content/plugins/sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php b/wp/wp-content/plugins/sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php index b94c66d20..d7100aa8e 100644 --- a/wp/wp-content/plugins/sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php +++ b/wp/wp-content/plugins/sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php @@ -20,6 +20,13 @@ class WP_SQLite_DB extends wpdb { */ protected $dbh; + /** + * Whether the PDO instance was provided externally through $GLOBALS['@pdo']. + * + * @var bool + */ + private $is_pdo_external; + /** * Backward compatibility, see wpdb::$allow_unsafe_unquoted_parameters. * @@ -71,17 +78,59 @@ public function set_charset( $dbh, $charset = null, $collate = null ) { } /** - * Method to get the character set for the database. - * Hardcoded to utf8mb4 for now. + * Retrieves the character set for the given column. + * + * This overrides wpdb::get_col_charset() to enable the parent's implementation + * for SQLite by temporarily setting the is_mysql flag. * - * @param string $table The table name. - * @param string $column The column name. + * @see wpdb::get_col_charset() * - * @return string The character set. + * @param string $table Table name. + * @param string $column Column name. + * @return string|false|WP_Error Column character set as a string. False if the column has + * no character set. WP_Error object on failure. */ public function get_col_charset( $table, $column ) { - // Hardcoded for now. - return 'utf8mb4'; + $original_is_mysql = $this->is_mysql ?? null; + + /* + * The parent method returns early when `$this->is_mysql` is falsy. + * Since SQLite doesn't set this flag, we enable it temporarily so + * the parent can run its full logic — querying column metadata via + * SHOW FULL COLUMNS (which the SQLite driver translates) and + * populating the `$this->col_meta` cache. + */ + try { + $this->is_mysql = true; + return parent::get_col_charset( $table, $column ); + } finally { + $this->is_mysql = $original_is_mysql; + } + } + + /** + * Retrieves the maximum string length allowed in a given column. + * + * This overrides wpdb::get_col_length() to enable the parent's implementation + * for SQLite by temporarily setting the is_mysql flag. + * + * @see wpdb::get_col_length() + * + * @param string $table Table name. + * @param string $column Column name. + * @return array|false|WP_Error Column length information, false if the column has + * no length. WP_Error object on failure. + */ + public function get_col_length( $table, $column ) { + $original_is_mysql = $this->is_mysql ?? null; + + // See get_col_charset() for an explanation of the is_mysql flag. + try { + $this->is_mysql = true; + return parent::get_col_length( $table, $column ); + } finally { + $this->is_mysql = $original_is_mysql; + } } /** @@ -130,14 +179,104 @@ public function set_sql_mode( $modes = array() ) { /** * Closes the current database connection. - * Noop in SQLite. * - * @return bool True to indicate the connection was successfully closed. + * This overrides wpdb::close() while closely mirroring its implementation. + * + * @see wpdb::close() + * + * @return bool True if the connection was successfully closed, + * false if it wasn't, or if the connection doesn't exist. */ public function close() { + if ( ! $this->dbh ) { + return false; + } + + $connection = $this->dbh->get_connection(); + $pdo = $connection->get_pdo(); + + try { + if ( $this->dbh->inTransaction() ) { + $this->dbh->rollBack(); + } elseif ( $pdo->inTransaction() ) { + $pdo->rollBack(); + } else { + /* + * On PHP < 8.4, PDO cannot detect transactions started via SQL. + * A savepoint ensures ROLLBACK succeeds with or without one. + */ + $pdo->exec( 'SAVEPOINT wp_sqlite_db_close' ); + $pdo->exec( 'ROLLBACK' ); + } + } catch ( Throwable $e ) { + return false; + } + + /* + * @TODO: Replace and deprecate the $GLOBALS['@pdo'] injection mechanism. + * PDO has no close method and is released only when all references are unset. + * Until then, retain external PDOs so reconnects reuse the same database. + */ + if ( + ! $this->is_pdo_external + && isset( $GLOBALS['@pdo'] ) + && $GLOBALS['@pdo'] === $pdo + ) { + unset( $GLOBALS['@pdo'] ); + } + + $connection->set_query_logger( null ); + $this->result = null; + $this->dbh = null; + $this->ready = false; + $this->has_connected = false; + return true; } + /** + * Determines the best charset and collation to use given a charset and collation. + * + * For example, when able, utf8mb4 should be used instead of utf8. + * + * This overrides wpdb::determine_charset() while closely mirroring its implementation. + * The override is needed because the parent checks for a mysqli connection object. + * + * @param string $charset The character set to check. + * @param string $collate The collation to check. + * @return array { + * The most appropriate character set and collation to use. + * + * @type string $charset Character set. + * @type string $collate Collation. + * } + */ + public function determine_charset( $charset, $collate ) { + if ( ! $this->dbh ) { + return compact( 'charset', 'collate' ); + } + + if ( 'utf8' === $charset ) { + $charset = 'utf8mb4'; + } + + if ( 'utf8mb4' === $charset ) { + // _general_ is outdated, so we can upgrade it to _unicode_, instead. + if ( ! $collate || 'utf8_general_ci' === $collate ) { + $collate = 'utf8mb4_unicode_ci'; + } else { + $collate = str_replace( 'utf8_', 'utf8mb4_', $collate ); + } + } + + // _unicode_520_ is a better collation, we should use that when it's available. + if ( $this->has_cap( 'utf8mb4_520' ) && 'utf8mb4_unicode_ci' === $collate ) { + $collate = 'utf8mb4_unicode_520_ci'; + } + + return compact( 'charset', 'collate' ); + } + /** * Method to select the database connection. * @@ -162,12 +301,20 @@ public function select( $db, $dbh = null ) { * @param string $data The string to escape. * * @return string escaped + * @throws RuntimeException When the database connection is not initialized. */ public function _real_escape( $data ) { if ( ! is_scalar( $data ) ) { return ''; } - $escaped = addslashes( $data ); + + if ( ! $this->dbh ) { + throw new RuntimeException( 'Cannot escape data without an active database connection.' ); + } + + // Escape the string without bounding quotes to mirror mysqli_real_escape_string(). + $quoted = $this->dbh->quote( (string) $data ); + $escaped = substr( $quoted, 1, -1 ); return $this->add_placeholder_escape( $escaped ); } @@ -268,19 +415,21 @@ public function flush() { * @see wpdb::db_connect() * * @param bool $allow_bail Not used. - * @return void + * @return bool True on a successful connection, false on failure. */ public function db_connect( $allow_bail = true ) { if ( $this->dbh ) { - return; + return $this->ready; } - $this->init_charset(); - $pdo = null; - if ( isset( $GLOBALS['@pdo'] ) ) { - $pdo = $GLOBALS['@pdo']; + $this->last_error = ''; + if ( ! isset( $this->charset ) ) { + $this->init_charset(); } + $this->is_pdo_external = isset( $GLOBALS['@pdo'] ); + $pdo = $this->is_pdo_external ? $GLOBALS['@pdo'] : null; + // Migrate the database file from a legacy path, if it exists. if ( ! defined( 'DB_FILE' ) && ! file_exists( FQDB ) ) { $old_db_path = FQDBDIR . '.ht.sqlite.php'; @@ -317,7 +466,7 @@ public function db_connect( $allow_bail = true ) { if ( null !== $pdo ) { $options['pdo'] = $pdo; } - $this->dbh = new WP_MySQL_On_SQLite( + $dbh = new WP_MySQL_On_SQLite( sprintf( 'mysql-on-sqlite:path=%s;dbname=%s', str_replace( ';', ';;', FQDB ), @@ -327,27 +476,38 @@ public function db_connect( $allow_bail = true ) { null, $options ); - $this->dbh->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO - $GLOBALS['@pdo'] = $this->dbh->get_connection()->get_pdo(); + $dbh->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO + $pdo = $dbh->get_connection()->get_pdo(); + $this->dbh = $dbh; + $GLOBALS['@pdo'] = $pdo; } catch ( Throwable $e ) { $this->last_error = $this->format_error_message( $e ); } if ( $this->last_error ) { return false; } + + $this->has_connected = true; + $this->set_charset( $this->dbh ); + $this->ready = true; $this->set_sql_mode(); + return true; } /** - * Method to dummy out wpdb::check_connection() + * Checks that the database connection is available. * * @param bool $allow_bail Not used. * - * @return bool + * @return bool True when the connection is available, false otherwise. */ public function check_connection( $allow_bail = true ) { - return true; + if ( $this->dbh ) { + return true; + } + + return $this->db_connect( $allow_bail ); } /** @@ -420,14 +580,13 @@ public function query( $query ) { $last_query_count = count( $this->queries ?? array() ); /* - * @TODO: WPDB uses "$this->check_current_query" to check table/column - * charset and strip all invalid characters from the query. - * This is an involved process that we can bypass for SQLite, - * if we simply strip all invalid UTF-8 characters from the query. + * @TODO: wpdb uses "$this->check_current_query" and table metadata to + * reject queries containing invalid text. Implement equivalent handling + * for SQLite without relying on the MySQL-specific conversion pipeline. * - * To do so, mb_convert_encoding can be used with an optional - * fallback to a htmlspecialchars method. E.g.: - * https://github.com/nette/utils/blob/be534713c227aeef57ce1883fc17bc9f9e29eca2/src/Utils/Strings.php#L42 + * PCRE's "u" modifier can validate UTF-8 without constructing a converted + * query copy: 1 === preg_match( '//u', $query ). The implementation must + * preserve wpdb's exemptions for prevalidated and binary data. */ $this->_do_query( $query ); @@ -559,21 +718,22 @@ protected function load_col_info() { } /** - * Method to return what the database can do. + * Determines whether the database supports a given feature. * - * This overrides wpdb::has_cap() to avoid using MySQL functions. - * SQLite supports subqueries, but not support collation, group_concat and set_charset. + * The utf8mb4 check is handled here because older WordPress versions inspect + * the MySQL client library. All other capabilities use the parent logic. * * @see wpdb::has_cap() * - * @param string $db_cap The feature to check for. Accepts 'collation', - * 'group_concat', 'subqueries', 'set_charset', - * 'utf8mb4', or 'utf8mb4_520'. - * - * @return bool Whether the database feature is supported, false otherwise. + * @param string $db_cap The feature to check for. + * @return bool True when the database feature is supported, false otherwise. */ public function has_cap( $db_cap ) { - return 'subqueries' === strtolower( $db_cap ); + if ( 'utf8mb4' === strtolower( $db_cap ) ) { + return true; + } + + return parent::has_cap( $db_cap ); } /** @@ -592,9 +752,13 @@ public function db_version() { /** * Returns the version of the SQLite engine. * - * @return string SQLite engine version as a string. + * @return string SQLite engine version, or an empty string while disconnected. */ public function db_server_info() { + if ( ! $this->dbh ) { + return ''; + } + return $this->dbh->get_sqlite_version(); }