From f3330d5005cffe53a568839379fe5e7ebbbdbb93 Mon Sep 17 00:00:00 2001 From: Jules Ivanic Date: Tue, 13 Oct 2026 20:05:03 +1100 Subject: [PATCH 1/3] DuckDB: support * RENAME, unparenthesized star modifiers and a trailing comma - `* RENAME (i AS x, j AS y)` and `* RENAME i AS x` are the new RENAME `ColumnsTransformer`. - After `*`, `EXCLUDE i`, `REPLACE i + 1 AS i` and `RENAME i AS x` may name a single column or item without parentheses, and print back that way. Unparenthesized REPLACE and RENAME require the `AS name`. - A select list may end with a comma before a clause such as FROM, WHERE or `ORDER BY`, or before `)`. Keywords that may name a column, as in `SELECT a, limit FROM t`, still do. The comma is not printed. --- .../expression/ColumnsTransformer.java | 49 +++++++++- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 94 +++++++++++++++++-- .../statement/select/DuckDBTest.java | 38 ++++++++ 3 files changed, 167 insertions(+), 14 deletions(-) diff --git a/src/main/java/net/sf/jsqlparser/expression/ColumnsTransformer.java b/src/main/java/net/sf/jsqlparser/expression/ColumnsTransformer.java index d411939f6..0f0dc1618 100644 --- a/src/main/java/net/sf/jsqlparser/expression/ColumnsTransformer.java +++ b/src/main/java/net/sf/jsqlparser/expression/ColumnsTransformer.java @@ -25,7 +25,7 @@ public class ColumnsTransformer extends ASTNodeAccessImpl { public enum ColumnsTransformerType { - APPLY, EXCEPT, EXCLUDE, REPLACE + APPLY, EXCEPT, EXCLUDE, REPLACE, RENAME } private ColumnsTransformerType type; @@ -33,6 +33,8 @@ public enum ColumnsTransformerType { private ParenthesedExpressionList exceptColumns; private StringValue exceptPattern; private List> replaceItems; + private List> renameItems; + private boolean parenthesized = true; private boolean strict; public ColumnsTransformer(ColumnsTransformerType type) { @@ -108,6 +110,30 @@ public ColumnsTransformer setReplaceItems(List> replaceItems) { return this; } + /** DuckDB's {@code RENAME (column AS name, ...)}: each item is a column with its new name. */ + public List> getRenameItems() { + return renameItems; + } + + public ColumnsTransformer setRenameItems(List> renameItems) { + this.renameItems = renameItems; + return this; + } + + /** + * Whether the columns or items of an {@code EXCLUDE}, {@code REPLACE} or {@code RENAME} are + * parenthesized; DuckDB also accepts a single one without parentheses, e.g. + * {@code * EXCLUDE i}. + */ + public boolean isParenthesized() { + return parenthesized; + } + + public ColumnsTransformer setParenthesized(boolean parenthesized) { + this.parenthesized = parenthesized; + return this; + } + void collectExpressions(List expressions) { switch (type) { case APPLY: @@ -135,6 +161,13 @@ void collectExpressions(List expressions) { } } break; + case RENAME: + if (renameItems != null) { + for (SelectItem item : renameItems) { + expressions.add(item.getExpression()); + } + } + break; default: throw new IllegalStateException("Unhandled ColumnsTransformerType: " + type); } @@ -150,11 +183,19 @@ public StringBuilder appendTo(StringBuilder builder) { .append(exceptPattern != null ? exceptPattern : exceptColumns); break; case EXCLUDE: - builder.append("EXCLUDE ").append(exceptColumns); + builder.append("EXCLUDE ") + .append(parenthesized ? exceptColumns : exceptColumns.get(0)); break; case REPLACE: - builder.append("REPLACE").append(strict ? " STRICT" : "").append("(") - .append(Select.getStringList(replaceItems)).append(")"); + builder.append("REPLACE").append(strict ? " STRICT" : "") + .append(parenthesized ? "(" : " ") + .append(Select.getStringList(replaceItems)) + .append(parenthesized ? ")" : ""); + break; + case RENAME: + builder.append("RENAME ").append(parenthesized ? "(" : "") + .append(Select.getStringList(renameItems)) + .append(parenthesized ? ")" : ""); break; default: throw new IllegalStateException("Unhandled ColumnsTransformerType: " + type); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 10e5f2779..cf0f0755a 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1713,6 +1713,38 @@ public class CCJSqlParser extends AbstractJSqlParser { } } + /** + * Whether the pending "," ends a select item list, as DuckDB allows: it is followed by a + * closing parenthesis or by a clause that cannot be read as a column such as limit or "group". + */ + private boolean isTrailingSelectItemCommaAhead() { + if (!",".equals(getToken(1).image)) { + return false; + } + Token next = getToken(2); + int following = getToken(3).kind; + switch (next.kind) { + case CLOSING_BRACKET: + case K_FROM: case K_WHERE: case K_HAVING: case K_WINDOW: case K_INTO: + case K_UNION: case K_EXCEPT: case K_INTERSECT: case K_MINUS: + return true; + case K_GROUP: case K_ORDER: + return following == K_BY; + case K_LIMIT: case K_OFFSET: + return following == S_LONG || following == OPENING_BRACKET + || "?".equals(getToken(3).image); + default: + return false; + } + } + + /** DuckDB's unparenthesized REPLACE and RENAME name their single item: * RENAME i AS x. */ + private static void requireTransformerAlias(SelectItem item) throws ParseException { + if (item.getAlias() == null) { + throw new ParseException("Expected AS and a name after " + item); + } + } + private boolean isAliasAhead() { if (isAsOfJoinAhead() || isSemiAntiJoinAhead() || isApproxNearestAhead()) { return false; @@ -8694,11 +8726,13 @@ List> SelectItemsList(): { selectItem=SelectItem() { selectItemsList = new ArrayList>(); selectItemsList.add(selectItem); } ( - LOOKAHEAD(2) "," selectItem=SelectItem() + LOOKAHEAD(2, { !isTrailingSelectItemCommaAhead() }) "," selectItem=SelectItem() { selectItemsList.add(selectItem); } )* + // DuckDB's trailing comma: SELECT a, b, FROM t + [ LOOKAHEAD({ isTrailingSelectItemCommaAhead() }) "," ] { return selectItemsList; } } @@ -8764,6 +8798,8 @@ boolean AllColumnsTransformerAhead() #void: | [ ] ( ParenthesedColumnList() | ) | ParenthesedColumnList() ) { return true; } + // DuckDB: * EXCLUDE i, * REPLACE expr AS i, * RENAME [(]i AS x[)] + | LOOKAHEAD( Column() | | ) { return true; } | { return false; } } @@ -8831,6 +8867,8 @@ ColumnsTransformer ColumnsTransformer(): ParenthesedExpressionList exceptColumns = null; StringValue exceptPattern = null; List> replaceItems; + SelectItem item; + Column column; ColumnsTransformer transformer; Token tk; boolean strict; @@ -8863,16 +8901,52 @@ ColumnsTransformer ColumnsTransformer(): transformer.setExceptColumns(exceptColumns); } } - | exceptColumns = ParenthesedColumnList() - { transformer = new ColumnsTransformer(ColumnsTransformer.ColumnsTransformerType.EXCLUDE) - .setExceptColumns(exceptColumns); } + | + { transformer = new ColumnsTransformer(ColumnsTransformer.ColumnsTransformerType.EXCLUDE); } + ( + exceptColumns = ParenthesedColumnList() + | + column = Column() + { + exceptColumns = new ParenthesedExpressionList(column); + transformer.setParenthesized(false); + } + ) + { transformer.setExceptColumns(exceptColumns); } | { strict = false; } - [ { strict = true; } ] - "(" replaceItems = SelectItemsList() ")" + [ LOOKAHEAD({ getToken(1).kind == K_STRICT && "(".equals(getToken(2).image) }) + { strict = true; } ] { transformer = new ColumnsTransformer(ColumnsTransformer.ColumnsTransformerType.REPLACE) - .setStrict(strict).setReplaceItems(replaceItems); } + .setStrict(strict); } + ( + LOOKAHEAD("(") "(" replaceItems = SelectItemsList() ")" + | + item = SelectItem() + { + requireTransformerAlias(item); + replaceItems = new ArrayList>(); + replaceItems.add(item); + transformer.setParenthesized(false); + } + ) + { transformer.setReplaceItems(replaceItems); } + + | + { transformer = new ColumnsTransformer(ColumnsTransformer.ColumnsTransformerType.RENAME); } + ( + LOOKAHEAD("(") "(" replaceItems = SelectItemsList() ")" + | + item = SelectItem() + { + requireTransformerAlias(item); + replaceItems = new ArrayList>(); + replaceItems.add(item); + transformer.setParenthesized(false); + } + ) + { transformer.setRenameItems(replaceItems); } ) { return transformer; } } @@ -8889,12 +8963,12 @@ AllColumns AllColumns(boolean allowAdditions): } { "*" - // BigData allows EXCEPT, DuckDB allows EXCLUDE, ClickHouse allows APPLY; + // BigData allows EXCEPT, DuckDB allows EXCLUDE and RENAME, ClickHouse allows APPLY; // they may repeat and combine in any order ( - LOOKAHEAD( "(" | [ ] "(" + LOOKAHEAD( "(" | | | [ ] ( ParenthesedColumnList() | ) - | ParenthesedColumnList() ) + | ( ParenthesedColumnList() | Column() ) ) { if (!allowAdditions || !AllColumnsTransformerAhead()) { break; 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 f7ea78cfe..e1a26623c 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -1021,4 +1021,42 @@ void testLargeArrayIsScannedOnce() throws JSQLParserException { } CCJSqlParserUtil.parse(sql.append("]").toString()); } + + @Test + void testStarRenameAndUnparenthesizedModifiers() throws JSQLParserException { + for (String sqlStr : new String[] {"SELECT * RENAME (i AS x, j AS y) FROM t", + "SELECT * RENAME i AS x FROM t", "SELECT * EXCLUDE i FROM t", + "SELECT * EXCLUDE t.i FROM t", "SELECT * REPLACE i + 1 AS i FROM t", + "SELECT * EXCLUDE (k) RENAME (i AS x) FROM t", + "SELECT t.* RENAME t.i AS x FROM t", + "SELECT COLUMNS(* REPLACE i + 1 AS i) FROM t"}) { + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } + + PlainSelect select = (PlainSelect) CCJSqlParserUtil + .parse("SELECT * RENAME (i AS x) FROM t"); + net.sf.jsqlparser.expression.ColumnsTransformer rename = + ((net.sf.jsqlparser.statement.select.AllColumns) select.getSelectItem(0) + .getExpression()).getTransformers().get(0); + Assertions.assertEquals( + net.sf.jsqlparser.expression.ColumnsTransformer.ColumnsTransformerType.RENAME, + rename.getType()); + Assertions.assertEquals("i AS x", rename.getRenameItems().get(0).toString()); + } + + @Test + void testTrailingCommaInSelectList() throws JSQLParserException { + Assertions.assertEquals("SELECT i, j FROM t WHERE i > 0", + CCJSqlParserUtil.parse("SELECT i, j, FROM t WHERE i > 0").toString()); + Assertions.assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse("SELECT * RENAME i FROM t")); + Assertions.assertEquals("SELECT 1 LIMIT 1", + CCJSqlParserUtil.parse("SELECT 1, LIMIT 1").toString()); + Assertions.assertEquals("SELECT (SELECT 1)", + CCJSqlParserUtil.parse("SELECT (SELECT 1,)").toString()); + // keywords that may name a column still do + for (String column : new String[] {"limit", "offset", "group", "order", "qualify"}) { + TestUtils.assertSqlCanBeParsedAndDeparsed("SELECT a, " + column + " FROM t", true); + } + } } From 6ec6e45c0b8178fb56a80509fa0cbf5056430676 Mon Sep 17 00:00:00 2001 From: Jules Ivanic Date: Tue, 13 Oct 2026 20:19:27 +1100 Subject: [PATCH 2/3] Keep stars aliased rename or replace and functions named limit - Unparenthesized REPLACE needs an AS at the top level of its item, and RENAME needs `column AS name` for each item, so `* rename` and `* replace` stay aliases of the star. - `REPLACE (a + 1) AS a` is a single item, as in DuckDB, not a list without an alias. - A comma before `limit(` or `offset(` starts a select item again, and `group by` / `order by` right before FROM or the end are a column and its alias. - A transformer with more than one item or column prints parentheses. --- .../expression/ColumnsTransformer.java | 15 +-- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 101 +++++++++++++++++- .../statement/select/DuckDBTest.java | 19 +++- 3 files changed, 121 insertions(+), 14 deletions(-) diff --git a/src/main/java/net/sf/jsqlparser/expression/ColumnsTransformer.java b/src/main/java/net/sf/jsqlparser/expression/ColumnsTransformer.java index 0f0dc1618..9f471fe16 100644 --- a/src/main/java/net/sf/jsqlparser/expression/ColumnsTransformer.java +++ b/src/main/java/net/sf/jsqlparser/expression/ColumnsTransformer.java @@ -183,19 +183,22 @@ public StringBuilder appendTo(StringBuilder builder) { .append(exceptPattern != null ? exceptPattern : exceptColumns); break; case EXCLUDE: - builder.append("EXCLUDE ") - .append(parenthesized ? exceptColumns : exceptColumns.get(0)); + builder.append("EXCLUDE ").append(parenthesized || exceptColumns.size() != 1 + ? exceptColumns + : exceptColumns.get(0)); break; case REPLACE: + boolean replaceParenthesized = parenthesized || replaceItems.size() != 1; builder.append("REPLACE").append(strict ? " STRICT" : "") - .append(parenthesized ? "(" : " ") + .append(replaceParenthesized ? "(" : " ") .append(Select.getStringList(replaceItems)) - .append(parenthesized ? ")" : ""); + .append(replaceParenthesized ? ")" : ""); break; case RENAME: - builder.append("RENAME ").append(parenthesized ? "(" : "") + boolean renameParenthesized = parenthesized || renameItems.size() != 1; + builder.append("RENAME ").append(renameParenthesized ? "(" : "") .append(Select.getStringList(renameItems)) - .append(parenthesized ? ")" : ""); + .append(renameParenthesized ? ")" : ""); break; default: throw new IllegalStateException("Unhandled ColumnsTransformerType: " + type); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index cf0f0755a..bc32ef541 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1729,15 +1729,103 @@ public class CCJSqlParser extends AbstractJSqlParser { case K_UNION: case K_EXCEPT: case K_INTERSECT: case K_MINUS: return true; case K_GROUP: case K_ORDER: - return following == K_BY; + // not a column named group aliased by: SELECT a, group by FROM t + int afterBy = getToken(4).kind; + return following == K_BY && afterBy != K_FROM && afterBy != EOF + && afterBy != CLOSING_BRACKET && afterBy != ST_SEMICOLON + && !",".equals(getToken(4).image); case K_LIMIT: case K_OFFSET: - return following == S_LONG || following == OPENING_BRACKET - || "?".equals(getToken(3).image); + return following == S_LONG || "?".equals(getToken(3).image); default: return false; } } + /** + * Whether the REPLACE at {@code offset} is DuckDB's unparenthesized {@code REPLACE expr AS name}: + * an AS follows at the top level before the end of the select item, so that a star aliased + * "replace" keeps parsing. With {@code offset} 0, the REPLACE has been consumed. + */ + private boolean isUnparenthesizedReplaceAhead(int offset) { + Token token = getToken(offset); + if (offset > 0 && token.kind != K_REPLACE) { + return false; + } + int depth = 0; + while (true) { + token = nextWithItemToken(token); + if (token.kind == EOF || ";".equals(token.image)) { + return false; + } + if ("(".equals(token.image) || "[".equals(token.image) || "{".equals(token.image)) { + depth++; + } else if (")".equals(token.image) || "]".equals(token.image) + || "}".equals(token.image)) { + if (--depth < 0) { + return false; + } + } else if (depth == 0) { + if (token.kind == K_AS) { + return true; + } + if (token.kind == K_FROM || ",".equals(token.image)) { + return false; + } + } + } + } + + private boolean isUnparenthesizedReplaceAhead() { + return isUnparenthesizedReplaceAhead(1); + } + + /** + * Whether DuckDB's {@code RENAME (column AS name, ...)} or {@code RENAME column AS name} + * follows, every column being renamed with AS, so that a star aliased "rename" keeps parsing. + */ + private boolean isRenameAhead() { + Token token = getToken(1); + if (token.kind != K_RENAME) { + return false; + } + token = nextWithItemToken(token); + boolean parenthesized = "(".equals(token.image); + if (parenthesized) { + token = nextWithItemToken(token); + } + while (true) { + // column [. column]* AS name + while (true) { + if (!isIdentifierishNameToken(token)) { + return false; + } + token = nextWithItemToken(token); + if (!".".equals(token.image)) { + break; + } + token = nextWithItemToken(token); + } + if (token.kind != K_AS) { + return false; + } + token = nextWithItemToken(token); + if (!isIdentifierishNameToken(token)) { + return false; + } + token = nextWithItemToken(token); + if (!parenthesized) { + return true; + } + if (")".equals(token.image)) { + return true; + } + if (!",".equals(token.image)) { + return false; + } + token = nextWithItemToken(token); + } + } + /** DuckDB's unparenthesized REPLACE and RENAME name their single item: * RENAME i AS x. */ private static void requireTransformerAlias(SelectItem item) throws ParseException { if (item.getAlias() == null) { @@ -8799,7 +8887,8 @@ boolean AllColumnsTransformerAhead() #void: | ParenthesedColumnList() ) { return true; } // DuckDB: * EXCLUDE i, * REPLACE expr AS i, * RENAME [(]i AS x[)] - | LOOKAHEAD( Column() | | ) { return true; } + | LOOKAHEAD( Column() ) { return true; } + | LOOKAHEAD({ isUnparenthesizedReplaceAhead() || isRenameAhead() }) { return true; } | { return false; } } @@ -8921,7 +9010,8 @@ ColumnsTransformer ColumnsTransformer(): { transformer = new ColumnsTransformer(ColumnsTransformer.ColumnsTransformerType.REPLACE) .setStrict(strict); } ( - LOOKAHEAD("(") "(" replaceItems = SelectItemsList() ")" + LOOKAHEAD({ "(".equals(getToken(1).image) && !isUnparenthesizedReplaceAhead(0) }) + "(" replaceItems = SelectItemsList() ")" | item = SelectItem() { @@ -8966,6 +9056,7 @@ AllColumns AllColumns(boolean allowAdditions): // BigData allows EXCEPT, DuckDB allows EXCLUDE and RENAME, ClickHouse allows APPLY; // they may repeat and combine in any order ( + // AllColumnsTransformerAhead() tells DuckDB's REPLACE and RENAME from an alias of the star LOOKAHEAD( "(" | | | [ ] ( ParenthesedColumnList() | ) | ( ParenthesedColumnList() | Column() ) ) 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 e1a26623c..d6ab6e06d 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -1042,6 +1042,18 @@ void testStarRenameAndUnparenthesizedModifiers() throws JSQLParserException { net.sf.jsqlparser.expression.ColumnsTransformer.ColumnsTransformerType.RENAME, rename.getType()); Assertions.assertEquals("i AS x", rename.getRenameItems().get(0).toString()); + + // a star aliased rename or replace keeps parsing + for (String sqlStr : new String[] {"SELECT * rename FROM t", "SELECT * replace FROM t", + "SELECT t.* rename, b FROM t", "SELECT * rename(a, b) FROM t"}) { + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } + // DuckDB reads a parenthesized expression followed by AS as the single item + select = (PlainSelect) CCJSqlParserUtil.parse("SELECT * REPLACE (a + 1) AS a FROM t"); + Assertions.assertEquals("(a + 1) AS a", + ((net.sf.jsqlparser.statement.select.AllColumns) select.getSelectItem(0) + .getExpression()).getTransformers().get(0).getReplaceItems().get(0) + .toString()); } @Test @@ -1054,9 +1066,10 @@ void testTrailingCommaInSelectList() throws JSQLParserException { CCJSqlParserUtil.parse("SELECT 1, LIMIT 1").toString()); Assertions.assertEquals("SELECT (SELECT 1)", CCJSqlParserUtil.parse("SELECT (SELECT 1,)").toString()); - // keywords that may name a column still do - for (String column : new String[] {"limit", "offset", "group", "order", "qualify"}) { - TestUtils.assertSqlCanBeParsedAndDeparsed("SELECT a, " + column + " FROM t", true); + // keywords that may name a column or a function still do + for (String item : new String[] {"limit", "offset", "group", "order", "qualify", + "limit(1)", "offset(1)", "group by", "order by"}) { + TestUtils.assertSqlCanBeParsedAndDeparsed("SELECT a, " + item + " FROM t", true); } } } From 83533813ab93dc6da274651a1dcb19bbbd8a3464 Mon Sep 17 00:00:00 2001 From: Jules Ivanic Date: Tue, 13 Oct 2026 21:01:45 +1100 Subject: [PATCH 3/3] Refer to `AllColumns` by its simple name in `DuckDBTest` --- .../java/net/sf/jsqlparser/statement/select/DuckDBTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 d6ab6e06d..77483f68d 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -1036,7 +1036,7 @@ void testStarRenameAndUnparenthesizedModifiers() throws JSQLParserException { PlainSelect select = (PlainSelect) CCJSqlParserUtil .parse("SELECT * RENAME (i AS x) FROM t"); net.sf.jsqlparser.expression.ColumnsTransformer rename = - ((net.sf.jsqlparser.statement.select.AllColumns) select.getSelectItem(0) + ((AllColumns) select.getSelectItem(0) .getExpression()).getTransformers().get(0); Assertions.assertEquals( net.sf.jsqlparser.expression.ColumnsTransformer.ColumnsTransformerType.RENAME, @@ -1051,7 +1051,7 @@ void testStarRenameAndUnparenthesizedModifiers() throws JSQLParserException { // DuckDB reads a parenthesized expression followed by AS as the single item select = (PlainSelect) CCJSqlParserUtil.parse("SELECT * REPLACE (a + 1) AS a FROM t"); Assertions.assertEquals("(a + 1) AS a", - ((net.sf.jsqlparser.statement.select.AllColumns) select.getSelectItem(0) + ((AllColumns) select.getSelectItem(0) .getExpression()).getTransformers().get(0).getReplaceItems().get(0) .toString()); }