Skip to content

Commit 38993c5

Browse files
authored
feat: support ClickHouse COLUMNS(...) APPLY/EXCEPT/REPLACE transformers (#2631) (#2635)
* feat(parser): support ClickHouse COLUMNS(...) APPLY/EXCEPT/REPLACE transformers Adds a dedicated select-item branch for a COLUMNS('regexp') matcher followed by one or more transformers, fixing #2631 (APPLY with a lambda such as x -> round(x, 2) failed to parse; without a lambda, APPLY(...) was silently mis-parsed as the select item alias). - new ColumnsExpression / ColumnsTransformer AST nodes with an ordered transformer list, since ClickHouse parses transformers in a loop (they may repeat and combine in any order) - semantic lookahead isColumnsExpressionAhead() keeps a plain COLUMNS('regexp') on the regular Function path - expression visitor, adapter, deparser, validator and TablesNamesFinder traverse the new node Signed-off-by: 付典 <fudianchn@gmail.com> * fix(parser): add default case to the ColumnsTransformer type switch Satisfies the PMD exhaustive-switch rule (Codacy '1 new issue') and fails loudly if a future ColumnsTransformerType constant is added without updating appendTo; unreachable for the current APPLY/EXCEPT/REPLACE constants, so behavior is unchanged. Same pattern as JsonFunction's Unhandled JsonOnResponseBehavior guard. Signed-off-by: 付典 <fudianchn@gmail.com> * fix: preserve COLUMNS traversal and alias boundaries Reuse alias grammar at each transformer boundary, preserve JJTree parents and lookahead visibility, and route all child expressions through visitor hooks. Add consumer and grammar regression coverage. Signed-off-by: 付典 <fudianchn@gmail.com> * style: use simple name for the Addition cast in ColumnsExpressionTest The fully qualified name trips the UnnecessaryFullyQualifiedName code style check. Import the type instead, matching the sibling ExpressionPrecedenceTest. Signed-off-by: 付典 <fudianchn@gmail.com> --------- Signed-off-by: 付典 <fudianchn@gmail.com>
1 parent 03396b6 commit 38993c5

11 files changed

Lines changed: 839 additions & 18 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ and missing syntax gets added on demand — [open an issue](https://github.com/J
140140
|---|---|
141141
| **Queries** | `SELECT` · `WITH …` · Piped SQL |
142142
| **ksqlDB windows** | JOIN `WITHIN`, window `GRACE PERIOD`, and `EMIT CHANGES`/`FINAL` |
143+
| **ClickHouse column selection** | `COLUMNS('regexp')` select items with chained `APPLY`, `EXCEPT`, and `REPLACE` transformers |
143144
| **DML** | `INSERT` · `UPDATE` · `UPSERT` · `MERGE` · `DELETE` · `TRUNCATE TABLE` |
144145
| **DDL** | `CREATE …` · `ALTER …` · `DROP …` |
145146
| **PostgreSQL RLS** | `CREATE POLICY` · `ALTER TABLE … ENABLE`/`DISABLE`/`FORCE`/`NO FORCE ROW LEVEL SECURITY` |
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/*-
2+
* #%L
3+
* JSQLParser library
4+
* %%
5+
* Copyright (C) 2004 - 2026 JSQLParser
6+
* %%
7+
* Dual licensed under GNU LGPL 2.1 or Apache License 2.0
8+
* #L%
9+
*/
10+
package net.sf.jsqlparser.expression;
11+
12+
import java.util.ArrayList;
13+
import java.util.List;
14+
import net.sf.jsqlparser.parser.ASTNodeAccessImpl;
15+
16+
/**
17+
* A ClickHouse {@code COLUMNS('regexp')} matcher with one or more transformers, for example
18+
* {@code COLUMNS('^metric_') APPLY(x -> round(x, 2))}.
19+
*
20+
* Without a transformer, {@code COLUMNS('regexp')} keeps parsing as a regular
21+
* {@link net.sf.jsqlparser.expression.Function}.
22+
*/
23+
public class ColumnsExpression extends ASTNodeAccessImpl implements Expression {
24+
25+
private Expression columns;
26+
private List<ColumnsTransformer> transformers;
27+
28+
public ColumnsExpression(Expression columns) {
29+
this.columns = columns;
30+
this.transformers = new ArrayList<>();
31+
}
32+
33+
public ColumnsExpression(Expression columns, List<ColumnsTransformer> transformers) {
34+
this.columns = columns;
35+
this.transformers = transformers;
36+
}
37+
38+
public Expression getColumns() {
39+
return columns;
40+
}
41+
42+
public ColumnsExpression setColumns(Expression columns) {
43+
this.columns = columns;
44+
return this;
45+
}
46+
47+
public List<ColumnsTransformer> getTransformers() {
48+
return transformers;
49+
}
50+
51+
public ColumnsExpression setTransformers(List<ColumnsTransformer> transformers) {
52+
this.transformers = transformers;
53+
return this;
54+
}
55+
56+
public List<Expression> getAllExpressions() {
57+
List<Expression> expressions = new ArrayList<>();
58+
if (columns != null) {
59+
expressions.add(columns);
60+
}
61+
for (ColumnsTransformer transformer : transformers) {
62+
transformer.collectExpressions(expressions);
63+
}
64+
return expressions;
65+
}
66+
67+
public StringBuilder appendTo(StringBuilder builder) {
68+
builder.append(columns);
69+
for (ColumnsTransformer transformer : transformers) {
70+
builder.append(" ").append(transformer);
71+
}
72+
return builder;
73+
}
74+
75+
@Override
76+
public String toString() {
77+
return appendTo(new StringBuilder()).toString();
78+
}
79+
80+
@Override
81+
public <T, S> T accept(ExpressionVisitor<T> expressionVisitor, S context) {
82+
return expressionVisitor.visit(this, context);
83+
}
84+
}
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
/*-
2+
* #%L
3+
* JSQLParser library
4+
* %%
5+
* Copyright (C) 2004 - 2026 JSQLParser
6+
* %%
7+
* Dual licensed under GNU LGPL 2.1 or Apache License 2.0
8+
* #L%
9+
*/
10+
package net.sf.jsqlparser.expression;
11+
12+
import java.util.List;
13+
import net.sf.jsqlparser.expression.operators.relational.ParenthesedExpressionList;
14+
import net.sf.jsqlparser.parser.ASTNodeAccessImpl;
15+
import net.sf.jsqlparser.schema.Column;
16+
import net.sf.jsqlparser.statement.select.Select;
17+
import net.sf.jsqlparser.statement.select.SelectItem;
18+
19+
/**
20+
* A ClickHouse transformer following a {@code COLUMNS(...)} matcher, for example the
21+
* {@code APPLY(x -> round(x, 2))} in {@code SELECT COLUMNS('^m') APPLY(x -> round(x, 2))}.
22+
*
23+
* ClickHouse parses its transformers in a loop, so they may repeat and combine in any order.
24+
*/
25+
public class ColumnsTransformer extends ASTNodeAccessImpl {
26+
27+
public enum ColumnsTransformerType {
28+
APPLY, EXCEPT, REPLACE
29+
}
30+
31+
private ColumnsTransformerType type;
32+
private Expression applyExpression;
33+
private ParenthesedExpressionList<Column> exceptColumns;
34+
private List<SelectItem<?>> replaceItems;
35+
36+
public ColumnsTransformer(ColumnsTransformerType type) {
37+
this.type = type;
38+
}
39+
40+
public ColumnsTransformerType getType() {
41+
return type;
42+
}
43+
44+
public ColumnsTransformer setType(ColumnsTransformerType type) {
45+
this.type = type;
46+
return this;
47+
}
48+
49+
public Expression getApplyExpression() {
50+
return applyExpression;
51+
}
52+
53+
public ColumnsTransformer setApplyExpression(Expression applyExpression) {
54+
this.applyExpression = applyExpression;
55+
return this;
56+
}
57+
58+
public ParenthesedExpressionList<Column> getExceptColumns() {
59+
return exceptColumns;
60+
}
61+
62+
public ColumnsTransformer setExceptColumns(ParenthesedExpressionList<Column> exceptColumns) {
63+
this.exceptColumns = exceptColumns;
64+
return this;
65+
}
66+
67+
public List<SelectItem<?>> getReplaceItems() {
68+
return replaceItems;
69+
}
70+
71+
public ColumnsTransformer setReplaceItems(List<SelectItem<?>> replaceItems) {
72+
this.replaceItems = replaceItems;
73+
return this;
74+
}
75+
76+
void collectExpressions(List<Expression> expressions) {
77+
switch (type) {
78+
case APPLY:
79+
if (applyExpression != null) {
80+
expressions.add(applyExpression);
81+
}
82+
break;
83+
case EXCEPT:
84+
if (exceptColumns != null) {
85+
expressions.addAll(exceptColumns);
86+
}
87+
break;
88+
case REPLACE:
89+
if (replaceItems != null) {
90+
for (SelectItem<?> item : replaceItems) {
91+
expressions.add(item.getExpression());
92+
}
93+
}
94+
break;
95+
default:
96+
throw new IllegalStateException("Unhandled ColumnsTransformerType: " + type);
97+
}
98+
}
99+
100+
public StringBuilder appendTo(StringBuilder builder) {
101+
switch (type) {
102+
case APPLY:
103+
builder.append("APPLY(").append(applyExpression).append(")");
104+
break;
105+
case EXCEPT:
106+
builder.append("EXCEPT ").append(exceptColumns);
107+
break;
108+
case REPLACE:
109+
builder.append("REPLACE(").append(Select.getStringList(replaceItems)).append(")");
110+
break;
111+
default:
112+
throw new IllegalStateException("Unhandled ColumnsTransformerType: " + type);
113+
}
114+
return builder;
115+
}
116+
117+
@Override
118+
public String toString() {
119+
return appendTo(new StringBuilder()).toString();
120+
}
121+
}

src/main/java/net/sf/jsqlparser/expression/ExpressionVisitor.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -813,6 +813,19 @@ default void visit(LambdaExpression lambdaExpression) {
813813
this.visit(lambdaExpression, null);
814814
}
815815

816+
default <S> T visit(ColumnsExpression columnsExpression, S context) {
817+
for (Expression expression : columnsExpression.getAllExpressions()) {
818+
if (expression != null) {
819+
expression.accept(this, context);
820+
}
821+
}
822+
return null;
823+
}
824+
825+
default void visit(ColumnsExpression columnsExpression) {
826+
this.visit(columnsExpression, null);
827+
}
828+
816829
<S> T visit(HighExpression highExpression, S context);
817830

818831
default void visit(HighExpression highExpression) {

src/main/java/net/sf/jsqlparser/expression/ExpressionVisitorAdapter.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -875,6 +875,11 @@ public <S> T visit(LambdaExpression lambdaExpression, S context) {
875875
return lambdaExpression.getExpression().accept(this, context);
876876
}
877877

878+
@Override
879+
public <S> T visit(ColumnsExpression columnsExpression, S context) {
880+
return visitExpressions(columnsExpression, context, columnsExpression.getAllExpressions());
881+
}
882+
878883
@Override
879884
public <S> T visit(HighExpression highExpression, S context) {
880885
return highExpression.getExpression().accept(this, context);

src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2241,6 +2241,16 @@ public <S> Void visit(LambdaExpression lambdaExpression, S context) {
22412241
return null;
22422242
}
22432243

2244+
@Override
2245+
public <S> Void visit(ColumnsExpression columnsExpression, S context) {
2246+
for (Expression expression : columnsExpression.getAllExpressions()) {
2247+
if (expression != null) {
2248+
expression.accept(this, context);
2249+
}
2250+
}
2251+
return null;
2252+
}
2253+
22442254
@Override
22452255
public <S> Void visit(HighExpression highExpression, S context) {
22462256
highExpression.getExpression().accept(this, context);

src/main/java/net/sf/jsqlparser/util/deparser/ExpressionDeParser.java

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727
import net.sf.jsqlparser.expression.CaseExpression;
2828
import net.sf.jsqlparser.expression.CastExpression;
2929
import net.sf.jsqlparser.expression.CollateExpression;
30+
import net.sf.jsqlparser.expression.ColumnsExpression;
31+
import net.sf.jsqlparser.expression.ColumnsTransformer;
3032
import net.sf.jsqlparser.expression.ConnectByPriorOperator;
3133
import net.sf.jsqlparser.expression.ConnectByRootOperator;
3234
import net.sf.jsqlparser.expression.DateTimeLiteralExpression;
@@ -1870,6 +1872,43 @@ public <S> StringBuilder visit(LambdaExpression lambdaExpression, S context) {
18701872
return builder;
18711873
}
18721874

1875+
@Override
1876+
public <S> StringBuilder visit(ColumnsExpression columnsExpression, S context) {
1877+
columnsExpression.getColumns().accept(this, context);
1878+
for (ColumnsTransformer transformer : columnsExpression.getTransformers()) {
1879+
switch (transformer.getType()) {
1880+
case APPLY:
1881+
builder.append(" APPLY(");
1882+
transformer.getApplyExpression().accept(this, context);
1883+
builder.append(")");
1884+
break;
1885+
case EXCEPT:
1886+
builder.append(" EXCEPT ");
1887+
transformer.getExceptColumns().accept(this, context);
1888+
break;
1889+
case REPLACE:
1890+
builder.append(" REPLACE(");
1891+
boolean first = true;
1892+
for (SelectItem<?> item : transformer.getReplaceItems()) {
1893+
if (!first) {
1894+
builder.append(", ");
1895+
}
1896+
first = false;
1897+
item.getExpression().accept(this, context);
1898+
if (item.getAlias() != null) {
1899+
builder.append(item.getAlias());
1900+
}
1901+
}
1902+
builder.append(")");
1903+
break;
1904+
default:
1905+
throw new IllegalStateException(
1906+
"Unhandled ColumnsTransformerType: " + transformer.getType());
1907+
}
1908+
}
1909+
return builder;
1910+
}
1911+
18731912
@Override
18741913
public <S> StringBuilder visit(HighExpression highExpression, S context) {
18751914
return builder.append(highExpression.toString());

src/main/java/net/sf/jsqlparser/util/validation/validator/ExpressionValidator.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import net.sf.jsqlparser.expression.CaseExpression;
2525
import net.sf.jsqlparser.expression.CastExpression;
2626
import net.sf.jsqlparser.expression.CollateExpression;
27+
import net.sf.jsqlparser.expression.ColumnsExpression;
2728
import net.sf.jsqlparser.expression.ConnectByPriorOperator;
2829
import net.sf.jsqlparser.expression.ConnectByRootOperator;
2930
import net.sf.jsqlparser.expression.DateTimeLiteralExpression;
@@ -1242,6 +1243,16 @@ public <S> Void visit(LambdaExpression lambdaExpression, S context) {
12421243
return null;
12431244
}
12441245

1246+
@Override
1247+
public <S> Void visit(ColumnsExpression columnsExpression, S context) {
1248+
for (Expression expression : columnsExpression.getAllExpressions()) {
1249+
if (expression != null) {
1250+
expression.accept(this, context);
1251+
}
1252+
}
1253+
return null;
1254+
}
1255+
12451256
@Override
12461257
public <S> Void visit(HighExpression highExpression, S context) {
12471258
highExpression.getExpression().accept(this, context);

0 commit comments

Comments
 (0)