Skip to content
Merged
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
29 changes: 29 additions & 0 deletions src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt
Original file line number Diff line number Diff line change
Expand Up @@ -782,6 +782,34 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
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<Expression> normalized = new ExpressionList<Expression>(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;
Expand Down Expand Up @@ -10869,6 +10897,7 @@ Function Function() #Function:
{
(
"{" <K_FN> function = InternalFunction(true) "}"
{ normalizeOdbcTimestampInterval(function); }
| LOOKAHEAD(3) function = SpecialStringFunctionWithNamedParameters()
| function = InternalFunction(false)
)
Expand Down
11 changes: 11 additions & 0 deletions src/site/sphinx/usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
==============================

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> columns = new ArrayList<>();
function.accept(new ExpressionVisitorAdapter<Void>() {
@Override
public <S> 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"));
}
}
Loading