From 631ab7fcd7d23ff6e980060855e545e17a0d98fd Mon Sep 17 00:00:00 2001 From: kyy <2414086450@qq.com> Date: Sat, 19 Sep 2026 22:58:18 +0800 Subject: [PATCH] Fix parsing of ClickHouse transformers after '*' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AllColumns modelled the trailing modifiers itself and only accepted a single EXCEPT/EXCLUDE followed by a single REPLACE, so '* APPLY(sum)' was parsed as a wildcard carrying the alias "APPLY(sum)" — a wrong AST that round-trips without error. AllColumns now holds a List, mirroring ColumnsExpression, and the production loops over ColumnsTransformer(), so the modifiers may repeat and combine in any order. ColumnsTransformerType gains EXCLUDE (DuckDB), which AllColumns previously carried as a plain string. The transformer lookahead is position specific: reusing ColumnsTransformerAhead() verbatim would turn '* REPLACE (x)' into an alias. --- .../expression/ColumnsTransformer.java | 8 +- .../statement/select/AllColumns.java | 81 ++++--------------- .../statement/select/AllTableColumns.java | 18 ++--- .../statement/select/FunctionAllColumns.java | 2 +- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 60 +++++++++++--- .../statement/select/ClickHouseTest.java | 25 +++++- 6 files changed, 100 insertions(+), 94 deletions(-) diff --git a/src/main/java/net/sf/jsqlparser/expression/ColumnsTransformer.java b/src/main/java/net/sf/jsqlparser/expression/ColumnsTransformer.java index fcf6517ce6..8fab9330e4 100644 --- a/src/main/java/net/sf/jsqlparser/expression/ColumnsTransformer.java +++ b/src/main/java/net/sf/jsqlparser/expression/ColumnsTransformer.java @@ -17,7 +17,7 @@ import net.sf.jsqlparser.statement.select.SelectItem; /** - * A ClickHouse transformer following a {@code COLUMNS(...)} matcher, for example the + * A ClickHouse transformer following a {@code COLUMNS(...)} matcher or a {@code *}, for example the * {@code APPLY(x -> round(x, 2))} in {@code SELECT COLUMNS('^m') APPLY(x -> round(x, 2))}. * * ClickHouse parses its transformers in a loop, so they may repeat and combine in any order. @@ -25,7 +25,7 @@ public class ColumnsTransformer extends ASTNodeAccessImpl { public enum ColumnsTransformerType { - APPLY, EXCEPT, REPLACE + APPLY, EXCEPT, EXCLUDE, REPLACE } private ColumnsTransformerType type; @@ -81,6 +81,7 @@ void collectExpressions(List expressions) { } break; case EXCEPT: + case EXCLUDE: if (exceptColumns != null) { expressions.addAll(exceptColumns); } @@ -105,6 +106,9 @@ public StringBuilder appendTo(StringBuilder builder) { case EXCEPT: builder.append("EXCEPT ").append(exceptColumns); break; + case EXCLUDE: + builder.append("EXCLUDE ").append(exceptColumns); + break; case REPLACE: builder.append("REPLACE(").append(Select.getStringList(replaceItems)).append(")"); break; diff --git a/src/main/java/net/sf/jsqlparser/statement/select/AllColumns.java b/src/main/java/net/sf/jsqlparser/statement/select/AllColumns.java index 08b8532e39..f667517a4b 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/AllColumns.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/AllColumns.java @@ -9,92 +9,45 @@ */ package net.sf.jsqlparser.statement.select; +import java.util.ArrayList; +import java.util.List; +import net.sf.jsqlparser.expression.ColumnsTransformer; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.ExpressionVisitor; -import net.sf.jsqlparser.expression.operators.relational.ExpressionList; import net.sf.jsqlparser.parser.ASTNodeAccessImpl; -import net.sf.jsqlparser.schema.Column; - -import java.util.ArrayList; -import java.util.List; public class AllColumns extends ASTNodeAccessImpl implements Expression { - protected ExpressionList exceptColumns; - protected List> replaceExpressions; - private String exceptKeyword; - - public AllColumns(ExpressionList exceptColumns, - List> replaceExpressions) { - this.exceptColumns = exceptColumns; - this.replaceExpressions = replaceExpressions; - this.exceptKeyword = exceptColumns != null ? "Except" : null; - } - - public AllColumns(ExpressionList exceptColumns, - List> replaceExpressions, String exceptKeyword) { - this.exceptColumns = exceptColumns; - this.replaceExpressions = replaceExpressions; - this.exceptKeyword = exceptKeyword; - } + private List transformers; public AllColumns() { - this(null, null); - } - - public ExpressionList getExceptColumns() { - return exceptColumns; + this(null); } - public AllColumns setExceptColumns(ExpressionList exceptColumns) { - this.exceptColumns = exceptColumns; - return this; + public AllColumns(List transformers) { + this.transformers = transformers; } - public ExpressionList addExceptColumn(Column column) { - if (exceptColumns == null) { - exceptColumns = new ExpressionList<>(); + public List getTransformers() { + if (transformers == null) { + transformers = new ArrayList<>(); } - exceptColumns.add(column); - return exceptColumns; + return transformers; } - public List> getReplaceExpressions() { - return replaceExpressions; - } - - public AllColumns setReplaceExpressions(List> replaceExpressions) { - this.replaceExpressions = replaceExpressions; + public AllColumns setTransformers(List transformers) { + this.transformers = transformers; return this; } - public List> addReplaceExpression(SelectItem selectItem) { - if (replaceExpressions == null) { - replaceExpressions = new ArrayList<>(); - } - replaceExpressions.add(selectItem); - return replaceExpressions; - } - - public String getExceptKeyword() { - return exceptKeyword; - } - - public AllColumns setExceptKeyword(String exceptKeyword) { - this.exceptKeyword = exceptKeyword; + public AllColumns addTransformer(ColumnsTransformer transformer) { + getTransformers().add(transformer); return this; } public StringBuilder appendTo(StringBuilder builder) { builder.append("*"); - if (exceptColumns != null && !exceptColumns.isEmpty()) { - builder.append(" ").append(exceptKeyword).append("( "); - exceptColumns.appendTo(builder); - builder.append(" )"); - } - if (replaceExpressions != null && !replaceExpressions.isEmpty()) { - builder.append(" REPLACE( "); - builder.append(Select.getStringList(replaceExpressions)); - builder.append(" )"); + for (ColumnsTransformer transformer : getTransformers()) { + builder.append(" ").append(transformer); } return builder; } diff --git a/src/main/java/net/sf/jsqlparser/statement/select/AllTableColumns.java b/src/main/java/net/sf/jsqlparser/statement/select/AllTableColumns.java index 0030da89b2..52b55878b8 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/AllTableColumns.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/AllTableColumns.java @@ -10,9 +10,8 @@ package net.sf.jsqlparser.statement.select; import java.util.List; +import net.sf.jsqlparser.expression.ColumnsTransformer; import net.sf.jsqlparser.expression.ExpressionVisitor; -import net.sf.jsqlparser.expression.operators.relational.ExpressionList; -import net.sf.jsqlparser.schema.Column; import net.sf.jsqlparser.schema.Table; import net.sf.jsqlparser.statement.ReturningReferenceType; @@ -22,24 +21,17 @@ public class AllTableColumns extends AllColumns { private ReturningReferenceType returningReferenceType = null; private String returningQualifier = null; - public AllTableColumns(Table table, ExpressionList exceptColumns, - List> replaceExpressions, String exceptKeyword) { - super(exceptColumns, replaceExpressions, exceptKeyword); + public AllTableColumns(Table table, List transformers) { + super(transformers); this.table = table; } - public AllTableColumns(Table table, ExpressionList exceptColumns, - List> replaceExpressions) { - this(table, exceptColumns, replaceExpressions, "EXCEPT"); - } - public AllTableColumns(Table table) { - this(table, null, null); + this(table, (List) null); } public AllTableColumns(Table table, AllColumns allColumns) { - this(table, allColumns.exceptColumns, allColumns.replaceExpressions, - allColumns.getExceptKeyword()); + this(table, allColumns.getTransformers()); } public Table getTable() { diff --git a/src/main/java/net/sf/jsqlparser/statement/select/FunctionAllColumns.java b/src/main/java/net/sf/jsqlparser/statement/select/FunctionAllColumns.java index 7a4fa5ce2d..97d54e4500 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/FunctionAllColumns.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/FunctionAllColumns.java @@ -17,7 +17,7 @@ public class FunctionAllColumns extends AllColumns { private Function function; public FunctionAllColumns(Function function) { - super(null, null, null); + super(null); this.function = function; } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index fd6b41f64b..ac1fe3f5d7 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -7846,7 +7846,27 @@ boolean ColumnsTransformerAhead() #void: { LOOKAHEAD( "(" RelObjectName() ")" ) { return true; } | LOOKAHEAD( ( | ) AliasColumns() ) { return false; } - | LOOKAHEAD( "(" | "(" | ParenthesedColumnList() ) + | LOOKAHEAD( "(" | "(" + | ( | ) ParenthesedColumnList() ) + { return true; } + | { return false; } +} + +/** + * Recognizes a transformer after the AllColumns-Pattern '*'. + * + * This differs from ColumnsTransformerAhead() in one point: after '*' an alias is not a + * meaningful reading of REPLACE, so REPLACE (col) stays a transformer here whereas + * APPLY (col1, col2) remains an alias. Reusing the COLUMNS(...) rules verbatim would turn + * '* REPLACE (x)' into an alias. + */ +boolean AllColumnsTransformerAhead() #void: +{} +{ + LOOKAHEAD( "(" RelObjectName() ")" ) { return true; } + | LOOKAHEAD( AliasColumns() ) { return false; } + | LOOKAHEAD( "(" | "(" + | ( | ) ParenthesedColumnList() ) { return true; } | { return false; } } @@ -7914,6 +7934,7 @@ ColumnsTransformer ColumnsTransformer(): ParenthesedExpressionList exceptColumns; List> replaceItems; ColumnsTransformer transformer; + Token tk; } { ( @@ -7927,8 +7948,11 @@ ColumnsTransformer ColumnsTransformer(): { transformer = new ColumnsTransformer(ColumnsTransformer.ColumnsTransformerType.APPLY) .setApplyExpression(applyExpression); } - | exceptColumns = ParenthesedColumnList() - { transformer = new ColumnsTransformer(ColumnsTransformer.ColumnsTransformerType.EXCEPT) + | ( tk= | tk= ) exceptColumns = ParenthesedColumnList() + { transformer = new ColumnsTransformer( + tk.kind == K_EXCLUDE + ? ColumnsTransformer.ColumnsTransformerType.EXCLUDE + : ColumnsTransformer.ColumnsTransformerType.EXCEPT) .setExceptColumns(exceptColumns); } | "(" replaceItems = SelectItemsList() ")" @@ -7945,20 +7969,32 @@ ColumnsTransformer ColumnsTransformer(): */ AllColumns AllColumns(boolean allowAdditions): { - ParenthesedExpressionList exceptColumns = null; - List> replaceExpressions = null; - String exceptKeyword=null; - Token tk; + ColumnsTransformer transformer; + List transformers = null; } { "*" - // BigData allows EXCEPT, DuckDB allows EXCLUDE - [ LOOKAHEAD(2, { allowAdditions }) ( tk= | tk= ) exceptColumns = ParenthesedColumnList() { exceptKeyword=tk.image; } ] - // BigData allows REPLACE - [ LOOKAHEAD(2, { allowAdditions }) "(" replaceExpressions = SelectItemsList() ")" ] + // BigData allows EXCEPT, DuckDB allows EXCLUDE, ClickHouse allows APPLY; + // they may repeat and combine in any order + ( + LOOKAHEAD( "(" | "(" + | ( | ) ParenthesedColumnList() ) + { + if (!allowAdditions || !AllColumnsTransformerAhead()) { + break; + } + } + transformer = ColumnsTransformer() + { + if (transformers == null) { + transformers = new ArrayList(); + } + transformers.add(transformer); + } + )* { - return new AllColumns(exceptColumns, replaceExpressions, exceptKeyword); + return new AllColumns(transformers); } } diff --git a/src/test/java/net/sf/jsqlparser/statement/select/ClickHouseTest.java b/src/test/java/net/sf/jsqlparser/statement/select/ClickHouseTest.java index 265c9621dd..4bce28de6e 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/ClickHouseTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/ClickHouseTest.java @@ -14,13 +14,12 @@ import java.util.ArrayList; import java.util.List; import java.util.Set; - import net.sf.jsqlparser.JSQLParserException; import net.sf.jsqlparser.expression.Alias; import net.sf.jsqlparser.expression.ColumnsExpression; import net.sf.jsqlparser.expression.ColumnsTransformer; -import net.sf.jsqlparser.expression.Function; import net.sf.jsqlparser.expression.ExpressionVisitorAdapter; +import net.sf.jsqlparser.expression.Function; import net.sf.jsqlparser.expression.LambdaExpression; import net.sf.jsqlparser.expression.operators.relational.ParenthesedExpressionList; import net.sf.jsqlparser.parser.CCJSqlParserUtil; @@ -337,6 +336,28 @@ public void testColumnsCombinedTransformers() throws JSQLParserException { assertSqlCanBeParsedAndDeparsed(sql, true); } + @Test + public void testAllColumnsApplyIssue2636() throws JSQLParserException { + // ClickHouse accepts the same transformers after '*': + // https://github.com/JSQLParser/JSqlParser/issues/2636 + String sql = "SELECT * APPLY(sum) FROM t"; + Select select = (Select) CCJSqlParserUtil.parse(sql); + PlainSelect plainSelect = (PlainSelect) select.getSelectBody(); + SelectItem selectItem = plainSelect.getSelectItems().get(0); + + Assertions.assertNull(selectItem.getAlias(), + "APPLY must not fall back to an alias"); + Assertions.assertInstanceOf(AllColumns.class, selectItem.getExpression()); + assertSqlCanBeParsedAndDeparsed(sql, true); + } + + @Test + public void testAllColumnsChainedTransformersIssue2636() throws JSQLParserException { + // ClickHouse parses the transformers in a loop, so they may repeat after '*' + String sql = "SELECT * APPLY(sum) EXCEPT (a) FROM t"; + assertSqlCanBeParsedAndDeparsed(sql, true); + } + @Test public void testColumnsApplyMultiParamLambda() throws JSQLParserException { String sql = "SELECT COLUMNS('m') APPLY((k, v) -> v > 5) FROM metrics";