Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions packages/mysql-on-sqlite/src/sqlite/class-wp-mysql-on-sqlite.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ),
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
}
}
}
}

Expand Down Expand Up @@ -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 );
Comment on lines +1055 to +1057

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex review feedback, requested by @brandonpayton.

Invalid literal defaults can be accepted and break later inserts. For example, SET DEFAULT 'invalid' succeeds on an INT column, then inserts omitting that column fail. This is an existing validation gap exposed by the new ALTER support.

Suggested fix: Add shared validation and normalization using the literal AST and complete column metadata. Reuse it across CREATE and ALTER operations, raise the existing invalid-default exception, and test rollback. This broader work could be a separate follow-up.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. This is fairly complex because it requires extensive type conversion and validation in PHP. It’s already tracked in #551 (and pre-existing for all existing ways to set column defaults). I guess I should address it soon in a follow-up, because particular instances of this issue keep coming up.

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 );

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Codex review feedback, requested by @brandonpayton.

Expression defaults can disable ON UPDATE CURRENT_TIMESTAMP. The rebuild checks whether EXTRA exactly equals on update CURRENT_TIMESTAMP. Adding DEFAULT_GENERATED causes it to skip recreating the trigger, leaving timestamps stale even though the reported schema retains ON UPDATE.

Suggested fix: Use str_contains() for that attribute check in get_sqlite_create_table_statement(). Add a regression test that updates an existing row while the expression default is active, covering permanent and temporary tables.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, I found this during self-review but decided to extract it to #554. The reason is that doing only the str_contains fix amplifies another issue with the timestamp trigger (it always overrides explicit values), and so I think it's better to fix both together as a follow-up.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prepared a fix in #560.

}
}

$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.
*
Expand Down Expand Up @@ -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 );
}
Expand Down Expand Up @@ -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 );
}
Expand All @@ -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.
Expand Down Expand Up @@ -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 ) . "'";
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading