From d46586f7bdae634598ebece2b541c09785fccacf Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Sat, 31 Oct 2026 20:04:54 +0100 Subject: [PATCH 01/11] Fix BINARY and VARBINARY column defaults Literal defaults on these columns were stored as written and then treated as text. SHOW CREATE TABLE turned x'00' into the string 'x''00''', which MySQL rejects, and inserts that omitted the column failed because a STRICT BLOB column can't hold a TEXT default. Store the defaults in hex like MySQL's INFORMATION_SCHEMA, write them to SQLite as blobs, and read the forms that older versions stored. Related to #551. Co-Authored-By: Claude Opus 5.5 --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 14 +++ ...s-wp-sqlite-information-schema-builder.php | 75 +++++++++++- .../tests/WP_MySQL_On_SQLite_Tests.php | 110 ++++++++++++++++++ 3 files changed, 197 insertions(+), 2 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php index ff338d712..44ede2785 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php @@ -7899,6 +7899,12 @@ private function format_sqlite_column_default( array $column ): string { return (string) bindec( substr( $column['COLUMN_DEFAULT'], 2, -1 ) ); } + // A BLOB column in a STRICT table only accepts a BLOB default. + if ( 'binary' === $column['DATA_TYPE'] || 'varbinary' === $column['DATA_TYPE'] ) { + $bytes = WP_SQLite_Information_Schema_Builder::get_binary_default_bytes( $column['COLUMN_DEFAULT'] ); + return sprintf( "x'%s'", bin2hex( $bytes ) ); + } + return $this->quote_sqlite_value( $column['COLUMN_DEFAULT'] ); } @@ -7914,6 +7920,14 @@ private function format_mysql_column_default( array $column ): string { return $column['COLUMN_DEFAULT']; } + // Like MySQL, use a hex literal for bytes that aren't valid UTF-8. + if ( 'binary' === $column['DATA_TYPE'] || 'varbinary' === $column['DATA_TYPE'] ) { + $bytes = WP_SQLite_Information_Schema_Builder::get_binary_default_bytes( $column['COLUMN_DEFAULT'] ); + return 1 === preg_match( '//u', $bytes ) + ? $this->quote_mysql_utf8_string_literal( $bytes ) + : '0x' . strtoupper( bin2hex( $bytes ) ); + } + return $this->quote_mysql_utf8_string_literal( $column['COLUMN_DEFAULT'] ); } 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 fd89329e9..f1697c8ee 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 @@ -878,6 +878,34 @@ public function record_drop_index( WP_Parser_Node $node ): void { $this->record_drop_index_data( $table_is_temporary, $table_name, $index_name ); } + /** + * Get the bytes of a BINARY or VARBINARY column default. + * + * Defaults are stored as "0x..." hex literals. In tables from older versions, + * they can also be any other hex or bit literal as written, or the bytes of + * a text default. + * + * @param string $default_value A stored default, or a hex or bit literal. + * @return string The default value bytes. + */ + public static function get_binary_default_bytes( string $default_value ): string { + if ( 1 === preg_match( "/\A(?|0x([0-9a-fA-F]+)|[xX]'([0-9a-fA-F]*)')\z/", $default_value, $matches ) ) { + // MySQL pads odd-length 0x literals with a leading zero. + return hex2bin( 1 === strlen( $matches[1] ) % 2 ? '0' . $matches[1] : $matches[1] ); + } + + if ( 1 === preg_match( "/\A(?|0b([01]+)|[bB]'([01]*)')\z/", $default_value, $matches ) ) { + $bits = str_pad( $matches[1], (int) ceil( strlen( $matches[1] ) / 8 ) * 8, '0', STR_PAD_LEFT ); + $bytes = ''; + for ( $i = 0; $i < strlen( $bits ); $i += 8 ) { + $bytes .= chr( bindec( substr( $bits, $i, 8 ) ) ); + } + return $bytes; + } + + return $default_value; + } + /** * Analyze ADD COLUMN definition and record data in the information schema. * @@ -2274,6 +2302,10 @@ private function get_literal_default( WP_Parser_Node $literal, string $data_type return null; } + if ( 'binary' === $data_type || 'varbinary' === $data_type ) { + return $this->get_binary_literal_default( $literal ); + } + // DEFAULT TRUE or DEFAULT FALSE if ( $literal->has_child_node( 'boolLiteral' ) ) { $bool_literal = $literal->get_first_child_node( 'boolLiteral' ); @@ -2315,11 +2347,14 @@ private function normalize_literal_default( string $default_value, string $data_ return $bit_default; } + if ( 'binary' === $data_type || 'varbinary' === $data_type ) { + return $this->format_binary_default( $default_value ); + } + /* - * @TODO: Non-BIT literal defaults, except for temporal zero values, are + * @TODO: Other literal defaults, except for temporal zero values, are * currently stored verbatim. To match MySQL, they should be normalized. * For example: - * - BINARY/VARBINARY defaults stored as 0x hex literals. * - Hex and bit literals coerced to raw bytes for text columns and to * their decimal value for numeric columns. * - The charset introducer dropped from text-literal defaults. @@ -2328,6 +2363,42 @@ private function normalize_literal_default( string $default_value, string $data_ return $default_value; } + /** + * Get the default of a BINARY or VARBINARY column from a literal. + * + * Hex and bit literals are byte strings. Other literals give the bytes of + * their text, so the text '0x41' stays distinct from the hex literal 0x41. + * + * @param WP_Parser_Node $literal The "literal" AST node. + * @return string The default value as stored in information schema. + */ + private function get_binary_literal_default( WP_Parser_Node $literal ): string { + $byte_string = $literal->get_first_child_token( WP_MySQL_Lexer::HEX_NUMBER ) + ?? $literal->get_first_child_token( WP_MySQL_Lexer::BIN_NUMBER ); + if ( null !== $byte_string ) { + return $this->format_binary_default( self::get_binary_default_bytes( $byte_string->get_value() ) ); + } + + $bool_literal = $literal->get_first_child_node( 'boolLiteral' ); + if ( null !== $bool_literal ) { + return $this->format_binary_default( $bool_literal->has_child_token( WP_MySQL_Lexer::TRUE_SYMBOL ) ? '1' : '0' ); + } + + return $this->format_binary_default( $this->get_value( $literal ) ); + } + + /** + * Format the bytes of a BINARY or VARBINARY default for the information schema. + * + * Like in MySQL, the bytes are shown in hex, except for an empty default. + * + * @param string $bytes The default value bytes. + * @return string The default value as stored in information schema. + */ + private function format_binary_default( string $bytes ): string { + return '' === $bytes ? '' : '0x' . strtoupper( bin2hex( $bytes ) ); + } + /** * Get the zero value of a temporal column when its default is zero. * 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 d987cb39e..1753d0c2e 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 @@ -772,6 +772,116 @@ public function testShowCreateTableWithDefaultValues(): void { ); } + /** + * @dataProvider data_binary_column_defaults + */ + public function testBinaryColumnDefaults( + string $column_type, + string $default_value, + string $expected_create_default, + string $expected_information_schema_default, + string $expected_value + ): void { + $this->assertQuery( "CREATE TABLE t (id INT PRIMARY KEY, v $column_type NOT NULL DEFAULT $default_value)" ); + + $this->assertQuery( 'SHOW CREATE TABLE t' ); + $this->assertStringContainsString( + "`v` $column_type NOT NULL DEFAULT $expected_create_default,", + $this->last_result[0]->{'Create Table'} + ); + + $this->assertQuery( "SELECT COLUMN_DEFAULT FROM information_schema.columns WHERE table_name = 't' AND column_name = 'v'" ); + $this->assertSame( $expected_information_schema_default, $this->last_result[0]->COLUMN_DEFAULT ); + + $this->assertQuery( 'INSERT INTO t (id) VALUES (1)' ); + $this->assertQuery( "INSERT INTO t VALUES (2, 'z')" ); + $this->assertQuery( 'UPDATE t SET v = DEFAULT WHERE id = 2' ); + $this->assertQuery( 'SELECT HEX(v) AS v FROM t ORDER BY id' ); + $this->assertSame( + array( $expected_value, $expected_value ), + array_column( $this->last_result, 'v' ) + ); + } + + public static function data_binary_column_defaults(): array { + return array( + 'hex number' => array( 'varbinary(4)', '0xDEADBEEF', '0xDEADBEEF', '0xDEADBEEF', 'DEADBEEF' ), + 'hex string' => array( 'varbinary(4)', "x'deadbeef'", '0xDEADBEEF', '0xDEADBEEF', 'DEADBEEF' ), + 'uppercase string' => array( 'varbinary(4)', "X'deadbeef'", '0xDEADBEEF', '0xDEADBEEF', 'DEADBEEF' ), + 'odd-length hex' => array( 'varbinary(4)', '0xa', "'\\n'", '0x0A', '0A' ), + 'bit string' => array( 'varbinary(4)', "b'01000001'", "'A'", '0x41', '41' ), + 'bit number' => array( 'varbinary(4)', '0b01000001', "'A'", '0x41', '41' ), + 'zero bytes' => array( 'binary(2)', "x'0000'", "'\\0\\0'", '0x0000', '0000' ), + 'escaped zero bytes' => array( 'binary(2)', "'\\0\\0'", "'\\0\\0'", '0x0000', '0000' ), + 'text' => array( 'varbinary(4)', "'F'", "'F'", '0x46', '46' ), + 'quoted text' => array( 'varbinary(4)', "'it''s'", "'it''s'", '0x69742773', '69742773' ), + 'empty text' => array( 'varbinary(4)', "''", "''", '', '' ), + 'text like a number' => array( 'varbinary(4)', "'0x41'", "'0x41'", '0x30783431', '30783431' ), + 'text like a string' => array( 'varbinary(8)', "'x''41'''", "'x''41'''", '0x7827343127', '7827343127' ), + 'number' => array( 'varbinary(4)', '5', "'5'", '0x35', '35' ), + 'negative number' => array( 'varbinary(4)', '-5', "'-5'", '0x2D35', '2D35' ), + 'true' => array( 'varbinary(4)', 'TRUE', "'1'", '0x31', '31' ), + 'false' => array( 'varbinary(4)', 'FALSE', "'0'", '0x30', '30' ), + ); + } + + public function testBinaryColumnSetDefault(): void { + $this->assertQuery( "CREATE TABLE t (id INT PRIMARY KEY, v VARBINARY(4) NOT NULL DEFAULT 'a')" ); + + $this->assertQuery( 'ALTER TABLE t ALTER COLUMN v SET DEFAULT 0xDEADBEEF' ); + + $this->assertQuery( 'SHOW CREATE TABLE t' ); + $this->assertStringContainsString( + '`v` varbinary(4) NOT NULL DEFAULT 0xDEADBEEF,', + $this->last_result[0]->{'Create Table'} + ); + $this->assertQuery( 'INSERT INTO t (id) VALUES (1)' ); + $this->assertQuery( 'SELECT HEX(v) AS v FROM t' ); + $this->assertSame( 'DEADBEEF', $this->last_result[0]->v ); + } + + /** + * Older versions stored most BINARY and VARBINARY defaults as written. + * + * @dataProvider data_binary_column_defaults_from_older_versions + */ + public function testBinaryColumnDefaultsFromOlderVersions( + string $stored_default, + string $expected_create_default, + string $expected_value + ): void { + $this->assertQuery( 'CREATE TABLE t (id INT PRIMARY KEY, v VARBINARY(16) NOT NULL)' ); + $statement = $this->sqlite->prepare( + "UPDATE _wp_sqlite_mysql_information_schema_columns SET COLUMN_DEFAULT = ? WHERE TABLE_NAME = 't' AND COLUMN_NAME = 'v'" + ); + $statement->execute( array( $stored_default ) ); + + $this->assertQuery( 'SHOW CREATE TABLE t' ); + $this->assertStringContainsString( + "`v` varbinary(16) NOT NULL DEFAULT $expected_create_default,", + $this->last_result[0]->{'Create Table'} + ); + + // A table rebuild writes the column default to SQLite as bytes. + $this->assertQuery( 'ALTER TABLE t MODIFY COLUMN id BIGINT' ); + $this->assertQuery( 'INSERT INTO t (id) VALUES (1)' ); + $this->assertQuery( 'SELECT HEX(v) AS v FROM t' ); + $this->assertSame( $expected_value, $this->last_result[0]->v ); + } + + public static function data_binary_column_defaults_from_older_versions(): array { + return array( + 'hex string' => array( "x'00000000000000000000000000000000'", "'\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0\\0'", '00000000000000000000000000000000' ), + 'uppercase hex string' => array( "X'DEADBEEF'", '0xDEADBEEF', 'DEADBEEF' ), + 'hex number' => array( '0xdeadbeef', '0xDEADBEEF', 'DEADBEEF' ), + 'bit string' => array( "b'01000001'", "'A'", '41' ), + 'bit number' => array( '0b01000001', "'A'", '41' ), + 'zero bytes' => array( "\0\0", "'\\0\\0'", '0000' ), + 'text' => array( 'abcd', "'abcd'", '61626364' ), + 'empty text' => array( '', "''", '' ), + ); + } + public function testShowCreateTableWithBitDefaultValues(): void { $this->assertQuery( "CREATE TABLE _tmp_bit_defaults ( From ebb5903cdf4adaef709183d11c60584cd4d03a91 Mon Sep 17 00:00:00 2001 From: Riad Benguella Date: Sun, 1 Nov 2026 11:33:46 +0100 Subject: [PATCH 02/11] Drop charset prefixes from defaults and cast generated binary defaults to BLOB Co-Authored-By: Claude Opus 5.5 --- .../src/mysql/class-wp-mysql-token.php | 5 +++ .../src/sqlite/class-wp-mysql-on-sqlite.php | 6 ++- ...s-wp-sqlite-information-schema-builder.php | 3 ++ .../tests/WP_MySQL_On_SQLite_Tests.php | 44 +++++++++++++++++++ .../mysql-parser/src/class-wp-mysql-token.php | 10 ++++- .../tests/WP_MySQL_Token_Tests.php | 5 +++ 6 files changed, 71 insertions(+), 2 deletions(-) diff --git a/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-token.php b/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-token.php index 98f266e40..e15ca8a3f 100644 --- a/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-token.php +++ b/packages/mysql-on-sqlite/src/mysql/class-wp-mysql-token.php @@ -64,9 +64,14 @@ public function get_name(): string { */ public function get_value(): string { $value = $this->get_bytes(); + if ( WP_MySQL_Lexer::NCHAR_TEXT === $this->id ) { + // Remove the national string prefix. + $value = substr( $value, 1 ); + } if ( WP_MySQL_Lexer::SINGLE_QUOTED_TEXT === $this->id || WP_MySQL_Lexer::DOUBLE_QUOTED_TEXT === $this->id + || WP_MySQL_Lexer::NCHAR_TEXT === $this->id || WP_MySQL_Lexer::BACK_TICK_QUOTED_ID === $this->id ) { // Remove bounding quotes. diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php index 44ede2785..848286dea 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php @@ -7413,7 +7413,11 @@ private function get_sqlite_create_table_statement( $ast = $this->create_parser( 'SELECT ' . $column['COLUMN_DEFAULT'] )->parse(); $expr = $ast->get_first_descendant_node( 'selectItem' )->get_first_child_node(); $default_clause = $this->translate( $expr ); - $query .= sprintf( ' DEFAULT (%s)', $default_clause ); + if ( 'BLOB' === $type ) { + // STRICT tables reject a TEXT default in a BLOB column. + $default_clause = sprintf( 'CAST(%s AS BLOB)', $default_clause ); + } + $query .= sprintf( ' DEFAULT (%s)', $default_clause ); } else { $query .= ' DEFAULT ' . $this->format_sqlite_column_default( $column ); } 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 f1697c8ee..30f43d147 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 @@ -3381,6 +3381,9 @@ private function get_value( WP_Parser_Node $node ): string { } } } + } elseif ( WP_MySQL_Lexer::UNDERSCORE_CHARSET === $child->id ) { + // A charset introducer isn't part of the value. + continue; } else { $value = $child->get_value(); } 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 1753d0c2e..321fda737 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 @@ -818,6 +818,11 @@ public static function data_binary_column_defaults(): array { 'empty text' => array( 'varbinary(4)', "''", "''", '', '' ), 'text like a number' => array( 'varbinary(4)', "'0x41'", "'0x41'", '0x30783431', '30783431' ), 'text like a string' => array( 'varbinary(8)', "'x''41'''", "'x''41'''", '0x7827343127', '7827343127' ), + 'binary introducer' => array( 'varbinary(4)', "_binary'abc'", "'abc'", '0x616263', '616263' ), + 'charset introducer' => array( 'varbinary(4)', "_utf8mb4'abc'", "'abc'", '0x616263', '616263' ), + 'hex introducer' => array( 'varbinary(4)', '_binary 0x616263', "'abc'", '0x616263', '616263' ), + 'national string' => array( 'varbinary(4)', "N'abc'", "'abc'", '0x616263', '616263' ), + 'national quoted' => array( 'varbinary(4)', "n'it''s'", "'it''s'", '0x69742773', '69742773' ), 'number' => array( 'varbinary(4)', '5', "'5'", '0x35', '35' ), 'negative number' => array( 'varbinary(4)', '-5', "'-5'", '0x2D35', '2D35' ), 'true' => array( 'varbinary(4)', 'TRUE', "'1'", '0x31', '31' ), @@ -840,6 +845,45 @@ public function testBinaryColumnSetDefault(): void { $this->assertSame( 'DEADBEEF', $this->last_result[0]->v ); } + /** + * @dataProvider data_binary_column_generated_defaults + */ + public function testBinaryColumnGeneratedDefaults( string $column_type, string $default_value, string $expected_value ): void { + $this->assertQuery( "CREATE TABLE t (id INT PRIMARY KEY, v $column_type NOT NULL DEFAULT $default_value)" ); + + $this->assertQuery( 'INSERT INTO t (id) VALUES (1)' ); + + $this->assertQuery( 'SELECT HEX(v) AS v FROM t' ); + $this->assertSame( $expected_value, $this->last_result[0]->v ); + } + + public static function data_binary_column_generated_defaults(): array { + return array( + 'varbinary text' => array( 'varbinary(4)', "('abc')", '616263' ), + 'binary text' => array( 'binary(3)', "('abc')", '616263' ), + 'blob text' => array( 'blob', "('abc')", '616263' ), + 'hex' => array( 'varbinary(4)', '(0x616263)', '616263' ), + 'function' => array( 'longblob', "(CONCAT('a', 'bc'))", '616263' ), + 'number' => array( 'varbinary(4)', '(1 + 1)', '32' ), + ); + } + + public function testTextColumnDefaultsWithPrefixes(): void { + $this->assertQuery( + "CREATE TABLE t ( + id INT PRIMARY KEY, + a VARCHAR(8) NOT NULL DEFAULT _utf8mb4'abc', + b VARCHAR(8) NOT NULL DEFAULT N'it''s' + )" + ); + + $this->assertQuery( 'INSERT INTO t (id) VALUES (1)' ); + + $this->assertQuery( 'SELECT a, b FROM t' ); + $this->assertSame( 'abc', $this->last_result[0]->a ); + $this->assertSame( "it's", $this->last_result[0]->b ); + } + /** * Older versions stored most BINARY and VARBINARY defaults as written. * diff --git a/packages/mysql-parser/src/class-wp-mysql-token.php b/packages/mysql-parser/src/class-wp-mysql-token.php index 010e03c46..2b8924197 100644 --- a/packages/mysql-parser/src/class-wp-mysql-token.php +++ b/packages/mysql-parser/src/class-wp-mysql-token.php @@ -62,7 +62,15 @@ public function get_name(): string { */ public function get_value(): string { $value = $this->get_bytes(); - if ( WP_MySQL_Lexer::TEXT_STRING === $this->id || WP_MySQL_Lexer::IDENT_QUOTED === $this->id ) { + if ( WP_MySQL_Lexer::NCHAR_STRING === $this->id ) { + // Remove the national string prefix. + $value = substr( $value, 1 ); + } + if ( + WP_MySQL_Lexer::TEXT_STRING === $this->id + || WP_MySQL_Lexer::NCHAR_STRING === $this->id + || WP_MySQL_Lexer::IDENT_QUOTED === $this->id + ) { // Remove bounding quotes. $quote = $value[0]; $value = substr( $value, 1, -1 ); diff --git a/packages/mysql-parser/tests/WP_MySQL_Token_Tests.php b/packages/mysql-parser/tests/WP_MySQL_Token_Tests.php index a35b619b6..d411f3cf5 100644 --- a/packages/mysql-parser/tests/WP_MySQL_Token_Tests.php +++ b/packages/mysql-parser/tests/WP_MySQL_Token_Tests.php @@ -29,6 +29,11 @@ public function test_get_value_unquotes_string_literals(): void { $this->assertSame( "new\nline", self::first_token( "SELECT 'new\\nline'", 'TEXT_STRING' )->get_value() ); } + public function test_get_value_unquotes_national_strings(): void { + $this->assertSame( "a'b", self::first_token( "SELECT N'a''b'", 'NCHAR_STRING' )->get_value() ); + $this->assertSame( "new\nline", self::first_token( "SELECT n'new\\nline'", 'NCHAR_STRING' )->get_value() ); + } + public function test_get_value_honors_no_backslash_escapes_mode(): void { $token = self::first_token( "SELECT 'a\\nb'", 'TEXT_STRING', array( 'NO_BACKSLASH_ESCAPES' ) ); $this->assertSame( 'a\\nb', $token->get_value() ); From dea4950f335ee546151835a73d20ec2899dba507 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Mon, 2 Nov 2026 11:11:52 +0100 Subject: [PATCH 03/11] Translate national strings like other string literals get_value() now unquotes national string tokens, but translate_token() still emitted their value as is, so the contents of N'0 OR 1=1' ran as SQL. --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 12 +++++---- .../WP_MySQL_On_SQLite_PDO_API_Tests.php | 4 +++ .../tests/WP_MySQL_On_SQLite_Tests.php | 26 +++++++++++++++++++ .../WP_MySQL_On_SQLite_Translation_Tests.php | 15 +++++++++++ 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php index 848286dea..1b4e23111 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php @@ -4383,7 +4383,7 @@ private function translate( $node ): ?string { } return $value; case 'textStringLiteral': - return $this->translate_string_literal( $node ); + return $this->translate_string_literal( $node->get_first_child_token() ); case 'dataType': case 'nchar': $child = $node->get_first_child(); @@ -4607,6 +4607,9 @@ private function translate_token( WP_MySQL_Token $token ): ?string { return sprintf( "x'%s'", $hex ); } return $value; + case WP_MySQL_Lexer::NCHAR_TEXT: + // A national string, such as N'abc', is a regular string in SQLite. + return $this->translate_string_literal( $token ); case WP_MySQL_Lexer::AUTO_INCREMENT_SYMBOL: return 'AUTOINCREMENT'; case WP_MySQL_Lexer::BINARY_SYMBOL: @@ -4657,11 +4660,10 @@ private function translate_sequence( array $nodes, string $separator = ' ' ): ?s /** * Translate a MySQL string literal to SQLite. * - * @param WP_Parser_Node $node The "textStringLiteral" AST node. - * @return string The translated value. + * @param WP_MySQL_Token $token The string literal token. + * @return string The translated value. */ - private function translate_string_literal( WP_Parser_Node $node ): string { - $token = $node->get_first_child_token(); + private function translate_string_literal( WP_MySQL_Token $token ): string { $value = $token->get_value(); /* diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php index e28a880b3..388d7d291 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_PDO_API_Tests.php @@ -945,6 +945,10 @@ public function test_quote_supports_parameter_types(): void { $this->assertSame( $quoted, $this->driver->quote( $value, PDO::PARAM_STR ) ); $this->assertSame( 'N' . $quoted, $this->driver->quote( $value, PDO::PARAM_STR_NATL ) ); + $this->assertSame( + $value, + $this->driver->query( 'SELECT ' . $this->driver->quote( $value, PDO::PARAM_STR_NATL ) )->fetchColumn() + ); $this->assertSame( $quoted, $this->driver->quote( $value, PDO::PARAM_STR_CHAR ) ); $this->assertSame( $quoted, 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 321fda737..63e1785fa 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 @@ -862,6 +862,7 @@ public static function data_binary_column_generated_defaults(): array { 'varbinary text' => array( 'varbinary(4)', "('abc')", '616263' ), 'binary text' => array( 'binary(3)', "('abc')", '616263' ), 'blob text' => array( 'blob', "('abc')", '616263' ), + 'national text' => array( 'varbinary(4)', "(N'abc')", '616263' ), 'hex' => array( 'varbinary(4)', '(0x616263)', '616263' ), 'function' => array( 'longblob', "(CONCAT('a', 'bc'))", '616263' ), 'number' => array( 'varbinary(4)', '(1 + 1)', '32' ), @@ -9667,6 +9668,31 @@ public function testBackslashEscapesInStringLiterals(): void { $this->assertSame( $backslash . '_', $result[0]->value_12 ); } + public function testNationalStringLiterals(): void { + $this->assertQuery( + "CREATE TABLE t ( + id INT PRIMARY KEY, + name VARCHAR(20) NOT NULL DEFAULT (N'it''s'), + CHECK (name <> N'x') + )" + ); + $this->assertQuery( 'INSERT INTO t (id) VALUES (1)' ); + $this->assertQuery( "INSERT INTO t VALUES (2, N'0 OR 1=1')" ); + $this->assertQueryError( + "INSERT INTO t VALUES (3, N'x')", + 'SQLSTATE[23000]: Integrity constraint violation: 19 CHECK constraint failed: t_chk_1' + ); + + $this->assertQuery( 'SELECT name FROM t ORDER BY id' ); + $this->assertSame( array( "it's", '0 OR 1=1' ), array_column( $this->last_result, 'name' ) ); + + // The string value must not be evaluated as SQL. + $this->assertQuery( "SELECT id FROM t WHERE name = N'0 OR 1=1'" ); + $this->assertSame( array( '2' ), array_column( $this->last_result, 'id' ) ); + $this->assertQuery( "SELECT id FROM t WHERE 'z' = N'0 OR 1=1'" ); + $this->assertSame( array(), $this->last_result ); + } + public function testBackslashEscapesInPatternMatching(): void { $backslash = chr( 92 ); 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 d0d522db4..3fe14414d 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 @@ -1754,6 +1754,21 @@ public function testHexadecimalLiterals(): void { ); } + public function testNationalStringLiterals(): void { + $this->assertQuery( + "SELECT 'abc' AS `N'abc'`", + "SELECT N'abc'" + ); + $this->assertQuery( + "SELECT 'it''s' AS `n'it''s'`", + "SELECT n'it''s'" + ); + $this->assertQuery( + "SELECT '0 OR 1=1' AS `N'0 OR 1=1'`", + "SELECT N'0 OR 1=1'" + ); + } + public function testSystemVariables(): void { $this->assertQuery( "SELECT 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION' AS `@@sql_mode`", From 309e8d3aafccf99590b2dab7a82782c2c8d640ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Mon, 2 Nov 2026 11:12:18 +0100 Subject: [PATCH 04/11] Remove the charset introducer item from the defaults TODO get_value() now drops charset introducers from literal defaults. --- .../src/sqlite/class-wp-sqlite-information-schema-builder.php | 1 - 1 file changed, 1 deletion(-) 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 30f43d147..b80f2cc9b 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 @@ -2357,7 +2357,6 @@ private function normalize_literal_default( string $default_value, string $data_ * For example: * - Hex and bit literals coerced to raw bytes for text columns and to * their decimal value for numeric columns. - * - The charset introducer dropped from text-literal defaults. * - Numeric defaults normalized, e.g. 1.0 to 1 or 1e3 to 1000. */ return $default_value; From fe1027d3054962fe34a29fa6d3c3a3fb77805511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Mon, 2 Nov 2026 11:13:26 +0100 Subject: [PATCH 05/11] Print binary defaults with 4-byte UTF-8 characters in hex MySQL checks the bytes against utf8mb3, so it prints 0xF09F9880 rather than a quoted emoji. See print_default_clause() in MySQL: https://github.com/mysql/mysql-server/blob/8.4/sql/sql_show.cc --- .../mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php | 5 +++-- packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php index 1b4e23111..9735cb263 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php @@ -7926,10 +7926,11 @@ private function format_mysql_column_default( array $column ): string { return $column['COLUMN_DEFAULT']; } - // Like MySQL, use a hex literal for bytes that aren't valid UTF-8. + // Like MySQL, use a hex literal for bytes that aren't valid utf8mb3, + // i.e., UTF-8 with no characters above U+FFFF. if ( 'binary' === $column['DATA_TYPE'] || 'varbinary' === $column['DATA_TYPE'] ) { $bytes = WP_SQLite_Information_Schema_Builder::get_binary_default_bytes( $column['COLUMN_DEFAULT'] ); - return 1 === preg_match( '//u', $bytes ) + return 1 === preg_match( '/\A[\x{0}-\x{FFFF}]*\z/u', $bytes ) ? $this->quote_mysql_utf8_string_literal( $bytes ) : '0x' . strtoupper( bin2hex( $bytes ) ); } 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 63e1785fa..c3e6aba01 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 @@ -813,6 +813,8 @@ public static function data_binary_column_defaults(): array { 'bit number' => array( 'varbinary(4)', '0b01000001', "'A'", '0x41', '41' ), 'zero bytes' => array( 'binary(2)', "x'0000'", "'\\0\\0'", '0x0000', '0000' ), 'escaped zero bytes' => array( 'binary(2)', "'\\0\\0'", "'\\0\\0'", '0x0000', '0000' ), + '3-byte utf-8' => array( 'varbinary(4)', '0xE282AC', "'€'", '0xE282AC', 'E282AC' ), + '4-byte utf-8' => array( 'varbinary(4)', '0xF09F9880', '0xF09F9880', '0xF09F9880', 'F09F9880' ), 'text' => array( 'varbinary(4)', "'F'", "'F'", '0x46', '46' ), 'quoted text' => array( 'varbinary(4)', "'it''s'", "'it''s'", '0x69742773', '69742773' ), 'empty text' => array( 'varbinary(4)', "''", "''", '', '' ), From 290353c3042d2f5c40dabd75bed284a36cdaebbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Mon, 2 Nov 2026 15:35:23 +0100 Subject: [PATCH 06/11] Extract table recreation from the temporal default migration The binary default migration will reuse it. --- .../sqlite/class-wp-sqlite-configurator.php | 49 ++++++++++++------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php index 2a37b6816..a85576b27 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php @@ -484,7 +484,7 @@ private function migrate_temporal_zero_defaults(): void { ); } - $alter_list = array(); + $defaults = array(); foreach ( $repaired_columns as $column ) { // Preserve numeric and string types so comparisons apply the same // SQLite conversion as when the defaults were stored. @@ -502,26 +502,37 @@ private function migrate_temporal_zero_defaults(): void { : array( $column['zero_value'] ) ); - $alter_list[] = sprintf( - 'ALTER COLUMN `%s` SET DEFAULT %s', - str_replace( '`', '``', $column['name'] ), - $this->driver->quote( $column['zero_value'] ) - ); + $defaults[ $column['name'] ] = $this->driver->quote( $column['zero_value'] ); } + $this->recreate_table( $table_name, $defaults ); + } + } - // ALTER TABLE stores the new defaults, and it recreates the table to apply them. - try { - $this->driver->query( - sprintf( 'ALTER TABLE `%s` %s', str_replace( '`', '``', $table_name ), implode( ', ', $alter_list ) ) - ); - } catch ( WP_MySQL_On_SQLite_Exception $e ) { - throw new WP_MySQL_On_SQLite_Exception( - $this->driver, - sprintf( 'Failed to recreate table "%s": %s', $table_name, $e->getMessage() ), - $e->getCode(), - $e - ); - } + /** + * Recreate a table with new column defaults. + * + * ALTER TABLE stores the new defaults, and it recreates the table to apply them. + * + * @param string $table_name The table name. + * @param array $defaults The new defaults as MySQL literals, keyed by column name. + */ + private function recreate_table( string $table_name, array $defaults ): void { + $alter_list = array(); + foreach ( $defaults as $column_name => $default ) { + $alter_list[] = sprintf( 'ALTER COLUMN `%s` SET DEFAULT %s', str_replace( '`', '``', $column_name ), $default ); + } + + try { + $this->driver->query( + sprintf( 'ALTER TABLE `%s` %s', str_replace( '`', '``', $table_name ), implode( ', ', $alter_list ) ) + ); + } catch ( WP_MySQL_On_SQLite_Exception $e ) { + throw new WP_MySQL_On_SQLite_Exception( + $this->driver, + sprintf( 'Failed to recreate table "%s": %s', $table_name, $e->getMessage() ), + $e->getCode(), + $e + ); } } } From 936a4c44c672b33690072859410d7d245d17f8c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Mon, 2 Nov 2026 15:38:32 +0100 Subject: [PATCH 07/11] Migrate binary column defaults stored by older versions Older versions stored BINARY and VARBINARY defaults as written, and they gave the SQLite tables TEXT defaults for them and for binary expression defaults. Inserts that omitted such a column kept failing until the table was recreated, and INFORMATION_SCHEMA kept showing the old forms. Store these defaults in hex and recreate the affected tables, like the temporal zero default migration does. Existing rows are kept as they are. STRICT tables rejected the TEXT defaults, so only the unsafe mode for SQLite < 3.37, which doesn't check types, can have rows with them. --- .../sqlite/class-wp-sqlite-configurator.php | 65 ++++++++- .../tests/WP_MySQL_On_SQLite_Tests.php | 132 ++++++++++++++++++ 2 files changed, 196 insertions(+), 1 deletion(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php index a85576b27..402e746bd 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php @@ -345,6 +345,11 @@ private function run_migrations( string $saved_version ): void { $this->migrate_datetime_precision(); $this->migrate_temporal_zero_defaults(); } + + // Fix binary column defaults stored by versions <= 3.1.2. + if ( version_compare( $saved_version, '3.1.2', '<=' ) ) { + $this->migrate_binary_defaults(); + } } /** @@ -508,10 +513,64 @@ private function migrate_temporal_zero_defaults(): void { } } + /** + * Migrate binary column defaults stored by older driver versions. + * + * Older driver versions stored BINARY and VARBINARY defaults as written, such + * as x'00', while MySQL stores their bytes in hex, such as 0x00. They also gave + * the SQLite tables TEXT defaults for these and for binary expression defaults, + * which STRICT tables reject in BLOB columns, so inserts omitting them failed. + */ + private function migrate_binary_defaults(): void { + $columns_table = $this->driver->get_connection()->quote_identifier( + $this->schema_builder->get_table_name( false, 'columns' ) + ); + + $columns = $this->driver->execute_sqlite_query( + sprintf( + "SELECT c.TABLE_NAME, c.COLUMN_NAME, c.DATA_TYPE, c.COLUMN_DEFAULT, c.EXTRA, p.dflt_value + FROM %s AS c + JOIN pragma_table_info(c.TABLE_NAME, 'main') AS p ON c.COLUMN_NAME = p.name + WHERE c.TABLE_SCHEMA = ? + AND c.DATA_TYPE IN ('binary', 'varbinary', 'tinyblob', 'blob', 'mediumblob', 'longblob') + AND c.COLUMN_DEFAULT IS NOT NULL", + $columns_table + ), + array( WP_SQLite_Information_Schema_Builder::SAVED_DATABASE_NAME ) + )->fetchAll( PDO::FETCH_ASSOC ); + + $table_defaults = array(); + foreach ( $columns as $column ) { + $table_name = $column['TABLE_NAME']; + $sqlite_default = (string) $column['dflt_value']; + if ( str_contains( $column['EXTRA'], 'DEFAULT_GENERATED' ) ) { + // Recreating the table casts the expression to BLOB. + if ( 1 !== preg_match( '/\ACAST\(.* AS BLOB\)\z/s', $sqlite_default ) ) { + $table_defaults[ $table_name ] = $table_defaults[ $table_name ] ?? array(); + } + } elseif ( 'binary' === $column['DATA_TYPE'] || 'varbinary' === $column['DATA_TYPE'] ) { + // A literal default is stored as "0x..." and used as "x'...'" in SQLite. + if ( + 1 !== preg_match( '/\A(?:0x(?:[0-9A-F]{2})+)?\z/', $column['COLUMN_DEFAULT'] ) + || 1 !== preg_match( "/\A[xX]'[0-9a-fA-F]*'\z/", $sqlite_default ) + ) { + $bytes = WP_SQLite_Information_Schema_Builder::get_binary_default_bytes( $column['COLUMN_DEFAULT'] ); + + $table_defaults[ $table_name ][ $column['COLUMN_NAME'] ] = '' === $bytes ? "''" : '0x' . bin2hex( $bytes ); + } + } + } + + foreach ( $table_defaults as $table_name => $defaults ) { + $this->recreate_table( $table_name, $defaults ); + } + } + /** * Recreate a table with new column defaults. * * ALTER TABLE stores the new defaults, and it recreates the table to apply them. + * Without new defaults, ALTER TABLE ... FORCE only recreates the table. * * @param string $table_name The table name. * @param array $defaults The new defaults as MySQL literals, keyed by column name. @@ -524,7 +583,11 @@ private function recreate_table( string $table_name, array $defaults ): void { try { $this->driver->query( - sprintf( 'ALTER TABLE `%s` %s', str_replace( '`', '``', $table_name ), implode( ', ', $alter_list ) ) + sprintf( + 'ALTER TABLE `%s` %s', + str_replace( '`', '``', $table_name ), + count( $alter_list ) > 0 ? implode( ', ', $alter_list ) : 'FORCE' + ) ); } catch ( WP_MySQL_On_SQLite_Exception $e ) { throw new WP_MySQL_On_SQLite_Exception( 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 c3e6aba01..93b5fd25c 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 @@ -165,6 +165,51 @@ private function createOnUpdateTrigger( string $table, string $column, bool $leg ); } + /** + * Create tables with binary column defaults, as driver versions <= 3.1.2 did. + * + * These versions stored BINARY and VARBINARY defaults as written, and they used + * them and the expression defaults of binary columns as TEXT defaults in SQLite. + */ + private function createBinaryDefaultTablesFromOlderVersion(): void { + $this->assertQuery( + "CREATE TABLE t ( + id INT PRIMARY KEY, + hex_string BINARY(2) NOT NULL DEFAULT x'0000', + hex_number VARBINARY(4) NOT NULL DEFAULT 0xDEADBEEF, + bit_string VARBINARY(4) NOT NULL DEFAULT b'01000001', + plain_text VARBINARY(4) NOT NULL DEFAULT 'abcd', + empty_text VARBINARY(4) NOT NULL DEFAULT '', + expression BLOB DEFAULT ('abc') + )" + ); + $this->assertQuery( "CREATE TABLE t_expression (id INT PRIMARY KEY, expression BLOB DEFAULT ('abc'))" ); + + $statement = $this->sqlite->prepare( + "UPDATE _wp_sqlite_mysql_information_schema_columns SET column_default = ? WHERE table_name = 't' AND column_name = ?" + ); + $statement->execute( array( "x'0000'", 'hex_string' ) ); + $statement->execute( array( '0xdeadbeef', 'hex_number' ) ); + $statement->execute( array( "b'01000001'", 'bit_string' ) ); + $statement->execute( array( 'abcd', 'plain_text' ) ); + + $this->sqlite->exec( 'DROP TABLE t' ); + $this->sqlite->exec( + "CREATE TABLE `t` ( + `id` INT NOT NULL, + `hex_string` BLOB NOT NULL DEFAULT 'x''0000''', + `hex_number` BLOB NOT NULL DEFAULT '0xdeadbeef', + `bit_string` BLOB NOT NULL DEFAULT 'b''01000001''', + `plain_text` BLOB NOT NULL DEFAULT 'abcd', + `empty_text` BLOB NOT NULL DEFAULT '', + `expression` BLOB DEFAULT ('abc'), + PRIMARY KEY (`id`) + ) STRICT" + ); + $this->sqlite->exec( 'DROP TABLE t_expression' ); + $this->sqlite->exec( "CREATE TABLE `t_expression` (`id` INT NOT NULL, `expression` BLOB DEFAULT ('abc'), PRIMARY KEY (`id`)) STRICT" ); + } + public function testRegexp() { $this->assertQuery( "INSERT INTO _options (option_name, option_value) VALUES ('rss_0123456789abcdef0123456789abcdef', '1');" @@ -929,6 +974,93 @@ public static function data_binary_column_defaults_from_older_versions(): array ); } + public function testConfigurationMigratesBinaryDefaultsFromOlderVersions(): void { + $this->createBinaryDefaultTablesFromOlderVersion(); + $this->assertQuery( "CREATE TABLE t_current (id INT PRIMARY KEY, v VARBINARY(4) NOT NULL DEFAULT 0x41, w BLOB DEFAULT ('abc'))" ); + $tables_query = "SELECT name, sql FROM sqlite_master WHERE name IN ('t', 't_expression', 't_current') ORDER BY name"; + $tables = $this->sqlite->query( $tables_query )->fetchAll( PDO::FETCH_KEY_PAIR ); + + $this->assertQueryError( + 'INSERT INTO t (id) VALUES (1)', + 'SQLSTATE[23000]: Integrity constraint violation: 19 cannot store TEXT value in BLOB column t.hex_string' + ); + + $this->reconnectAfterDriverUpgrade(); + + $this->assertQuery( "SELECT COLUMN_NAME, COLUMN_DEFAULT FROM information_schema.columns WHERE table_name = 't' ORDER BY ordinal_position" ); + $this->assertSame( + array( + 'id' => null, + 'hex_string' => '0x0000', + 'hex_number' => '0xDEADBEEF', + 'bit_string' => '0x41', + 'plain_text' => '0x61626364', + 'empty_text' => '', + 'expression' => "'abc'", + ), + array_column( $this->last_result, 'COLUMN_DEFAULT', 'COLUMN_NAME' ) + ); + + $this->assertQuery( 'INSERT INTO t (id) VALUES (1)' ); + $this->assertQuery( 'INSERT INTO t_expression (id) VALUES (1)' ); + $this->assertQuery( 'SELECT HEX(hex_string) AS a, HEX(hex_number) AS b, HEX(bit_string) AS c, HEX(plain_text) AS d, HEX(empty_text) AS e, HEX(expression) AS f FROM t' ); + $this->assertEquals( + array( + (object) array( + 'a' => '0000', + 'b' => 'DEADBEEF', + 'c' => '41', + 'd' => '61626364', + 'e' => '', + 'f' => '616263', + ), + ), + $this->last_result + ); + $this->assertQuery( 'SELECT HEX(expression) AS expression FROM t_expression' ); + $this->assertSame( '616263', $this->last_result[0]->expression ); + + // A table from the current version isn't recreated. + $migrated_tables = $this->sqlite->query( $tables_query )->fetchAll( PDO::FETCH_KEY_PAIR ); + $this->assertSame( $tables['t_current'], $migrated_tables['t_current'] ); + + // Configuring the database again changes nothing. + $this->reconnectAfterDriverUpgrade(); + $this->assertSame( $migrated_tables, $this->sqlite->query( $tables_query )->fetchAll( PDO::FETCH_KEY_PAIR ) ); + } + + /** + * @dataProvider data_binary_default_migration_driver_versions + */ + public function testConfigurationMigratesBinaryDefaultsOnlyForOlderDriverVersions( string $saved_version, string $expected_default ): void { + $this->createBinaryDefaultTablesFromOlderVersion(); + + // Configure the database as if it was last configured by the given driver version. + $this->sqlite->exec( + sprintf( + "UPDATE %s SET value = '%s' WHERE name = '%s'", + WP_MySQL_On_SQLite::GLOBAL_VARIABLES_TABLE_NAME, + $saved_version, + WP_MySQL_On_SQLite::DRIVER_VERSION_VARIABLE_NAME + ) + ); + $configurator = new WP_SQLite_Configurator( + $this->engine, + new WP_SQLite_Information_Schema_Builder( WP_MySQL_On_SQLite::RESERVED_PREFIX, $this->engine->get_connection() ) + ); + $configurator->configure_database(); + + $this->assertQuery( "SELECT COLUMN_DEFAULT FROM information_schema.columns WHERE table_name = 't' AND column_name = 'hex_number'" ); + $this->assertSame( $expected_default, $this->last_result[0]->COLUMN_DEFAULT ); + } + + public static function data_binary_default_migration_driver_versions(): array { + return array( + 'migrated from 3.1.2' => array( '3.1.2', '0xDEADBEEF' ), + 'not migrated from 3.1.3' => array( '3.1.3', '0xdeadbeef' ), + ); + } + public function testShowCreateTableWithBitDefaultValues(): void { $this->assertQuery( "CREATE TABLE _tmp_bit_defaults ( From f563d5413c65b0a53e1106ea38085fa51656fd07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Mon, 2 Nov 2026 15:56:25 +0100 Subject: [PATCH 08/11] Read binary defaults only in their stored hex form The migration converts the forms that older versions stored, so the driver no longer decodes them on every read. Until the migration runs, such as on a build before the next version bump, an old default is handled as before this PR, so inserts fail rather than store the wrong bytes. --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 32 +++++++++++++++---- ...s-wp-sqlite-information-schema-builder.php | 9 +++--- .../tests/WP_MySQL_On_SQLite_Tests.php | 6 ++-- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php index 9735cb263..f554faf35 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php @@ -7906,8 +7906,8 @@ private function format_sqlite_column_default( array $column ): string { } // A BLOB column in a STRICT table only accepts a BLOB default. - if ( 'binary' === $column['DATA_TYPE'] || 'varbinary' === $column['DATA_TYPE'] ) { - $bytes = WP_SQLite_Information_Schema_Builder::get_binary_default_bytes( $column['COLUMN_DEFAULT'] ); + $bytes = $this->get_binary_column_default_bytes( $column ); + if ( null !== $bytes ) { return sprintf( "x'%s'", bin2hex( $bytes ) ); } @@ -7926,18 +7926,38 @@ private function format_mysql_column_default( array $column ): string { return $column['COLUMN_DEFAULT']; } - // Like MySQL, use a hex literal for bytes that aren't valid utf8mb3, + // Like MySQL, keep the hex literal for bytes that aren't valid utf8mb3, // i.e., UTF-8 with no characters above U+FFFF. - if ( 'binary' === $column['DATA_TYPE'] || 'varbinary' === $column['DATA_TYPE'] ) { - $bytes = WP_SQLite_Information_Schema_Builder::get_binary_default_bytes( $column['COLUMN_DEFAULT'] ); + $bytes = $this->get_binary_column_default_bytes( $column ); + if ( null !== $bytes ) { return 1 === preg_match( '/\A[\x{0}-\x{FFFF}]*\z/u', $bytes ) ? $this->quote_mysql_utf8_string_literal( $bytes ) - : '0x' . strtoupper( bin2hex( $bytes ) ); + : $column['COLUMN_DEFAULT']; } return $this->quote_mysql_utf8_string_literal( $column['COLUMN_DEFAULT'] ); } + /** + * Get the bytes of a BINARY or VARBINARY column default. + * + * These defaults are stored as "0x..." hex literals, or as an empty string. + * The forms that older driver versions stored are migrated to this format. + * + * @param array $column The information schema column data. + * @return string|null The default value bytes, or null when the column + * is not binary or its default is not migrated. + */ + private function get_binary_column_default_bytes( array $column ): ?string { + if ( + ( 'binary' !== $column['DATA_TYPE'] && 'varbinary' !== $column['DATA_TYPE'] ) + || 1 !== preg_match( '/\A(?:0x((?:[0-9A-F]{2})+))?\z/', $column['COLUMN_DEFAULT'], $matches ) + ) { + return null; + } + return hex2bin( $matches[1] ?? '' ); + } + /** * Unquote a quoted SQLite identifier. * 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 b80f2cc9b..f7a49423f 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 @@ -879,13 +879,12 @@ public function record_drop_index( WP_Parser_Node $node ): void { } /** - * Get the bytes of a BINARY or VARBINARY column default. + * Get the bytes of a BINARY or VARBINARY default from a hex or bit literal. * - * Defaults are stored as "0x..." hex literals. In tables from older versions, - * they can also be any other hex or bit literal as written, or the bytes of - * a text default. + * Older driver versions stored these defaults as written, so this also reads + * any other value as the bytes of a text default, for their migration. * - * @param string $default_value A stored default, or a hex or bit literal. + * @param string $default_value A hex or bit literal, or a default stored by an older version. * @return string The default value bytes. */ public static function get_binary_default_bytes( string $default_value ): string { 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 93b5fd25c..6d61f863e 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 @@ -948,14 +948,14 @@ public function testBinaryColumnDefaultsFromOlderVersions( ); $statement->execute( array( $stored_default ) ); + // The driver upgrade migrates the default and recreates the table. + $this->reconnectAfterDriverUpgrade(); + $this->assertQuery( 'SHOW CREATE TABLE t' ); $this->assertStringContainsString( "`v` varbinary(16) NOT NULL DEFAULT $expected_create_default,", $this->last_result[0]->{'Create Table'} ); - - // A table rebuild writes the column default to SQLite as bytes. - $this->assertQuery( 'ALTER TABLE t MODIFY COLUMN id BIGINT' ); $this->assertQuery( 'INSERT INTO t (id) VALUES (1)' ); $this->assertQuery( 'SELECT HEX(v) AS v FROM t' ); $this->assertSame( $expected_value, $this->last_result[0]->v ); From d7819c546f8046ff3cc462c6bbf41ea148ec75a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Mon, 2 Nov 2026 16:15:56 +0100 Subject: [PATCH 09/11] Fix binary default migration tests on SQLite < 3.37 Older driver versions created STRICT tables only on SQLite 3.37.0 and newer, and older SQLite can't parse them. Their non-STRICT tables accept the TEXT defaults, so expect the failing insert only with STRICT tables. --- .../tests/WP_MySQL_On_SQLite_Tests.php | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) 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 6d61f863e..d3bc9cab4 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 @@ -193,6 +193,9 @@ private function createBinaryDefaultTablesFromOlderVersion(): void { $statement->execute( array( "b'01000001'", 'bit_string' ) ); $statement->execute( array( 'abcd', 'plain_text' ) ); + $is_legacy_sqlite = version_compare( $this->engine->get_sqlite_version(), WP_MySQL_On_SQLite::MINIMUM_SQLITE_VERSION, '<' ); + $strict = $is_legacy_sqlite ? '' : ' STRICT'; + $this->sqlite->exec( 'DROP TABLE t' ); $this->sqlite->exec( "CREATE TABLE `t` ( @@ -204,10 +207,10 @@ private function createBinaryDefaultTablesFromOlderVersion(): void { `empty_text` BLOB NOT NULL DEFAULT '', `expression` BLOB DEFAULT ('abc'), PRIMARY KEY (`id`) - ) STRICT" + )$strict" ); $this->sqlite->exec( 'DROP TABLE t_expression' ); - $this->sqlite->exec( "CREATE TABLE `t_expression` (`id` INT NOT NULL, `expression` BLOB DEFAULT ('abc'), PRIMARY KEY (`id`)) STRICT" ); + $this->sqlite->exec( "CREATE TABLE `t_expression` (`id` INT NOT NULL, `expression` BLOB DEFAULT ('abc'), PRIMARY KEY (`id`))$strict" ); } public function testRegexp() { @@ -980,10 +983,14 @@ public function testConfigurationMigratesBinaryDefaultsFromOlderVersions(): void $tables_query = "SELECT name, sql FROM sqlite_master WHERE name IN ('t', 't_expression', 't_current') ORDER BY name"; $tables = $this->sqlite->query( $tables_query )->fetchAll( PDO::FETCH_KEY_PAIR ); - $this->assertQueryError( - 'INSERT INTO t (id) VALUES (1)', - 'SQLSTATE[23000]: Integrity constraint violation: 19 cannot store TEXT value in BLOB column t.hex_string' - ); + // STRICT tables reject the TEXT defaults of older versions. + $is_legacy_sqlite = version_compare( $this->engine->get_sqlite_version(), WP_MySQL_On_SQLite::MINIMUM_SQLITE_VERSION, '<' ); + if ( ! $is_legacy_sqlite ) { + $this->assertQueryError( + 'INSERT INTO t (id) VALUES (1)', + 'SQLSTATE[23000]: Integrity constraint violation: 19 cannot store TEXT value in BLOB column t.hex_string' + ); + } $this->reconnectAfterDriverUpgrade(); From 88dfafa0e8567c561d6215407e457e5aa0cdb848 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Mon, 2 Nov 2026 16:45:23 +0100 Subject: [PATCH 10/11] Name national string columns like MySQL SELECT N'abc' named its column N'abc', while MySQL names it abc. A national string has no textStringLiteral node, so the alias logic didn't treat it as a bare string literal. --- .../mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php | 6 +++++- packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php | 4 ++++ .../tests/WP_MySQL_On_SQLite_Translation_Tests.php | 6 +++--- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php index f554faf35..bef1bedeb 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php @@ -5593,6 +5593,7 @@ private function translate_select_item( WP_Parser_Node $node ): string { * * Descend the AST until we reach a textStringLiteral. If at any level * we don't have a single child node, bail out; it's not a bare literal. + * A national string, such as N'abc', is a single NCHAR_TEXT token. */ $current = $node; while ( 'textStringLiteral' !== $current->rule_name ) { @@ -5602,7 +5603,10 @@ private function translate_select_item( WP_Parser_Node $node ): string { } $current = $children[0]; } - if ( 'textStringLiteral' === $current->rule_name ) { + if ( + 'textStringLiteral' === $current->rule_name + || ( 1 === count( $current->get_children() ) && $current->has_child_token( WP_MySQL_Lexer::NCHAR_TEXT ) ) + ) { $alias = $current->get_first_child_token()->get_value(); // When the literal value contains a NULL byte, MySQL truncates the 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 d3bc9cab4..4f87a3a6f 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 @@ -10638,6 +10638,10 @@ public function testSelectColumnNames(): void { $result = $this->assertQuery( "SELECT 'abc\0def' AS `col1`" ); $this->assertSame( array( 'col1' ), array_keys( (array) $result[0] ) ); + // National string literals (no explicit alias). + $result = $this->assertQuery( "SELECT N'abc', n'it''s'" ); + $this->assertSame( array( 'abc', "it's" ), array_keys( (array) $result[0] ) ); + // Columns (no explicit alias). $result = $this->assertQuery( 'SELECT id, name FROM t' ); $this->assertSame( array( 'id', 'name' ), array_keys( (array) $result[0] ) ); 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 3fe14414d..f6c253d2d 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 @@ -1756,15 +1756,15 @@ public function testHexadecimalLiterals(): void { public function testNationalStringLiterals(): void { $this->assertQuery( - "SELECT 'abc' AS `N'abc'`", + "SELECT 'abc' AS `abc`", "SELECT N'abc'" ); $this->assertQuery( - "SELECT 'it''s' AS `n'it''s'`", + "SELECT 'it''s' AS `it's`", "SELECT n'it''s'" ); $this->assertQuery( - "SELECT '0 OR 1=1' AS `N'0 OR 1=1'`", + "SELECT '0 OR 1=1' AS `0 OR 1=1`", "SELECT N'0 OR 1=1'" ); } From 5ddbb28f93a019c1de8edda46d52ea307b65f690 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jake=C5=A1?= Date: Mon, 2 Nov 2026 16:46:14 +0100 Subject: [PATCH 11/11] Escape blob literal defaults when reconstructing tables The reconstructor wrapped the bytes of a blob literal default in quotes as they were. A quote failed to parse, and a backslash changed the value. BINARY and VARBINARY defaults are now blob literals in SQLite, so this broke recovering any such default with these bytes. --- ...qlite-information-schema-reconstructor.php | 4 +-- ...Information_Schema_Reconstructor_Tests.php | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-reconstructor.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-reconstructor.php index e1d905a2a..e00e439cd 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-reconstructor.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-reconstructor.php @@ -600,8 +600,8 @@ private function generate_column_default( string $mysql_type, ?string $default_v // BLOB literals (string). E.g.: x'1a2f', X'1A2F' // Checking the prefix is enough as SQLite doesn't allow malformed values. if ( str_starts_with( $uppercase_default_value, "X'" ) ) { - // Convert the hex string to ASCII bytes. - return "'" . pack( 'H*', substr( $default_value, 2, -1 ) ) . "'"; + // Convert the hex string to bytes, and quote them as a string literal. + return $this->quote_mysql_utf8_string_literal( pack( 'H*', substr( $default_value, 2, -1 ) ) ); } // Unquoted string literal. E.g.: abc diff --git a/packages/mysql-on-sqlite/tests/WP_SQLite_Information_Schema_Reconstructor_Tests.php b/packages/mysql-on-sqlite/tests/WP_SQLite_Information_Schema_Reconstructor_Tests.php index b09b8f40e..1d36f5b5a 100644 --- a/packages/mysql-on-sqlite/tests/WP_SQLite_Information_Schema_Reconstructor_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_SQLite_Information_Schema_Reconstructor_Tests.php @@ -504,6 +504,38 @@ public function testDefaultValueEscaping(): void { ); } + public function testBlobDefaultValueEscaping(): void { + $this->engine->get_connection()->query( + " + CREATE TABLE t ( + col1 blob DEFAULT x'69742773', -- it's + col2 blob DEFAULT x'615c6e62', -- a\\nb + col3 blob DEFAULT x'610a62', -- a, newline, b + col4 blob DEFAULT x'00ff', -- not UTF-8 + last blob -- See the comment in testDefaultValueEscaping(). + ) + " + ); + + $this->reconstructor->ensure_correct_information_schema(); + $result = $this->assertQuery( 'SHOW CREATE TABLE t' ); + $this->assertSame( + implode( + "\n", + array( + 'CREATE TABLE `t` (', + " `col1` varbinary(65535) DEFAULT 'it''s',", + " `col2` varbinary(65535) DEFAULT 'a\\\\nb',", + " `col3` varbinary(65535) DEFAULT 'a\\nb',", + ' `col4` varbinary(65535) DEFAULT 0x00FF,', + ' `last` blob DEFAULT NULL', + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci', + ) + ), + $result[0]->{'Create Table'} + ); + } + public function testInvalidDataTypeCacheDataForDecimalDefinition(): void { // Recreate the invalid database state before the following fix: // https://github.com/WordPress/sqlite-database-integration/pull/126