From ed3fb3a88cb90031c51b0db0b26ece1f8228ad3b Mon Sep 17 00:00:00 2001 From: Jules Ivanic Date: Tue, 13 Oct 2026 20:39:20 +1100 Subject: [PATCH 1/2] DuckDB: support QUALIFY after WINDOW, OVER (w ...) and FILTER without WHERE - QUALIFY may follow the WINDOW clause and prints back there (`PlainSelect.isQualifyAfterWindow()`). - A window specification may start with the name of the window it extends, `OVER (w ORDER BY b)`, also in WINDOW definitions (`WindowDefinition.getBaseWindowName()`). - `FILTER (b > 1)` without WHERE is accepted except under the PostgreSQL dialect (`AnalyticExpression.isFilterWhereKeyword()`). --- .../expression/AnalyticExpression.java | 13 ++++++- .../expression/WindowDefinition.java | 27 +++++++++++++ .../statement/select/PlainSelect.java | 16 +++++++- .../util/deparser/ExpressionDeParser.java | 3 ++ .../util/deparser/SelectDeParser.java | 6 ++- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 38 ++++++++++++++++--- .../statement/select/DuckDBTest.java | 27 +++++++++++++ 7 files changed, 122 insertions(+), 8 deletions(-) diff --git a/src/main/java/net/sf/jsqlparser/expression/AnalyticExpression.java b/src/main/java/net/sf/jsqlparser/expression/AnalyticExpression.java index ce19f49cd..0f8f56dd6 100644 --- a/src/main/java/net/sf/jsqlparser/expression/AnalyticExpression.java +++ b/src/main/java/net/sf/jsqlparser/expression/AnalyticExpression.java @@ -41,6 +41,7 @@ public class AnalyticExpression extends ASTNodeAccessImpl implements Expression private boolean unique = false; private boolean ignoreNullsOutside = false; // IGNORE NULLS outside function parameters private Expression filterExpression = null; + private boolean filterWhereKeyword = true; private List funcOrderBy = null; private String onOverflowTruncate = null; @@ -391,12 +392,22 @@ public String toString() { /** Renders the filter using the caller's expression writer. */ public void appendFilterTo(StringBuilder builder, Consumer expressionPrinter) { if (filterExpression != null) { - builder.append("FILTER (WHERE "); + builder.append(filterWhereKeyword ? "FILTER (WHERE " : "FILTER ("); expressionPrinter.accept(filterExpression); builder.append(')'); } } + /** Whether the FILTER clause has WHERE, which DuckDB allows to leave out. */ + public boolean isFilterWhereKeyword() { + return filterWhereKeyword; + } + + public AnalyticExpression setFilterWhereKeyword(boolean filterWhereKeyword) { + this.filterWhereKeyword = filterWhereKeyword; + return this; + } + public boolean isAllColumns() { return allColumns; } diff --git a/src/main/java/net/sf/jsqlparser/expression/WindowDefinition.java b/src/main/java/net/sf/jsqlparser/expression/WindowDefinition.java index 2996821cf..cbf625354 100644 --- a/src/main/java/net/sf/jsqlparser/expression/WindowDefinition.java +++ b/src/main/java/net/sf/jsqlparser/expression/WindowDefinition.java @@ -23,6 +23,7 @@ public class WindowDefinition implements Serializable { final PartitionByClause partitionBy = new PartitionByClause(); WindowElement windowElement = null; private String windowName; + private String baseWindowName; public OrderByClause getOrderBy() { return orderBy; @@ -61,6 +62,31 @@ public void setPartitionExpressionList(ExpressionList partitionExpre partitionBy.setExpressions(partitionExpressionList, brackets); } + /** DuckDB: the window this one extends, as w in OVER (w ORDER BY b), or {@code null}. */ + public String getBaseWindowName() { + return baseWindowName; + } + + public WindowDefinition setBaseWindowName(String baseWindowName) { + this.baseWindowName = baseWindowName; + return this; + } + + /** Appends the base window name, separated from any following part. */ + public void appendBaseWindowNameTo(StringBuilder builder) { + if (baseWindowName != null) { + builder.append(baseWindowName); + boolean more = partitionBy.getPartitionExpressionList() != null + && !partitionBy.getPartitionExpressionList().isEmpty() + || orderBy.getOrderByElements() != null + && !orderBy.getOrderByElements().isEmpty() + || windowElement != null; + if (more) { + builder.append(' '); + } + } + } + public String getWindowName() { return windowName; } @@ -105,6 +131,7 @@ public String toString() { b.append(windowName).append(" AS "); } b.append("("); + appendBaseWindowNameTo(b); partitionBy.toStringPartitionBy(b); orderBy.toStringOrderByElements(b); diff --git a/src/main/java/net/sf/jsqlparser/statement/select/PlainSelect.java b/src/main/java/net/sf/jsqlparser/statement/select/PlainSelect.java index 700ff7cf0..25ee11654 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/PlainSelect.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/PlainSelect.java @@ -45,6 +45,7 @@ public class PlainSelect extends Select { private GroupByElement groupBy; private Expression having; private Expression qualify; + private boolean qualifyAfterWindow; private OptimizeFor optimizeFor; private Skip skip; private final EnumSet mySqlSelectModifiers = @@ -442,6 +443,16 @@ public Expression getQualify() { return qualify; } + /** Whether QUALIFY follows the WINDOW clause, as in DuckDB, rather than preceding it. */ + public boolean isQualifyAfterWindow() { + return qualifyAfterWindow; + } + + public PlainSelect setQualifyAfterWindow(boolean qualifyAfterWindow) { + this.qualifyAfterWindow = qualifyAfterWindow; + return this; + } + public PlainSelect setQualify(Expression qualify) { this.qualify = qualify; return this; @@ -595,7 +606,7 @@ public StringBuilder appendSelectBodyTo(StringBuilder builder) { if (having != null) { builder.append(" HAVING ").append(having); } - if (qualify != null) { + if (qualify != null && !qualifyAfterWindow) { builder.append(" QUALIFY ").append(qualify); } if (windowDefinitions != null) { @@ -603,6 +614,9 @@ public StringBuilder appendSelectBodyTo(StringBuilder builder) { builder.append(windowDefinitions.stream().map(WindowDefinition::toString) .collect(joining(", "))); } + if (qualify != null && qualifyAfterWindow) { + builder.append(" QUALIFY ").append(qualify); + } appendEmitClauseTo(builder); if (intoTempTable != null) { builder.append(" INTO TEMP ").append(intoTempTable); diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java index d0700d731..32a6d20ca 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java @@ -1346,6 +1346,9 @@ public StringBuilder visit(AnalyticExpression analyticExpression, S context) builder.append(" ").append(analyticExpression.getWindowName()); } else if (analyticExpression.getType() != AnalyticType.WITHIN_GROUP_OVER) { builder.append(" ("); + if (analyticExpression.getWindowDefinition() != null) { + analyticExpression.getWindowDefinition().appendBaseWindowNameTo(builder); + } if (partitionExpressionList != null && !partitionExpressionList.isEmpty()) { diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java index 31a3b9fa5..8a0eb6e49 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java @@ -233,7 +233,7 @@ public StringBuilder visit(PlainSelect plainSelect, S context) { builder.append(" HAVING "); plainSelect.getHaving().accept(expressionVisitor, context); } - if (plainSelect.getQualify() != null) { + if (plainSelect.getQualify() != null && !plainSelect.isQualifyAfterWindow()) { builder.append(" QUALIFY "); plainSelect.getQualify().accept(expressionVisitor, context); } @@ -242,6 +242,10 @@ public StringBuilder visit(PlainSelect plainSelect, S context) { builder.append(plainSelect.getWindowDefinitions().stream() .map(WindowDefinition::toString).collect(joining(", "))); } + if (plainSelect.getQualify() != null && plainSelect.isQualifyAfterWindow()) { + builder.append(" QUALIFY "); + plainSelect.getQualify().accept(expressionVisitor, context); + } Alias alias = plainSelect.getAlias(); if (alias != null) { builder.append(alias); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index bc32ef541..c7c846002 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1833,6 +1833,17 @@ public class CCJSqlParser extends AbstractJSqlParser { } } + /** Whether a window specification starts with the name of the window it extends. */ + private boolean isBaseWindowNameAhead() { + Token token = getToken(1); + switch (token.kind) { + case K_PARTITION: case K_ORDER: case K_ROWS: case K_RANGE: case K_GROUPS: + return false; + default: + return isIdentifierishNameToken(token); + } + } + private boolean isAliasAhead() { if (isAsOfJoinAhead() || isSemiAntiJoinAhead() || isApproxNearestAhead()) { return false; @@ -8409,6 +8420,9 @@ PlainSelect PlainSelect() #PlainSelect: ( LOOKAHEAD(2) "," windowName = RelObjectName() winDef = windowDefinition() { winDefs.add(winDef.withWindowName(windowName)); } )* { plainSelect.setWindowDefinitions(winDefs); } ] + // DuckDB: QUALIFY after WINDOW + [ LOOKAHEAD(2, { plainSelect.getQualify() == null }) qualify=Qualify() + { plainSelect.setQualify(qualify); plainSelect.setQualifyAfterWindow(true); } ] [ LOOKAHEAD( ) orderByElements = OrderByElements() { plainSelect.setOrderByElements(orderByElements); } ] [ LOOKAHEAD(2) interpolateElements = InterpolateClause() { plainSelect.setInterpolate(interpolateElements); } ] [ LOOKAHEAD(2) forClause = ForClause() {plainSelect.setForClause(forClause);} ] @@ -13498,8 +13512,12 @@ WindowDefinition windowDefinition() : { WindowElement windowElement = null; boolean partitionByBrackets = false; WindowDefinition winDef = new WindowDefinition(); + String baseWindowName; } { "(" + // DuckDB: (w ORDER BY b) extends the named window w + [ LOOKAHEAD({ isBaseWindowNameAhead() }) baseWindowName=RelObjectName() + { winDef.setBaseWindowName(baseWindowName); } ] [ (LOOKAHEAD(3) expressionList=ComplexExpressionList() | "(" {partitionByBrackets = true;} expressionList=ComplexExpressionList() ")" ) @@ -13523,7 +13541,7 @@ AnalyticExpression AnalyticExpression(Function function) : { ( ( - filter=AggregateFilter() { retval.setType(AnalyticType.FILTER_ONLY); } + filter=AggregateFilter(retval) { retval.setType(AnalyticType.FILTER_ONLY); } [ LOOKAHEAD(2) windowFun(retval) ] { if (Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)) @@ -13535,7 +13553,7 @@ AnalyticExpression AnalyticExpression(Function function) : ) | windowFun(retval) [ LOOKAHEAD({ retval.getType() == AnalyticType.WITHIN_GROUP - && getToken(1).kind == K_FILTER }) filter=AggregateFilter() ] + && getToken(1).kind == K_FILTER }) filter=AggregateFilter(retval) ] ) { retval.setFilterExpression(filter); @@ -13543,11 +13561,21 @@ AnalyticExpression AnalyticExpression(Function function) : } } -/** Shared FILTER predicate for ordinary, window, and ordered-set aggregates. */ -Expression AggregateFilter(): +/** + * Shared FILTER predicate for ordinary, window, and ordered-set aggregates. DuckDB also accepts + * it without WHERE, FILTER (x > 1), but PostgreSQL does not. + */ +Expression AggregateFilter(AnalyticExpression analyticExpression): { Expression filter; } { - "(" filter=Expression() ")" + "(" + ( + + | + LOOKAHEAD({ !Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)) }) + { analyticExpression.setFilterWhereKeyword(false); } + ) + filter=Expression() ")" { return filter; } } diff --git a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java index 77483f68d..57f86039e 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -1072,4 +1072,31 @@ void testTrailingCommaInSelectList() throws JSQLParserException { TestUtils.assertSqlCanBeParsedAndDeparsed("SELECT a, " + item + " FROM t", true); } } + + @Test + void testWindowAndFilterForms() throws JSQLParserException { + for (String sqlStr : new String[] { + "SELECT a FROM t WINDOW w AS (ORDER BY a) QUALIFY row_number() OVER w = 1", + "SELECT a FROM t QUALIFY row_number() OVER w = 1 WINDOW w AS (ORDER BY a)", + "SELECT sum(a) OVER (w ROWS 2 PRECEDING) FROM t WINDOW w AS (ORDER BY b)", + "SELECT sum(a) OVER (w) FROM t WINDOW w AS (ORDER BY b)", + "SELECT sum(a) OVER w2 FROM t WINDOW w AS (PARTITION BY c), w2 AS (w ORDER BY b)", + "SELECT sum(a) OVER (ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) FROM t", + "SELECT sum(a) FILTER (b > 1) FROM t", + "SELECT count(*) FILTER (b > 1) OVER () FROM t"}) { + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } + PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse( + "SELECT sum(a) FILTER (b > 1) OVER (w ORDER BY c) FROM t WINDOW w AS (PARTITION BY d)"); + net.sf.jsqlparser.expression.AnalyticExpression sum = + (net.sf.jsqlparser.expression.AnalyticExpression) select.getSelectItem(0) + .getExpression(); + Assertions.assertFalse(sum.isFilterWhereKeyword()); + // PostgreSQL requires WHERE + Assertions.assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse("SELECT sum(a) FILTER (b > 1) FROM t", + parser -> parser.withDialect( + net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect.POSTGRESQL))); + Assertions.assertEquals("w", sum.getWindowDefinition().getBaseWindowName()); + } } From 70e67f4fd856f973c2bd88e00dd1e50e2883787b Mon Sep 17 00:00:00 2001 From: Jules Ivanic Date: Tue, 13 Oct 2026 20:58:52 +1100 Subject: [PATCH 2/2] Keep window specifications parseable inside lookahead - The base window name is a choice rather than an optional, so that lookahead scans through a window specification keep its check: `JSON_VALUE(... DEFAULT sum(a) OVER (PARTITION BY b) ON EMPTY)` parses again. - Only an OVER window may extend a named window, not WITHIN GROUP. - QUALIFY after WINDOW requires a WINDOW clause. --- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 25 +++++++++++++------ .../statement/select/DuckDBTest.java | 9 ++++++- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index c7c846002..d0d543b71 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -8419,10 +8419,10 @@ PlainSelect PlainSelect() #PlainSelect: windowName = RelObjectName() winDef = windowDefinition() { List winDefs = new ArrayList(); winDefs.add(winDef.withWindowName(windowName)); } ( LOOKAHEAD(2) "," windowName = RelObjectName() winDef = windowDefinition() { winDefs.add(winDef.withWindowName(windowName)); } )* { plainSelect.setWindowDefinitions(winDefs); } + // DuckDB: QUALIFY after WINDOW + [ LOOKAHEAD(2, { plainSelect.getQualify() == null }) qualify=Qualify() + { plainSelect.setQualify(qualify); plainSelect.setQualifyAfterWindow(true); } ] ] - // DuckDB: QUALIFY after WINDOW - [ LOOKAHEAD(2, { plainSelect.getQualify() == null }) qualify=Qualify() - { plainSelect.setQualify(qualify); plainSelect.setQualifyAfterWindow(true); } ] [ LOOKAHEAD( ) orderByElements = OrderByElements() { plainSelect.setOrderByElements(orderByElements); } ] [ LOOKAHEAD(2) interpolateElements = InterpolateClause() { plainSelect.setInterpolate(interpolateElements); } ] [ LOOKAHEAD(2) forClause = ForClause() {plainSelect.setForClause(forClause);} ] @@ -13493,7 +13493,13 @@ void windowFun(AnalyticExpression retval):{ ( windowName = RelObjectName() { retval.setWindowName(windowName); } | - winDef = windowDefinition() { retval.setWindowDefinition(winDef); } + winDef = windowDefinition() + { + if (winDef.getBaseWindowName() != null && retval.getType() != AnalyticType.OVER) { + throw new ParseException("Only an OVER window may extend a named window"); + } + retval.setWindowDefinition(winDef); + } [ LOOKAHEAD(2) "(" @@ -13515,9 +13521,14 @@ WindowDefinition windowDefinition() : { String baseWindowName; } { "(" - // DuckDB: (w ORDER BY b) extends the named window w - [ LOOKAHEAD({ isBaseWindowNameAhead() }) baseWindowName=RelObjectName() - { winDef.setBaseWindowName(baseWindowName); } ] + // DuckDB: (w ORDER BY b) extends the named window w. A choice, not an optional, so that + // lookahead scans through a window specification keep the check + ( + LOOKAHEAD({ isBaseWindowNameAhead() }) baseWindowName=RelObjectName() + { winDef.setBaseWindowName(baseWindowName); } + | + {} + ) [ (LOOKAHEAD(3) expressionList=ComplexExpressionList() | "(" {partitionByBrackets = true;} expressionList=ComplexExpressionList() ")" ) diff --git a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java index 57f86039e..7d98c6b7c 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -1083,7 +1083,10 @@ void testWindowAndFilterForms() throws JSQLParserException { "SELECT sum(a) OVER w2 FROM t WINDOW w AS (PARTITION BY c), w2 AS (w ORDER BY b)", "SELECT sum(a) OVER (ROWS BETWEEN 1 PRECEDING AND CURRENT ROW) FROM t", "SELECT sum(a) FILTER (b > 1) FROM t", - "SELECT count(*) FILTER (b > 1) OVER () FROM t"}) { + "SELECT count(*) FILTER (b > 1) OVER () FROM t", + // window specifications inside syntactic lookahead + "SELECT JSON_VALUE(j, '$.a' DEFAULT sum(a) OVER (PARTITION BY b) ON EMPTY) FROM t", + "SELECT JSON_VALUE(j, '$.a' DEFAULT sum(a) OVER (w ORDER BY b) ON EMPTY) FROM t"}) { TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); } PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse( @@ -1092,6 +1095,10 @@ void testWindowAndFilterForms() throws JSQLParserException { (net.sf.jsqlparser.expression.AnalyticExpression) select.getSelectItem(0) .getExpression(); Assertions.assertFalse(sum.isFilterWhereKeyword()); + Assertions.assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse( + "SELECT percentile_cont(0.5) WITHIN GROUP (w ORDER BY a) FROM t")); + Assertions.assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse("SELECT a FROM t ORDER SIBLINGS BY a QUALIFY x = 1")); // PostgreSQL requires WHERE Assertions.assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse("SELECT sum(a) FILTER (b > 1) FROM t",