From dccd7b236df822a54e2b6dccd3555aecd988fd23 Mon Sep 17 00:00:00 2001 From: Samuel Mbabhazi Date: Sun, 20 Sep 2026 22:45:09 +0200 Subject: [PATCH] Translate date arithmetic written with INTERVAL operators MySQL accepts `expr + INTERVAL n unit`, `expr - INTERVAL n unit` and `INTERVAL n unit + expr`, and the parser already does too, but the translator passed the expression through unchanged, so SQLite rejected it with a syntax error. The three forms now share the DATETIME() translation used by DATE_ADD() and DATE_SUB(), which gains two fixes on the way: a negative value no longer produces a modifier that SQLite rejects, and a computed value is parenthesized so that `||` does not bind to it first. Fixes #454 Fixes #90 --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 112 +++++++++++++++-- .../tests/WP_MySQL_On_SQLite_Tests.php | 116 +++++++++++++++++- 2 files changed, 216 insertions(+), 12 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 485a260a4..33868423d 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 @@ -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': @@ -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. * @@ -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(); @@ -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. * 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 828c2218d..1c32c5b5d 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 @@ -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' @@ -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'] ); }