diff --git a/src/main/java/net/sf/jsqlparser/expression/ColumnsTransformer.java b/src/main/java/net/sf/jsqlparser/expression/ColumnsTransformer.java index d411939f6..9f471fe16 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,22 @@ public StringBuilder appendTo(StringBuilder builder) { .append(exceptPattern != null ? exceptPattern : exceptColumns); break; case EXCLUDE: - builder.append("EXCLUDE ").append(exceptColumns); + builder.append("EXCLUDE ").append(parenthesized || exceptColumns.size() != 1 + ? exceptColumns + : exceptColumns.get(0)); break; case REPLACE: - builder.append("REPLACE").append(strict ? " STRICT" : "").append("(") - .append(Select.getStringList(replaceItems)).append(")"); + boolean replaceParenthesized = parenthesized || replaceItems.size() != 1; + builder.append("REPLACE").append(strict ? " STRICT" : "") + .append(replaceParenthesized ? "(" : " ") + .append(Select.getStringList(replaceItems)) + .append(replaceParenthesized ? ")" : ""); + break; + case RENAME: + boolean renameParenthesized = parenthesized || renameItems.size() != 1; + builder.append("RENAME ").append(renameParenthesized ? "(" : "") + .append(Select.getStringList(renameItems)) + .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 10e5f2779..bc32ef541 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1713,6 +1713,126 @@ 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: + // 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 || "?".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) { + throw new ParseException("Expected AS and a name after " + item); + } + } + private boolean isAliasAhead() { if (isAsOfJoinAhead() || isSemiAntiJoinAhead() || isApproxNearestAhead()) { return false; @@ -8694,11 +8814,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 +8886,9 @@ boolean AllColumnsTransformerAhead() #void: | [ ] ( ParenthesedColumnList() | ) | ParenthesedColumnList() ) { return true; } + // DuckDB: * EXCLUDE i, * REPLACE expr AS i, * RENAME [(]i AS x[)] + | LOOKAHEAD( Column() ) { return true; } + | LOOKAHEAD({ isUnparenthesizedReplaceAhead() || isRenameAhead() }) { return true; } | { return false; } } @@ -8831,6 +8956,8 @@ ColumnsTransformer ColumnsTransformer(): ParenthesedExpressionList exceptColumns = null; StringValue exceptPattern = null; List> replaceItems; + SelectItem item; + Column column; ColumnsTransformer transformer; Token tk; boolean strict; @@ -8863,16 +8990,53 @@ 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({ "(".equals(getToken(1).image) && !isUnparenthesizedReplaceAhead(0) }) + "(" 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 +9053,13 @@ 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( "(" | [ ] "(" + // AllColumnsTransformerAhead() tells DuckDB's REPLACE and RENAME from an alias of the star + 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..77483f68d 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,55 @@ 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 = + ((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()); + + // 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", + ((AllColumns) select.getSelectItem(0) + .getExpression()).getTransformers().get(0).getReplaceItems().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 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); + } + } }