diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index 527508607..934d3c571 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -18,20 +18,10 @@ const expectedErrors = [ const expectedFailures = [ 'Tests_Admin_wpSiteHealth::test_object_cache_thresholds with data set #3', - 'Tests_Comment::test_wp_new_comment_respects_comment_field_lengths', 'Tests_Comment::test_wp_update_comment', 'Tests_Comment_CheckComment::test_should_return_false_when_comment_previously_approved_is_enabled_and_author_does_not_have_approved_comment', 'Tests_Comment_CheckComment::test_should_return_true_when_comment_previously_approved_is_enabled_and_author_has_approved_comment', 'Tests_Comment_CheckComment::test_should_return_false_when_comment_previously_approved_is_enabled_and_user_does_not_have_a_previously_approved_comment_with_any_email', - 'Tests_DB_Charset::test_get_column_charset with data set #0', - 'Tests_DB_Charset::test_get_column_charset with data set #1', - 'Tests_DB_Charset::test_get_column_charset with data set #2', - 'Tests_DB_Charset::test_get_column_charset with data set #3', - 'Tests_DB_Charset::test_get_column_charset with data set #4', - 'Tests_DB_Charset::test_get_column_charset with data set #5', - 'Tests_DB_Charset::test_get_column_charset with data set #6', - 'Tests_DB_Charset::test_get_column_charset with data set #7', - 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #0', 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #1', 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #2', 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #3', @@ -39,7 +29,6 @@ const expectedFailures = [ 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #5', 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #6', 'Tests_DB_Charset::test_get_column_charset_is_mysql_undefined with data set #7', - 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #0', 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #1', 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #2', 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #3', @@ -47,7 +36,6 @@ const expectedFailures = [ 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #5', 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #6', 'Tests_DB_Charset::test_get_column_charset_non_mysql with data set #7', - 'Tests_DB_Charset::test_process_field_charsets_on_nonexistent_table', 'Tests_DB_Charset::test_strip_invalid_text with data set #21', 'Tests_DB_Charset::test_strip_invalid_text with data set #22', 'Tests_DB_Charset::test_strip_invalid_text with data set #23', @@ -64,23 +52,10 @@ const expectedFailures = [ 'Tests_DB_Charset::test_strip_invalid_text with data set #39', 'Tests_DB_Charset::test_strip_invalid_text with data set #40', 'Tests_DB_Charset::test_strip_invalid_text with data set #41', - 'Tests_DB_Charset::test_strip_invalid_text_for_column_bails_if_ascii_input_too_long', 'Tests_DB_dbDelta::test_spatial_indices', - 'Tests_DB::test_charset_switched_to_utf8mb4', - 'Tests_DB::test_close', - 'Tests_DB::test_delete_value_too_long_for_field with data set "too long"', - 'Tests_DB::test_has_cap', - 'Tests_DB::test_insert_value_too_long_for_field with data set "too long"', 'Tests_DB::test_mysqli_flush_sync', - 'Tests_DB::test_non_unicode_collations', - 'Tests_DB::test_pre_get_col_charset_filter', - 'Tests_DB::test_process_fields_on_nonexistent_table', - 'Tests_DB::test_process_fields_value_too_long_for_field with data set "too long"', 'Tests_DB::test_query_value_contains_invalid_chars', - 'Tests_DB::test_replace_value_too_long_for_field with data set "too long"', 'Tests_DB::test_replace', - 'Tests_DB::test_supports_collation', - 'Tests_DB::test_update_value_too_long_for_field with data set "too long"', 'Tests_Menu_Walker_Nav_Menu::test_start_el_with_empty_attributes with data set #1', 'Tests_Menu_Walker_Nav_Menu::test_start_el_with_empty_attributes with data set #2', 'Tests_Menu_Walker_Nav_Menu::test_start_el_with_empty_attributes with data set #3', diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php index 170ba01c8..2b85f078d 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-connection.php +++ b/packages/mysql-on-sqlite/src/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/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php b/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php index b94c66d20..9d318af96 100644 --- a/packages/plugin-sqlite-database-integration/wp-includes/sqlite/class-wp-sqlite-db.php +++ b/packages/plugin-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. * @@ -268,19 +407,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 +458,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 +468,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 +572,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 +710,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 +744,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(); }