From 5b2a95157c1cef50ef9239aef7cb0b11acdaccac Mon Sep 17 00:00:00 2001 From: Samuel Mbabhazi Date: Sun, 20 Sep 2026 22:45:09 +0200 Subject: [PATCH 1/5] 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 | 111 +++++++++++++++-- .../tests/WP_MySQL_On_SQLite_Tests.php | 116 +++++++++++++++++- 2 files changed, 215 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 06218632c..a42159df4 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 @@ -4349,6 +4349,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': @@ -4915,9 +4917,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. * @@ -5051,18 +5114,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(); @@ -5076,6 +5132,39 @@ private function translate_runtime_function_call( WP_Parser_Node $node ): string } } + /** + * Translate MySQL date arithmetic to a SQLite DATETIME() call. + * + * Parenthesize the value before appending the unit: SQLite evaluates "||" + * before "*". Negate the value for subtraction so negative intervals work + * without invalid sign combinations such as "+-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 659d38b1d..aa26d6158 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 @@ -932,6 +932,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' @@ -13508,7 +13622,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'] ); } From c0b12b116ca6a07b5d4d85d393db82bc7ca6a8f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Mon, 19 Oct 2026 10:58:14 +0200 Subject: [PATCH 2/5] Reject the prefix INTERVAL form The grammar doesn't group "INTERVAL n unit + expr" like MySQL. Its operand includes AND, OR, and XOR, which MySQL applies to the whole expression, so this query deleted every row, while MySQL deletes none: DELETE FROM t WHERE INTERVAL 30 DAY + d < '2025-01-01' AND id = 99 The parser also matches "expr + INTERVAL 1 DAY + x" as "expr + (INTERVAL 1 DAY + x)". The prefix form is rare, and the reported queries use the suffix form, so reject it until the grammar follows MySQL's precedence. --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 16 +++++---- .../tests/WP_MySQL_On_SQLite_Tests.php | 36 +++++++++++++++---- .../WP_MySQL_On_SQLite_Translation_Tests.php | 9 +++++ 3 files changed, 48 insertions(+), 13 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 a42159df4..0591058ec 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 @@ -4917,14 +4917,16 @@ private function translate_simple_expr_body( WP_Parser_Node $node ): string { } } - // Translate "INTERVAL n unit + expr", the prefix form of date arithmetic. + /* + * Reject "INTERVAL n unit + expr": the parser includes following AND, OR, + * and XOR conditions in "expr", unlike MySQL. It also reads + * "expr + INTERVAL 1 DAY + x" as "expr + (INTERVAL 1 DAY + x)". + * + * TODO: Support prefix intervals after switching to the LALR(1) parser. + */ 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] + throw $this->new_not_supported_exception( + 'INTERVAL n unit followed by "+"; use "(expr + INTERVAL n unit)" instead' ); } 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 aa26d6158..9047d4967 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 @@ -967,12 +967,6 @@ public function testIntervalArithmetic() { ); $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" @@ -1044,6 +1038,36 @@ public function testIntervalArithmeticOnColumns() { "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' ) ) ); + + // Conditions after the interval are not part of it. + $this->assertQuery( + "DELETE FROM _events WHERE occurred_at < '2024-01-03 00:00:00' - INTERVAL 1 DAY AND id = 99" + ); + $result = $this->assertQuery( 'SELECT COUNT(*) as count FROM _events' ); + $this->assertEquals( 3, $result[0]->count ); + } + + public function testIntervalArithmeticPrefixForm() { + // TODO: With LALR(1), assert MySQL results instead of expecting errors. + // Keep the DELETE regression check to verify that unrelated rows survive. + $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')" + ); + + $error = 'MySQL query not supported. Cause: INTERVAL n unit followed by "+"; use "(expr + INTERVAL n unit)" instead'; + $this->assertQueryError( "SELECT INTERVAL 5 HOUR + '2024-01-01 10:00:00'", $error ); + $this->assertQueryError( + "DELETE FROM _events WHERE INTERVAL 30 DAY + occurred_at < '2025-01-01' AND id = 99", + $error + ); + + // The parser treats a suffix interval followed by "+" as a prefix interval. + $this->assertQueryError( "SELECT '2024-01-01 10:00:00' + INTERVAL 1 DAY + 0", $error ); + $this->assertQueryError( "SELECT '2024-01-01 10:00:00' - INTERVAL 1 DAY + 0", $error ); + + $result = $this->assertQuery( 'SELECT COUNT(*) as count FROM _events' ); + $this->assertEquals( 2, $result[0]->count ); } public function testLeftFunction1Char() { diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php index d6720e5b6..645324190 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php @@ -1735,6 +1735,15 @@ public function testConcatFunction(): void { ); } + public function testIntervalArithmeticInParentheses(): void { + // TODO: With LALR(1), also test these expressions without parentheses. + // Parentheses prevent the parser from treating this as a prefix interval. + $this->assertQuery( + "SELECT ( DATETIME('2024-01-01', (1) || ' DAY') ) + 0 AS `a`", + "SELECT ('2024-01-01' + INTERVAL 1 DAY) + 0 AS a" + ); + } + public function testIndexHints(): void { // USE INDEX $this->assertQuery( From 3996f2d421d85bac52529b104440174e46c1b441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Mon, 19 Oct 2026 10:58:55 +0200 Subject: [PATCH 3/5] Reject INTERVAL on a partial operand The grammar nests "op bitExpr" to the right, regardless of operator precedence. In "1 + d + INTERVAL 1 DAY", the interval was applied to "d" only, while MySQL applies it to "1 + d". Reject an interval in such a nested operand, and suggest parentheses. The rewritten fold also no longer duplicates translate_sequence(), and the docblock no longer claims that INTERVAL is the only "bitExpr" form that SQLite can't run. --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 81 +++++++++++-------- .../tests/WP_MySQL_On_SQLite_Tests.php | 21 +++++ .../WP_MySQL_On_SQLite_Translation_Tests.php | 6 ++ 3 files changed, 73 insertions(+), 35 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 0591058ec..897882960 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 @@ -4936,51 +4936,62 @@ private function translate_simple_expr_body( WP_Parser_Node $node ): string { /** * 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. + * Translate interval additions and subtractions to nested DATETIME() calls, + * applying each interval from left to right, as MySQL does. + * + * TODO: After switching to LALR(1), use the tree's operator grouping + * and remove the loop over this parser's flat interval sequence. * * @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() ); - } - + /* + * The first child is an expression. Each interval adds four children: + * operator, INTERVAL, value, unit. A final "op bitExpr" may follow. + */ $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; - } + $sql = $this->translate( $children[0] ); + $i = 1; + while ( + isset( $children[ $i + 1 ] ) + && $children[ $i + 1 ] instanceof WP_Parser_Token + && WP_MySQL_Lexer::INTERVAL_SYMBOL === $children[ $i + 1 ]->id + ) { + $sql = $this->translate_interval_arithmetic( + $sql, + WP_MySQL_Lexer::MINUS_OPERATOR === $children[ $i ]->id, + $children[ $i + 2 ], + $children[ $i + 3 ] + ); + $i += 4; + } - $translated = $this->translate( $child ); - if ( null !== $translated ) { - $parts[] = $translated; - } + if ( $i === $count ) { + return $sql; + } + + /* + * The parser groups "a + b + INTERVAL 1 DAY" as "a + (b + INTERVAL 1 DAY)". + * MySQL applies the interval to "a + b". Reject this form to avoid + * applying the interval to only part of the expression. For simplicity, + * also reject operators that bind less tightly than "+", such as "|". + * + * TODO: Remove this guard and support mixed arithmetic with LALR(1). + */ + $operator = $children[ $i ]; + $operand = $children[ $i + 1 ]; + if ( $operand->has_child_token( WP_MySQL_Lexer::INTERVAL_SYMBOL ) ) { + throw $this->new_not_supported_exception( + sprintf( + 'INTERVAL after the "%s" operator; add parentheses to make the grouping explicit', + $operator->get_value() + ) + ); } - return implode( ' ', $parts ); + return sprintf( '%s %s %s', $sql, $this->translate( $operator ), $this->translate( $operand ) ); } /** 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 9047d4967..ccc1db009 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 @@ -1070,6 +1070,27 @@ public function testIntervalArithmeticPrefixForm() { $this->assertEquals( 2, $result[0]->count ); } + public function testIntervalArithmeticOnPartialOperand() { + // TODO: With LALR(1), replace these rejection tests with MySQL result checks. + // The parser applies the interval to the date alone. + // MySQL applies it to the sum, so reject the unparenthesized expression. + $this->assertQueryError( + "SELECT 1 + '2024-01-01' + INTERVAL 1 DAY", + 'MySQL query not supported. Cause: INTERVAL after the "+" operator; add parentheses to make the grouping explicit' + ); + + $this->assertQueryError( + "SELECT 2 * '2024-01-01' - INTERVAL 1 DAY", + 'MySQL query not supported. Cause: INTERVAL after the "*" operator; add parentheses to make the grouping explicit' + ); + + // MySQL groups "|" like the parser, but it is rejected for simplicity. + $this->assertQueryError( + "SELECT 0 | '2024-01-01' + INTERVAL 1 DAY", + 'MySQL query not supported. Cause: INTERVAL after the "|" operator; add parentheses to make the grouping explicit' + ); + } + public function testLeftFunction1Char() { $result = $this->assertQuery( 'SELECT LEFT("abc", 1) as output' diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php index 645324190..4f93d0c6d 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php @@ -1742,6 +1742,12 @@ public function testIntervalArithmeticInParentheses(): void { "SELECT ( DATETIME('2024-01-01', (1) || ' DAY') ) + 0 AS `a`", "SELECT ('2024-01-01' + INTERVAL 1 DAY) + 0 AS a" ); + + // Parentheses make the interval apply to the whole expression. + $this->assertQuery( + "SELECT DATETIME(( 1 + '2024-01-01' ), (1) || ' DAY') AS `a`", + "SELECT (1 + '2024-01-01') + INTERVAL 1 DAY AS a" + ); } public function testIndexHints(): void { From 5b7787c67cb79c4b29ab81b8c9a44696ec5f2300 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Mon, 19 Oct 2026 10:22:33 +0200 Subject: [PATCH 4/5] Map INTERVAL units by token type The unit was taken from the token text, so units SQLite doesn't support produced an invalid DATETIME() modifier, and the result was NULL. With the INTERVAL operators, "UPDATE t SET d = d + INTERVAL 1 QUARTER" wrote NULL into the column, while MySQL adds 3 months. The same happened with the SQL_TSI_* synonyms, whose text differs from the unit they map to. Units are now looked up by token type. QUARTER becomes 3 months, and MICROSECOND and the composite units, such as HOUR_MINUTE, are rejected. --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 35 +++++++++-- .../tests/WP_MySQL_On_SQLite_Tests.php | 58 +++++++++++++++++++ 2 files changed, 88 insertions(+), 5 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 897882960..b9b050493 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 @@ -330,6 +330,23 @@ class WP_MySQL_On_SQLite extends PDO { '%y' => '%y', ); + /** + * Map MySQL INTERVAL units to SQLite units and value multipliers. + * + * MICROSECOND is excluded because DATETIME() drops fractional seconds. + * Composite units, such as HOUR_MINUTE, are not supported. + */ + private const INTERVAL_UNIT_MAP = array( + WP_MySQL_Lexer::SECOND_SYMBOL => array( 'SECOND', 1 ), + WP_MySQL_Lexer::MINUTE_SYMBOL => array( 'MINUTE', 1 ), + WP_MySQL_Lexer::HOUR_SYMBOL => array( 'HOUR', 1 ), + WP_MySQL_Lexer::DAY_SYMBOL => array( 'DAY', 1 ), + WP_MySQL_Lexer::WEEK_SYMBOL => array( 'DAY', 7 ), + WP_MySQL_Lexer::MONTH_SYMBOL => array( 'MONTH', 1 ), + WP_MySQL_Lexer::QUARTER_SYMBOL => array( 'MONTH', 3 ), + WP_MySQL_Lexer::YEAR_SYMBOL => array( 'YEAR', 1 ), + ); + /** * A map of MySQL data types to implicit default values for non-strict mode. * @@ -5151,7 +5168,7 @@ private function translate_runtime_function_call( WP_Parser_Node $node ): string * Parenthesize the value before appending the unit: SQLite evaluates "||" * before "*". Negate the value for subtraction so negative intervals work * without invalid sign combinations such as "+-5 HOUR". - * A WEEK interval becomes seven days, as SQLite has no week modifier. + * Convert units where needed, e.g., WEEK to 7 days. * * @param string $expr The translated date expression. * @param bool $subtract Whether the interval is subtracted. @@ -5166,11 +5183,19 @@ private function translate_interval_arithmetic( WP_Parser_Node $value, WP_Parser_Node $interval ): string { + // Token IDs also cover unit synonyms, e.g., SQL_TSI_HOUR. + $unit_token = $interval->get_first_descendant_token(); + if ( ! isset( self::INTERVAL_UNIT_MAP[ $unit_token->id ] ) ) { + throw $this->new_not_supported_exception( + sprintf( 'INTERVAL unit: %s', $unit_token->get_value() ) + ); + } + + list( $unit, $multiplier ) = self::INTERVAL_UNIT_MAP[ $unit_token->id ]; + $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 ( 1 !== $multiplier ) { + $value_sql = sprintf( '(%s * %d)', $value_sql, $multiplier ); } if ( $subtract ) { $value_sql = '-' . $value_sql; 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 ccc1db009..0758cb21c 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 @@ -1091,6 +1091,64 @@ public function testIntervalArithmeticOnPartialOperand() { ); } + public function testIntervalArithmeticUnits() { + $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 '2024-01-01 10:00:00' + INTERVAL 1 QUARTER as output" + ); + $this->assertEquals( '2024-04-01 10:00:00', $result[0]->output ); + + $result = $this->assertQuery( + "SELECT '2024-01-01 10:00:00' + INTERVAL 2 * 2 QUARTER as output" + ); + $this->assertEquals( '2025-01-01 10:00:00', $result[0]->output ); + + $result = $this->assertQuery( + "SELECT DATE_SUB('2024-01-01 10:00:00', INTERVAL -2 QUARTER) as output" + ); + $this->assertEquals( '2024-07-01 10:00:00', $result[0]->output ); + + // The SQL_TSI_* units are synonyms for the plain units. + $result = $this->assertQuery( + "SELECT '2024-01-01 10:00:00' + INTERVAL 1 SQL_TSI_HOUR as output" + ); + $this->assertEquals( '2024-01-01 11:00:00', $result[0]->output ); + + $result = $this->assertQuery( + "SELECT '2024-01-01 10:00:00' - INTERVAL 1 SQL_TSI_QUARTER as output" + ); + $this->assertEquals( '2023-10-01 10:00:00', $result[0]->output ); + + $result = $this->assertQuery( + "SELECT DATE_ADD('2024-01-01 10:00:00', INTERVAL 2 SQL_TSI_WEEK) as output" + ); + $this->assertEquals( '2024-01-15 10:00:00', $result[0]->output ); + + $this->assertQuery( 'UPDATE _events SET occurred_at = occurred_at + INTERVAL 1 QUARTER' ); + $result = $this->assertQuery( 'SELECT occurred_at FROM _events ORDER BY id' ); + $this->assertEquals( '2024-04-01 10:00:00', $result[0]->occurred_at ); + $this->assertEquals( '2024-04-03 10:00:00', $result[1]->occurred_at ); + $this->assertNull( $result[2]->occurred_at ); + } + + public function testIntervalArithmeticWithUnsupportedUnits() { + // SQLite DATETIME() drops fractional seconds. + $this->assertQueryError( + "SELECT '2024-01-01 10:00:00' + INTERVAL 1 MICROSECOND", + 'MySQL query not supported. Cause: INTERVAL unit: MICROSECOND' + ); + + $this->assertQueryError( + "SELECT DATE_ADD('2024-01-01 10:00:00', INTERVAL '1:30' HOUR_MINUTE)", + 'MySQL query not supported. Cause: INTERVAL unit: HOUR_MINUTE' + ); + } + public function testLeftFunction1Char() { $result = $this->assertQuery( 'SELECT LEFT("abc", 1) as output' From bc9ea364693ce266c8637f8d073dcc1de8b17253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Jakes=CC=8C?= Date: Tue, 20 Oct 2026 11:13:54 +0200 Subject: [PATCH 5/5] Return a date for date-only interval arithmetic MySQL returns a date when it adds only days, weeks, months, quarters, or years to a date. The translation always returned a datetime, so '2024-01-01' + INTERVAL 1 DAY gave '2024-01-02 00:00:00'. SQLite compares these values as strings, so comparisons with DATE values matched the wrong rows. This query also deleted the '2024-01-02' row, which MySQL keeps: DELETE FROM t WHERE d < '2024-01-03' - INTERVAL 1 DAY Dates are stored as 10-character strings, so use DATE() for such inputs. Chained intervals now become a single call with one modifier each, so the date expression isn't repeated for every interval. This fixes the first case of the linked issue. Month ends, ADDDATE(), and SUBDATE() remain open there. https://github.com/WordPress/sqlite-database-integration/issues/531 --- .../src/sqlite/class-wp-mysql-on-sqlite.php | 94 +++++++++++-------- .../tests/WP_MySQL_On_SQLite_Tests.php | 57 ++++++++++- .../WP_MySQL_On_SQLite_Translation_Tests.php | 21 ++++- 3 files changed, 128 insertions(+), 44 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 b9b050493..57d72f70c 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 @@ -4953,8 +4953,8 @@ private function translate_simple_expr_body( WP_Parser_Node $node ): string { /** * Translate a "bitExpr" AST node. * - * Translate interval additions and subtractions to nested DATETIME() calls, - * applying each interval from left to right, as MySQL does. + * Translate interval additions and subtractions to a single date function + * call, applying each interval from left to right, as MySQL does. * * TODO: After switching to LALR(1), use the tree's operator grouping * and remove the loop over this parser's flat interval sequence. @@ -4968,22 +4968,25 @@ private function translate_bit_expr( WP_Parser_Node $node ): ?string { * The first child is an expression. Each interval adds four children: * operator, INTERVAL, value, unit. A final "op bitExpr" may follow. */ - $children = $node->get_children(); - $count = count( $children ); - $sql = $this->translate( $children[0] ); - $i = 1; + $children = $node->get_children(); + $count = count( $children ); + $sql = $this->translate( $children[0] ); + $intervals = array(); + $i = 1; while ( isset( $children[ $i + 1 ] ) && $children[ $i + 1 ] instanceof WP_Parser_Token && WP_MySQL_Lexer::INTERVAL_SYMBOL === $children[ $i + 1 ]->id ) { - $sql = $this->translate_interval_arithmetic( - $sql, + $intervals[] = array( WP_MySQL_Lexer::MINUS_OPERATOR === $children[ $i ]->id, $children[ $i + 2 ], - $children[ $i + 3 ] + $children[ $i + 3 ], ); - $i += 4; + $i += 4; + } + if ( count( $intervals ) > 0 ) { + $sql = $this->translate_interval_arithmetic( $sql, $intervals ); } if ( $i === $count ) { @@ -5146,9 +5149,7 @@ private function translate_runtime_function_call( WP_Parser_Node $node ): string $nodes = $node->get_child_nodes(); return $this->translate_interval_arithmetic( $this->translate( $nodes[0] ), - WP_MySQL_Lexer::DATE_SUB_SYMBOL === $child->id, - $nodes[1], - $nodes[2] + array( array( WP_MySQL_Lexer::DATE_SUB_SYMBOL === $child->id, $nodes[1], $nodes[2] ) ) ); case WP_MySQL_Lexer::LEFT_SYMBOL: $nodes = $node->get_child_nodes(); @@ -5163,44 +5164,59 @@ private function translate_runtime_function_call( WP_Parser_Node $node ): string } /** - * Translate MySQL date arithmetic to a SQLite DATETIME() call. + * Translate MySQL date arithmetic to a SQLite DATE() or DATETIME() call. * + * Each interval becomes a modifier, and SQLite applies them from left to right. * Parenthesize the value before appending the unit: SQLite evaluates "||" * before "*". Negate the value for subtraction so negative intervals work * without invalid sign combinations such as "+-5 HOUR". * Convert units where needed, e.g., WEEK to 7 days. * - * @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. + * MySQL returns a date when it adds only date units to a date. The driver + * stores dates as 10-character strings, so use DATE() for such inputs. + * + * @param string $expr The translated date expression. + * @param array $intervals List of intervals, each as an array of: + * - bool Whether the interval is subtracted. + * - WP_Parser_Node The interval value expression node. + * - WP_Parser_Node 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 { - // Token IDs also cover unit synonyms, e.g., SQL_TSI_HOUR. - $unit_token = $interval->get_first_descendant_token(); - if ( ! isset( self::INTERVAL_UNIT_MAP[ $unit_token->id ] ) ) { - throw $this->new_not_supported_exception( - sprintf( 'INTERVAL unit: %s', $unit_token->get_value() ) - ); - } + private function translate_interval_arithmetic( string $expr, array $intervals ): string { + $modifiers = array(); + $date_units = true; + foreach ( $intervals as list( $subtract, $value, $interval ) ) { + // Token IDs also cover unit synonyms, e.g., SQL_TSI_HOUR. + $unit_token = $interval->get_first_descendant_token(); + if ( ! isset( self::INTERVAL_UNIT_MAP[ $unit_token->id ] ) ) { + throw $this->new_not_supported_exception( + sprintf( 'INTERVAL unit: %s', $unit_token->get_value() ) + ); + } - list( $unit, $multiplier ) = self::INTERVAL_UNIT_MAP[ $unit_token->id ]; + list( $unit, $multiplier ) = self::INTERVAL_UNIT_MAP[ $unit_token->id ]; - $value_sql = sprintf( '(%s)', $this->translate( $value ) ); - if ( 1 !== $multiplier ) { - $value_sql = sprintf( '(%s * %d)', $value_sql, $multiplier ); + $value_sql = sprintf( '(%s)', $this->translate( $value ) ); + if ( 1 !== $multiplier ) { + $value_sql = sprintf( '(%s * %d)', $value_sql, $multiplier ); + } + if ( $subtract ) { + $value_sql = '-' . $value_sql; + } + $modifiers[] = sprintf( "%s || ' %s'", $value_sql, $unit ); + $date_units = $date_units && in_array( $unit, array( 'DAY', 'MONTH', 'YEAR' ), true ); } - if ( $subtract ) { - $value_sql = '-' . $value_sql; + + $modifiers_sql = implode( ', ', $modifiers ); + if ( ! $date_units ) { + return sprintf( 'DATETIME(%s, %s)', $expr, $modifiers_sql ); } - return sprintf( "DATETIME(%s, %s || ' %s')", $expr, $value_sql, $unit ); + return sprintf( + 'CASE WHEN LENGTH(%1$s) = 10 THEN DATE(%1$s, %2$s) ELSE DATETIME(%1$s, %2$s) END', + $expr, + $modifiers_sql + ); } /** 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 0758cb21c..d404a0b44 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 @@ -1047,6 +1047,58 @@ public function testIntervalArithmeticOnColumns() { $this->assertEquals( 3, $result[0]->count ); } + public function testIntervalArithmeticOnDates() { + // Expected values were taken from MySQL 8.4. + // Adding only date units to a date returns a date. + $result = $this->assertQuery( "SELECT '2024-01-01' + INTERVAL 1 DAY as output" ); + $this->assertEquals( '2024-01-02', $result[0]->output ); + + $result = $this->assertQuery( "SELECT '2024-01-01' + INTERVAL 1 DAY + INTERVAL 1 MONTH as output" ); + $this->assertEquals( '2024-02-02', $result[0]->output ); + + $result = $this->assertQuery( "SELECT '2024-01-01' - INTERVAL 1 YEAR as output" ); + $this->assertEquals( '2023-01-01', $result[0]->output ); + + $result = $this->assertQuery( "SELECT DATE_ADD('2024-01-01', INTERVAL 1 WEEK) as output" ); + $this->assertEquals( '2024-01-08', $result[0]->output ); + + $result = $this->assertQuery( "SELECT DATE_SUB('2024-01-01', INTERVAL 1 QUARTER) as output" ); + $this->assertEquals( '2023-10-01', $result[0]->output ); + + // Time units and datetime inputs return a datetime. + $result = $this->assertQuery( "SELECT '2024-01-01' + INTERVAL 30 SECOND as output" ); + $this->assertEquals( '2024-01-01 00:00:30', $result[0]->output ); + + $result = $this->assertQuery( "SELECT '2024-01-01' + INTERVAL 1 DAY - INTERVAL 1 HOUR as output" ); + $this->assertEquals( '2024-01-01 23:00:00', $result[0]->output ); + + $result = $this->assertQuery( "SELECT '2024-01-01 00:00:00' + INTERVAL 1 DAY as output" ); + $this->assertEquals( '2024-01-02 00:00:00', $result[0]->output ); + + $this->assertQuery( 'CREATE TABLE _events (id INT, d DATE, dt DATETIME)' ); + $this->assertQuery( + "INSERT INTO _events VALUES (1, '2024-01-01', '2024-01-01 00:00:00'), (2, '2024-01-02', '2024-01-02 00:00:00'), (3, '2024-01-03', '2024-01-03 00:00:00')" + ); + + $result = $this->assertQuery( + 'SELECT d + INTERVAL 1 DAY as date_output, dt + INTERVAL 1 DAY as datetime_output, d + INTERVAL 1 HOUR as hour_output FROM _events WHERE id = 1' + ); + $this->assertEquals( '2024-01-02', $result[0]->date_output ); + $this->assertEquals( '2024-01-02 00:00:00', $result[0]->datetime_output ); + $this->assertEquals( '2024-01-01 01:00:00', $result[0]->hour_output ); + + // Comparisons with dates match the same rows as in MySQL. + $result = $this->assertQuery( "SELECT id FROM _events WHERE d = '2024-01-03' - INTERVAL 1 DAY" ); + $this->assertEquals( array( 2 ), array_map( 'intval', array_column( $result, 'id' ) ) ); + + $result = $this->assertQuery( "SELECT COUNT(*) as count FROM _events WHERE DATE_ADD(d, INTERVAL 7 DAY) = '2024-01-08'" ); + $this->assertEquals( 1, $result[0]->count ); + + $this->assertQuery( "DELETE FROM _events WHERE d < '2024-01-03' - INTERVAL 1 DAY" ); + $result = $this->assertQuery( 'SELECT id FROM _events ORDER BY id' ); + $this->assertEquals( array( 2, 3 ), array_map( 'intval', array_column( $result, 'id' ) ) ); + } + public function testIntervalArithmeticPrefixForm() { // TODO: With LALR(1), assert MySQL results instead of expecting errors. // Keep the DELETE regression check to verify that unrelated rows survive. @@ -13725,7 +13777,10 @@ 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( + "CASE WHEN LENGTH(CURRENT_TIMESTAMP) = 10 THEN DATE(CURRENT_TIMESTAMP, (1) || ' YEAR') ELSE DATETIME(CURRENT_TIMESTAMP, (1) || ' YEAR') END", + $result[2]['dflt_value'] + ); $this->assertSame( "('a' || 'b')", $result[3]['dflt_value'] ); } diff --git a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php index 4f93d0c6d..a09a0dcdb 100644 --- a/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php +++ b/packages/mysql-on-sqlite/tests/WP_MySQL_On_SQLite_Translation_Tests.php @@ -1739,14 +1739,27 @@ public function testIntervalArithmeticInParentheses(): void { // TODO: With LALR(1), also test these expressions without parentheses. // Parentheses prevent the parser from treating this as a prefix interval. $this->assertQuery( - "SELECT ( DATETIME('2024-01-01', (1) || ' DAY') ) + 0 AS `a`", - "SELECT ('2024-01-01' + INTERVAL 1 DAY) + 0 AS a" + "SELECT ( DATETIME('2024-01-01', (1) || ' HOUR') ) + 0 AS `a`", + "SELECT ('2024-01-01' + INTERVAL 1 HOUR) + 0 AS a" ); // Parentheses make the interval apply to the whole expression. $this->assertQuery( - "SELECT DATETIME(( 1 + '2024-01-01' ), (1) || ' DAY') AS `a`", - "SELECT (1 + '2024-01-01') + INTERVAL 1 DAY AS a" + "SELECT DATETIME(( 1 + '2024-01-01' ), (1) || ' HOUR') AS `a`", + "SELECT (1 + '2024-01-01') + INTERVAL 1 HOUR AS a" + ); + } + + public function testIntervalArithmeticChains(): void { + // Only date units return a date for date inputs, which have 10 characters. + $this->assertQuery( + "SELECT CASE WHEN LENGTH('2024-01-01') = 10 THEN DATE('2024-01-01', (1) || ' DAY', -(2) || ' MONTH') ELSE DATETIME('2024-01-01', (1) || ' DAY', -(2) || ' MONTH') END AS `a`", + "SELECT '2024-01-01' + INTERVAL 1 DAY - INTERVAL 2 MONTH AS a" + ); + + $this->assertQuery( + "SELECT DATETIME('2024-01-01', (1) || ' DAY', (2) || ' HOUR') AS `a`", + "SELECT '2024-01-01' + INTERVAL 1 DAY + INTERVAL 2 HOUR AS a" ); }