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 57d72f70c..6c3af4b25 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 @@ -3012,6 +3012,10 @@ private function execute_alter_table_statement( WP_Parser_Node $node ): void { array( $this->get_saved_db_name( $database ), $table_name ) )->fetchAll( PDO::FETCH_ASSOC ); + if ( 0 === count( $column_names ) ) { + throw $this->new_table_not_found_exception( sprintf( '%s.%s', $database, $table_name ) ); + } + // Track column renames and removals. $column_map = array_combine( array_column( $column_names, 'COLUMN_NAME_LOWERCASE' ), @@ -8110,6 +8114,14 @@ private function convert_information_schema_exception( WP_SQLite_Information_Sch null, array( '42S21', 1060, $driver_message ) ); + case WP_SQLite_Information_Schema_Exception::TYPE_COLUMN_NOT_FOUND: + $driver_message = sprintf( "Unknown column '%s' in '%s'", $e->get_data()['column_name'], $e->get_data()['table_name'] ); + return $this->new_driver_exception( + 'SQLSTATE[42S22]: Column not found: 1054 ' . $driver_message, + '42S22', + null, + array( '42S22', 1054, $driver_message ) + ); case WP_SQLite_Information_Schema_Exception::TYPE_DUPLICATE_KEY_NAME: $driver_message = sprintf( "Duplicate key name '%s'", $e->get_data()['key_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 9cd5c3dfd..0b90df108 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 @@ -765,6 +765,16 @@ public function record_alter_table( WP_Parser_Node $node ): void { continue; } } + + // ALTER [COLUMN] + if ( WP_MySQL_Lexer::ALTER_SYMBOL === $first_token->id ) { + // SET DEFAULT and DROP DEFAULT + $column_ref = $action->get_first_child_node( 'fieldIdentifier' ); + if ( null !== $column_ref && $action->has_child_token( WP_MySQL_Lexer::DEFAULT_SYMBOL ) ) { + $name = $this->get_value( $column_ref ); + $this->record_alter_column_default( $table_is_temporary, $table_name, $name, $action ); + } + } } } @@ -1002,6 +1012,103 @@ private function record_modify_column( $this->record_change_column( $table_is_temporary, $table_name, $column_name, $column_name, $node ); } + /** + * Record SET DEFAULT or DROP DEFAULT data in the information schema. + * + * @param bool $table_is_temporary Whether the table is temporary. + * @param string $table_name The table name. + * @param string $column_name The column name. + * @param WP_Parser_Node $node The "alterListItem" AST node. + */ + private function record_alter_column_default( + bool $table_is_temporary, + string $table_name, + string $column_name, + WP_Parser_Node $node + ): void { + $columns_table = $this->get_table_name( $table_is_temporary, 'columns' ); + $column = $this->connection->query( + 'SELECT DATA_TYPE, IS_NULLABLE, EXTRA FROM ' . $this->connection->quote_identifier( $columns_table ) . ' + WHERE table_schema = ? AND table_name = ? AND column_name = ?', + array( self::SAVED_DATABASE_NAME, $table_name, $column_name ) + )->fetch( PDO::FETCH_ASSOC ); + if ( false === $column ) { + throw WP_SQLite_Information_Schema_Exception::column_not_found( $column_name, $table_name ); + } + + /* + * MySQL accepts these changes but hides them from its schema. + * These are known MySQL bugs that are not worth replicating. + * + * @see https://bugs.mysql.com/bug.php?id=111702 + * @see https://bugs.mysql.com/bug.php?id=80222 + */ + if ( str_contains( $column['EXTRA'], 'auto_increment' ) ) { + throw new Exception( 'SET DEFAULT and DROP DEFAULT on AUTO_INCREMENT columns are not supported.' ); + } + + $default = null; + $extra = trim( str_replace( 'DEFAULT_GENERATED', '', $column['EXTRA'] ) ); + if ( $node->has_child_token( WP_MySQL_Lexer::SET_SYMBOL ) ) { + $expression = $node->get_first_child_node( 'exprWithParentheses' ); + $literal = $expression ? $this->get_parenthesized_default_literal( $expression ) : null; + $default = $literal + ? $this->get_literal_default( $literal, $column['DATA_TYPE'], $column_name ) + : $this->get_default_value( $node, $column['DATA_TYPE'], $column_name ); + if ( null === $default && 'NO' === $column['IS_NULLABLE'] ) { + throw WP_SQLite_Information_Schema_Exception::invalid_default_value( $column_name ); + } + if ( $expression && ! $literal ) { + $extra = trim( 'DEFAULT_GENERATED ' . $extra ); + } + } + + $this->update_values( + $columns_table, + array( + 'column_default' => $default, + 'extra' => $extra, + ), + array( + 'table_schema' => self::SAVED_DATABASE_NAME, + 'table_name' => $table_name, + 'column_name' => $column_name, + ) + ); + } + + /** + * Find a literal wrapped only in parentheses or unary plus operators. + * + * @param WP_Parser_Node $node The default expression. + * @return WP_Parser_Node|null The literal, or null for other expressions. + */ + private function get_parenthesized_default_literal( WP_Parser_Node $node ): ?WP_Parser_Node { + while ( 'literal' !== $node->rule_name ) { + $children = $node->get_children(); + if ( + 3 === count( $children ) + && $children[0] instanceof WP_Parser_Token + && WP_MySQL_Lexer::OPEN_PAR_SYMBOL === $children[0]->id + && $children[2] instanceof WP_Parser_Token + && WP_MySQL_Lexer::CLOSE_PAR_SYMBOL === $children[2]->id + ) { + $children = array( $children[1] ); + } elseif ( + 2 === count( $children ) + && $children[0] instanceof WP_Parser_Token + && WP_MySQL_Lexer::PLUS_OPERATOR === $children[0]->id + ) { + $children = array( $children[1] ); + } + if ( 1 !== count( $children ) || ! $children[0] instanceof WP_Parser_Node ) { + return null; + } + $node = $children[0]; + } + return $node; + } + /** * Record DROP COLUMN data in the information schema. * @@ -2079,34 +2186,54 @@ private function get_column_default( WP_Parser_Node $node, string $data_type, st if ( null === $default_attr ) { return null; } + return $this->get_default_value( $default_attr, $data_type, $column_name ); + } + /** + * Extract a default value from a column attribute or ALTER COLUMN action. + * + * @param WP_Parser_Node $node The "columnAttribute" or "alterListItem" 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_default_value( WP_Parser_Node $node, string $data_type, string $column_name ): ?string { /* * [GRAMMAR] - * DEFAULT_SYMBOL ( - * signedLiteral - * | NOW_SYMBOL timeFunctionParameters? - * | {serverVersion >= 80013}? exprWithParentheses - * ) + * columnAttribute: + * DEFAULT_SYMBOL ( + * signedLiteral + * | NOW_SYMBOL timeFunctionParameters? + * | {serverVersion >= 80013}? exprWithParentheses + * ) + * + * alterListItem: + * ALTER_SYMBOL COLUMN_SYMBOL? fieldIdentifier SET_SYMBOL DEFAULT_SYMBOL ( + * {serverVersion >= 80014}? exprWithParentheses + * | signedLiteral + * ) */ // DEFAULT NOW() - if ( $default_attr->has_child_token( WP_MySQL_Lexer::NOW_SYMBOL ) ) { + if ( $node->has_child_token( WP_MySQL_Lexer::NOW_SYMBOL ) ) { return 'CURRENT_TIMESTAMP'; } // DEFAULT signedLiteral - $signed_literal = $default_attr->get_first_child_node( 'signedLiteral' ); + $signed_literal = $node->get_first_child_node( 'signedLiteral' ); if ( $signed_literal ) { $literal = $signed_literal->get_first_child_node( 'literal' ); if ( null === $literal ) { // A signed number, such as "-5", has no "literal" child node. - return $this->get_value( $signed_literal ); + // Like MySQL, drop the unary plus, e.g., "+5" becomes "5". + $number = ltrim( $this->get_value( $signed_literal ), '+' ); + return $this->normalize_literal_default( $number, $data_type, $column_name ); } return $this->get_literal_default( $literal, $data_type, $column_name ); } // DEFAULT (expression) - MySQL 8.0.13+ supports exprWithParentheses - $expr_with_parens = $default_attr->get_first_child_node( 'exprWithParentheses' ); + $expr_with_parens = $node->get_first_child_node( 'exprWithParentheses' ); if ( $expr_with_parens ) { return $this->serialize_mysql_expression( $expr_with_parens ); } @@ -2135,14 +2262,24 @@ private function get_literal_default( WP_Parser_Node $literal, string $data_type return 'bit' === $data_type ? "b'{$bool_value}'" : $bool_value; } - $default = $this->get_value( $literal ); + return $this->normalize_literal_default( $this->get_value( $literal ), $data_type, $column_name ); + } + /** + * Normalize a literal default value for the column data type. + * + * @param string $default_value The literal default value. + * @param string $data_type The column data type as stored in information schema. + * @param string $column_name The column name. + * @return string The default value as stored in information schema. + */ + private function normalize_literal_default( string $default_value, string $data_type, string $column_name ): string { if ( 'bit' === $data_type ) { /* * @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 ); + $bit_default = $this->get_bit_default( $default_value ); if ( null === $bit_default ) { throw WP_SQLite_Information_Schema_Exception::invalid_default_value( $column_name ); } @@ -2158,13 +2295,13 @@ private function get_literal_default( WP_Parser_Node $literal, string $data_type * - The charset introducer dropped from text-literal defaults. * - Numeric defaults normalized, e.g. 1.0 to 1 or 1e3 to 1000. */ - return $default; + return $default_value; } /** * Normalize a BIT default value to a MySQL bit literal. * - * The value may be a bit literal, a hex literal, or a decimal. + * The value may be a bit literal, a hex literal, or a non-negative decimal. * * @param string $default_value The BIT default value. * @return string|null The default as a bit literal, e.g. "b'101'", or null when not a bit value. @@ -2194,8 +2331,8 @@ private function get_bit_default( string $default_value ): ?string { return "b'" . decbin( hexdec( $matches[1] ) ) . "'"; } - // Decimal, e.g. 5, or a numeric string literal such as '0'. - if ( is_numeric( $value ) ) { + // Non-negative decimal, e.g. 5, or a numeric string literal such as '0'. + if ( is_numeric( $value ) && $value >= 0 ) { return "b'" . decbin( (int) $value ) . "'"; } 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 c88b50bda..9dbe567b3 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 @@ -12,6 +12,7 @@ class WP_SQLite_Information_Schema_Exception extends Exception { // Information schema exception types. const TYPE_DUPLICATE_TABLE_NAME = 'duplicate-table-name'; const TYPE_DUPLICATE_COLUMN_NAME = 'duplicate-column-name'; + const TYPE_COLUMN_NOT_FOUND = 'column-not-found'; const TYPE_DUPLICATE_KEY_NAME = 'duplicate-key-name'; const TYPE_KEY_COLUMN_NOT_FOUND = 'key-column-not-found'; const TYPE_CONSTRAINT_DOES_NOT_EXIST = 'constraint-does-not-exist'; @@ -97,6 +98,24 @@ public static function duplicate_column_name( string $column_name ): WP_SQLite_I ); } + /** + * Create a column not found exception. + * + * @param string $column_name The name of the affected column. + * @param string $table_name The name of the affected table. + * @return self The exception instance. + */ + public static function column_not_found( string $column_name, string $table_name ): WP_SQLite_Information_Schema_Exception { + return new self( + self::TYPE_COLUMN_NOT_FOUND, + sprintf( "Unknown column '%s' in '%s'.", $column_name, $table_name ), + array( + 'column_name' => $column_name, + 'table_name' => $table_name, + ) + ); + } + /** * Create a duplicate key name exception. * diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Metadata_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Metadata_Tests.php index 1215dd288..727e4c76a 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Metadata_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Metadata_Tests.php @@ -2413,7 +2413,7 @@ public function testInformationSchemaAlterTableDropMissingConstraint(): void { $this->expectException( WP_MySQL_On_SQLite_Exception::class ); $this->expectExceptionMessage( "SQLSTATE[HY000]: General error: 3940 Constraint 'cnst' does not exist." ); $this->expectExceptionCode( 'HY000' ); - $this->assertQuery( 'ALTER TABLE t2 DROP CONSTRAINT cnst' ); + $this->assertQuery( 'ALTER TABLE t1 DROP CONSTRAINT cnst' ); } public function testInformationSchemaAlterTableDropConstraintWithAmbiguousName(): void { 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 d404a0b44..4ec8f3aa5 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 @@ -711,6 +711,7 @@ public function testShowCreateTableWithBitDefaultValues(): void { empty_string BIT(1) DEFAULT '', quoted_zero BIT(1) DEFAULT '0', integer_five BIT(4) DEFAULT 5, + signed_five BIT(4) DEFAULT +5, bit_literal_five BIT(4) DEFAULT b'0101', binary_number_five BIT(4) DEFAULT 0b0101, hex_literal_five BIT(4) DEFAULT x'05', @@ -726,6 +727,7 @@ public function testShowCreateTableWithBitDefaultValues(): void { $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( "`signed_five` bit(4) DEFAULT b'101'", $create_table ); $this->assertStringContainsString( "`bit_literal_five` bit(4) DEFAULT b'101'", $create_table ); $this->assertStringContainsString( "`binary_number_five` bit(4) DEFAULT b'101'", $create_table ); $this->assertStringContainsString( "`hex_literal_five` bit(4) DEFAULT b'101'", $create_table ); @@ -743,6 +745,7 @@ public function testShowCreateTableWithBitDefaultValues(): void { 'empty_string' => '0', 'quoted_zero' => '0', 'integer_five' => '5', + 'signed_five' => '5', 'bit_literal_five' => '5', 'binary_number_five' => '5', 'hex_literal_five' => '5', @@ -794,6 +797,10 @@ public static function invalidBitDefaultQueries(): array { "ALTER TABLE _tmp_invalid_bit_default MODIFY COLUMN injected BIT(1) DEFAULT '$payload'", 'injected', ), + 'ALTER COLUMN' => array( + "ALTER TABLE _tmp_invalid_bit_default ALTER COLUMN injected SET 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', @@ -810,6 +817,42 @@ public static function invalidBitDefaultQueries(): array { "ALTER TABLE _tmp_invalid_bit_default MODIFY COLUMN injected BIT(1) DEFAULT '0x05; DROP TABLE users; -- '", 'injected', ), + 'negative number' => array( + 'ALTER TABLE _tmp_invalid_bit_default MODIFY COLUMN injected BIT(1) DEFAULT -1', + 'injected', + ), + 'negative string' => array( + "ALTER TABLE _tmp_invalid_bit_default MODIFY COLUMN injected BIT(1) DEFAULT '-1'", + 'injected', + ), + 'negative SET' => array( + 'ALTER TABLE _tmp_invalid_bit_default ALTER COLUMN injected SET DEFAULT -1', + 'injected', + ), + 'CREATE fraction' => array( + 'CREATE TABLE _tmp_created_invalid_bit_default (injected BIT(1) DEFAULT -0.1)', + 'injected', + ), + 'ADD fraction' => array( + 'ALTER TABLE _tmp_invalid_bit_default ADD COLUMN injected_2 BIT(1) DEFAULT -0.1', + 'injected_2', + ), + 'negative fraction' => array( + 'ALTER TABLE _tmp_invalid_bit_default MODIFY COLUMN injected BIT(1) DEFAULT -0.1', + 'injected', + ), + 'fraction string' => array( + "ALTER TABLE _tmp_invalid_bit_default MODIFY COLUMN injected BIT(1) DEFAULT '-0.1'", + 'injected', + ), + 'fraction SET' => array( + 'ALTER TABLE _tmp_invalid_bit_default ALTER COLUMN injected SET DEFAULT -0.1', + 'injected', + ), + 'SET fraction text' => array( + "ALTER TABLE _tmp_invalid_bit_default ALTER COLUMN injected SET DEFAULT '-0.1'", + 'injected', + ), ); } @@ -2546,6 +2589,367 @@ public function testAlterTableAddIndexWithOrder(): void { ); } + /** + * @dataProvider alterColumnDefaultTableVariants + */ + public function testAlterColumnDefaults( string $temporary, string $column_keyword ): void { + $this->assertQuery( "CREATE $temporary TABLE t (id INT, n INT NOT NULL DEFAULT 1, v VARCHAR(10) DEFAULT 'x')" ); + $this->assertQuery( 'INSERT INTO t (id) VALUES (1)' ); + $this->assertQuery( "ALTER TABLE t ALTER $column_keyword n SET DEFAULT 5" ); + $this->assertQuery( "ALTER TABLE t ALTER $column_keyword v DROP DEFAULT" ); + $this->assertQuery( 'INSERT INTO t (id) VALUES (2)' ); + + $result = $this->assertQuery( 'SELECT * FROM t ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'n' => '1', + 'v' => 'x', + ), + (object) array( + 'id' => '2', + 'n' => '5', + 'v' => null, + ), + ), + $result + ); + + $result = $this->assertQuery( 'SHOW CREATE TABLE t' ); + $this->assertStringContainsString( "`n` int NOT NULL DEFAULT '5'", $result[0]->{'Create Table'} ); + $this->assertStringContainsString( '`v` varchar(10) DEFAULT NULL', $result[0]->{'Create Table'} ); + + $result = $this->assertQuery( 'SHOW COLUMNS FROM t' ); + $this->assertSame( '5', $result[1]->Default ); + $this->assertNull( $result[2]->Default ); + + $this->assertQuery( "ALTER TABLE t ALTER $column_keyword n DROP DEFAULT" ); + $result = $this->assertQuery( 'SHOW CREATE TABLE t' ); + $this->assertStringContainsString( "`n` int NOT NULL,\n", $result[0]->{'Create Table'} ); + $this->assertQueryError( + 'INSERT INTO t (id) VALUES (3)', + 'SQLSTATE[23000]: Integrity constraint violation: 19 NOT NULL constraint failed: t.n' + ); + } + + public static function alterColumnDefaultTableVariants(): array { + return array( + 'permanent with COLUMN' => array( '', 'COLUMN' ), + 'permanent without COLUMN' => array( '', '' ), + 'temporary with COLUMN' => array( 'TEMPORARY', 'COLUMN' ), + 'temporary without COLUMN' => array( 'TEMPORARY', '' ), + ); + } + + /** + * @dataProvider alterColumnDefaultValues + */ + public function testAlterColumnDefaultValues( + string $type, + string $default_clause, + ?string $stored_default, + ?string $inserted_value, + string $shown_default, + string $extra = '' + ): void { + $this->assertQuery( "CREATE TABLE t (id INT, value $type COMMENT 'keep')" ); + $metadata_query = "SELECT * FROM information_schema.columns WHERE table_schema = 'wp' AND table_name = 't' AND column_name = 'value'"; + $original = $this->assertQuery( $metadata_query )[0]; + + $this->assertQuery( "ALTER TABLE t ALTER COLUMN value SET DEFAULT $default_clause" ); + $expected = clone $original; + $expected->COLUMN_DEFAULT = $stored_default; + $expected->EXTRA = $extra; + $this->assertEquals( array( $expected ), $this->assertQuery( $metadata_query ) ); + + $result = $this->assertQuery( 'SHOW CREATE TABLE t' ); + $this->assertStringContainsString( "`value` $type DEFAULT $shown_default COMMENT 'keep'", $result[0]->{'Create Table'} ); + $this->assertQuery( 'INSERT INTO t (id) VALUES (1)' ); + $result = $this->assertQuery( 'SELECT value FROM t WHERE id = 1' ); + $this->assertSame( $inserted_value, $result[0]->value ); + + $this->assertQuery( 'ALTER TABLE t ALTER value DROP DEFAULT' ); + $this->assertEquals( array( $original ), $this->assertQuery( $metadata_query ) ); + $this->assertQuery( 'INSERT INTO t (id) VALUES (2)' ); + $result = $this->assertQuery( 'SELECT value FROM t ORDER BY id' ); + $this->assertSame( $inserted_value, $result[0]->value ); + $this->assertNull( $result[1]->value ); + } + + public static function alterColumnDefaultValues(): array { + return array( + 'integer' => array( 'int', '5', '5', '5', "'5'" ), + 'wrapped integer' => array( 'int', '((+5))', '5', '5', "'5'" ), + 'negative integer' => array( 'int', '-5', '-5', '-5', "'-5'" ), + 'positive integer' => array( 'int', '+5', '5', '5', "'5'" ), + 'decimal' => array( 'decimal(5,2)', '-1.25', '-1.25', '-1.25', "'-1.25'" ), + 'true' => array( 'int', 'TRUE', '1', '1', "'1'" ), + 'false' => array( 'int', 'FALSE', '0', '0', "'0'" ), + 'null' => array( 'int', 'NULL', null, null, 'NULL' ), + 'empty string' => array( 'varchar(50)', "''", '', '', "''" ), + 'quoted string' => array( 'varchar(50)', "'it''s a test'", "it's a test", "it's a test", "'it''s a test'" ), + 'unicode string' => array( 'varchar(50)', "'Příliš 🙂'", 'Příliš 🙂', 'Příliš 🙂', "'Příliš 🙂'" ), + 'bit number' => array( 'bit(4)', '5', "b'101'", '5', "b'101'" ), + 'bit signed number' => array( 'bit(4)', '+5', "b'101'", '5', "b'101'" ), + 'bit literal' => array( 'bit(4)', "b'0101'", "b'101'", '5', "b'101'" ), + 'bit hex literal' => array( 'bit(4)', "x'05'", "b'101'", '5', "b'101'" ), + 'bit true' => array( 'bit(4)', 'TRUE', "b'1'", '1', "b'1'" ), + 'bit false' => array( 'bit(4)', 'FALSE', "b'0'", '0', "b'0'" ), + 'datetime' => array( 'datetime', "'2026-10-22 12:34:56'", '2026-10-22 12:34:56', '2026-10-22 12:34:56', "'2026-10-22 12:34:56'" ), + 'numeric expression' => array( 'int', '(1 + 2)', '1 + 2', '3', '(1 + 2)', 'DEFAULT_GENERATED' ), + 'string expression' => array( 'varchar(50)', "(CONCAT('a', 'b'))", "CONCAT('a' , 'b')", 'ab', "(CONCAT('a' , 'b'))", 'DEFAULT_GENERATED' ), + ); + } + + public function testAlterColumnDefaultReplacesExpressionAndPreservesExtra(): void { + $this->assertQuery( 'CREATE TABLE t (id INT, value TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP)' ); + $this->assertQuery( "ALTER TABLE t ALTER value SET DEFAULT '2000-01-01 00:00:00'" ); + $result = $this->assertQuery( 'SHOW COLUMNS FROM t' ); + $this->assertSame( '2000-01-01 00:00:00', $result[1]->Default ); + $this->assertSame( 'on update CURRENT_TIMESTAMP', $result[1]->Extra ); + + $this->assertQuery( 'ALTER TABLE t ALTER value SET DEFAULT (NOW())' ); + $result = $this->assertQuery( 'SHOW COLUMNS FROM t' ); + $this->assertSame( 'NOW()', $result[1]->Default ); + $this->assertSame( 'DEFAULT_GENERATED on update CURRENT_TIMESTAMP', $result[1]->Extra ); + $this->assertQuery( 'INSERT INTO t (id) VALUES (1)' ); + $result = $this->assertQuery( 'SELECT value FROM t' ); + $this->assertRegExp( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $result[0]->value ); + $this->assertQuery( 'ALTER TABLE t ALTER value DROP DEFAULT' ); + $result = $this->assertQuery( 'SHOW COLUMNS FROM t' ); + $this->assertNull( $result[1]->Default ); + $this->assertSame( 'on update CURRENT_TIMESTAMP', $result[1]->Extra ); + $this->assertQuery( 'INSERT INTO t (id) VALUES (2)' ); + $result = $this->assertQuery( 'SELECT value FROM t WHERE id = 2' ); + $this->assertNull( $result[0]->value ); + $this->assertQuery( 'UPDATE t SET id = 3 WHERE id = 2' ); + $result = $this->assertQuery( 'SELECT value FROM t WHERE id = 3' ); + $this->assertRegExp( '/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/', $result[0]->value ); + } + + public function testAlterColumnDefaultsWithMultipleActions(): void { + $this->assertQuery( "CREATE TABLE t (id INT, n INT DEFAULT 1, v VARCHAR(10) DEFAULT 'x')" ); + $this->assertQuery( 'INSERT INTO t (id) VALUES (1)' ); + $this->assertQuery( 'ALTER TABLE t ALTER COLUMN N SET DEFAULT 5, ALTER v DROP DEFAULT, ALTER id SET VISIBLE, ADD added INT DEFAULT 9' ); + $this->assertQuery( 'INSERT INTO t (id) VALUES (2)' ); + $result = $this->assertQuery( 'SELECT * FROM t ORDER BY id' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'n' => '1', + 'v' => 'x', + 'added' => '9', + ), + (object) array( + 'id' => '2', + 'n' => '5', + 'v' => null, + 'added' => '9', + ), + ), + $result + ); + } + + public function testAlterColumnDefaultsOnShadowingTemporaryTable(): void { + $this->assertQuery( 'CREATE TABLE t (id INT, value INT DEFAULT 1)' ); + $this->assertQuery( 'CREATE TEMPORARY TABLE t (id INT, value INT DEFAULT 2)' ); + $this->assertQuery( 'ALTER TABLE t ALTER value SET DEFAULT 3' ); + $this->assertQuery( 'INSERT INTO t (id) VALUES (1)' ); + $result = $this->assertQuery( 'SELECT value FROM t' ); + $this->assertSame( '3', $result[0]->value ); + $this->assertQuery( 'DROP TEMPORARY TABLE t' ); + $this->assertQuery( 'INSERT INTO t (id) VALUES (1)' ); + $result = $this->assertQuery( 'SELECT value FROM t' ); + $this->assertSame( '1', $result[0]->value ); + } + + public function testAlterColumnDefaultWithQuotedIdentifiers(): void { + $this->assertQuery( 'CREATE TABLE `table``name` (`column``name` INT DEFAULT 1, id INT)' ); + $this->assertQuery( 'ALTER TABLE `table``name` ALTER `column``name` SET DEFAULT NULL' ); + $this->assertQuery( 'INSERT INTO `table``name` (id) VALUES (1)' ); + $result = $this->assertQuery( 'SELECT * FROM `table``name`' ); + $this->assertNull( $result[0]->{'column`name'} ); + + $this->assertQuery( 'ALTER TABLE `table``name` ALTER `column``name` SET DEFAULT (1 + 2)' ); + $this->assertQuery( 'ALTER TABLE `table``name` ALTER `column``name` SET DEFAULT (3 + 4)' ); + $result = $this->assertQuery( 'SHOW COLUMNS FROM `table``name`' ); + $this->assertSame( '3 + 4', $result[0]->Default ); + $this->assertSame( 'DEFAULT_GENERATED', $result[0]->Extra ); + $this->assertQuery( 'INSERT INTO `table``name` (id) VALUES (2)' ); + $result = $this->assertQuery( 'SELECT * FROM `table``name` WHERE `column``name` IS NOT NULL' ); + $this->assertSame( '7', $result[0]->{'column`name'} ); + } + + /** + * @dataProvider alterColumnDefaultNullValues + */ + public function testAlterColumnDefaultRejectsNullOnNotNullColumn( string $default_clause ): void { + $this->assertQuery( 'CREATE TABLE t (id INT, value INT NOT NULL DEFAULT 1)' ); + $original = $this->assertQuery( 'SHOW CREATE TABLE t' ); + $this->assertQueryError( + "ALTER TABLE t ALTER id SET DEFAULT 5, ALTER value SET DEFAULT $default_clause", + "SQLSTATE[42000]: Syntax error or access violation: 1067 Invalid default value for 'value'" + ); + $this->assertEquals( $original, $this->assertQuery( 'SHOW CREATE TABLE t' ) ); + $this->assertQuery( 'INSERT INTO t (id) VALUES (1)' ); + $this->assertSame( '1', $this->assertQuery( 'SELECT value FROM t' )[0]->value ); + + $this->assertQuery( "ALTER TABLE t ALTER id SET DEFAULT $default_clause" ); + $this->assertNull( $this->assertQuery( 'SHOW COLUMNS FROM t' )[0]->Default ); + } + + public static function alterColumnDefaultNullValues(): array { + return array( array( 'NULL' ), array( '(NULL)' ), array( '((NULL))' ), array( '(+NULL)' ) ); + } + + /** + * @dataProvider alterAutoIncrementDefaultActions + */ + public function testAlterColumnDefaultRejectsAutoIncrementColumn( string $action, string $temporary ): void { + $this->assertQuery( "CREATE $temporary TABLE t (id INT PRIMARY KEY AUTO_INCREMENT, value INT DEFAULT 1)" ); + $this->assertQuery( 'INSERT INTO t (value) VALUES (1)' ); + $columns = $this->assertQuery( 'SHOW COLUMNS FROM t' ); + $create_table = $this->assertQuery( 'SHOW CREATE TABLE t' ); + + $this->assertQueryError( + "ALTER TABLE t ALTER value SET DEFAULT 5, ALTER id $action", + 'SET DEFAULT and DROP DEFAULT on AUTO_INCREMENT columns are not supported.' + ); + $this->assertEquals( $columns, $this->assertQuery( 'SHOW COLUMNS FROM t' ) ); + $this->assertEquals( $create_table, $this->assertQuery( 'SHOW CREATE TABLE t' ) ); + $this->assertQuery( 'INSERT INTO t (id) VALUES (NULL)' ); + $this->assertQuery( 'INSERT INTO t (value) VALUES (2)' ); + $this->assertEquals( + array( + (object) array( + 'id' => '1', + 'value' => '1', + ), + (object) array( + 'id' => '2', + 'value' => '1', + ), + (object) array( + 'id' => '3', + 'value' => '2', + ), + ), + $this->assertQuery( 'SELECT * FROM t ORDER BY id' ) + ); + } + + public static function alterAutoIncrementDefaultActions(): array { + return array( + 'literal' => array( 'SET DEFAULT 10', '' ), + 'parenthesized literal' => array( 'SET DEFAULT (10)', '' ), + 'nested literal' => array( 'SET DEFAULT ((10))', '' ), + 'unary plus' => array( 'SET DEFAULT (+10)', '' ), + 'numeric string' => array( "SET DEFAULT '10'", '' ), + 'null' => array( 'SET DEFAULT NULL', '' ), + 'parenthesized null' => array( 'SET DEFAULT (NULL)', '' ), + 'expression' => array( 'SET DEFAULT (1 + 2)', '' ), + 'drop default' => array( 'DROP DEFAULT', '' ), + 'temporary set default' => array( 'SET DEFAULT 10', 'TEMPORARY' ), + 'temporary drop default' => array( 'DROP DEFAULT', 'TEMPORARY' ), + ); + } + + public function testAlterColumnDefaultRollsBackInvalidDefault(): void { + $this->assertQuery( 'CREATE TABLE t (id INT, value BIT(4) DEFAULT 1)' ); + $this->assertQuery( 'INSERT INTO t (id) VALUES (1)' ); + $original = $this->assertQuery( 'SHOW CREATE TABLE t' ); + $this->assertQueryError( + "ALTER TABLE t ALTER id SET DEFAULT 5, ALTER value SET DEFAULT 'invalid'", + "SQLSTATE[42000]: Syntax error or access violation: 1067 Invalid default value for 'value'" + ); + $this->assertEquals( $original, $this->assertQuery( 'SHOW CREATE TABLE t' ) ); + $this->assertQuery( 'INSERT INTO t (id) VALUES (2)' ); + $result = $this->assertQuery( 'SELECT value FROM t ORDER BY id' ); + $this->assertEquals( array( (object) array( 'value' => '1' ), (object) array( 'value' => '1' ) ), $result ); + } + + /** + * @dataProvider alterMissingColumnDefaultActions + */ + public function testAlterMissingTableDefault( string $action ): void { + $error = "SQLSTATE[42S02]: Base table or view not found: 1146 Table 'wp.missing' doesn't exist"; + foreach ( array( 'missing', '`wp`.`missing`' ) as $table ) { + $this->assertQueryError( "ALTER TABLE $table ALTER id $action", $error ); + $this->assertSame( array( '42S02', 1146, "Table 'wp.missing' doesn't exist" ), $this->engine->errorInfo() ); + } + + $this->assertQuery( 'CREATE TEMPORARY TABLE missing (id INT DEFAULT 1)' ); + $this->assertQuery( "ALTER TABLE missing ALTER id $action" ); + $this->assertQuery( 'DROP TEMPORARY TABLE missing' ); + $this->assertQueryError( "ALTER TABLE missing ALTER id $action", $error ); + } + + /** + * @dataProvider alterMissingColumnDefaultActions + */ + public function testAlterMissingColumnDefault( string $action ): void { + $this->assertQuery( 'CREATE TABLE t (id INT DEFAULT 1)' ); + $this->assertQueryError( + "ALTER TABLE t ALTER id SET DEFAULT 5, ALTER missing $action", + "SQLSTATE[42S22]: Column not found: 1054 Unknown column 'missing' in 't'" + ); + $result = $this->assertQuery( 'SHOW COLUMNS FROM t' ); + $this->assertSame( '1', $result[0]->Default ); + } + + public static function alterMissingColumnDefaultActions(): array { + return array( + array( 'SET DEFAULT 1' ), + array( 'DROP DEFAULT' ), + ); + } + + /** + * @dataProvider alterColumnDefaultWithOtherColumnActions + */ + public function testAlterColumnDefaultWithOtherColumnActions( string $actions, string $column_name, ?string $expected_default ): void { + $this->assertQuery( 'CREATE TABLE t (id INT, value INT DEFAULT 1)' ); + $this->assertQuery( 'ALTER TABLE t ' . $actions ); + $defaults = array_column( $this->assertQuery( 'SHOW COLUMNS FROM t' ), 'Default', 'Field' ); + $this->assertSame( $expected_default, $defaults[ $column_name ] ); + } + + public static function alterColumnDefaultWithOtherColumnActions(): array { + // The actions are applied in order. MySQL rejects these statements with error 1054. + return array( + 'added column' => array( 'ADD added INT, ALTER added SET DEFAULT 5', 'added', '5' ), + 'modified column' => array( 'MODIFY value INT DEFAULT 2, ALTER value SET DEFAULT 5', 'value', '5' ), + 'later modification' => array( 'ALTER value SET DEFAULT 5, MODIFY value INT DEFAULT 2', 'value', '2' ), + 'new column name' => array( 'CHANGE value renamed INT, ALTER renamed SET DEFAULT 5', 'renamed', '5' ), + 'repeated default' => array( 'ALTER value SET DEFAULT 5, ALTER VALUE SET DEFAULT 6', 'value', '6' ), + 'later drop default' => array( 'ALTER value SET DEFAULT 5, ALTER value DROP DEFAULT', 'value', null ), + 'visibility change' => array( 'ALTER value SET INVISIBLE, ALTER value SET DEFAULT 5', 'value', '5' ), + ); + } + + /** + * @dataProvider alterColumnDefaultOnRemovedColumnActions + */ + public function testAlterColumnDefaultOnRemovedColumn( string $actions ): void { + $this->assertQuery( 'CREATE TABLE t (id INT, value INT DEFAULT 1)' ); + $original = $this->assertQuery( 'SHOW CREATE TABLE t' ); + $this->assertQueryError( + 'ALTER TABLE t ' . $actions, + "SQLSTATE[42S22]: Column not found: 1054 Unknown column 'value' in 't'" + ); + $this->assertEquals( $original, $this->assertQuery( 'SHOW CREATE TABLE t' ) ); + } + + public static function alterColumnDefaultOnRemovedColumnActions(): array { + return array( + 'renamed column' => array( 'CHANGE value renamed INT, ALTER value SET DEFAULT 5' ), + 'dropped column' => array( 'DROP value, ALTER value SET DEFAULT 5' ), + ); + } + public function testAlterTableModifyColumn() { $this->assertQuery( "CREATE TABLE _tmp_table ( diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 19dfec077..b5bb38aa5 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -62,6 +62,7 @@ /packages/mysql-on-sqlite/src/sqlite/*\.php + /packages/plugin-sqlite-database-integration/wp-includes/database/sqlite/*\.php /tests/*