Skip to content
Closed
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
112 changes: 101 additions & 11 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 @@ -4341,6 +4341,8 @@ private function translate( $node ): ?string {
return null;
}
return $this->translate_sequence( $node->get_children() );
case 'bitExpr':
return $this->translate_bit_expr( $node );
case 'simpleExprBody':
return $this->translate_simple_expr_body( $node );
case 'predicateOperations':
Expand Down Expand Up @@ -4907,9 +4909,70 @@ private function translate_simple_expr_body( WP_Parser_Node $node ): string {
}
}

// Translate "INTERVAL n unit + expr", the prefix form of date arithmetic.
if ( null !== $token && WP_MySQL_Lexer::INTERVAL_SYMBOL === $token->id ) {
$nodes = $node->get_child_nodes();
return $this->translate_interval_arithmetic(
$this->translate( $nodes[2] ),
false,
$nodes[0],
$nodes[1]
);
}

return $this->translate_sequence( $node->get_children() );
}

/**
* Translate a "bitExpr" AST node.
*
* Date arithmetic is the only "bitExpr" form that SQLite cannot run as
* written: "expr + INTERVAL n unit" and "expr - INTERVAL n unit". Each
* interval is folded into the expression on its left, so a chain such as
* "expr + INTERVAL 5 HOUR + INTERVAL 1 DAY" becomes nested DATETIME() calls
* applied in the same order as in MySQL.
*
* @param WP_Parser_Node $node The "bitExpr" AST node.
* @return string|null The translated value.
* @throws WP_MySQL_On_SQLite_Exception When the translation fails.
*/
private function translate_bit_expr( WP_Parser_Node $node ): ?string {
if ( ! $node->has_child_token( WP_MySQL_Lexer::INTERVAL_SYMBOL ) ) {
return $this->translate_sequence( $node->get_children() );
}

$children = $node->get_children();
$count = count( $children );
$parts = array();
for ( $i = 0; $i < $count; $i++ ) {
$child = $children[ $i ];
if (
$child instanceof WP_Parser_Token
&& ( WP_MySQL_Lexer::PLUS_OPERATOR === $child->id || WP_MySQL_Lexer::MINUS_OPERATOR === $child->id )
&& isset( $children[ $i + 1 ] )
&& $children[ $i + 1 ] instanceof WP_Parser_Token
&& WP_MySQL_Lexer::INTERVAL_SYMBOL === $children[ $i + 1 ]->id
) {
$parts = array(
$this->translate_interval_arithmetic(
implode( ' ', $parts ),
WP_MySQL_Lexer::MINUS_OPERATOR === $child->id,
$children[ $i + 2 ],
$children[ $i + 3 ]
),
);
$i += 3;
continue;
}

$translated = $this->translate( $child );
if ( null !== $translated ) {
$parts[] = $translated;
}
}
return implode( ' ', $parts );
}

