db_connect()". * 2. The database connection call initializes the SQLite driver. * 3. The SQLite driver initializes and runs "WP_SQLite_Configurator". * 4. The configurator uses "WP_SQLite_Information_Schema_Reconstructor", * which requires "wp-admin/includes/schema.php" when in WordPress. * 5. The "wp-admin/includes/schema.php" requires the "$wpdb" global, * which creates a circular dependency. */ $GLOBALS['wpdb'] = $this; // The SQLite driver may use the charset and collation while connecting. $this->init_charset(); parent::__construct( '', '', $dbname, '' ); } /** * Returns the active MySQL-on-SQLite driver. * * @return WP_MySQL_On_SQLite The active driver. * @throws RuntimeException When there is no active database connection. */ public function get_driver(): WP_MySQL_On_SQLite { if ( ! $this->dbh ) { throw new RuntimeException( 'Cannot access the driver without an active database connection.' ); } return $this->dbh; } /** * Method to set character set for the database. * * This overrides wpdb::set_charset(), only to dummy out the MySQL function. * * @see wpdb::set_charset() * * @param resource $dbh The resource given by mysql_connect. * @param string $charset Optional. The character set. Default null. * @param string $collate Optional. The collation. Default null. */ public function set_charset( $dbh, $charset = null, $collate = null ) { } /** * 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. * * @see wpdb::get_col_charset() * * @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 ) { $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; } } /** * Changes the current SQL mode, and ensures its WordPress compatibility. * * If no modes are passed, it will ensure the current MySQL server modes are compatible. * * This overrides wpdb::set_sql_mode() while closely mirroring its implementation. * * @param array $modes Optional. A list of SQL modes to set. Default empty array. */ public function set_sql_mode( $modes = array() ) { if ( empty( $modes ) ) { $result = $this->dbh->query( 'SELECT @@SESSION.sql_mode' )->fetchAll( PDO::FETCH_OBJ ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO if ( ! isset( $result[0] ) ) { return; } $modes_str = $result[0]->{'@@SESSION.sql_mode'}; if ( empty( $modes_str ) ) { return; } $modes = explode( ',', $modes_str ); } $modes = array_change_key_case( $modes, CASE_UPPER ); /** * Filters the list of incompatible SQL modes to exclude. * * @since 3.9.0 * * @param array $incompatible_modes An array of incompatible modes. */ $incompatible_modes = (array) apply_filters( 'incompatible_sql_modes', $this->incompatible_modes ); foreach ( $modes as $i => $mode ) { if ( in_array( $mode, $incompatible_modes, true ) ) { unset( $modes[ $i ] ); } } $modes_str = implode( ',', $modes ); $this->dbh->query( "SET SESSION sql_mode='$modes_str'" ); } /** * Closes the current database connection. * * 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; } if ( 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; } /** * Sets $this->charset and $this->collate. * * This overrides wpdb::init_charset(). SQLite stores all text as UTF-8, and * the emulated MySQL connection always uses utf8mb4 (see set_charset()), so * the charset is always utf8mb4, and only a UTF-8 DB_COLLATE applies. * * Unlike in wpdb, the result doesn't depend on the database connection. * This is important, as the SQLite driver may use the charset and collation * while connecting (see the constructor). * * @see wpdb::init_charset() */ public function init_charset() { $collate = defined( 'DB_COLLATE' ) ? strtolower( (string) DB_COLLATE ) : ''; // MySQL 8.0.30 and newer name the utf8 collations "utf8mb3_*". $collate = preg_replace( '/^utf8mb3_/', 'utf8_', $collate ); // Collations of other charsets don't apply to the utf8mb4 connection. if ( ! preg_match( '/^utf8(mb4)?_/', $collate ) ) { $collate = ''; } $charset_collate = $this->resolve_charset( 'utf8mb4', $collate ); $this->charset = $charset_collate['charset']; $this->collate = $charset_collate['collate']; } /** * 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' ); } return $this->resolve_charset( $charset, $collate ); } /** * Method to select the database connection. * * This overrides wpdb::select(), only to dummy out the MySQL function. * * @see wpdb::select() * * @param string $db MySQL database name. Not used. * @param resource|null $dbh Optional link identifier. */ public function select( $db, $dbh = null ) { $this->ready = true; } /** * Method to escape characters. * * This overrides wpdb::_real_escape() to avoid using mysql_real_escape_string(). * * @see wpdb::_real_escape() * * @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 ''; } 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 ); } /** * Prints SQL/DB error. * * This overrides wpdb::print_error() while closely mirroring its implementation. * * @global array $EZSQL_ERROR Stores error information of query and error string. * * @param string $str The error to display. * @return void|false Void if the showing of errors is enabled, false if disabled. */ public function print_error( $str = '' ) { global $EZSQL_ERROR; if ( ! $str ) { $str = $this->last_error; } $EZSQL_ERROR[] = array( 'query' => $this->last_query, 'error_str' => $str, ); if ( $this->suppress_errors ) { return false; } $caller = $this->get_caller(); if ( $caller ) { // Not translated, as this will only appear in the error log. $error_str = sprintf( 'WordPress database error %1$s for query %2$s made by %3$s', $str, $this->last_query, $caller ); } else { $error_str = sprintf( 'WordPress database error %1$s for query %2$s', $str, $this->last_query ); } error_log( $error_str ); // Are we showing errors? if ( ! $this->show_errors ) { return false; } wp_load_translations_early(); // If there is an error then take note of it. if ( is_multisite() ) { $msg = sprintf( "%s [%s]\n%s\n", __( 'WordPress database error:' ), $str, $this->last_query ); if ( defined( 'ERRORLOGFILE' ) ) { error_log( $msg, 3, ERRORLOGFILE ); } if ( defined( 'DIEONDBERROR' ) ) { wp_die( $msg ); } } else { $str = htmlspecialchars( $str, ENT_QUOTES ); $query = htmlspecialchars( $this->last_query, ENT_QUOTES ); printf( '

%s [%s]
%s

', __( 'WordPress database error:' ), $str, $query ); } } /** * Method to flush cached data. * * This overrides wpdb::flush(). This is not necessarily overridden, because * $result will never be resource. * * @see wpdb::flush */ public function flush() { $this->last_result = array(); $this->col_info = null; $this->last_query = null; $this->rows_affected = 0; $this->num_rows = 0; $this->last_error = ''; $this->result = null; } /** * Method to do the database connection. * * This overrides wpdb::db_connect() to avoid using MySQL function. * * @see wpdb::db_connect() * * @param bool $allow_bail Not used. * @return bool True on a successful connection, false on failure. */ public function db_connect( $allow_bail = true ) { if ( $this->dbh ) { return $this->ready; } $this->last_error = ''; if ( isset( $GLOBALS['@pdo'] ) ) { trigger_error( 'PDO injection via $GLOBALS[\'@pdo\'] is no longer supported. The existing PDO will be ignored and a new connection will be created.', E_USER_WARNING ); } if ( null === $this->dbname || '' === $this->dbname ) { $this->bail( 'The database name was not set. The SQLite driver requires a database name to be set to emulate MySQL information schema tables.', 'db_connect_fail' ); return false; } try { $options = array( 'sqlite_journal_mode' => defined( 'SQLITE_JOURNAL_MODE' ) ? SQLITE_JOURNAL_MODE : null, ); $dbh = new WP_MySQL_On_SQLite( sprintf( 'mysql-on-sqlite:path=%s;dbname=%s', str_replace( ';', ';;', DB_PATH ), str_replace( ';', ';;', $this->dbname ) ), null, null, $options ); $dbh->setAttribute( PDO::ATTR_STRINGIFY_FETCHES, true ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO $this->dbh = $dbh; /** * Exposes the underlying PDO SQLite connection for backward compatibility. * * @deprecated 3.0.0 Use WP_SQLite_DB::get_driver() with * WP_MySQL_On_SQLite::get_sqlite_pdo() instead. */ $GLOBALS['@pdo'] = $dbh->get_sqlite_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; } /** * Checks that the database connection is available. * * @param bool $allow_bail Not used. * * @return bool True when the connection is available, false otherwise. */ public function check_connection( $allow_bail = true ) { if ( $this->dbh ) { return true; } return $this->db_connect( $allow_bail ); } /** * Prepares a SQL query for safe execution. * * See "wpdb::prepare()". This override only fixes a WPDB test issue. * * @param string $query Query statement with `sprintf()`-like placeholders. * @param array|mixed $args The array of variables or the first variable to substitute. * @param mixed ...$args Further variables to substitute when using individual arguments. * @return string|void Sanitized query string, if there is a query to prepare. */ public function prepare( $query, ...$args ) { /* * Sync "$allow_unsafe_unquoted_parameters" with the WPDB parent property. * This is only needed because some WPDB tests are accessing the private * property externally via PHP reflection. This should be fixed WP tests. */ $wpdb_allow_unsafe_unquoted_parameters = $this->__get( 'allow_unsafe_unquoted_parameters' ); if ( $wpdb_allow_unsafe_unquoted_parameters !== $this->allow_unsafe_unquoted_parameters ) { $property = new ReflectionProperty( 'wpdb', 'allow_unsafe_unquoted_parameters' ); $property->setAccessible( true ); $property->setValue( $this, $this->allow_unsafe_unquoted_parameters ); $property->setAccessible( false ); } return parent::prepare( $query, ...$args ); } /** * Performs a database query. * * This overrides wpdb::query() while closely mirroring its implementation. * * @see wpdb::query() * * @param string $query Database query. * * @param string $query Database query. * @return int|bool Boolean true for CREATE, ALTER, TRUNCATE and DROP queries. Number of rows * affected/selected for all other queries. Boolean false on error. */ public function query( $query ) { // Query Monitor integration: $query_monitor_active = defined( 'SQLITE_QUERY_MONITOR_LOADED' ) && SQLITE_QUERY_MONITOR_LOADED; if ( $query_monitor_active && $this->show_errors ) { $this->hide_errors(); } if ( ! $this->ready ) { $this->check_current_query = true; return false; } $query = apply_filters( 'query', $query ); if ( ! $query ) { $this->insert_id = 0; return false; } $this->flush(); // Log how the function was called. $this->func_call = "\$db->query(\"$query\")"; /* * Mirror wpdb's query text validation. * TODO: Add full charset enforcement to MySQL on SQLite, where column * types and SQL mode are known, so all callers are protected. */ if ( $this->check_current_query && ! $this->check_ascii( $query ) ) { $stripped_query = $this->strip_invalid_text_from_query( $query ); // Charset discovery can run queries, so clear their results. $this->flush(); if ( $stripped_query !== $query ) { $this->insert_id = 0; $this->last_query = $query; wp_load_translations_early(); $this->last_error = __( 'WordPress database error: Could not perform query because it contains invalid data.' ); return false; } } $this->check_current_query = true; // Keep track of the last query for debug. $this->last_query = $query; // Save the query count after any charset discovery queries. $last_query_count = count( $this->queries ?? array() ); $this->_do_query( $query ); if ( $this->last_error ) { // Clear insert_id on a subsequent failed insert. if ( $this->insert_id && preg_match( '/^\s*(insert|replace)\s/i', $query ) ) { $this->insert_id = 0; } $this->print_error(); return false; } if ( preg_match( '/^\s*(create|alter|truncate|drop)\s/i', $query ) ) { $return_val = true; } elseif ( preg_match( '/^\s*(insert|delete|update|replace)\s/i', $query ) ) { $this->rows_affected = $this->result->rowCount(); // Take note of the insert_id. if ( preg_match( '/^\s*(insert|replace)\s/i', $query ) ) { $this->insert_id = (int) $this->dbh->lastInsertId(); } // Return number of rows affected. $return_val = $this->rows_affected; } else { $num_rows = 0; if ( $this->result->columnCount() > 0 ) { $this->last_result = $this->result->fetchAll(); $num_rows = count( $this->last_result ); } // Log and return the number of rows selected. $this->num_rows = $num_rows; $return_val = $num_rows; } // Query monitor integration: if ( $query_monitor_active && class_exists( 'QM_Backtrace' ) ) { if ( did_action( 'qm/cease' ) ) { $this->queries = array(); } $i = $last_query_count; if ( ! isset( $this->queries[ $i ] ) ) { return $return_val; } $this->queries[ $i ]['trace'] = new QM_Backtrace(); if ( ! isset( $this->queries[ $i ][3] ) ) { $this->queries[ $i ][3] = $this->time_start; } if ( $this->last_error && ! $this->suppress_errors ) { $this->queries[ $i ]['result'] = new WP_Error( 'qmdb', $this->last_error ); } else { $this->queries[ $i ]['result'] = (int) $return_val; } // Add SQLite query data. $this->queries[ $i ]['sqlite_queries'] = $this->dbh->get_last_sqlite_queries(); } return $return_val; } /** * Internal function to perform the SQLite query call. * * This closely mirrors wpdb::_do_query(). * * @see wpdb::_do_query() * * @param string $query The query to run. */ private function _do_query( $query ) { if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) { $this->timer_start(); } try { $this->result = $this->dbh->query( $query, PDO::FETCH_OBJ ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO } catch ( Throwable $e ) { $this->last_error = $this->format_error_message( $e ); } ++$this->num_queries; if ( defined( 'SAVEQUERIES' ) && SAVEQUERIES ) { $this->log_query( $query, $this->timer_stop(), $this->get_caller(), $this->time_start, array() ); } } /** * Method to set the class variable $col_info. * * This overrides wpdb::load_col_info(), which uses a mysql function. * * @see wpdb::load_col_info() */ protected function load_col_info() { if ( $this->col_info ) { return; } $this->col_info = array(); if ( null === $this->result ) { return; } for ( $i = 0; $i < $this->result->columnCount(); $i++ ) { $column = $this->result->getColumnMeta( $i ); $this->col_info[] = (object) array( 'name' => $column['name'], 'orgname' => $column['mysqli:orgname'], 'table' => $column['table'], 'orgtable' => $column['mysqli:orgtable'], 'def' => '', // Unused, always ''. 'db' => $column['mysqli:db'], 'catalog' => 'def', // Unused, always 'def'. 'max_length' => 0, // As of PHP 8.1, this is always 0. 'length' => $column['len'], 'charsetnr' => $column['mysqli:charsetnr'], 'flags' => $column['mysqli:flags'], 'type' => $column['mysqli:type'], 'decimals' => $column['precision'], ); } } /** * Determines whether the database supports a given feature. * * 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. * @return bool True when the database feature is supported, false otherwise. */ public function has_cap( $db_cap ) { if ( 'utf8mb4' === strtolower( $db_cap ) ) { return true; } return parent::has_cap( $db_cap ); } /** * Retrieves the emulated database server version number. * * This mirrors wpdb::db_version(), but must also be defined here because * WordPress 5.4 and older fetch server information directly from the MySQL * extension instead of delegating to wpdb::db_server_info(). * * @see wpdb::db_version() * * @return string Version number on success, or an empty string while disconnected. */ public function db_version() { return preg_replace( '/[^0-9.].*/', '', $this->db_server_info() ); } /** * Returns the raw version string of the emulated MySQL server. * * @see wpdb::db_server_info() * * @return string Emulated MySQL server version, or an empty string while disconnected. */ public function db_server_info() { if ( ! $this->dbh ) { return ''; } return $this->dbh->getAttribute( PDO::ATTR_SERVER_VERSION ); // phpcs:ignore WordPress.DB.RestrictedClasses.mysql__PDO } /** * Resolves the charset and collation as wpdb::determine_charset() does. * * Unlike in wpdb, this doesn't need a database connection. The emulated * MySQL server (5.7 or newer) always supports utf8mb4_unicode_520_ci. * * @see wpdb::determine_charset() * * @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. * } */ private function resolve_charset( $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 ( 'utf8mb4_unicode_ci' === $collate ) { $collate = 'utf8mb4_unicode_520_ci'; } return compact( 'charset', 'collate' ); } /** * Format MySQL-on-SQLite driver error message. * * @return string */ private function format_error_message( Throwable $e ) { $output = '
 
' . PHP_EOL; // Queries. if ( $e instanceof WP_MySQL_On_SQLite_Exception ) { $driver = $e->get_driver(); $output .= '
' . PHP_EOL; $output .= '

MySQL query:

' . PHP_EOL; $output .= '

' . $driver->get_last_mysql_query() . '

' . PHP_EOL; $output .= '

Queries made or created this session were:

' . PHP_EOL; $output .= '
    ' . PHP_EOL; foreach ( $driver->get_last_sqlite_queries() as $q ) { $message = "Executing: {$q['sql']} | " . ( $q['params'] ? 'parameters: ' . implode( ', ', $q['params'] ) : '(no parameters)' ); $output .= '
  1. ' . htmlspecialchars( $message ) . '
  2. ' . PHP_EOL; } $output .= '
' . PHP_EOL; $output .= '
' . PHP_EOL; } // Message. $output .= '
' . PHP_EOL; $output .= $e->getMessage() . PHP_EOL; $output .= '
' . PHP_EOL; // Backtrace. $output .= '

Backtrace:

' . PHP_EOL; $output .= '
' . $e->getTraceAsString() . '
' . PHP_EOL; return $output; } }