From 8319a291bad764765150f252da7f5a288067464c Mon Sep 17 00:00:00 2001 From: Wojtek Naruniec Date: Thu, 24 Sep 2026 13:30:21 +0200 Subject: [PATCH 1/4] Determine wpdb charset and collation after connecting WP_SQLite_DB::db_connect() initialized the charset only before the driver connection existed, when determine_charset() returns its inputs unchanged. With the common DB_CHARSET 'utf8' and DB_COLLATE '', $wpdb->collate stayed empty, so get_charset_collate() produced no COLLATE clause and new tables fell back to the MySQL 8-only utf8mb4_0900_ai_ci collation. Like wpdb::db_connect(), initialize the charset again once connected, so it resolves to utf8mb4 with utf8mb4_unicode_520_ci. Replace the hardcoded utf8mb4 charset in the constructor with an init_charset() override that keeps the charset utf8mb4 on reconnects too, and preserves configured collations that are compatible with it. Reported in https://github.com/Automattic/studio/issues/4737 Co-Authored-By: Claude Opus 5.5 --- .../wp-includes/sqlite/class-wp-sqlite-db.php | 39 ++++++++- ...Lite_Database_Integration_Charset_Test.php | 86 +++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 tests/phpunit/WP_SQLite_Database_Integration_Charset_Test.php 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 7f3944bd5..793fa202d 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 @@ -50,7 +50,6 @@ public function __construct( $dbname ) { $GLOBALS['wpdb'] = $this; parent::__construct( '', '', $dbname, '' ); - $this->charset = 'utf8mb4'; } /** @@ -232,6 +231,34 @@ public function close() { return true; } + /** + * Sets $this->charset and $this->collate. + * + * This extends wpdb::init_charset() to always use the utf8mb4 charset. + * + * SQLite stores all text as UTF-8, and the emulated MySQL connection always + * uses utf8mb4 (see set_charset()). When DB_CHARSET is empty or names another + * charset, the connection charset is used, together with the best compatible + * collation. This way, $this->charset always describes the connection charset, + * and wpdb::get_charset_collate() always specifies an explicit collation. + * + * @see wpdb::init_charset() + */ + public function init_charset() { + parent::init_charset(); + + // Without a connection, the charset can't be determined (see determine_charset()). + if ( ! $this->dbh || 'utf8mb4' === $this->charset ) { + return; + } + + // Keep a configured collation only when it's compatible with utf8mb4. + $collate = preg_match( '/^utf8(mb4)?_/i', (string) $this->collate ) ? $this->collate : ''; + $charset_collate = $this->determine_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. * @@ -428,6 +455,12 @@ public function db_connect( $allow_bail = true ) { ); } + /* + * Initialize the charset before connecting, as the SQLite driver may need + * it while configuring the database (see the constructor). Without a + * connection, the charset and collation are not resolved yet, so this is + * done again after connecting, when the server capabilities are known. + */ if ( ! isset( $this->charset ) ) { $this->init_charset(); } @@ -492,6 +525,10 @@ public function db_connect( $allow_bail = true ) { return false; } + if ( ! $this->has_connected ) { + $this->init_charset(); + } + $this->has_connected = true; $this->set_charset( $this->dbh ); diff --git a/tests/phpunit/WP_SQLite_Database_Integration_Charset_Test.php b/tests/phpunit/WP_SQLite_Database_Integration_Charset_Test.php new file mode 100644 index 000000000..739cfc54c --- /dev/null +++ b/tests/phpunit/WP_SQLite_Database_Integration_Charset_Test.php @@ -0,0 +1,86 @@ +assertSame( 'utf8mb4', $wpdb->charset ); + $this->assertSame( 'utf8mb4_unicode_520_ci', $wpdb->collate ); + $this->assertSame( + 'DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_520_ci', + $wpdb->get_charset_collate() + ); + } + + public function test_charset_and_collation_are_determined_after_reconnecting() { + global $wpdb; + + $this->assertTrue( $wpdb->close() ); + $this->assertTrue( $wpdb->check_connection() ); + + $this->assertSame( 'utf8mb4', $wpdb->charset ); + $this->assertSame( 'utf8mb4_unicode_520_ci', $wpdb->collate ); + } + + public function test_table_created_with_charset_collate_uses_determined_collation() { + global $wpdb; + + $table = $wpdb->prefix . 'sqlite_charset_collate_test'; + $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $table ) ); + + try { + $this->assertTrue( + $wpdb->query( + $wpdb->prepare( 'CREATE TABLE %i (id int, name varchar(20))', $table ) + . ' ' . $wpdb->get_charset_collate() + ) + ); + $this->assertSame( '', $wpdb->last_error ); + + $this->assertSame( + 'utf8mb4_unicode_520_ci', + $wpdb->get_var( + $wpdb->prepare( + 'SELECT table_collation FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = %s', + $table + ) + ) + ); + $this->assertSame( + 'utf8mb4_unicode_520_ci', + $wpdb->get_var( + $wpdb->prepare( + "SELECT collation_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = %s AND column_name = 'name'", + $table + ) + ) + ); + + $create_table = $wpdb->get_row( $wpdb->prepare( 'SHOW CREATE TABLE %i', $table ), ARRAY_N )[1]; + $this->assertStringContainsString( 'DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci', $create_table ); + $this->assertStringNotContainsString( 'utf8mb4_0900_ai_ci', $create_table ); + } finally { + $wpdb->query( $wpdb->prepare( 'DROP TABLE IF EXISTS %i', $table ) ); + } + } + + public function test_explicit_collation_is_preserved() { + global $wpdb; + + $this->assertSame( + array( + 'charset' => 'utf8mb4', + 'collate' => 'utf8mb4_bin', + ), + $wpdb->determine_charset( 'utf8mb4', 'utf8mb4_bin' ) + ); + $this->assertSame( + array( + 'charset' => 'utf8mb4', + 'collate' => 'utf8mb4_swedish_ci', + ), + $wpdb->determine_charset( 'utf8', 'utf8_swedish_ci' ) + ); + } +} From 370addefb69bfe93bb7f5ae0ba406ebf4a939062 Mon Sep 17 00:00:00 2001 From: Wojtek Naruniec Date: Thu, 24 Sep 2026 13:30:21 +0200 Subject: [PATCH 2/4] Add utf8mb4_unicode_520_ci to the emulated collations WordPress uses utf8mb4_unicode_520_ci by default, but it was missing from INFORMATION_SCHEMA.COLLATIONS and SHOW COLLATION, so tools like phpMyAdmin couldn't display it for tables that use it. Co-Authored-By: Claude Opus 5.5 --- ...lass-wp-sqlite-information-schema-builder.php | 1 + .../tests/WP_MySQL_On_SQLite_Metadata_Tests.php | 16 +++++++++++++--- .../tests/WP_MySQL_On_SQLite_Tests.php | 2 +- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php index 3aa4069c7..12b34be10 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php @@ -450,6 +450,7 @@ public function get_computed_information_schema_table_definition( string $table_ ('utf8_unicode_ci', 'utf8', 192, '', 'Yes', 8, 'PAD SPACE'), ('utf8mb4_bin', 'utf8mb4', 46, '', 'Yes', 1, 'PAD SPACE'), ('utf8mb4_unicode_ci', 'utf8mb4', 224, '', 'Yes', 8, 'PAD SPACE'), + ('utf8mb4_unicode_520_ci', 'utf8mb4', 246, '', 'Yes', 8, 'PAD SPACE'), ('utf8mb4_0900_ai_ci', 'utf8mb4', 255, 'Yes', 'Yes', 0, 'NO PAD') )"; default: diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Metadata_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Metadata_Tests.php index 96aa13131..e8a8f4d33 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Metadata_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Metadata_Tests.php @@ -186,6 +186,15 @@ public function testInfromationSchemaCollations(): void { 'SORTLEN' => '1', 'PAD_ATTRIBUTE' => 'PAD SPACE', ), + (object) array( + 'COLLATION_NAME' => 'utf8mb4_unicode_520_ci', + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'ID' => '246', + 'IS_DEFAULT' => '', + 'IS_COMPILED' => 'Yes', + 'SORTLEN' => '8', + 'PAD_ATTRIBUTE' => 'PAD SPACE', + ), (object) array( 'COLLATION_NAME' => 'utf8mb4_unicode_ci', 'CHARACTER_SET_NAME' => 'utf8mb4', @@ -516,19 +525,20 @@ public function testShowCollation(): void { // Simple. $this->assertQuery( 'SHOW COLLATION' ); $actual = $this->last_result; - $this->assertCount( 7, $actual ); + $this->assertCount( 8, $actual ); $this->assertEquals( 'binary', $actual[0]->Collation ); $this->assertEquals( 'utf8_bin', $actual[1]->Collation ); $this->assertEquals( 'utf8_general_ci', $actual[2]->Collation ); $this->assertEquals( 'utf8_unicode_ci', $actual[3]->Collation ); $this->assertEquals( 'utf8mb4_bin', $actual[4]->Collation ); $this->assertEquals( 'utf8mb4_unicode_ci', $actual[5]->Collation ); - $this->assertEquals( 'utf8mb4_0900_ai_ci', $actual[6]->Collation ); + $this->assertEquals( 'utf8mb4_unicode_520_ci', $actual[6]->Collation ); + $this->assertEquals( 'utf8mb4_0900_ai_ci', $actual[7]->Collation ); // With LIKE clause. $this->assertQuery( "SHOW COLLATION LIKE 'utf8%'" ); $actual = $this->last_result; - $this->assertCount( 6, $actual ); + $this->assertCount( 7, $actual ); $this->assertEquals( 'utf8_bin', $actual[0]->Collation ); $this->assertEquals( 'utf8_general_ci', $actual[1]->Collation ); $this->assertEquals( 'utf8_unicode_ci', $actual[2]->Collation ); diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php index 828c2218d..403167f0c 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php @@ -4083,7 +4083,7 @@ public function testFoundRowsWithoutSqlCalcFoundRows(): void { // SHOW COLLATION $this->assertQuery( 'SHOW COLLATION' ); $result = $this->assertQuery( 'SELECT FOUND_ROWS()' ); - $this->assertSame( '7', $result[0]->{'FOUND_ROWS()'} ); + $this->assertSame( '8', $result[0]->{'FOUND_ROWS()'} ); // SHOW DATABASES $this->assertQuery( 'SHOW DATABASES' ); From 7cd15a0e2afce7af0641d5035801677ee7ada52d Mon Sep 17 00:00:00 2001 From: Wojtek Naruniec Date: Thu, 24 Sep 2026 15:36:33 +0200 Subject: [PATCH 3/4] Inherit column collations from the table default Character columns without an explicit charset or collation were always recorded with utf8mb4_0900_ai_ci, instead of the table's default collation as in MySQL. Tables created with utf8mb4_unicode_520_ci therefore still got MySQL 8-only collations on their columns. The table collation is now read only from the table options, so that a column COLLATE clause is no longer used as the table collation, and a table DEFAULT CHARSET without a collation now uses that charset's default. Co-Authored-By: Claude Opus 5.5 --- ...s-wp-sqlite-information-schema-builder.php | 138 ++++++++++++++---- .../WP_MySQL_On_SQLite_Metadata_Tests.php | 101 +++++++++++++ .../WP_MySQL_On_SQLite_Translation_Tests.php | 1 + 3 files changed, 210 insertions(+), 30 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php index 12b34be10..507bef8d9 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php @@ -560,7 +560,10 @@ public function record_create_table( WP_Parser_Node $node ): void { $table_name, $column_name, $column_node, - $column_position + $column_position, + function () use ( $table_collation ) { + return $table_collation; + } ); try { @@ -866,7 +869,15 @@ private function record_add_column( array( self::SAVED_DATABASE_NAME, $table_name ) )->fetchColumn(); - $column_data = $this->extract_column_data( $table_name, $column_name, $node, (int) $position + 1 ); + $column_data = $this->extract_column_data( + $table_name, + $column_name, + $node, + (int) $position + 1, + function () use ( $table_is_temporary, $table_name ) { + return $this->get_recorded_table_collation( $table_is_temporary, $table_name ); + } + ); try { $this->insert_values( $this->get_table_name( $table_is_temporary, 'columns' ), @@ -916,7 +927,15 @@ private function record_change_column( string $new_column_name, WP_Parser_Node $node ): void { - $column_data = $this->extract_column_data( $table_name, $new_column_name, $node, 0 ); + $column_data = $this->extract_column_data( + $table_name, + $new_column_name, + $node, + 0, + function () use ( $table_is_temporary, $table_name ) { + return $this->get_recorded_table_collation( $table_is_temporary, $table_name ); + } + ); unset( $column_data['ordinal_position'] ); $this->update_values( $this->get_table_name( $table_is_temporary, 'columns' ), @@ -1499,13 +1518,21 @@ private function record_drop_check_constraint( /** * Analyze "columnDefinition" or "fieldDefinition" AST node and extract column data. * - * @param string $table_name The table name. - * @param string $column_name The column name. - * @param WP_Parser_Node $node The "columnDefinition" or "fieldDefinition" AST node. - * @param int $position The ordinal position of the column in the table. - * @return array Column data for the information schema. + * @param string $table_name The table name. + * @param string $column_name The column name. + * @param WP_Parser_Node $node The "columnDefinition" or "fieldDefinition" AST node. + * @param int $position The ordinal position of the column in the table. + * @param callable $get_table_collation Returns the default collation of the table. + * Called only when the column inherits it. + * @return array Column data for the information schema. */ - private function extract_column_data( string $table_name, string $column_name, WP_Parser_Node $node, int $position ): array { + private function extract_column_data( + string $table_name, + string $column_name, + WP_Parser_Node $node, + int $position, + callable $get_table_collation + ): array { list ( $data_type, $column_type ) = $this->get_column_data_types( $node ); $default = $this->get_column_default( $node, $data_type, $column_name ); @@ -1514,7 +1541,7 @@ private function extract_column_data( string $table_name, string $column_name, W $extra = $this->get_column_extra( $node ); $comment = $this->get_column_comment( $node ); - list ( $charset, $collation ) = $this->get_column_charset_and_collation( $node, $data_type ); + list ( $charset, $collation ) = $this->get_column_charset_and_collation( $node, $data_type, $get_table_collation ); list ( $char_length, $octet_length ) = $this->get_column_lengths( $node, $data_type, $charset ); list ( $precision, $scale ) = $this->get_column_numeric_attributes( $node, $data_type ); $datetime_precision = $this->get_column_datetime_precision( $node, $data_type ); @@ -2018,12 +2045,44 @@ private function get_table_engine( WP_Parser_Node $node ): string { * @return string The table collation as stored in information schema. */ private function get_table_collation( WP_Parser_Node $node ): string { - $collate_node = $node->get_first_descendant_node( 'collationName' ); - if ( null === $collate_node ) { - // @TODO: Use default DB collation or DB_CHARSET & DB_COLLATE. - return 'utf8mb4_0900_ai_ci'; + // Only table options apply; column definitions may specify their own collation. + $options_node = $node->get_first_descendant_node( 'createTableOptions' ); + + $collation_node = $options_node ? $options_node->get_first_descendant_node( 'defaultCollation' ) : null; + if ( null !== $collation_node ) { + return strtolower( $this->get_value( $collation_node->get_first_child_node( 'collationName' ) ) ); + } + + $charset_node = $options_node ? $options_node->get_first_descendant_node( 'defaultCharset' ) : null; + if ( null !== $charset_node ) { + $charset = strtolower( $this->get_value( $charset_node->get_first_child_node( 'charsetName' ) ) ); + return $this->get_charset_default_collation( 'utf8mb3' === $charset ? 'utf8' : $charset ); } - return strtolower( $this->get_value( $collate_node ) ); + + // @TODO: Use the default collation of the database. + return 'utf8mb4_0900_ai_ci'; + } + + /** + * Get the table collation that is recorded in the information schema. + * + * @param bool $table_is_temporary Whether the table is temporary. + * @param string $table_name The table name. + * @return string The table collation as stored in information schema. + */ + private function get_recorded_table_collation( bool $table_is_temporary, string $table_name ): string { + $collation = $this->connection->query( + ' + SELECT table_collation + FROM ' . $this->connection->quote_identifier( $this->get_table_name( $table_is_temporary, 'tables' ) ) . ' + WHERE table_schema = ? + AND table_name = ? + ', + array( self::SAVED_DATABASE_NAME, $table_name ) + )->fetchColumn(); + + // @TODO: Use the default collation of the database. + return false === $collation || null === $collation ? 'utf8mb4_0900_ai_ci' : $collation; } /** @@ -2443,11 +2502,12 @@ private function get_column_data_types( WP_Parser_Node $node ): array { /** * Extract column charset and collation from the "columnDefinition" or "fieldDefinition" AST node. * - * @param WP_Parser_Node $node The "columnDefinition" or "fieldDefinition" AST node. - * @param string $data_type The column data type as stored in information schema. - * @return array{ string|null, string|null } The column charset and collation as stored in information schema. + * @param WP_Parser_Node $node The "columnDefinition" or "fieldDefinition" AST node. + * @param string $data_type The column data type as stored in information schema. + * @param callable $get_table_collation Returns the default collation of the table. + * @return array{ string|null, string|null } The column charset and collation as stored in information schema. */ - private function get_column_charset_and_collation( WP_Parser_Node $node, string $data_type ): array { + private function get_column_charset_and_collation( WP_Parser_Node $node, string $data_type, callable $get_table_collation ): array { if ( ! ( 'char' === $data_type || 'varchar' === $data_type @@ -2508,30 +2568,48 @@ private function get_column_charset_and_collation( WP_Parser_Node $node, string $collation = strtolower( $this->get_value( $collation_node ) ); } - // Defaults. - // @TODO: These are hardcoded now. We should get them from table/DB. + // When neither charset nor collation is set, the table defaults are used. if ( null === $charset && null === $collation ) { - $charset = 'utf8mb4'; + $table_collation = $get_table_collation(); + $charset = $this->get_collation_charset( $table_collation ); + if ( ! $is_binary ) { + $collation = $table_collation; + } // @TODO: "BINARY" (seems to change varchar to varbinary). // @TODO: "DEFAULT" } // If only one of charset/collation is set, the other one is derived. if ( null === $collation ) { - if ( $is_binary ) { - $collation = $charset . '_bin'; - } elseif ( isset( self::CHARSET_DEFAULT_COLLATION_MAP[ $charset ] ) ) { - $collation = self::CHARSET_DEFAULT_COLLATION_MAP[ $charset ]; - } else { - $collation = $charset . '_general_ci'; - } + $collation = $is_binary ? $charset . '_bin' : $this->get_charset_default_collation( $charset ); } elseif ( null === $charset ) { - $charset = substr( $collation, 0, strpos( $collation, '_' ) ); + $charset = $this->get_collation_charset( $collation ); } return array( $charset, $collation ); } + /** + * Get the default collation for a given charset. + * + * @param string $charset The charset name. + * @return string The default collation of the charset. + */ + private function get_charset_default_collation( string $charset ): string { + return self::CHARSET_DEFAULT_COLLATION_MAP[ $charset ] ?? $charset . '_general_ci'; + } + + /** + * Get the charset for a given collation. + * + * @param string $collation The collation name. + * @return string The charset of the collation. + */ + private function get_collation_charset( string $collation ): string { + $separator_position = strpos( $collation, '_' ); + return false === $separator_position ? $collation : substr( $collation, 0, $separator_position ); + } + /** * Extract column length info from the "columnDefinition" or "fieldDefinition" AST node. * diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Metadata_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Metadata_Tests.php index e8a8f4d33..393f1b66f 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Metadata_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Metadata_Tests.php @@ -128,6 +128,107 @@ public function testInfromationSchemaCharacterSets(): void { ); } + public function testTableCollationFromTableOptions(): void { + $this->assertQuery( 'CREATE TABLE t1 (a TEXT)' ); + $this->assertQuery( 'CREATE TABLE t2 (a TEXT COLLATE utf8mb4_bin)' ); + $this->assertQuery( 'CREATE TABLE t3 (a TEXT) DEFAULT CHARSET=latin1' ); + $this->assertQuery( 'CREATE TABLE t4 (a TEXT) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci' ); + + $result = $this->assertQuery( + "SELECT table_name, table_collation FROM information_schema.tables WHERE table_schema = 'wp' ORDER BY table_name" + ); + $this->assertEquals( + array( + (object) array( + 'TABLE_NAME' => 't1', + 'TABLE_COLLATION' => 'utf8mb4_0900_ai_ci', + ), + (object) array( + 'TABLE_NAME' => 't2', + 'TABLE_COLLATION' => 'utf8mb4_0900_ai_ci', + ), + (object) array( + 'TABLE_NAME' => 't3', + 'TABLE_COLLATION' => 'latin1_swedish_ci', + ), + (object) array( + 'TABLE_NAME' => 't4', + 'TABLE_COLLATION' => 'utf8mb4_unicode_520_ci', + ), + ), + $result + ); + } + + public function testColumnCollationInheritedFromTable(): void { + $this->assertQuery( + 'CREATE TABLE t ( + a VARCHAR(10), + b VARCHAR(10) BINARY, + c VARCHAR(10) CHARACTER SET utf8mb4, + d VARCHAR(10) COLLATE utf8mb4_bin, + e INT + ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci' + ); + $this->assertQuery( 'ALTER TABLE t ADD COLUMN f TEXT' ); + $this->assertQuery( 'ALTER TABLE t MODIFY COLUMN e VARCHAR(10)' ); + $this->assertQuery( 'CREATE TABLE t2 (a TEXT) DEFAULT CHARSET=latin1' ); + + $result = $this->assertQuery( + "SELECT table_name, column_name, character_set_name, collation_name + FROM information_schema.columns + WHERE table_schema = 'wp' + ORDER BY table_name, column_name" + ); + $this->assertEquals( + array( + (object) array( + 'TABLE_NAME' => 't', + 'COLUMN_NAME' => 'a', + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'COLLATION_NAME' => 'utf8mb4_unicode_520_ci', + ), + (object) array( + 'TABLE_NAME' => 't', + 'COLUMN_NAME' => 'b', + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'COLLATION_NAME' => 'utf8mb4_bin', + ), + (object) array( + 'TABLE_NAME' => 't', + 'COLUMN_NAME' => 'c', + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'COLLATION_NAME' => 'utf8mb4_0900_ai_ci', + ), + (object) array( + 'TABLE_NAME' => 't', + 'COLUMN_NAME' => 'd', + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'COLLATION_NAME' => 'utf8mb4_bin', + ), + (object) array( + 'TABLE_NAME' => 't', + 'COLUMN_NAME' => 'e', + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'COLLATION_NAME' => 'utf8mb4_unicode_520_ci', + ), + (object) array( + 'TABLE_NAME' => 't', + 'COLUMN_NAME' => 'f', + 'CHARACTER_SET_NAME' => 'utf8mb4', + 'COLLATION_NAME' => 'utf8mb4_unicode_520_ci', + ), + (object) array( + 'TABLE_NAME' => 't2', + 'COLUMN_NAME' => 'a', + 'CHARACTER_SET_NAME' => 'latin1', + 'COLLATION_NAME' => 'latin1_swedish_ci', + ), + ), + $result + ); + } + public function testInfromationSchemaCollations(): void { $result = $this->assertQuery( 'SELECT * FROM INFORMATION_SCHEMA.COLLATIONS ORDER BY COLLATION_NAME' ); $this->assertEquals( diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php index 020f3adc9..9849c957d 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php @@ -1031,6 +1031,7 @@ public function testAlterTableAddMultipleColumns(): void { 'INSERT INTO `_wp_sqlite_mysql_information_schema_columns` (`table_schema`, `table_name`, `column_name`, `ordinal_position`, `column_default`, `is_nullable`, `data_type`, `character_maximum_length`, `character_octet_length`, `numeric_precision`, `numeric_scale`, `datetime_precision`, `character_set_name`, `collation_name`, `column_type`, `column_key`, `extra`, `privileges`, `column_comment`, `generation_expression`, `srs_id`)' . " VALUES ('sqlite_database', 't', 'a', 2, null, 'YES', 'int', null, null, 10, 0, null, null, null, 'int', '', '', 'select,insert,update,references', '', '', null)", "SELECT MAX(ordinal_position) FROM `_wp_sqlite_mysql_information_schema_columns` WHERE table_schema = 'sqlite_database' AND table_name = 't'", + "SELECT table_collation FROM `_wp_sqlite_mysql_information_schema_tables` WHERE table_schema = 'sqlite_database' AND table_name = 't'", 'INSERT INTO `_wp_sqlite_mysql_information_schema_columns` (`table_schema`, `table_name`, `column_name`, `ordinal_position`, `column_default`, `is_nullable`, `data_type`, `character_maximum_length`, `character_octet_length`, `numeric_precision`, `numeric_scale`, `datetime_precision`, `character_set_name`, `collation_name`, `column_type`, `column_key`, `extra`, `privileges`, `column_comment`, `generation_expression`, `srs_id`)' . " VALUES ('sqlite_database', 't', 'b', 3, null, 'YES', 'text', 65535, 65535, null, null, null, 'utf8mb4', 'utf8mb4_0900_ai_ci', 'text', '', '', 'select,insert,update,references', '', '', null)", "SELECT MAX(ordinal_position) FROM `_wp_sqlite_mysql_information_schema_columns` WHERE table_schema = 'sqlite_database' AND table_name = 't'", From 6d003d63fbc3554c8bdca7ea425e92cdfd64798a Mon Sep 17 00:00:00 2001 From: Wojtek Naruniec Date: Thu, 24 Sep 2026 16:06:52 +0200 Subject: [PATCH 4/4] Use the connection charset when stripping invalid text without a charset When $wpdb->charset is empty and a value must be checked by the database, wpdb::strip_invalid_text() falls back to mysqli_character_set_name(), which fails with a TypeError for the SQLite driver. Use the emulated connection charset (utf8mb4) instead. This surfaced in Tests_DB_Charset::test_no_db_charset_defined once tables with a non-UTF-8 DEFAULT CHARSET got their charset recorded correctly. Co-Authored-By: Claude Opus 5.5 --- .../wp-includes/sqlite/class-wp-sqlite-db.php | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) 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 793fa202d..6cff1c331 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 @@ -738,6 +738,41 @@ private function _do_query( $query ) { } } + /** + * Strips any invalid characters based on value/charset pairs. + * + * This overrides wpdb::strip_invalid_text() to enable the parent's implementation + * for SQLite when no charset is set, by temporarily using the connection charset. + * + * @see wpdb::strip_invalid_text() + * + * @param array $data Array of value arrays. Each value array has the keys 'value', + * 'charset', and 'length'. An optional 'ascii' key can be set + * to false to avoid redundant ASCII checks. + * @return array|WP_Error The $data parameter, with invalid characters removed from each value. + * This works as a passthrough: any additional keys such as 'field' are + * retained in each value array. If we cannot remove invalid characters, + * a WP_Error object is returned. + */ + protected function strip_invalid_text( $data ) { + $original_charset = $this->charset; + if ( $original_charset ) { + return parent::strip_invalid_text( $data ); + } + + /* + * Without a charset, the parent method falls back to the charset of the + * mysqli connection. The emulated MySQL connection always uses utf8mb4 + * (see init_charset()), so we use it temporarily instead. + */ + try { + $this->charset = 'utf8mb4'; + return parent::strip_invalid_text( $data ); + } finally { + $this->charset = $original_charset; + } + } + /** * Method to set the class variable $col_info. *