/**
* Translate a MySQL CAST expression to SQLite.
*
Expand Down Expand Up @@ -5034,18 +5097,11 @@ private function translate_runtime_function_call( WP_Parser_Node $node ): string
case WP_MySQL_Lexer::DATE_ADD_SYMBOL:
case WP_MySQL_Lexer::DATE_SUB_SYMBOL:
$nodes = $node->get_child_nodes();
$value = $this->translate( $nodes[1] );
$unit = $this->translate( $nodes[2] );
if ( 'WEEK' === $unit ) {
$unit = 'DAY';
$value = 7 * $value;
}
return sprintf(
"DATETIME(%s, '%s' || %s || ' %s')",
return $this->translate_interval_arithmetic(
$this->translate( $nodes[0] ),
WP_MySQL_Lexer::DATE_SUB_SYMBOL === $child->id ? '-' : '+',
$value,
$unit
WP_MySQL_Lexer::DATE_SUB_SYMBOL === $child->id,
$nodes[1],
$nodes[2]
);
case WP_MySQL_Lexer::LEFT_SYMBOL:
$nodes = $node->get_child_nodes();
Expand All @@ -5059,6 +5115,40 @@ private function translate_runtime_function_call( WP_Parser_Node $node ): string
}
}

/**
* Translate MySQL date arithmetic to a SQLite DATETIME() call.
*
* SQLite reads the modifier as text, and "||" binds tighter than "*", so
* the value is parenthesized before the unit is appended. Subtraction
* negates the value instead of prefixing "-", which keeps a negative
* interval such as "INTERVAL -5 HOUR" valid: SQLite rejects "+-5 HOUR".
* A WEEK interval becomes seven days, as SQLite has no week modifier.
*
* @param string $expr The translated date expression.
* @param bool $subtract Whether the interval is subtracted.
* @param WP_Parser_Node $value The interval value expression node.
* @param WP_Parser_Node $interval The "interval" AST node holding the unit.
* @return string The translated value.
* @throws WP_MySQL_On_SQLite_Exception When the translation fails.
*/
private function translate_interval_arithmetic(
string $expr,
bool $subtract,
WP_Parser_Node $value,
WP_Parser_Node $interval
): string {
$value_sql = sprintf( '(%s)', $this->translate( $value ) );
$unit = strtoupper( $this->translate( $interval ) );
if ( 'WEEK' === $unit ) {
$value_sql = sprintf( '(%s * 7)', $value_sql );
$unit = 'DAY';
}
if ( $subtract ) {
$value_sql = '-' . $value_sql;
}
return sprintf( "DATETIME(%s, %s || ' %s')", $expr, $value_sql, $unit );
}

