From 55481bc0ee3652002681921451cdc6c678ab5bef Mon Sep 17 00:00:00 2001 From: minleejae Date: Sun, 20 Sep 2026 20:54:02 +0900 Subject: [PATCH] fix: support FILTER after ordered-set aggregates Signed-off-by: minleejae --- .../expression/AnalyticExpression.java | 20 ++++- .../util/deparser/ExpressionDeParser.java | 12 ++- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 21 ++++- .../expression/OrderedSetFilterTest.java | 89 +++++++++++++++++++ 4 files changed, 132 insertions(+), 10 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/expression/OrderedSetFilterTest.java diff --git a/src/main/java/net/sf/jsqlparser/expression/AnalyticExpression.java b/src/main/java/net/sf/jsqlparser/expression/AnalyticExpression.java index c01df2ee3..ce19f49cd 100644 --- a/src/main/java/net/sf/jsqlparser/expression/AnalyticExpression.java +++ b/src/main/java/net/sf/jsqlparser/expression/AnalyticExpression.java @@ -9,6 +9,7 @@ */ package net.sf.jsqlparser.expression; +import java.util.function.Consumer; import java.util.Locale; import net.sf.jsqlparser.expression.operators.relational.ExpressionList; import net.sf.jsqlparser.parser.ASTNodeAccessImpl; @@ -338,10 +339,8 @@ public String toString() { b.append(keep).append(" "); } - if (filterExpression != null) { - b.append("FILTER (WHERE "); - b.append(filterExpression); - b.append(")"); + if (filterExpression != null && type != AnalyticType.WITHIN_GROUP) { + appendFilterTo(b, b::append); if (type != AnalyticType.FILTER_ONLY) { b.append(" "); } @@ -382,9 +381,22 @@ public String toString() { b.append(windowDef.toString()); } + if (filterExpression != null && type == AnalyticType.WITHIN_GROUP) { + b.append(' '); + appendFilterTo(b, b::append); + } return b.toString(); } + /** Renders the filter using the caller's expression writer. */ + public void appendFilterTo(StringBuilder builder, Consumer expressionPrinter) { + if (filterExpression != null) { + builder.append("FILTER (WHERE "); + expressionPrinter.accept(filterExpression); + builder.append(')'); + } + } + public boolean isAllColumns() { return allColumns; } 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 6c067c200..3ad46ae72 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java @@ -1278,10 +1278,9 @@ public StringBuilder visit(AnalyticExpression analyticExpression, S context) builder.append(" "); } - if (analyticExpression.getFilterExpression() != null) { - builder.append("FILTER (WHERE "); - analyticExpression.getFilterExpression().accept(this, context); - builder.append(")"); + if (analyticExpression.getFilterExpression() != null + && analyticExpression.getType() != AnalyticType.WITHIN_GROUP) { + analyticExpression.appendFilterTo(builder, filter -> filter.accept(this, context)); if (analyticExpression.getType() != AnalyticType.FILTER_ONLY) { builder.append(" "); } @@ -1361,6 +1360,11 @@ public StringBuilder visit(AnalyticExpression analyticExpression, S context) builder.append(")"); } + if (analyticExpression.getFilterExpression() != null + && analyticExpression.getType() == AnalyticType.WITHIN_GROUP) { + builder.append(' '); + analyticExpression.appendFilterTo(builder, filter -> filter.accept(this, context)); + } return builder; } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 5e6d9c46e..5f157aac0 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -12343,17 +12343,34 @@ AnalyticExpression AnalyticExpression(Function function) : { ( ( - "(" {retval.setType(AnalyticType.FILTER_ONLY);} filter = Expression() ")" + filter=AggregateFilter() { retval.setType(AnalyticType.FILTER_ONLY); } [ LOOKAHEAD(2) windowFun(retval) ] + { + if (Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)) + && (retval.getType() == AnalyticType.WITHIN_GROUP + || retval.getType() == AnalyticType.WITHIN_GROUP_OVER)) { + throw new ParseException("FILTER must follow WITHIN GROUP"); + } + } ) | windowFun(retval) + [ LOOKAHEAD({ retval.getType() == AnalyticType.WITHIN_GROUP + && getToken(1).kind == K_FILTER }) filter=AggregateFilter() ] ) { retval.setFilterExpression(filter); - return retval; + if (true) { return retval; } } } +/** Shared FILTER predicate for ordinary, window, and ordered-set aggregates. */ +Expression AggregateFilter(): +{ Expression filter; } +{ + "(" filter=Expression() ")" + { return filter; } +} + WindowElement WindowElement(): { WindowElement windowElement = new WindowElement(); diff --git a/src/test/java/net/sf/jsqlparser/expression/OrderedSetFilterTest.java b/src/test/java/net/sf/jsqlparser/expression/OrderedSetFilterTest.java new file mode 100644 index 000000000..72f783fea --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/expression/OrderedSetFilterTest.java @@ -0,0 +1,89 @@ +/*- + * #%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.expression; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.util.deparser.ExpressionDeParser; +import net.sf.jsqlparser.util.deparser.SelectDeParser; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class OrderedSetFilterTest { + @ParameterizedTest + @ValueSource(strings = { + "percentile_cont(0.5) WITHIN GROUP (ORDER BY score) FILTER (WHERE active)", + "percentile_disc(0.5) WITHIN GROUP (ORDER BY score DESC NULLS LAST) " + + "FILTER (WHERE active AND score > 0)", + "mode() WITHIN GROUP (ORDER BY score) FILTER (WHERE active)", + "rank(5) WITHIN GROUP (ORDER BY score) FILTER (WHERE NOT active)", + "sum(score) FILTER (WHERE active)", + "sum(score) FILTER (WHERE active) OVER (PARTITION BY team)", + "percentile_cont(0.5) WITHIN GROUP (ORDER BY score)" + }) + void preservesAggregateClauses(String expression) throws JSQLParserException { + String sql = "SELECT " + expression + " FROM measurements"; + PlainSelect select = parse(sql); + StringBuilder output = new StringBuilder(); + select.accept(new StatementDeParser(output), null); + assertEquals(sql, select.toString().replace(" )", ")")); + assertEquals(sql, output.toString().replace(" )", ")")); + assertEquals(select.toString(), parse(output.toString()).toString()); + assertEquals(select.toString(), CCJSqlParserUtil.parse(sql).toString()); + } + + @Test + void visitsBothOrderingAndFilterExpressions() throws JSQLParserException { + PlainSelect select = parse("SELECT rank(5) WITHIN GROUP (ORDER BY score + 1) " + + "FILTER (WHERE score > 2) FROM measurements"); + AnalyticExpression aggregate = assertInstanceOf(AnalyticExpression.class, + select.getSelectItem(0).getExpression()); + assertEquals(AnalyticType.WITHIN_GROUP, aggregate.getType()); + assertEquals("score > 2", aggregate.getFilterExpression().toString()); + StringBuilder output = new StringBuilder(); + ExpressionDeParser expressions = new ExpressionDeParser() { + @Override + public StringBuilder visit(LongValue value, S context) { + return getBuilder().append(value.getValue() + 10); + } + }; + select.accept(new StatementDeParser(expressions, new SelectDeParser(), output), null); + String expected = "SELECT rank(15) WITHIN GROUP (ORDER BY score + 11) " + + "FILTER (WHERE score > 12) FROM measurements"; + assertEquals(expected, output.toString()); + assertEquals(expected, parse(output.toString()).toString()); + } + + @ParameterizedTest + @ValueSource(strings = { + "percentile_cont(0.5) FILTER (WHERE active) WITHIN GROUP (ORDER BY score)", + "percentile_cont(0.5) WITHIN GROUP (ORDER BY score) FILTER (active)", + "percentile_cont(0.5) WITHIN GROUP (ORDER BY score) FILTER (WHERE)", + "percentile_cont(0.5) WITHIN GROUP (ORDER BY score) " + + "FILTER (WHERE active) FILTER (WHERE active)", + "sum(score) OVER (PARTITION BY team) FILTER (WHERE active)" + }) + void rejectsInvalidPostgresFilterPlacement(String expression) { + assertThrows(JSQLParserException.class, + () -> parse("SELECT " + expression + " FROM measurements")); + } + + private static PlainSelect parse(String sql) throws JSQLParserException { + return (PlainSelect) CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.POSTGRESQL)); + } +}