From e3926fcd04983303d586d9eb0c5fcd1068c81e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Thu, 22 Oct 2026 09:13:18 +0200 Subject: [PATCH 1/3] Apply the PDO lint exemption through the plugin symlink The plugin exposes MySQL on SQLite through a symlink. Apply the existing driver exemption there as well, since this code implements the database layer and cannot use wpdb. --- phpcs.xml.dist | 1 + 1 file changed, 1 insertion(+) 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/* From b585b07bfea3f8c64c1f945fa93ee962aa6bb6d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Thu, 22 Oct 2026 09:13:40 +0200 Subject: [PATCH 2/3] Apply ALTER COLUMN default changes Record SET DEFAULT and DROP DEFAULT using shared default handling, in order with the other ALTER TABLE actions. Preserve unrelated column metadata and reject missing columns. Reject NULL defaults on NOT NULL columns and keep accepted AUTO_INCREMENT defaults unset. Cover inserted values, schema metadata, temporary tables, quoted names, expressions, and rollback on failure. Fixes https://github.com/WordPress/sqlite-database-integration/issues/553 --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 12 + ...s-wp-sqlite-information-schema-builder.php | 168 +++++++- ...wp-sqlite-information-schema-exception.php | 19 + .../WP_MySQL_On_SQLite_Metadata_Tests.php | 2 +- .../tests/WP_MySQL_On_SQLite_Tests.php | 395 ++++++++++++++++++ 5 files changed, 580 insertions(+), 16 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 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..6c3c68658 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,104 @@ 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 ); + } + + $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 ) { + if ( str_contains( $extra, 'auto_increment' ) ) { + throw WP_SQLite_Information_Schema_Exception::invalid_default_value( $column_name ); + } + $extra = trim( 'DEFAULT_GENERATED ' . $extra ); + } + } + + /* + * MySQL hides SET DEFAULT and DROP DEFAULT on AUTO_INCREMENT columns from + * the schema, but applies them to inserts that omit the column. Emulating + * that would need hidden metadata, so the driver keeps generating IDs. + */ + if ( str_contains( $extra, 'auto_increment' ) ) { + $default = null; + } + + $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 +2187,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 +2263,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 +2296,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 +2332,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..8a7ff4a3e 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,358 @@ 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 alterAutoIncrementDefaultValues + */ + public function testAlterColumnDefaultOnAutoIncrementColumn( string $default_clause, bool $valid ): void { + $this->assertQuery( 'CREATE TABLE t (id INT PRIMARY KEY AUTO_INCREMENT, value INT DEFAULT 1)' ); + if ( $valid ) { + $this->assertQuery( "ALTER TABLE t ALTER id SET DEFAULT $default_clause, ALTER value SET DEFAULT 5" ); + } else { + $this->assertQueryError( + "ALTER TABLE t ALTER value SET DEFAULT 5, ALTER id SET DEFAULT $default_clause", + "SQLSTATE[42000]: Syntax error or access violation: 1067 Invalid default value for 'id'" + ); + } + $columns = $this->assertQuery( 'SHOW COLUMNS FROM t' ); + $this->assertNull( $columns[0]->Default ); + $this->assertSame( 'auto_increment', $columns[0]->Extra ); + $this->assertSame( $valid ? '5' : '1', $columns[1]->Default ); + $create_table = $this->assertQuery( 'SHOW CREATE TABLE t' )[0]->{'Create Table'}; + $this->assertStringContainsString( "`id` int NOT NULL AUTO_INCREMENT,\n", $create_table ); + + // Unlike MySQL, the driver ignores an accepted default and generates an ID. + $this->assertQuery( 'INSERT INTO t (value) VALUES (1)' ); + $this->assertSame( '1', $this->assertQuery( 'SELECT id FROM t' )[0]->id ); + + // Unlike MySQL in strict mode, the driver doesn't reject an omitted ID after DROP DEFAULT. + $this->assertQuery( 'ALTER TABLE t ALTER id DROP DEFAULT' ); + $this->assertQuery( 'INSERT INTO t (value) VALUES (1)' ); + $this->assertSame( '2', $this->assertQuery( 'SELECT MAX(id) AS id FROM t' )[0]->id ); + } + + public static function alterAutoIncrementDefaultValues(): array { + return array( + 'literal' => array( '10', true ), + 'parenthesized literal' => array( '(10)', true ), + 'nested literal' => array( '((10))', true ), + 'unary plus' => array( '(+10)', true ), + 'numeric string' => array( "'10'", true ), + 'null' => array( 'NULL', false ), + 'parenthesized null' => array( '(NULL)', false ), + 'expression' => array( '(1 + 2)', false ), + ); + } + + 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 ( From cd96b9e6fd3014af82f56d5a7c532a42d36d2029 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Fri, 23 Oct 2026 10:33:55 +0200 Subject: [PATCH 3/3] Reject default changes on AUTO_INCREMENT columns Reject ALTER COLUMN SET DEFAULT and DROP DEFAULT instead of silently discarding them. MySQL hides these defaults from schema metadata, so matching its behavior would require separate hidden state. Verify rejection, rollback, and subsequent inserts for regular and temporary tables. https://github.com/WordPress/sqlite-database-integration/pull/555#discussion_r4354448757 --- ...s-wp-sqlite-information-schema-builder.php | 23 +++--- .../tests/WP_MySQL_On_SQLite_Tests.php | 75 +++++++++++-------- 2 files changed, 53 insertions(+), 45 deletions(-) diff --git a/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php b/packages/mysql-on-sqlite/src/sqlite/class-wp-sqlite-information-schema-builder.php index 6c3c68658..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 @@ -1036,6 +1036,17 @@ private function record_alter_column_default( 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 ) ) { @@ -1048,22 +1059,10 @@ private function record_alter_column_default( throw WP_SQLite_Information_Schema_Exception::invalid_default_value( $column_name ); } if ( $expression && ! $literal ) { - if ( str_contains( $extra, 'auto_increment' ) ) { - throw WP_SQLite_Information_Schema_Exception::invalid_default_value( $column_name ); - } $extra = trim( 'DEFAULT_GENERATED ' . $extra ); } } - /* - * MySQL hides SET DEFAULT and DROP DEFAULT on AUTO_INCREMENT columns from - * the schema, but applies them to inserts that omit the column. Emulating - * that would need hidden metadata, so the driver keeps generating IDs. - */ - if ( str_contains( $extra, 'auto_increment' ) ) { - $default = null; - } - $this->update_values( $columns_table, array( 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 8a7ff4a3e..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 @@ -2806,45 +2806,54 @@ public static function alterColumnDefaultNullValues(): array { } /** - * @dataProvider alterAutoIncrementDefaultValues + * @dataProvider alterAutoIncrementDefaultActions */ - public function testAlterColumnDefaultOnAutoIncrementColumn( string $default_clause, bool $valid ): void { - $this->assertQuery( 'CREATE TABLE t (id INT PRIMARY KEY AUTO_INCREMENT, value INT DEFAULT 1)' ); - if ( $valid ) { - $this->assertQuery( "ALTER TABLE t ALTER id SET DEFAULT $default_clause, ALTER value SET DEFAULT 5" ); - } else { - $this->assertQueryError( - "ALTER TABLE t ALTER value SET DEFAULT 5, ALTER id SET DEFAULT $default_clause", - "SQLSTATE[42000]: Syntax error or access violation: 1067 Invalid default value for 'id'" - ); - } - $columns = $this->assertQuery( 'SHOW COLUMNS FROM t' ); - $this->assertNull( $columns[0]->Default ); - $this->assertSame( 'auto_increment', $columns[0]->Extra ); - $this->assertSame( $valid ? '5' : '1', $columns[1]->Default ); - $create_table = $this->assertQuery( 'SHOW CREATE TABLE t' )[0]->{'Create Table'}; - $this->assertStringContainsString( "`id` int NOT NULL AUTO_INCREMENT,\n", $create_table ); - - // Unlike MySQL, the driver ignores an accepted default and generates an ID. + 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)' ); - $this->assertSame( '1', $this->assertQuery( 'SELECT id FROM t' )[0]->id ); + $columns = $this->assertQuery( 'SHOW COLUMNS FROM t' ); + $create_table = $this->assertQuery( 'SHOW CREATE TABLE t' ); - // Unlike MySQL in strict mode, the driver doesn't reject an omitted ID after DROP DEFAULT. - $this->assertQuery( 'ALTER TABLE t ALTER id DROP DEFAULT' ); - $this->assertQuery( 'INSERT INTO t (value) VALUES (1)' ); - $this->assertSame( '2', $this->assertQuery( 'SELECT MAX(id) AS id FROM t' )[0]->id ); + $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 alterAutoIncrementDefaultValues(): array { + public static function alterAutoIncrementDefaultActions(): array { return array( - 'literal' => array( '10', true ), - 'parenthesized literal' => array( '(10)', true ), - 'nested literal' => array( '((10))', true ), - 'unary plus' => array( '(+10)', true ), - 'numeric string' => array( "'10'", true ), - 'null' => array( 'NULL', false ), - 'parenthesized null' => array( '(NULL)', false ), - 'expression' => array( '(1 + 2)', false ), + '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' ), ); }