Skip to content

Commit 9ec28b2

Browse files
committed
fix(parser): classify ODBC timestamp interval arguments
Use the existing time-unit AST for standard SQL_TSI_* keywords in escaped TIMESTAMPADD and TIMESTAMPDIFF calls, while preserving ordinary function and identifier handling. Fixes #1139.
1 parent eddb1fb commit 9ec28b2

5 files changed

Lines changed: 144 additions & 1 deletion

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,9 @@ Background reading: the [Google research paper](https://storage.googleapis.com/g
208208
[BigQuery pipe syntax](https://cloud.google.com/bigquery/docs/reference/standard-sql/pipe-syntax)
209209
and [DuckDB FROM-first syntax](https://duckdb.org/docs/sql/query_syntax/from.html#from-first-syntax).
210210

211+
ODBC `{fn TIMESTAMPADD(...)}` and `{fn TIMESTAMPDIFF(...)}` expose standard
212+
`SQL_TSI_*` interval arguments as time-unit expressions, preserving column traversal.
213+
211214
## Java version
212215

213216
| JSqlParser | Runtime | Notes |

src/main/java/net/sf/jsqlparser/expression/DateUnitExpression.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,19 @@ public String toString() {
4242
}
4343

4444
public enum DateUnit {
45-
CENTURY, DECADE, YEAR, QUARTER, MONTH, WEEK, DAY, HOUR, MINUTE, SECOND, MILLISECOND, MICROSECOND, NANOSECOND;
45+
CENTURY, DECADE, YEAR, QUARTER, MONTH, WEEK, DAY, HOUR, MINUTE, SECOND, MILLISECOND, MICROSECOND, NANOSECOND, SQL_TSI_FRAC_SECOND, SQL_TSI_SECOND, SQL_TSI_MINUTE, SQL_TSI_HOUR, SQL_TSI_DAY, SQL_TSI_WEEK, SQL_TSI_MONTH, SQL_TSI_QUARTER, SQL_TSI_YEAR;
46+
47+
/** Returns an ODBC interval keyword, or null when the text is not one. */
48+
public static DateUnit fromOdbcInterval(String text) {
49+
if (text == null || !text.toUpperCase(Locale.ROOT).startsWith("SQL_TSI_")) {
50+
return null;
51+
}
52+
try {
53+
return from(text);
54+
} catch (IllegalArgumentException exception) {
55+
return null;
56+
}
57+
}
4658

4759
public static DateUnit from(String UnitStr) {
4860
return Enum.valueOf(DateUnit.class, UnitStr.toUpperCase(Locale.ROOT));

src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,34 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
782782
function.setExtraKeyword(getNextToken().image);
783783
}
784784

785+
private void normalizeOdbcTimestampInterval(Function function) {
786+
if (!function.isEscaped() || function.getMultipartName().size() != 1
787+
|| !("TIMESTAMPADD".equalsIgnoreCase(function.getName())
788+
|| "TIMESTAMPDIFF".equalsIgnoreCase(function.getName()))) {
789+
return;
790+
}
791+
ExpressionList<?> parameters = function.getParameters();
792+
if (parameters == null || parameters.size() != 3
793+
|| parameters instanceof ParenthesedExpressionList
794+
|| !(parameters.get(0) instanceof Column)) {
795+
return;
796+
}
797+
Column column = (Column) parameters.get(0);
798+
if (column.getTable() != null) {
799+
return;
800+
}
801+
DateUnitExpression.DateUnit unit =
802+
DateUnitExpression.DateUnit.fromOdbcInterval(column.getColumnName());
803+
if (unit != null) {
804+
DateUnitExpression interval = new DateUnitExpression(unit);
805+
linkAST(interval, column.getASTNode());
806+
ExpressionList<Expression> normalized = new ExpressionList<Expression>(parameters);
807+
normalized.setASTNode(parameters.getASTNode());
808+
normalized.set(0, interval);
809+
function.setParameters(normalized);
810+
}
811+
}
812+
785813
private boolean isKeywordArgumentAhead() {
786814
Token t = getToken(1);
787815
if (t.kind == EOF || t.image.equals(")")) return false;
@@ -10869,6 +10897,7 @@ Function Function() #Function:
1086910897
{
1087010898
(
1087110899
"{" <K_FN> function = InternalFunction(true) "}"
10900+
{ normalizeOdbcTimestampInterval(function); }
1087210901
| LOOKAHEAD(3) function = SpecialStringFunctionWithNamedParameters()
1087310902
| function = InternalFunction(false)
1087410903
)

src/site/sphinx/usage.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -639,6 +639,17 @@ The object model works in both directions. Build the tree from Java and print it
639639
Assertions.assertEquals(expectedSQLStr, builder.toString());
640640
641641
642+
ODBC timestamp intervals
643+
==============================
644+
645+
In ODBC escapes such as ``{fn TIMESTAMPADD(SQL_TSI_YEAR, 2, travel_date)}`` and
646+
``{fn TIMESTAMPDIFF(SQL_TSI_DAY, start_date, end_date)}``, the first argument is a
647+
``DateUnitExpression`` for the nine standard ``SQL_TSI_*`` interval keywords.
648+
The original ODBC keyword is preserved on output and is not visited as a column.
649+
This applies only to unqualified, escaped calls with three arguments and a bare
650+
interval keyword. Ordinary calls, qualified names, quoted identifiers and other
651+
arguments keep their existing expression interpretation.
652+
642653
Handle Parse Errors
643654
==============================
644655

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*-
2+
* #%L
3+
* JSQLParser library
4+
* %%
5+
* Copyright (C) 2004 - 2026 JSQLParser
6+
* %%
7+
* Dual licensed under GNU LGPL 2.1 or Apache License 2.0
8+
* #L%
9+
*/
10+
package net.sf.jsqlparser.parser;
11+
12+
import java.util.ArrayList;
13+
import java.util.List;
14+
import net.sf.jsqlparser.expression.DateUnitExpression;
15+
import net.sf.jsqlparser.expression.DateUnitExpression.DateUnit;
16+
import net.sf.jsqlparser.expression.ExpressionVisitorAdapter;
17+
import net.sf.jsqlparser.expression.Function;
18+
import net.sf.jsqlparser.schema.Column;
19+
import net.sf.jsqlparser.statement.select.PlainSelect;
20+
import org.junit.jupiter.api.Test;
21+
import org.junit.jupiter.params.ParameterizedTest;
22+
import org.junit.jupiter.params.provider.ValueSource;
23+
24+
import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed;
25+
import static org.junit.jupiter.api.Assertions.*;
26+
27+
class OdbcTimestampIntervalTest {
28+
@ParameterizedTest
29+
@ValueSource(strings = {"SQL_TSI_FRAC_SECOND", "SQL_TSI_SECOND", "SQL_TSI_MINUTE",
30+
"SQL_TSI_HOUR", "SQL_TSI_DAY", "SQL_TSI_WEEK", "SQL_TSI_MONTH",
31+
"SQL_TSI_QUARTER", "SQL_TSI_YEAR"})
32+
void recognizesStandardIntervalsForBothFunctions(String unit) throws Exception {
33+
for (String name : List.of("TIMESTAMPADD", "TIMESTAMPDIFF")) {
34+
String sql = "SELECT {fn " + name + "(" + unit + ", 2, travel_date)} FROM t";
35+
for (boolean complex : new boolean[] {false, true}) {
36+
PlainSelect select = (PlainSelect) assertSqlCanBeParsedAndDeparsed(sql, true,
37+
parser -> parser.withAllowComplexParsing(complex));
38+
Function function = (Function) select.getSelectItem(0).getExpression();
39+
DateUnitExpression interval = assertInstanceOf(DateUnitExpression.class,
40+
function.getParameters().get(0));
41+
assertEquals(DateUnit.from(unit), interval.getType());
42+
assertEquals(unit, interval.toString());
43+
assertNotNull(interval.getASTNode());
44+
assertEquals(select.toString(),
45+
CCJSqlParserUtil.parse(select.toString()).toString());
46+
List<String> columns = new ArrayList<>();
47+
function.accept(new ExpressionVisitorAdapter<Void>() {
48+
@Override
49+
public <S> Void visit(Column column, S context) {
50+
columns.add(column.getColumnName());
51+
return null;
52+
}
53+
}, null);
54+
assertEquals(List.of("travel_date"), columns);
55+
}
56+
}
57+
}
58+
59+
@ParameterizedTest
60+
@ValueSource(strings = {
61+
"TIMESTAMPADD(SQL_TSI_YEAR, 2, travel_date)",
62+
"{fn other(SQL_TSI_YEAR, 2, travel_date)}",
63+
"{fn schema.TIMESTAMPADD(SQL_TSI_YEAR, 2, travel_date)}",
64+
"{fn TIMESTAMPADD(t.SQL_TSI_YEAR, 2, travel_date)}",
65+
"{fn TIMESTAMPADD(\"SQL_TSI_YEAR\", 2, travel_date)}",
66+
"{fn TIMESTAMPADD(SQL_TSI_UNKNOWN, 2, travel_date)}",
67+
"{fn TIMESTAMPADD(SQL_TSI_YEAR, travel_date)}"
68+
})
69+
void preservesIdentifiersOutsideTheOdbcIntervalPosition(String expression) throws Exception {
70+
PlainSelect select =
71+
(PlainSelect) CCJSqlParserUtil.parse("SELECT " + expression + " FROM t");
72+
Function function = (Function) select.getSelectItem(0).getExpression();
73+
assertInstanceOf(Column.class, function.getParameters().get(0));
74+
}
75+
76+
@Test
77+
void handlesCaseAndNestedEscapesWithoutChangingOtherArguments() throws Exception {
78+
String sql = "SELECT {fn timestampdiff(sql_tsi_year, {fn CURDATE()}, SQL_TSI_YEAR)} FROM t";
79+
PlainSelect select = (PlainSelect) assertSqlCanBeParsedAndDeparsed(sql, true, null);
80+
Function function = (Function) select.getSelectItem(0).getExpression();
81+
assertInstanceOf(DateUnitExpression.class, function.getParameters().get(0));
82+
assertInstanceOf(Function.class, function.getParameters().get(1));
83+
assertInstanceOf(Column.class, function.getParameters().get(2));
84+
assertNull(DateUnit.fromOdbcInterval(null));
85+
assertNull(DateUnit.fromOdbcInterval("YEAR"));
86+
assertNull(DateUnit.fromOdbcInterval("SQL_TSI_UNKNOWN"));
87+
}
88+
}

0 commit comments

Comments
 (0)