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 c2644d145..d92b7e9dc 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 @@ -7908,6 +7908,14 @@ private function convert_information_schema_exception( WP_SQLite_Information_Sch null, array( '42000', 1072, $driver_message ) ); + case WP_SQLite_Information_Schema_Exception::TYPE_INVALID_DEFAULT_VALUE: + $driver_message = sprintf( "Invalid default value for '%s'", $e->get_data()['column_name'] ); + return $this->new_driver_exception( + 'SQLSTATE[42000]: Syntax error or access violation: 1067 ' . $driver_message, + '42000', + null, + array( '42000', 1067, $driver_message ) + ); case WP_SQLite_Information_Schema_Exception::TYPE_CONSTRAINT_DOES_NOT_EXIST: $driver_message = sprintf( "Constraint '%s' does not exist.", $e->get_data()['name'] ); return $this->new_driver_exception( 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 b113f293b..3aa4069c7 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 @@ -1507,7 +1507,7 @@ private function record_drop_check_constraint( private function extract_column_data( string $table_name, string $column_name, WP_Parser_Node $node, int $position ): array { list ( $data_type, $column_type ) = $this->get_column_data_types( $node ); - $default = $this->get_column_default( $node, $data_type ); + $default = $this->get_column_default( $node, $data_type, $column_name ); $nullable = $this->get_column_nullable( $node ); $key = $this->get_column_key( $node ); $extra = $this->get_column_extra( $node ); @@ -2043,11 +2043,12 @@ private function get_table_comment( WP_Parser_Node $node ): string { /** * Extract column default value from the "columnDefinition" or "fieldDefinition" AST node. * - * @param WP_Parser_Node $node The "columnDefinition" or "fieldDefinition" AST node. - * @param string $data_type The column data type as stored in information schema. - * @return string|null The column default as stored in information schema. + * @param WP_Parser_Node $node The "columnDefinition" or "fieldDefinition" AST node. + * @param string $data_type The column data type as stored in information schema. + * @param string $column_name The column name. + * @return string|null The column default as stored in information schema. */ - private function get_column_default( WP_Parser_Node $node, string $data_type ): ?string { + private function get_column_default( WP_Parser_Node $node, string $data_type, string $column_name ): ?string { $default_attr = null; foreach ( $node->get_descendant_nodes( 'columnAttribute' ) as $attr ) { if ( $attr->has_child_token( WP_MySQL_Lexer::DEFAULT_SYMBOL ) ) { @@ -2081,7 +2082,7 @@ private function get_column_default( WP_Parser_Node $node, string $data_type ): // A signed number, such as "-5", has no "literal" child node. return $this->get_value( $signed_literal ); } - return $this->get_literal_default( $literal, $data_type ); + return $this->get_literal_default( $literal, $data_type, $column_name ); } // DEFAULT (expression) - MySQL 8.0.13+ supports exprWithParentheses @@ -2096,11 +2097,12 @@ private function get_column_default( WP_Parser_Node $node, string $data_type ): /** * Extract and normalize a literal default value. * - * @param WP_Parser_Node $literal The "literal" AST node. - * @param string $data_type The column data type as stored in information schema. - * @return string|null The default value as stored in information schema. + * @param WP_Parser_Node $literal The "literal" AST node. + * @param string $data_type The column data type as stored in information schema. + * @param string $column_name The column name. + * @return string|null The default value as stored in information schema. */ - private function get_literal_default( WP_Parser_Node $literal, string $data_type ): ?string { + private function get_literal_default( WP_Parser_Node $literal, string $data_type, string $column_name ): ?string { // DEFAULT NULL if ( $literal->has_child_node( 'nullLiteral' ) ) { return null; @@ -2116,7 +2118,15 @@ private function get_literal_default( WP_Parser_Node $literal, string $data_type $default = $this->get_value( $literal ); if ( 'bit' === $data_type ) { - return $this->get_bit_default( $default ) ?? $default; + /* + * @TODO: Validate and normalize defaults from their AST and the full + * column definition before storing them in the information schema. + */ + $bit_default = $this->get_bit_default( $default ); + if ( null === $bit_default ) { + throw WP_SQLite_Information_Schema_Exception::invalid_default_value( $column_name ); + } + return $bit_default; } /* @@ -2142,15 +2152,26 @@ private function get_literal_default( WP_Parser_Node $literal, string $data_type private function get_bit_default( string $default_value ): ?string { $value = strtolower( $default_value ); + // An empty string coerces to zero. + if ( '' === $value ) { + return "b'0'"; + } + // Bit literal, e.g. b'101' or 0b101. - if ( str_starts_with( $value, "b'" ) || str_starts_with( $value, '0b' ) ) { - $bits = ltrim( rtrim( substr( $value, 2 ), "'" ), '0' ); + if ( + preg_match( "/\Ab'([01]*)'\z/", $value, $matches ) + || preg_match( '/\A0b([01]+)\z/', $value, $matches ) + ) { + $bits = ltrim( $matches[1], '0' ); return "b'" . ( '' === $bits ? '0' : $bits ) . "'"; } // Hex literal, e.g. x'05' or 0x05. - if ( str_starts_with( $value, "x'" ) || str_starts_with( $value, '0x' ) ) { - return "b'" . decbin( hexdec( rtrim( substr( $value, 2 ), "'" ) ) ) . "'"; + if ( + preg_match( "/\Ax'([0-9a-f]*)'\z/", $value, $matches ) + || preg_match( '/\A0x([0-9a-f]+)\z/', $value, $matches ) + ) { + return "b'" . decbin( hexdec( $matches[1] ) ) . "'"; } // Decimal, e.g. 5, or a numeric string literal such as '0'. diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-exception.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-exception.php index 7b7e62920..c88b50bda 100644 --- a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-exception.php +++ b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-exception.php @@ -16,6 +16,7 @@ class WP_SQLite_Information_Schema_Exception extends Exception { const TYPE_KEY_COLUMN_NOT_FOUND = 'key-column-not-found'; const TYPE_CONSTRAINT_DOES_NOT_EXIST = 'constraint-does-not-exist'; const TYPE_MULTIPLE_CONSTRAINTS_WITH_NAME = 'multiple-constraints-with-name'; + const TYPE_INVALID_DEFAULT_VALUE = 'invalid-default-value'; /** * The exception type. @@ -151,4 +152,18 @@ public static function multiple_constraints_with_name( string $name ): WP_SQLite array( 'name' => $name ) ); } + + /** + * Create an invalid default value exception. + * + * @param string $column_name The name of the affected column. + * @return self The exception instance. + */ + public static function invalid_default_value( string $column_name ): WP_SQLite_Information_Schema_Exception { + return new self( + self::TYPE_INVALID_DEFAULT_VALUE, + sprintf( "Invalid default value for '%s'.", $column_name ), + array( 'column_name' => $column_name ) + ); + } } 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 e60034f2d..1681fe15f 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 @@ -704,6 +704,7 @@ public function testShowCreateTableWithBitDefaultValues(): void { $this->assertQuery( "CREATE TABLE _tmp_bit_defaults ( id INT DEFAULT 0, + empty_string BIT(1) DEFAULT '', quoted_zero BIT(1) DEFAULT '0', integer_five BIT(4) DEFAULT 5, bit_literal_five BIT(4) DEFAULT b'0101', @@ -718,6 +719,7 @@ public function testShowCreateTableWithBitDefaultValues(): void { $this->assertQuery( 'SHOW CREATE TABLE _tmp_bit_defaults;' ); $results = $this->last_result; $create_table = $results[0]->{'Create Table'}; + $this->assertStringContainsString( "`empty_string` bit(1) DEFAULT b'0'", $create_table ); $this->assertStringContainsString( "`quoted_zero` bit(1) DEFAULT b'0'", $create_table ); $this->assertStringContainsString( "`integer_five` bit(4) DEFAULT b'101'", $create_table ); $this->assertStringContainsString( "`bit_literal_five` bit(4) DEFAULT b'101'", $create_table ); @@ -734,6 +736,7 @@ public function testShowCreateTableWithBitDefaultValues(): void { array( (object) array( 'id' => '1', + 'empty_string' => '0', 'quoted_zero' => '0', 'integer_five' => '5', 'bit_literal_five' => '5', @@ -748,6 +751,64 @@ public function testShowCreateTableWithBitDefaultValues(): void { ); } + /** + * @dataProvider invalidBitDefaultQueries + */ + public function testRejectsInvalidBitDefaultValues( string $query, string $column_name ): void { + $this->assertQuery( 'CREATE TABLE _tmp_invalid_bit_default (id INT, injected BIT(1))' ); + + $exception = null; + try { + $this->query( $query ); + } catch ( WP_MySQL_On_SQLite_Exception $e ) { + $exception = $e; + } + + $driver_message = sprintf( "Invalid default value for '%s'", $column_name ); + $this->assertInstanceOf( WP_MySQL_On_SQLite_Exception::class, $exception ); + $this->assertSame( + 'SQLSTATE[42000]: Syntax error or access violation: 1067 ' . $driver_message, + $exception->getMessage() + ); + $this->assertSame( '42000', $exception->getCode() ); + $this->assertSame( array( '42000', 1067, $driver_message ), $exception->errorInfo ); + } + + public static function invalidBitDefaultQueries(): array { + $payload = "0,\n PRIMARY KEY (`id`)\n); DROP TABLE users; --"; + + return array( + 'CREATE TABLE' => array( + "CREATE TABLE _tmp_created_invalid_bit_default (injected BIT(1) DEFAULT '$payload')", + 'injected', + ), + 'ALTER TABLE ADD' => array( + "ALTER TABLE _tmp_invalid_bit_default ADD COLUMN injected_2 BIT(1) DEFAULT '$payload'", + 'injected_2', + ), + 'ALTER TABLE MODIFY' => array( + "ALTER TABLE _tmp_invalid_bit_default MODIFY COLUMN injected BIT(1) DEFAULT '$payload'", + 'injected', + ), + 'bit-like string' => array( + "ALTER TABLE _tmp_invalid_bit_default MODIFY COLUMN injected BIT(1) DEFAULT 'b''0''; DROP TABLE users; -- '", + 'injected', + ), + '0b-like string' => array( + "ALTER TABLE _tmp_invalid_bit_default MODIFY COLUMN injected BIT(1) DEFAULT '0b0''; DROP TABLE users; -- '", + 'injected', + ), + 'hex-like string' => array( + "ALTER TABLE _tmp_invalid_bit_default MODIFY COLUMN injected BIT(1) DEFAULT 'x''05''; DROP TABLE users; -- '", + 'injected', + ), + '0x-like string' => array( + "ALTER TABLE _tmp_invalid_bit_default MODIFY COLUMN injected BIT(1) DEFAULT '0x05; DROP TABLE users; -- '", + 'injected', + ), + ); + } + public function testUpdateWithBitColumnDefaultValue(): void { $this->assertQuery( "CREATE TABLE _tmp_bit_defaults (