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 ff338d712..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 @@ -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(); /* @@ -5591,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 ) { @@ -5600,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 @@ -7413,7 +7419,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 ); } @@ -7899,6 +7909,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. + $bytes = $this->get_binary_column_default_bytes( $column ); + if ( null !== $bytes ) { + return sprintf( "x'%s'", bin2hex( $bytes ) ); + } + return $this->quote_sqlite_value( $column['COLUMN_DEFAULT'] ); } @@ -7914,9 +7930,38 @@ private function format_mysql_column_default( array $column ): string { return $column['COLUMN_DEFAULT']; } + // Like MySQL, keep the hex literal for bytes that aren't valid utf8mb3, + // i.e., UTF-8 with no characters above U+FFFF. + $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 ) + : $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-configurator.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-configurator.php index 2a37b6816..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(); + } } /** @@ -484,7 +489,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 +507,95 @@ 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 - ); + /** + * 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. + */ + 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 ), + count( $alter_list ) > 0 ? implode( ', ', $alter_list ) : 'FORCE' + ) + ); + } 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 + ); + } } } 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..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 @@ -878,6 +878,33 @@ 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 default from a hex or bit literal. + * + * 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 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 { + 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 +2301,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,19 +2346,57 @@ 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. * - Numeric defaults normalized, e.g. 1.0 to 1 or 1e3 to 1000. */ 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. * @@ -3310,6 +3379,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/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_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 d987cb39e..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 @@ -165,6 +165,54 @@ 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' ) ); + + $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` ( + `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');" @@ -772,6 +820,254 @@ 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' ), + '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)', "''", "''", '', '' ), + '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' ), + '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 ); + } + + /** + * @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' ), + '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' ), + ); + } + + 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. + * + * @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 ) ); + + // 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'} + ); + $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 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 ); + + // 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(); + + $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 ( @@ -9513,6 +9809,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 ); @@ -10317,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 d0d522db4..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 @@ -1754,6 +1754,21 @@ public function testHexadecimalLiterals(): void { ); } + public function testNationalStringLiterals(): void { + $this->assertQuery( + "SELECT 'abc' AS `abc`", + "SELECT N'abc'" + ); + $this->assertQuery( + "SELECT 'it''s' AS `it's`", + "SELECT n'it''s'" + ); + $this->assertQuery( + "SELECT '0 OR 1=1' AS `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`", 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 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() );