diff --git a/README.md b/README.md index 27202cacc..79e5ff509 100644 --- a/README.md +++ b/README.md @@ -208,6 +208,9 @@ Background reading: the [Google research paper](https://storage.googleapis.com/g [BigQuery pipe syntax](https://cloud.google.com/bigquery/docs/reference/standard-sql/pipe-syntax) and [DuckDB FROM-first syntax](https://duckdb.org/docs/sql/query_syntax/from.html#from-first-syntax). +ODBC `{fn TIMESTAMPADD(...)}` and `{fn TIMESTAMPDIFF(...)}` expose standard +`SQL_TSI_*` interval arguments as time-unit expressions, preserving column traversal. + ## Java version | JSqlParser | Runtime | Notes | diff --git a/src/main/java/net/sf/jsqlparser/expression/DateUnitExpression.java b/src/main/java/net/sf/jsqlparser/expression/DateUnitExpression.java index 922c0c419..61fa514ff 100644 --- a/src/main/java/net/sf/jsqlparser/expression/DateUnitExpression.java +++ b/src/main/java/net/sf/jsqlparser/expression/DateUnitExpression.java @@ -42,7 +42,19 @@ public String toString() { } public enum DateUnit { - CENTURY, DECADE, YEAR, QUARTER, MONTH, WEEK, DAY, HOUR, MINUTE, SECOND, MILLISECOND, MICROSECOND, NANOSECOND; + 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; + + /** Returns an ODBC interval keyword, or null when the text is not one. */ + public static DateUnit fromOdbcInterval(String text) { + if (text == null || !text.toUpperCase(Locale.ROOT).startsWith("SQL_TSI_")) { + return null; + } + try { + return from(text); + } catch (IllegalArgumentException exception) { + return null; + } + } public static DateUnit from(String UnitStr) { return Enum.valueOf(DateUnit.class, UnitStr.toUpperCase(Locale.ROOT)); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index e89a86094..63598e742 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -782,6 +782,34 @@ public class CCJSqlParser extends AbstractJSqlParser { function.setExtraKeyword(getNextToken().image); } + private void normalizeOdbcTimestampInterval(Function function) { + if (!function.isEscaped() || function.getMultipartName().size() != 1 + || !("TIMESTAMPADD".equalsIgnoreCase(function.getName()) + || "TIMESTAMPDIFF".equalsIgnoreCase(function.getName()))) { + return; + } + ExpressionList parameters = function.getParameters(); + if (parameters == null || parameters.size() != 3 + || parameters instanceof ParenthesedExpressionList + || !(parameters.get(0) instanceof Column)) { + return; + } + Column column = (Column) parameters.get(0); + if (column.getTable() != null) { + return; + } + DateUnitExpression.DateUnit unit = + DateUnitExpression.DateUnit.fromOdbcInterval(column.getColumnName()); + if (unit != null) { + DateUnitExpression interval = new DateUnitExpression(unit); + linkAST(interval, column.getASTNode()); + ExpressionList normalized = new ExpressionList(parameters); + normalized.setASTNode(parameters.getASTNode()); + normalized.set(0, interval); + function.setParameters(normalized); + } + } + private boolean isKeywordArgumentAhead() { Token t = getToken(1); if (t.kind == EOF || t.image.equals(")")) return false; @@ -10869,6 +10897,7 @@ Function Function() #Function: { ( "{" function = InternalFunction(true) "}" + { normalizeOdbcTimestampInterval(function); } | LOOKAHEAD(3) function = SpecialStringFunctionWithNamedParameters() | function = InternalFunction(false) ) diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 6d3995c0c..388531e41 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -639,6 +639,17 @@ The object model works in both directions. Build the tree from Java and print it Assertions.assertEquals(expectedSQLStr, builder.toString()); +ODBC timestamp intervals +============================== + +In ODBC escapes such as ``{fn TIMESTAMPADD(SQL_TSI_YEAR, 2, travel_date)}`` and +``{fn TIMESTAMPDIFF(SQL_TSI_DAY, start_date, end_date)}``, the first argument is a +``DateUnitExpression`` for the nine standard ``SQL_TSI_*`` interval keywords. +The original ODBC keyword is preserved on output and is not visited as a column. +This applies only to unqualified, escaped calls with three arguments and a bare +interval keyword. Ordinary calls, qualified names, quoted identifiers and other +arguments keep their existing expression interpretation. + Handle Parse Errors ============================== diff --git a/src/test/java/net/sf/jsqlparser/parser/OdbcTimestampIntervalTest.java b/src/test/java/net/sf/jsqlparser/parser/OdbcTimestampIntervalTest.java new file mode 100644 index 000000000..381bbccda --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/parser/OdbcTimestampIntervalTest.java @@ -0,0 +1,88 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.parser; + +import java.util.ArrayList; +import java.util.List; +import net.sf.jsqlparser.expression.DateUnitExpression; +import net.sf.jsqlparser.expression.DateUnitExpression.DateUnit; +import net.sf.jsqlparser.expression.ExpressionVisitorAdapter; +import net.sf.jsqlparser.expression.Function; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.statement.select.PlainSelect; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.junit.jupiter.api.Assertions.*; + +class OdbcTimestampIntervalTest { + @ParameterizedTest + @ValueSource(strings = {"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"}) + void recognizesStandardIntervalsForBothFunctions(String unit) throws Exception { + for (String name : List.of("TIMESTAMPADD", "TIMESTAMPDIFF")) { + String sql = "SELECT {fn " + name + "(" + unit + ", 2, travel_date)} FROM t"; + for (boolean complex : new boolean[] {false, true}) { + PlainSelect select = (PlainSelect) assertSqlCanBeParsedAndDeparsed(sql, true, + parser -> parser.withAllowComplexParsing(complex)); + Function function = (Function) select.getSelectItem(0).getExpression(); + DateUnitExpression interval = assertInstanceOf(DateUnitExpression.class, + function.getParameters().get(0)); + assertEquals(DateUnit.from(unit), interval.getType()); + assertEquals(unit, interval.toString()); + assertNotNull(interval.getASTNode()); + assertEquals(select.toString(), + CCJSqlParserUtil.parse(select.toString()).toString()); + List columns = new ArrayList<>(); + function.accept(new ExpressionVisitorAdapter() { + @Override + public Void visit(Column column, S context) { + columns.add(column.getColumnName()); + return null; + } + }, null); + assertEquals(List.of("travel_date"), columns); + } + } + } + + @ParameterizedTest + @ValueSource(strings = { + "TIMESTAMPADD(SQL_TSI_YEAR, 2, travel_date)", + "{fn other(SQL_TSI_YEAR, 2, travel_date)}", + "{fn schema.TIMESTAMPADD(SQL_TSI_YEAR, 2, travel_date)}", + "{fn TIMESTAMPADD(t.SQL_TSI_YEAR, 2, travel_date)}", + "{fn TIMESTAMPADD(\"SQL_TSI_YEAR\", 2, travel_date)}", + "{fn TIMESTAMPADD(SQL_TSI_UNKNOWN, 2, travel_date)}", + "{fn TIMESTAMPADD(SQL_TSI_YEAR, travel_date)}" + }) + void preservesIdentifiersOutsideTheOdbcIntervalPosition(String expression) throws Exception { + PlainSelect select = + (PlainSelect) CCJSqlParserUtil.parse("SELECT " + expression + " FROM t"); + Function function = (Function) select.getSelectItem(0).getExpression(); + assertInstanceOf(Column.class, function.getParameters().get(0)); + } + + @Test + void handlesCaseAndNestedEscapesWithoutChangingOtherArguments() throws Exception { + String sql = "SELECT {fn timestampdiff(sql_tsi_year, {fn CURDATE()}, SQL_TSI_YEAR)} FROM t"; + PlainSelect select = (PlainSelect) assertSqlCanBeParsedAndDeparsed(sql, true, null); + Function function = (Function) select.getSelectItem(0).getExpression(); + assertInstanceOf(DateUnitExpression.class, function.getParameters().get(0)); + assertInstanceOf(Function.class, function.getParameters().get(1)); + assertInstanceOf(Column.class, function.getParameters().get(2)); + assertNull(DateUnit.fromOdbcInterval(null)); + assertNull(DateUnit.fromOdbcInterval("YEAR")); + assertNull(DateUnit.fromOdbcInterval("SQL_TSI_UNKNOWN")); + } +}