From 9013237cdbbede436b95ce61777527fd76eeb4b3 Mon Sep 17 00:00:00 2001 From: Chloe Pomegranate Date: Fri, 20 Mar 2026 13:55:31 +0000 Subject: [PATCH 1/8] Address WordPress PHPUnit test fails: charset detection, length validation, capabilities --- .../wp-includes/sqlite/class-wp-sqlite-db.php | 255 ++++++++++++++++-- 1 file changed, 235 insertions(+), 20 deletions(-) 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..02a573759 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 @@ -71,17 +71,154 @@ 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. * - * @param string $table The table name. - * @param string $column The column name. + * @since 2.3.0 * - * @return string The character set. + * @param string $table Table name. + * @param string $column Column name. + * @return string|false Column character set as a string. False if the column has + * no character set (e.g., numeric or binary columns). */ public function get_col_charset( $table, $column ) { - // Hardcoded for now. - return 'utf8mb4'; + $table_key = strtolower( $table ); + $column_key = strtolower( $column ); + + /** + * Filters the column charset value before the DB is checked. + * + * @since 2.3.0 + * + * @param string|null|false $charset The character set to use. Default null. + * @param string $table The name of the table being checked. + * @param string $column The name of the column being checked. + */ + $charset = apply_filters( 'pre_get_col_charset', null, $table, $column ); + if ( null !== $charset ) { + return $charset; + } + + // Use SHOW FULL COLUMNS to get column metadata with collation info. + // This works because the driver translates this to query the information schema. + $results = $this->get_results( "SHOW FULL COLUMNS FROM `{$table_key}`" ); + + if ( ! $results ) { + return false; + } + + // Build column metadata cache. + $columns = array(); + foreach ( $results as $col ) { + $columns[ strtolower( $col->Field ) ] = $col; + } + $this->col_meta[ $table_key ] = $columns; + + // Check if column exists. + if ( ! isset( $columns[ $column_key ] ) ) { + return false; + } + + // Return false for non-string columns (no collation). + if ( empty( $columns[ $column_key ]->Collation ) ) { + return false; + } + + // Extract charset from collation (e.g., 'utf8mb4_general_ci' -> 'utf8mb4'). + list( $charset ) = explode( '_', $columns[ $column_key ]->Collation ); + return $charset; + } + + /** + * Retrieves the maximum string length allowed in a given column. + * + * @since 2.3.0 + * + * @param string $table Table name. + * @param string $column Column name. + * @return array|false { + * Array of column length information, false if the column has no length + * (for example, numeric column). + * + * @type string $type One of 'byte' or 'char'. + * @type int $length The column length. + * } + */ + public function get_col_length( $table, $column ) { + $table_key = strtolower( $table ); + $column_key = strtolower( $column ); + + // Check cached column metadata first. + if ( isset( $this->col_meta[ $table_key ][ $column_key ] ) ) { + $type = $this->col_meta[ $table_key ][ $column_key ]->Type; + } else { + // Query column info if not cached. + $results = $this->get_results( "SHOW FULL COLUMNS FROM `{$table_key}`" ); + if ( ! $results ) { + return false; + } + + $columns = array(); + foreach ( $results as $col ) { + $columns[ strtolower( $col->Field ) ] = $col; + } + $this->col_meta[ $table_key ] = $columns; + + if ( ! isset( $columns[ $column_key ] ) ) { + return false; + } + $type = $columns[ $column_key ]->Type; + } + + // Parse the type to get length info. + $typeinfo = explode( '(', $type ); + $basetype = strtolower( $typeinfo[0] ); + + if ( ! empty( $typeinfo[1] ) ) { + $length = (int) trim( $typeinfo[1], ')' ); + } else { + $length = false; + } + + switch ( $basetype ) { + case 'char': + case 'varchar': + return array( + 'type' => 'char', + 'length' => $length, + ); + case 'binary': + case 'varbinary': + return array( + 'type' => 'byte', + 'length' => $length, + ); + case 'tinyblob': + case 'tinytext': + return array( + 'type' => 'byte', + 'length' => 255, + ); + case 'blob': + case 'text': + return array( + 'type' => 'byte', + 'length' => 65535, + ); + case 'mediumblob': + case 'mediumtext': + return array( + 'type' => 'byte', + 'length' => 16777215, + ); + case 'longblob': + case 'longtext': + return array( + 'type' => 'byte', + 'length' => 4294967295, + ); + default: + return false; + } } /** @@ -134,10 +271,64 @@ public function set_sql_mode( $modes = array() ) { * * @return bool True to indicate the connection was successfully closed. */ + /** + * Close the database connection. + * + * @since 2.3.0 + * + * @return bool True if connection was closed successfully. + */ public function close() { + if ( ! $this->dbh ) { + return false; + } + + $this->dbh = null; + $this->ready = false; + $this->has_connected = false; + return true; } + /** + * Determines the best charset and collation for the database connection. + * + * This overrides wpdb::determine_charset() to handle SQLite's lack of mysqli. + * WordPress expects utf8 to be upgraded to utf8mb4 when supported. + * + * @since 2.3.0 + * + * @param string $charset The character set to check. + * @param string $collate The collation to check. + * @return array { + * Array containing the determined charset and collation. + * + * @type string $charset The determined character set. + * @type string $collate The determined collation. + * } + */ + public function determine_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 ( $this->has_cap( 'utf8mb4_520' ) && 'utf8mb4_unicode_ci' === $collate ) { + $collate = 'utf8mb4_unicode_520_ci'; + } + + return compact( 'charset', 'collate' ); + } + /** * Method to select the database connection. * @@ -419,16 +610,27 @@ public function query( $query ) { // Save the query count before running another 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. - * - * 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 - */ + // Check for invalid text in the query, similar to parent wpdb behavior. + if ( $this->check_current_query && ! $this->check_ascii( $query ) ) { + $stripped_query = $this->strip_invalid_text_from_query( $query ); + /* + * strip_invalid_text_from_query() can perform queries, so we need + * to flush again, just to make sure everything is clear. + */ + $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; + $this->_do_query( $query ); if ( $this->last_error ) { @@ -562,18 +764,31 @@ protected function load_col_info() { * Method to return what the database can do. * * This overrides wpdb::has_cap() to avoid using MySQL functions. - * SQLite supports subqueries, but not support collation, group_concat and set_charset. + * SQLite via this driver supports all common MySQL capabilities. * * @see wpdb::has_cap() * * @param string $db_cap The feature to check for. Accepts 'collation', * 'group_concat', 'subqueries', 'set_charset', - * 'utf8mb4', or 'utf8mb4_520'. + * 'utf8mb4', 'utf8mb4_520', or 'identifier_placeholders'. * * @return bool Whether the database feature is supported, false otherwise. */ public function has_cap( $db_cap ) { - return 'subqueries' === strtolower( $db_cap ); + $db_cap = strtolower( $db_cap ); + + switch ( $db_cap ) { + case 'collation': + case 'group_concat': + case 'subqueries': + case 'set_charset': + case 'utf8mb4': + case 'utf8mb4_520': + case 'identifier_placeholders': + return true; + } + + return false; } /** From 3942a8663bad5ff4f767d01a421dce468ca3a8aa Mon Sep 17 00:00:00 2001 From: Chloe Pomegranate Date: Tue, 31 Mar 2026 12:52:03 +0100 Subject: [PATCH 2/8] WordPress PHPUnit test fails: charset detection, length validation, capabilities | Amends from review --- .../wp-includes/sqlite/class-wp-sqlite-db.php | 220 +++++------------- 1 file changed, 64 insertions(+), 156 deletions(-) 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 02a573759..0ffad3f92 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 @@ -73,151 +73,52 @@ public function set_charset( $dbh, $charset = null, $collate = null ) { /** * Retrieves the character set for the given column. * - * @since 2.3.0 + * 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 Column character set as a string. False if the column has - * no character set (e.g., numeric or binary columns). + * @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 ) { - $table_key = strtolower( $table ); - $column_key = strtolower( $column ); - - /** - * Filters the column charset value before the DB is checked. - * - * @since 2.3.0 - * - * @param string|null|false $charset The character set to use. Default null. - * @param string $table The name of the table being checked. - * @param string $column The name of the column being checked. + /* + * 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. */ - $charset = apply_filters( 'pre_get_col_charset', null, $table, $column ); - if ( null !== $charset ) { - return $charset; - } - - // Use SHOW FULL COLUMNS to get column metadata with collation info. - // This works because the driver translates this to query the information schema. - $results = $this->get_results( "SHOW FULL COLUMNS FROM `{$table_key}`" ); - - if ( ! $results ) { - return false; - } - - // Build column metadata cache. - $columns = array(); - foreach ( $results as $col ) { - $columns[ strtolower( $col->Field ) ] = $col; - } - $this->col_meta[ $table_key ] = $columns; - - // Check if column exists. - if ( ! isset( $columns[ $column_key ] ) ) { - return false; - } - - // Return false for non-string columns (no collation). - if ( empty( $columns[ $column_key ]->Collation ) ) { - return false; + try { + $this->is_mysql = true; + return parent::get_col_charset( $table, $column ); + } finally { + $this->is_mysql = null; } - - // Extract charset from collation (e.g., 'utf8mb4_general_ci' -> 'utf8mb4'). - list( $charset ) = explode( '_', $columns[ $column_key ]->Collation ); - return $charset; } /** * Retrieves the maximum string length allowed in a given column. * - * @since 2.3.0 + * 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 { - * Array of column length information, false if the column has no length - * (for example, numeric column). - * - * @type string $type One of 'byte' or 'char'. - * @type int $length The column length. - * } + * @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 ) { - $table_key = strtolower( $table ); - $column_key = strtolower( $column ); - - // Check cached column metadata first. - if ( isset( $this->col_meta[ $table_key ][ $column_key ] ) ) { - $type = $this->col_meta[ $table_key ][ $column_key ]->Type; - } else { - // Query column info if not cached. - $results = $this->get_results( "SHOW FULL COLUMNS FROM `{$table_key}`" ); - if ( ! $results ) { - return false; - } - - $columns = array(); - foreach ( $results as $col ) { - $columns[ strtolower( $col->Field ) ] = $col; - } - $this->col_meta[ $table_key ] = $columns; - - if ( ! isset( $columns[ $column_key ] ) ) { - return false; - } - $type = $columns[ $column_key ]->Type; - } - - // Parse the type to get length info. - $typeinfo = explode( '(', $type ); - $basetype = strtolower( $typeinfo[0] ); - - if ( ! empty( $typeinfo[1] ) ) { - $length = (int) trim( $typeinfo[1], ')' ); - } else { - $length = false; - } - - switch ( $basetype ) { - case 'char': - case 'varchar': - return array( - 'type' => 'char', - 'length' => $length, - ); - case 'binary': - case 'varbinary': - return array( - 'type' => 'byte', - 'length' => $length, - ); - case 'tinyblob': - case 'tinytext': - return array( - 'type' => 'byte', - 'length' => 255, - ); - case 'blob': - case 'text': - return array( - 'type' => 'byte', - 'length' => 65535, - ); - case 'mediumblob': - case 'mediumtext': - return array( - 'type' => 'byte', - 'length' => 16777215, - ); - case 'longblob': - case 'longtext': - return array( - 'type' => 'byte', - 'length' => 4294967295, - ); - default: - return false; + // 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 = null; } } @@ -267,16 +168,13 @@ 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. - */ - /** - * Close the database connection. + * This overrides wpdb::close() while closely mirroring its implementation. * - * @since 2.3.0 + * @see wpdb::close() * - * @return bool True if connection was closed successfully. + * @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 ) { @@ -291,20 +189,20 @@ public function close() { } /** - * Determines the best charset and collation for the database connection. + * Determines the best charset and collation to use given a charset and collation. * - * This overrides wpdb::determine_charset() to handle SQLite's lack of mysqli. - * WordPress expects utf8 to be upgraded to utf8mb4 when supported. + * For example, when able, utf8mb4 should be used instead of utf8. * - * @since 2.3.0 + * 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 { - * Array containing the determined charset and collation. + * The most appropriate character set and collation to use. * - * @type string $charset The determined character set. - * @type string $collate The determined collation. + * @type string $charset Character set. + * @type string $collate Collation. * } */ public function determine_charset( $charset, $collate ) { @@ -610,14 +508,23 @@ public function query( $query ) { // Save the query count before running another query. $last_query_count = count( $this->queries ?? array() ); - // Check for invalid text in the query, similar to parent wpdb behavior. + /* + * Strip invalid UTF-8 characters from non-ASCII queries. + * + * SQLite stores all text as UTF-8, so we simply ensure the query + * contains only valid UTF-8 sequences rather than using the parent's + * MySQL-specific charset detection pipeline. + */ if ( $this->check_current_query && ! $this->check_ascii( $query ) ) { - $stripped_query = $this->strip_invalid_text_from_query( $query ); - /* - * strip_invalid_text_from_query() can perform queries, so we need - * to flush again, just to make sure everything is clear. - */ - $this->flush(); + if ( function_exists( 'mb_convert_encoding' ) ) { + $stripped_query = mb_convert_encoding( $query, 'UTF-8', 'UTF-8' ); + } else { + $stripped_query = htmlspecialchars_decode( + htmlspecialchars( $query, ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8' ), + ENT_NOQUOTES + ); + } + if ( $stripped_query !== $query ) { $this->insert_id = 0; $this->last_query = $query; @@ -761,17 +668,18 @@ 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 via this driver supports all common MySQL capabilities. + * This overrides wpdb::has_cap() while closely mirroring its implementation. + * The override is needed because the parent's 'utf8mb4' capability check calls + * mysqli_get_client_info(), which is environment-dependent and not applicable + * for SQLite. * * @see wpdb::has_cap() * * @param string $db_cap The feature to check for. Accepts 'collation', * 'group_concat', 'subqueries', 'set_charset', - * 'utf8mb4', 'utf8mb4_520', or 'identifier_placeholders'. - * + * 'utf8mb4', or 'utf8mb4_520'. * @return bool Whether the database feature is supported, false otherwise. */ public function has_cap( $db_cap ) { @@ -783,9 +691,9 @@ public function has_cap( $db_cap ) { case 'subqueries': case 'set_charset': case 'utf8mb4': - case 'utf8mb4_520': - case 'identifier_placeholders': return true; + case 'utf8mb4_520': + return version_compare( $GLOBALS['wp_version'], '4.6', '>=' ); } return false; From 2f1b435736d0cfd116951b7fcc00259c0de5f081 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Fri, 17 Jul 2026 14:29:19 +0200 Subject: [PATCH 3/8] Reconnect wpdb after closing the SQLite driver Roll back active transactions and discard the driver handle on close. Release internally created PDO instances so reconnecting creates a fresh connection, while preserving externally supplied PDO instances for compatibility with the global injection mechanism. --- .github/workflows/wp-tests-phpunit-run.js | 1 - .../wp-includes/sqlite/class-wp-sqlite-db.php | 60 +++++++++++++++---- 2 files changed, 50 insertions(+), 11 deletions(-) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index 527508607..97b765d4f 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -67,7 +67,6 @@ const expectedFailures = [ '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"', 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 0ffad3f92..bcb406404 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 the global connection hook. + * + * @var bool + */ + private $is_pdo_external; + /** * Backward compatibility, see wpdb::$allow_unsafe_unquoted_parameters. * @@ -38,6 +45,8 @@ class WP_SQLite_DB extends wpdb { * @param string $dbname Database name. */ public function __construct( $dbname ) { + $this->is_pdo_external = isset( $GLOBALS['@pdo'] ); + /** * We need to initialize the "$wpdb" global early, so that the SQLite * driver can configure the database. The call stack goes like this: @@ -181,6 +190,27 @@ public function close() { return false; } + try { + if ( $this->dbh->inTransaction() ) { + $this->dbh->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'] === $this->dbh->get_connection()->get_pdo() + ) { + unset( $GLOBALS['@pdo'] ); + } + $this->dbh = null; $this->ready = false; $this->has_connected = false; @@ -357,12 +387,14 @@ 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->last_error = ''; $this->init_charset(); $pdo = null; @@ -406,7 +438,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 ), @@ -416,27 +448,35 @@ 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->ready = true; + $this->ready = true; + $this->has_connected = 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 ); } /** @@ -486,7 +526,7 @@ public function query( $query ) { $this->hide_errors(); } - if ( ! $this->ready ) { + if ( ! $this->ready && ! $this->check_connection() ) { return false; } From 692a78469d770869ca2efcacce3bac4b2dd4484d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Fri, 17 Jul 2026 14:30:00 +0200 Subject: [PATCH 4/8] Preserve charset while disconnected Return the requested charset and collation unchanged when the wpdb instance has no active SQLite handle. Re-enable the WordPress charset tests that this behavior now satisfies. --- .github/workflows/wp-tests-phpunit-run.js | 2 -- .../wp-includes/sqlite/class-wp-sqlite-db.php | 13 +++++++++++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index 97b765d4f..bdefc08ca 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -66,12 +66,10 @@ const expectedFailures = [ '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_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"', 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 bcb406404..89403f02b 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 @@ -236,6 +236,10 @@ public function close() { * } */ public function determine_charset( $charset, $collate ) { + if ( ! $this->dbh ) { + return compact( 'charset', 'collate' ); + } + if ( 'utf8' === $charset ) { $charset = 'utf8mb4'; } @@ -395,7 +399,6 @@ public function db_connect( $allow_bail = true ) { } $this->last_error = ''; - $this->init_charset(); $pdo = null; if ( isset( $GLOBALS['@pdo'] ) ) { @@ -458,8 +461,14 @@ public function db_connect( $allow_bail = true ) { if ( $this->last_error ) { return false; } - $this->ready = true; + if ( ! $this->has_connected ) { + $this->init_charset(); + } + $this->has_connected = true; + $this->set_charset( $this->dbh ); + + $this->ready = true; $this->set_sql_mode(); return true; } From 7a6938720c8aad920e242267b529ad96309fdbfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Fri, 17 Jul 2026 14:31:05 +0200 Subject: [PATCH 5/8] Delegate wpdb capability checks Handle only the MySQL-client-specific utf8mb4 capability in the SQLite override and defer all other capabilities to WordPress. This restores identifier placeholders and database-version-based utf8mb4_520 detection. --- .github/workflows/wp-tests-phpunit-run.js | 2 -- .../wp-includes/sqlite/class-wp-sqlite-db.php | 33 +++++++------------ 2 files changed, 12 insertions(+), 23 deletions(-) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index bdefc08ca..41f935f96 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -67,7 +67,6 @@ const expectedFailures = [ 'Tests_DB_Charset::test_strip_invalid_text_for_column_bails_if_ascii_input_too_long', 'Tests_DB_dbDelta::test_spatial_indices', '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_pre_get_col_charset_filter', @@ -76,7 +75,6 @@ const expectedFailures = [ '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', 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 89403f02b..4f0191478 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 @@ -719,33 +719,20 @@ protected function load_col_info() { /** * Determines whether the database supports a given feature. * - * This overrides wpdb::has_cap() while closely mirroring its implementation. - * The override is needed because the parent's 'utf8mb4' capability check calls - * mysqli_get_client_info(), which is environment-dependent and not applicable - * for SQLite. + * 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 ) { - $db_cap = strtolower( $db_cap ); - - switch ( $db_cap ) { - case 'collation': - case 'group_concat': - case 'subqueries': - case 'set_charset': - case 'utf8mb4': - return true; - case 'utf8mb4_520': - return version_compare( $GLOBALS['wp_version'], '4.6', '>=' ); + if ( 'utf8mb4' === strtolower( $db_cap ) ) { + return true; } - return false; + return parent::has_cap( $db_cap ); } /** @@ -764,9 +751,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(); } From 5359f88c7a92a0a28862cf8ad79658041433b1c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Fri, 17 Jul 2026 14:34:02 +0200 Subject: [PATCH 6/8] Preserve wpdb type during metadata lookup Restore the previous is_mysql value after column charset and length lookups, treating an unset flag as null. --- .../wp-includes/sqlite/class-wp-sqlite-db.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 4f0191478..c5e80c35f 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 @@ -93,6 +93,8 @@ public function set_charset( $dbh, $charset = null, $collate = null ) { * 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 @@ -104,7 +106,7 @@ public function get_col_charset( $table, $column ) { $this->is_mysql = true; return parent::get_col_charset( $table, $column ); } finally { - $this->is_mysql = null; + $this->is_mysql = $original_is_mysql; } } @@ -122,12 +124,14 @@ public function get_col_charset( $table, $column ) { * 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 = null; + $this->is_mysql = $original_is_mysql; } } From 09b9fc5bae0f0717718069dc5a28b9f33a1fd334 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Fri, 17 Jul 2026 14:43:09 +0200 Subject: [PATCH 7/8] Update WordPress test expectations Remove existing WordPress tests that now pass through the charset and field-validation changes from the expected error and failure lists. Keep the remaining unsupported cases explicit. --- .github/workflows/wp-tests-phpunit-run.js | 22 ---------------------- 1 file changed, 22 deletions(-) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index 41f935f96..9ab390f74 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -12,26 +12,15 @@ const path = require( 'path' ); const requiresNativeParserExtension = process.env.WP_SQLITE_REQUIRE_NATIVE_PARSER_EXTENSION === '1'; const expectedErrors = [ - 'Tests_DB_Charset::test_invalid_characters_in_query', 'Tests_DB_Charset::test_set_charset_changes_the_connection_collation', ]; 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 +28,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 +35,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,18 +51,9 @@ 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_delete_value_too_long_for_field with data set "too long"', - 'Tests_DB::test_insert_value_too_long_for_field with data set "too long"', 'Tests_DB::test_mysqli_flush_sync', - '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_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', From 00c2ff304b00fff4617d0fad939b6502a28d7ad1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Fri, 17 Jul 2026 22:08:40 +0200 Subject: [PATCH 8/8] Defer SQLite query UTF-8 validation Restore the previous query path and expected WordPress test outcomes so UTF-8 validation can be designed separately. Keep a focused TODO recommending PCRE validation while preserving wpdb exemptions. --- .github/workflows/wp-tests-phpunit-run.js | 2 ++ .../wp-includes/sqlite/class-wp-sqlite-db.php | 33 ++++--------------- 2 files changed, 8 insertions(+), 27 deletions(-) diff --git a/.github/workflows/wp-tests-phpunit-run.js b/.github/workflows/wp-tests-phpunit-run.js index 9ab390f74..934d3c571 100644 --- a/.github/workflows/wp-tests-phpunit-run.js +++ b/.github/workflows/wp-tests-phpunit-run.js @@ -12,6 +12,7 @@ const path = require( 'path' ); const requiresNativeParserExtension = process.env.WP_SQLITE_REQUIRE_NATIVE_PARSER_EXTENSION === '1'; const expectedErrors = [ + 'Tests_DB_Charset::test_invalid_characters_in_query', 'Tests_DB_Charset::test_set_charset_changes_the_connection_collation', ]; @@ -53,6 +54,7 @@ const expectedFailures = [ 'Tests_DB_Charset::test_strip_invalid_text with data set #41', 'Tests_DB_dbDelta::test_spatial_indices', 'Tests_DB::test_mysqli_flush_sync', + 'Tests_DB::test_query_value_contains_invalid_chars', 'Tests_DB::test_replace', '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', 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 c5e80c35f..f23682143 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 @@ -562,35 +562,14 @@ public function query( $query ) { $last_query_count = count( $this->queries ?? array() ); /* - * Strip invalid UTF-8 characters from non-ASCII queries. + * @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. * - * SQLite stores all text as UTF-8, so we simply ensure the query - * contains only valid UTF-8 sequences rather than using the parent's - * MySQL-specific charset detection pipeline. + * 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. */ - if ( $this->check_current_query && ! $this->check_ascii( $query ) ) { - if ( function_exists( 'mb_convert_encoding' ) ) { - $stripped_query = mb_convert_encoding( $query, 'UTF-8', 'UTF-8' ); - } else { - $stripped_query = htmlspecialchars_decode( - htmlspecialchars( $query, ENT_NOQUOTES | ENT_SUBSTITUTE, 'UTF-8' ), - ENT_NOQUOTES - ); - } - - 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; - $this->_do_query( $query ); if ( $this->last_error ) {