/**
* Translate a MySQL function call to SQLite.
*
Expand Down
116 changes: 115 additions & 1 deletion packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Tests.php
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,120 @@ public function testDateSubFunction() {
$this->assertEquals( '2007-01-02 13:29:17', $result[0]->output );
}

public function testDateAddFunctionWithNegativeAndComputedIntervals() {
// Expected values were taken from MySQL 8.4.
$result = $this->assertQuery(
"SELECT DATE_ADD('2024-01-01 10:00:00', INTERVAL -5 HOUR) as output"
);
$this->assertEquals( '2024-01-01 05:00:00', $result[0]->output );

$result = $this->assertQuery(
"SELECT DATE_SUB('2024-01-01 10:00:00', INTERVAL -5 HOUR) as output"
);
$this->assertEquals( '2024-01-01 15:00:00', $result[0]->output );

$result = $this->assertQuery(
"SELECT DATE_SUB('2024-01-01 10:00:00', INTERVAL 2 * 3 HOUR) as output"
);
$this->assertEquals( '2024-01-01 04:00:00', $result[0]->output );

$result = $this->assertQuery(
"SELECT DATE_ADD('2024-01-01 10:00:00', INTERVAL 1 week) as output"
);
$this->assertEquals( '2024-01-08 10:00:00', $result[0]->output );
}

public function testIntervalArithmetic() {
// Expected values were taken from MySQL 8.4.
$result = $this->assertQuery(
"SELECT '2024-01-01 10:00:00' - INTERVAL 5 HOUR as output"
);
$this->assertEquals( '2024-01-01 05:00:00', $result[0]->output );

$result = $this->assertQuery(
"SELECT '2024-01-01 10:00:00' + INTERVAL 5 HOUR as output"
);
$this->assertEquals( '2024-01-01 15:00:00', $result[0]->output );

// The interval can also come first.
$result = $this->assertQuery(
"SELECT INTERVAL 5 HOUR + '2024-01-01 10:00:00' as output"
);
$this->assertEquals( '2024-01-01 15:00:00', $result[0]->output );

// Intervals chain from left to right.
$result = $this->assertQuery(
"SELECT '2024-01-01 10:00:00' + INTERVAL 5 HOUR + INTERVAL 1 DAY as output"
);
$this->assertEquals( '2024-01-02 15:00:00', $result[0]->output );

$result = $this->assertQuery(
"SELECT '2024-01-01 10:00:00' + INTERVAL 1 DAY - INTERVAL 5 HOUR as output"
);
$this->assertEquals( '2024-01-02 05:00:00', $result[0]->output );

$result = $this->assertQuery(
"SELECT '2024-01-01 10:00:00' + INTERVAL 1 WEEK as output"
);
$this->assertEquals( '2024-01-08 10:00:00', $result[0]->output );

$result = $this->assertQuery(
"SELECT '2024-01-01 10:00:00' + INTERVAL 1 hour as output"
);
$this->assertEquals( '2024-01-01 11:00:00', $result[0]->output );

// The value is an expression, and may be negative or a string.
$result = $this->assertQuery(
"SELECT '2024-01-01 10:00:00' - INTERVAL 2 * 3 HOUR as output"
);
$this->assertEquals( '2024-01-01 04:00:00', $result[0]->output );

$result = $this->assertQuery(
"SELECT '2024-01-01 10:00:00' + INTERVAL -5 HOUR as output"
);
$this->assertEquals( '2024-01-01 05:00:00', $result[0]->output );

$result = $this->assertQuery(
"SELECT '2024-01-01 10:00:00' + INTERVAL '5' HOUR as output"
);
$this->assertEquals( '2024-01-01 15:00:00', $result[0]->output );

// NULL on either side yields NULL.
$result = $this->assertQuery( 'SELECT NULL + INTERVAL 1 DAY as output' );
$this->assertNull( $result[0]->output );

$result = $this->assertQuery(
"SELECT '2024-01-01 10:00:00' + INTERVAL NULL DAY as output"
);
$this->assertNull( $result[0]->output );

// The case from the original report.
$result = $this->assertQuery(
"SELECT DATE('2024-01-01 10:00:00' - INTERVAL 5 HOUR) as output"
);
$this->assertEquals( '2024-01-01', $result[0]->output );
}

public function testIntervalArithmeticOnColumns() {
$this->assertQuery( 'CREATE TABLE _events (id INT, occurred_at DATETIME)' );
$this->assertQuery(
"INSERT INTO _events VALUES (1, '2024-01-01 10:00:00'), (2, '2024-01-03 10:00:00'), (3, NULL)"
);

// Expected values were taken from MySQL 8.4.
$result = $this->assertQuery(
'SELECT id, occurred_at - INTERVAL 1 DAY as output FROM _events ORDER BY id'
);
$this->assertEquals( '2023-12-31 10:00:00', $result[0]->output );
$this->assertEquals( '2024-01-02 10:00:00', $result[1]->output );
$this->assertNull( $result[2]->output );

$result = $this->assertQuery(
"SELECT id FROM _events WHERE occurred_at > '2024-01-02 00:00:00' - INTERVAL 1 DAY ORDER BY id"
);
$this->assertEquals( array( 1, 2 ), array_map( 'intval', array_column( $result, 'id' ) ) );
}

public function testLeftFunction1Char() {
$result = $this->assertQuery(
'SELECT LEFT("abc", 1) as output'
Expand Down Expand Up @@ -13133,7 +13247,7 @@ public function testCreateTableWithDefaultExpressions(): void {
$result = $this->sqlite->query( 'PRAGMA table_info(t)' )->fetchAll();
$this->assertSame( null, $result[0]['dflt_value'] );
$this->assertSame( '1 + 2', $result[1]['dflt_value'] );
$this->assertSame( "DATETIME(CURRENT_TIMESTAMP, '+' || 1 || ' YEAR')", $result[2]['dflt_value'] );
$this->assertSame( "DATETIME(CURRENT_TIMESTAMP, (1) || ' YEAR')", $result[2]['dflt_value'] );
$this->assertSame( "('a' || 'b')", $result[3]['dflt_value'] );
}

Expand Down
Loading