From d9b97b903e0b6b2daa81f8f9211e67a54d814a95 Mon Sep 17 00:00:00 2001 From: Hayssam Saleh Date: Tue, 15 Sep 2026 14:09:38 +0200 Subject: [PATCH 01/19] Support DuckDB GLOB pattern matching operator Adds GLOB as a LikeExpression keyword so `col GLOB 'pattern'` and `col NOT GLOB 'pattern'` parse and deparse. GLOB stays a non-reserved keyword and remains usable as an identifier. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqinwHBKuAtPEmtXr3b5P2 --- .../operators/relational/LikeExpression.java | 2 +- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 3 +++ .../statement/select/DuckDBTest.java | 18 ++++++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/sf/jsqlparser/expression/operators/relational/LikeExpression.java b/src/main/java/net/sf/jsqlparser/expression/operators/relational/LikeExpression.java index 4d9201e5e4..92563d7d85 100644 --- a/src/main/java/net/sf/jsqlparser/expression/operators/relational/LikeExpression.java +++ b/src/main/java/net/sf/jsqlparser/expression/operators/relational/LikeExpression.java @@ -119,7 +119,7 @@ public LikeExpression withRightExpression(Expression arg0) { } public enum KeyWord { - LIKE, ILIKE, RLIKE, REGEXP_LIKE, REGEXP, SIMILAR_TO, MATCH_ANY, MATCH_ALL, MATCH_PHRASE, MATCH_PHRASE_PREFIX, MATCH_REGEXP; + LIKE, ILIKE, RLIKE, REGEXP_LIKE, REGEXP, GLOB, SIMILAR_TO, MATCH_ANY, MATCH_ALL, MATCH_PHRASE, MATCH_PHRASE_PREFIX, MATCH_REGEXP; public static KeyWord from(String keyword) { return Enum.valueOf(KeyWord.class, diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 4ab8d15051..ab11945aa2 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1713,6 +1713,7 @@ public class CCJSqlParser extends AbstractJSqlParser { case K_RLIKE: // RLIKE case K_REGEXP_LIKE: // REGEXP_LIKE case K_REGEXP: // REGEXP + case K_GLOB: // GLOB (DuckDB/SQLite) case K_SIMILAR_TO: // SIMILAR TO (in LikeExpression) case K_SIMILAR: // SIMILAR TO (in SimilarToExpression) case K_MATCH_ANY: // MATCH_ANY @@ -2055,6 +2056,7 @@ String NonReservedWord() : | tk= | tk= | tk= + | tk= | tk= | tk= | tk= @@ -8981,6 +8983,7 @@ Expression LikeExpression(Expression leftExpression) #LikeExpression: | token = | token = | token = + | token = | token = | token = | token = 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 aad68683bb..3dd3524511 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -36,4 +36,22 @@ void testCreateWithStruct() throws JSQLParserException { ");"; TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); } + + @Test + void testGlobOperator() throws JSQLParserException { + String sqlStr = "SELECT * FROM t WHERE b GLOB 'y*'"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } + + @Test + void testNotGlobOperator() throws JSQLParserException { + String sqlStr = "SELECT * FROM t WHERE b NOT GLOB 'y*'"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } + + @Test + void testGlobRemainsUsableAsIdentifier() throws JSQLParserException { + String sqlStr = "SELECT glob FROM t"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } } From 50a25f089e1438bd10af5b7ec289bb57fc8e5736 Mon Sep 17 00:00:00 2001 From: Hayssam Saleh Date: Tue, 15 Sep 2026 14:13:25 +0200 Subject: [PATCH 02/19] Support standalone SEMI and ANTI joins DuckDB allows SEMI/ANTI JOIN without a LEFT prefix, and ANTI as a join type at all. ANTI previously parsed silently as a table alias, producing a wrong AST from valid SQL. Adds Join.isAnti(), the LEFT/RIGHT/FULL SEMI|ANTI prefixes and the standalone form, keeping ANTI usable as an identifier outside a join prefix. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqinwHBKuAtPEmtXr3b5P2 --- .../sf/jsqlparser/parser/feature/Feature.java | 4 ++ .../sf/jsqlparser/statement/select/Join.java | 16 ++++++++ .../validation/feature/FeaturesAllowed.java | 1 + .../validation/validator/SelectValidator.java | 1 + .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 26 ++++++++++--- .../statement/select/DuckDBTest.java | 37 +++++++++++++++++++ 6 files changed, 80 insertions(+), 5 deletions(-) diff --git a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java index 21ae94db54..0653d81fad 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -182,6 +182,10 @@ public enum Feature { * "SEMI" join */ joinSemi, + /** + * @see net.sf.jsqlparser.statement.select.Join#isAnti() + */ + joinAnti, /** * "INNER" join */ diff --git a/src/main/java/net/sf/jsqlparser/statement/select/Join.java b/src/main/java/net/sf/jsqlparser/statement/select/Join.java index d134689f6f..935c5ae38f 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/Join.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/Join.java @@ -36,6 +36,7 @@ public class Join extends ASTNodeAccessImpl { private boolean simple = false; private boolean cross = false; private boolean semi = false; + private boolean anti = false; private boolean any = false; private boolean all = false; private boolean straight = false; @@ -194,6 +195,19 @@ public Join withFetch(boolean b) { * * @return true if is a "SEMI" join */ + public boolean isAnti() { + return anti; + } + + public void setAnti(boolean b) { + anti = b; + } + + public Join withAnti(boolean b) { + this.setAnti(b); + return this; + } + public boolean isSemi() { return semi; } @@ -511,6 +525,8 @@ public StringBuilder appendJoinTypeTo(StringBuilder builder) { builder.append("INNER "); } else if (isSemi()) { builder.append("SEMI "); + } else if (isAnti()) { + builder.append("ANTI "); } if (isArray()) { diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/FeaturesAllowed.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/FeaturesAllowed.java index 0048f8db83..f8c5694aaf 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/FeaturesAllowed.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/FeaturesAllowed.java @@ -59,6 +59,7 @@ public class FeaturesAllowed implements FeatureSetValidation, ModifyableFeatureS Feature.joinCross, Feature.joinOuter, Feature.joinSemi, + Feature.joinAnti, Feature.joinInner, Feature.joinStraight, Feature.joinApply, diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/SelectValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/SelectValidator.java index 71c33f9024..4961fd7cf0 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/SelectValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/SelectValidator.java @@ -328,6 +328,7 @@ public void validateOptionalJoin(Join join) { validateFeature(c, join.isOuter(), Feature.joinOuter); validateFeature(c, join.isInner(), Feature.joinInner); validateFeature(c, join.isSemi(), Feature.joinSemi); + validateFeature(c, join.isAnti(), Feature.joinAnti); validateFeature(c, join.isStraight(), Feature.joinStraight); validateFeature(c, join.isApply(), Feature.joinApply); validateFeature(c, join.isAsOf(), Feature.joinAsOf); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index ab11945aa2..9674ca7911 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1063,9 +1063,11 @@ public class CCJSqlParser extends AbstractJSqlParser { case K_CONNECT: return nextKind != K_BY; case K_START: return nextKind != K_WITH; case K_LEFT: return nextKind != K_JOIN && nextKind != K_OUTER - && nextKind != K_SEMI && nextKind != K_ARRAY_LITERAL; + && nextKind != K_SEMI && nextKind != K_ANTI + && nextKind != K_ARRAY_LITERAL; case K_RIGHT: return nextKind != K_JOIN && nextKind != K_OUTER - && nextKind != K_SEMI && nextKind != K_ARRAY_LITERAL; + && nextKind != K_SEMI && nextKind != K_ANTI + && nextKind != K_ARRAY_LITERAL; case K_ARRAY_LITERAL: return nextKind != K_JOIN; case K_ALL: return nextKind != K_JOIN; @@ -1209,6 +1211,15 @@ public class CCJSqlParser extends AbstractJSqlParser { return isReservedKeywordSafeByFollower(); } + /** SEMI/ANTI stay contextual identifiers unless they prefix a JOIN. */ + private boolean isSemiAntiJoinAhead() { + int kind = getToken(1).kind; + if (kind != K_SEMI && kind != K_ANTI) { + return false; + } + return getToken(2).kind == K_JOIN; + } + /** ASOF stays a contextual identifier outside a join prefix. */ private boolean isAsOfJoinAhead() { if (!isKeywordAhead("ASOF")) { @@ -1238,7 +1249,7 @@ public class CCJSqlParser extends AbstractJSqlParser { * reserved keywords. */ private boolean isAliasAhead() { - if (isAsOfJoinAhead()) { + if (isAsOfJoinAhead() || isSemiAntiJoinAhead()) { return false; } Token t = getToken(1); @@ -1930,6 +1941,7 @@ String NonReservedWord() : | tk= | tk= | tk= + | tk= | tk= | tk= | tk= @@ -7735,15 +7747,19 @@ Join JoinerExpression() #JoinerExpression: [ ( - { join.setLeft(true); } [ { join.setSemi(true); } | { join.setOuter(true); } | { join.setAny(true); } | { join.setAll(true); } ] + { join.setLeft(true); } [ { join.setSemi(true); } | { join.setAnti(true); } | { join.setOuter(true); } | { join.setAny(true); } | { join.setAll(true); } ] | ( { join.setRight(true); } | { join.setFull(true); } - ) [ { join.setOuter(true); } | { join.setAny(true); } | { join.setAll(true); } ] + ) [ { join.setSemi(true); } | { join.setAnti(true); } | { join.setOuter(true); } | { join.setAny(true); } | { join.setAll(true); } ] | { join.setInner(true); } + | + // DuckDB allows SEMI/ANTI without a LEFT prefix + LOOKAHEAD({ isSemiAntiJoinAhead() }) + ( { join.setSemi(true); } | { join.setAnti(true); } ) ) | { join.setCross(true); } 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 3dd3524511..95e941c738 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -54,4 +54,41 @@ void testGlobRemainsUsableAsIdentifier() throws JSQLParserException { String sqlStr = "SELECT glob FROM t"; TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); } + + @Test + void testSemiJoinWithoutLeft() throws JSQLParserException { + String sqlStr = "SELECT * FROM t SEMI JOIN u USING (id)"; + PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + Join join = select.getJoins().get(0); + + Assertions.assertTrue(join.isSemi()); + Assertions.assertFalse(join.isLeft()); + Assertions.assertNull(((Table) select.getFromItem()).getAlias()); + } + + @Test + void testAntiJoinWithoutLeft() throws JSQLParserException { + String sqlStr = "SELECT * FROM t ANTI JOIN u ON t.id = u.id"; + PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + Join join = select.getJoins().get(0); + + Assertions.assertTrue(join.isAnti()); + Assertions.assertNull(((Table) select.getFromItem()).getAlias()); + } + + @Test + void testLeftAntiJoin() throws JSQLParserException { + String sqlStr = "SELECT * FROM t LEFT ANTI JOIN u ON t.id = u.id"; + PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + Join join = select.getJoins().get(0); + + Assertions.assertTrue(join.isLeft()); + Assertions.assertTrue(join.isAnti()); + } + + @Test + void testAntiRemainsUsableAsIdentifier() throws JSQLParserException { + String sqlStr = "SELECT anti FROM t anti"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } } From a9345f129cd3eafbd9c6d4b4accb418235b5530f Mon Sep 17 00:00:00 2001 From: Hayssam Saleh Date: Tue, 15 Sep 2026 14:40:04 +0200 Subject: [PATCH 03/19] Support DuckDB MAP(key, value) column type DuckDB parameterises a type with other types, e.g. MAP(VARCHAR, INTEGER). Type arguments previously accepted identifiers and literals but not data type tokens. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqinwHBKuAtPEmtXr3b5P2 --- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 3 +++ .../statement/select/DuckDBTest.java | 26 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 9674ca7911..f6ceb7dcd8 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -13846,6 +13846,9 @@ ColDataType ColDataType(): | tk= | + // DuckDB parameterises types with other types: MAP(VARCHAR, INTEGER) + tk= + | tk= ) ) 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 95e941c738..04e582fa57 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -11,6 +11,7 @@ import net.sf.jsqlparser.JSQLParserException; import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.create.table.CreateTable; import net.sf.jsqlparser.test.TestUtils; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -91,4 +92,29 @@ void testAntiRemainsUsableAsIdentifier() throws JSQLParserException { String sqlStr = "SELECT anti FROM t anti"; TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); } + + @Test + void testMapColumnType() throws JSQLParserException { + String sqlStr = "CREATE TABLE t (m MAP(VARCHAR, INTEGER))"; + CreateTable createTable = + (CreateTable) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals("MAP", + createTable.getColumnDefinitions().get(0).getColDataType().getDataType()); + Assertions.assertEquals(java.util.Arrays.asList("VARCHAR", "INTEGER"), + createTable.getColumnDefinitions().get(0).getColDataType() + .getArgumentsStringList()); + } + + @Test + void testMapCast() throws JSQLParserException { + String sqlStr = "SELECT CAST(m AS MAP(VARCHAR, INTEGER)) FROM t"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } + + @Test + void testDecimalTypeArgumentsStillParse() throws JSQLParserException { + String sqlStr = "CREATE TABLE t (a DECIMAL(10, 2), b VARCHAR(255))"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } } From d7f5fe23ecb905ae2e260cd4f151c9dffe44fe3c Mon Sep 17 00:00:00 2001 From: Hayssam Saleh Date: Tue, 15 Sep 2026 14:43:26 +0200 Subject: [PATCH 04/19] Support DuckDB INSERT ... BY NAME / BY POSITION DuckDB matches an INSERT's source columns either positionally or by name. BY previously bound as a table alias, swallowing the clause. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqinwHBKuAtPEmtXr3b5P2 --- .../jsqlparser/statement/insert/Insert.java | 37 +++++++++++++++++++ .../util/deparser/InsertDeParser.java | 4 ++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 23 +++++++++++- .../statement/select/DuckDBTest.java | 25 +++++++++++++ 4 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/sf/jsqlparser/statement/insert/Insert.java b/src/main/java/net/sf/jsqlparser/statement/insert/Insert.java index 307d0384b2..3541c1022f 100644 --- a/src/main/java/net/sf/jsqlparser/statement/insert/Insert.java +++ b/src/main/java/net/sf/jsqlparser/statement/insert/Insert.java @@ -59,6 +59,7 @@ public enum OverridingMode { private InsertConflictAction conflictAction; private InsertDuplicateAction duplicateAction; private Alias rowAlias; + private ColumnMatching columnMatching; private boolean oracleMultiInsert = false; private boolean oracleMultiInsertFirst = false; private List oracleMultiInsertBranches; @@ -231,6 +232,38 @@ public void setWithItemsList(List> withItemsList) { this.withItemsList = withItemsList; } + /** + * DuckDB matches the source columns of an INSERT either positionally or by name: + * {@code INSERT INTO tbl BY NAME SELECT ...}. + */ + public enum ColumnMatching { + BY_NAME("BY NAME"), BY_POSITION("BY POSITION"); + + private final String keywords; + + ColumnMatching(String keywords) { + this.keywords = keywords; + } + + @Override + public String toString() { + return keywords; + } + } + + public ColumnMatching getColumnMatching() { + return columnMatching; + } + + public void setColumnMatching(ColumnMatching columnMatching) { + this.columnMatching = columnMatching; + } + + public Insert withColumnMatching(ColumnMatching columnMatching) { + setColumnMatching(columnMatching); + return this; + } + public OverridingMode getOverridingMode() { return overridingMode; } @@ -406,6 +439,10 @@ private void appendInsertTargetAndValues(StringBuilder sql) { sql.append(") "); } + if (columnMatching != null) { + sql.append(columnMatching).append(" "); + } + if (isOverriding()) { sql.append("OVERRIDING ").append(overridingMode).append(" VALUE "); } diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/InsertDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/InsertDeParser.java index 596af04565..7abcbc0c57 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/InsertDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/InsertDeParser.java @@ -104,6 +104,10 @@ public void deParse(Insert insert) { builder.append(")"); } + if (insert.getColumnMatching() != null) { + builder.append(" ").append(insert.getColumnMatching()); + } + if (insert.isOverriding()) { builder.append(" OVERRIDING ").append(insert.getOverridingMode()).append(" VALUE"); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index f6ceb7dcd8..16fac10400 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1211,6 +1211,13 @@ public class CCJSqlParser extends AbstractJSqlParser { return isReservedKeywordSafeByFollower(); } + /** DuckDB's INSERT ... BY NAME|POSITION column matching, never a table alias. */ + private boolean isColumnMatchingAhead() { + return getToken(1).kind == K_BY + && ("NAME".equalsIgnoreCase(getToken(2).image) + || "POSITION".equalsIgnoreCase(getToken(2).image)); + } + /** SEMI/ANTI stay contextual identifiers unless they prefix a JOIN. */ private boolean isSemiAntiJoinAhead() { int kind = getToken(1).kind; @@ -5128,12 +5135,26 @@ Insert Insert(): ] table=TableReference() [ LOOKAHEAD(2) "(" partitions=Partitions() ")" ] - [ LOOKAHEAD({ isAliasAhead() && !(getToken(1).kind == K_OVERRIDING + [ LOOKAHEAD({ isAliasAhead() && !isColumnMatchingAhead() + && !(getToken(1).kind == K_OVERRIDING && (getToken(2).kind == K_SYSTEM || getToken(2).kind == K_USER)) }) [ { useAs = true; } ] name=RelObjectName() { table.setAlias(new Alias(name,useAs)); }] [ LOOKAHEAD(2) "(" columns=ColumnList() ")" ] + // DuckDB: INSERT INTO tbl BY NAME|POSITION SELECT ... + [ + LOOKAHEAD({ isColumnMatchingAhead() }) + + ( + { insert.setColumnMatching(Insert.ColumnMatching.BY_NAME); } + | + // POSITION is lexed as a string-function name + + { insert.setColumnMatching(Insert.ColumnMatching.BY_POSITION); } + ) + ] + [ LOOKAHEAD(2) ( { insert.setOverridingMode(Insert.OverridingMode.SYSTEM); } | { insert.setOverridingMode(Insert.OverridingMode.USER); } ) ] 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 04e582fa57..2c9992123c 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -12,6 +12,7 @@ import net.sf.jsqlparser.JSQLParserException; import net.sf.jsqlparser.schema.Table; import net.sf.jsqlparser.statement.create.table.CreateTable; +import net.sf.jsqlparser.statement.insert.Insert; import net.sf.jsqlparser.test.TestUtils; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -117,4 +118,28 @@ void testDecimalTypeArgumentsStillParse() throws JSQLParserException { String sqlStr = "CREATE TABLE t (a DECIMAL(10, 2), b VARCHAR(255))"; TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); } + + @Test + void testInsertByName() throws JSQLParserException { + String sqlStr = "INSERT INTO t BY NAME SELECT 1 AS b, 2 AS a"; + Insert insert = (Insert) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals(Insert.ColumnMatching.BY_NAME, insert.getColumnMatching()); + } + + @Test + void testInsertByPosition() throws JSQLParserException { + String sqlStr = "INSERT INTO t BY POSITION SELECT 1, 2"; + Insert insert = (Insert) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals(Insert.ColumnMatching.BY_POSITION, insert.getColumnMatching()); + } + + @Test + void testInsertWithoutColumnMatching() throws JSQLParserException { + String sqlStr = "INSERT INTO t (a, b) VALUES (1, 2)"; + Insert insert = (Insert) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertNull(insert.getColumnMatching()); + } } From 34c97e4b49816c4fd6f99aac0c056e8b3e932809 Mon Sep 17 00:00:00 2001 From: Hayssam Saleh Date: Tue, 15 Sep 2026 14:46:21 +0200 Subject: [PATCH 05/19] Support DuckDB USING SAMPLE sizes and methods DuckDB writes the sample size first and the method behind it: USING SAMPLE 10%, USING SAMPLE 10 PERCENT (bernoulli), USING SAMPLE 10 ROWS (system, 377), USING SAMPLE reservoir (50 ROWS). The method is now optional, RESERVOIR is a known method, and a bare sample size accepts a unit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqinwHBKuAtPEmtXr3b5P2 --- .../statement/select/SampleClause.java | 31 ++++++++++++++-- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 37 ++++++++++++++++++- .../statement/select/DuckDBTest.java | 34 +++++++++++++++++ 3 files changed, 96 insertions(+), 6 deletions(-) diff --git a/src/main/java/net/sf/jsqlparser/statement/select/SampleClause.java b/src/main/java/net/sf/jsqlparser/statement/select/SampleClause.java index e6bc8f21d9..a3c0203dfa 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/SampleClause.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/SampleClause.java @@ -17,6 +17,7 @@ public class SampleClause { private Number percentageArgument; private String percentageUnit; private boolean argumentInBrackets = true; + private boolean methodInBrackets = false; // ClickHouse specific private Number offsetArgument; private Number repeatArgument; @@ -130,10 +131,23 @@ public SampleClause setMethod(String method) { return this; } + /** + * DuckDB writes the sampling method behind the sample size: + * {@code USING SAMPLE 10 PERCENT (bernoulli)}. + */ + public boolean isMethodInBrackets() { + return methodInBrackets; + } + + public SampleClause setMethodInBrackets(boolean methodInBrackets) { + this.methodInBrackets = methodInBrackets; + return this; + } + public StringBuilder appendTo(StringBuilder builder) { builder.append(" ").append(keyword); - if (method != null) { + if (method != null && !methodInBrackets) { builder.append(" ").append(method); } @@ -144,11 +158,20 @@ public StringBuilder appendTo(StringBuilder builder) { } else { builder.append(" ").append(percentageArgument); if (percentageUnit != null) { - builder.append(" ").append(percentageUnit); + // "10%" has no blank, "10 PERCENT" and "10 ROWS" do + builder.append("%".equals(percentageUnit) ? "" : " ").append(percentageUnit); } } } + if (methodInBrackets) { + builder.append(" (").append(method); + if (seedArgument != null) { + builder.append(", ").append(seedArgument); + } + builder.append(")"); + } + if (offsetArgument != null) { builder.append(" OFFSET ").append(offsetArgument); } @@ -157,7 +180,7 @@ public StringBuilder appendTo(StringBuilder builder) { builder.append(" REPEATABLE (").append(repeatArgument).append(")"); } - if (seedArgument != null) { + if (seedArgument != null && !methodInBrackets) { builder.append(" SEED (").append(seedArgument).append(")"); } @@ -189,7 +212,7 @@ public String toString() { } public enum SampleMethod { - BERNOULLI, SYSTEM, BLOCK; + BERNOULLI, SYSTEM, BLOCK, RESERVOIR; public static SampleMethod from(String sampleMethod) { return Enum.valueOf(SampleMethod.class, diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 16fac10400..63effbefd6 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1211,6 +1211,12 @@ public class CCJSqlParser extends AbstractJSqlParser { return isReservedKeywordSafeByFollower(); } + /** DuckDB sampling methods, which are ordinary identifiers except for SYSTEM/BERNOULLI. */ + private boolean isSampleMethod(Token token) { + return token.kind == K_SYSTEM || token.kind == K_BERNOULLI + || (token.image != null && "RESERVOIR".equalsIgnoreCase(token.image)); + } + /** DuckDB's INSERT ... BY NAME|POSITION column matching, never a table alias. */ private boolean isColumnMatchingAhead() { return getToken(1).kind == K_BY @@ -5871,6 +5877,7 @@ SampleClause SampleClause(): Number offsetArgument = null; Number repeatArgument=null; Number seedArgument=null; + boolean methodInBrackets = false; } { ( @@ -5887,9 +5894,13 @@ SampleClause SampleClause(): ) | ( - // Duck DB + // Duck DB: the method is optional and may follow the sample size instead { keyword = "USING SAMPLE"; } - ( token = | token = ) { method = token.image; } + [ + LOOKAHEAD({ isSampleMethod(getToken(1)) }) + ( token = | token = | token = ) + { method = token.image; } + ] ) ) @@ -5909,12 +5920,34 @@ SampleClause SampleClause(): [ LOOKAHEAD(2) "(" seedArgument = Number() ")" ] | percentageArgument = Number() { argumentInBrackets = false; } + // greedy: a unit directly after the sample size always belongs to the clause + [ + LOOKAHEAD(1) + ( + "%" { percentageUnit="%"; } + | + { percentageUnit="PERCENT"; } + | + { percentageUnit="ROWS"; } + ) + ] [ LOOKAHEAD(2) offsetArgument = Number() ] ) + // DuckDB: USING SAMPLE 10 PERCENT (bernoulli[, seed]) + [ + LOOKAHEAD({ method == null && getToken(1).kind == OPENING_BRACKET + && isSampleMethod(getToken(2)) }) + "(" ( token = | token = | token = ) + { method = token.image; methodInBrackets = true; } + [ "," seedArgument = Number() ] + ")" + ] + { sampleClause = new SampleClause(keyword, method, percentageArgument, percentageUnit, repeatArgument, seedArgument); sampleClause.setArgumentInBrackets(argumentInBrackets); + sampleClause.setMethodInBrackets(methodInBrackets); sampleClause.setOffsetArgument(offsetArgument); return sampleClause; } 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 2c9992123c..cfd3b3444a 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -142,4 +142,38 @@ void testInsertWithoutColumnMatching() throws JSQLParserException { Assertions.assertNull(insert.getColumnMatching()); } + + @Test + void testUsingSamplePercentShorthand() throws JSQLParserException { + String sqlStr = "SELECT * FROM t USING SAMPLE 10%"; + PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + SampleClause sampleClause = select.getFromItem().getSampleClause(); + + Assertions.assertEquals("%", sampleClause.getPercentageUnit()); + Assertions.assertNull(sampleClause.getMethod()); + } + + @Test + void testUsingSamplePercentWithMethodInBrackets() throws JSQLParserException { + String sqlStr = "SELECT * FROM t USING SAMPLE 10 PERCENT (bernoulli)"; + PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals(SampleClause.SampleMethod.BERNOULLI, + select.getFromItem().getSampleClause().getMethod()); + } + + @Test + void testUsingSampleRowsWithSeed() throws JSQLParserException { + String sqlStr = "SELECT * FROM t USING SAMPLE 10 ROWS (system, 377)"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } + + @Test + void testUsingSampleReservoir() throws JSQLParserException { + String sqlStr = "SELECT * FROM t USING SAMPLE reservoir (50 ROWS)"; + PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals(SampleClause.SampleMethod.RESERVOIR, + select.getFromItem().getSampleClause().getMethod()); + } } From 26a1fba94d2d254e030276ccf15999254fac93bf Mon Sep 17 00:00:00 2001 From: Hayssam Saleh Date: Tue, 15 Sep 2026 14:48:08 +0200 Subject: [PATCH 06/19] Support DuckDB DESCRIBE DESCRIBE now accepts a query as well as a table, so DESCRIBE SELECT ..., DESCRIBE FROM ... and DESCRIBE WITH ... parse. The described query is reachable from TablesNamesFinder and rendered by the deparser. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqinwHBKuAtPEmtXr3b5P2 --- .../statement/DescribeStatement.java | 19 +++++++++++- .../sf/jsqlparser/util/TablesNamesFinder.java | 6 +++- .../util/deparser/StatementDeParser.java | 6 +++- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 10 ++++++- .../statement/select/DuckDBTest.java | 29 +++++++++++++++++++ 5 files changed, 66 insertions(+), 4 deletions(-) diff --git a/src/main/java/net/sf/jsqlparser/statement/DescribeStatement.java b/src/main/java/net/sf/jsqlparser/statement/DescribeStatement.java index 10a92764cc..054b648436 100644 --- a/src/main/java/net/sf/jsqlparser/statement/DescribeStatement.java +++ b/src/main/java/net/sf/jsqlparser/statement/DescribeStatement.java @@ -10,10 +10,12 @@ package net.sf.jsqlparser.statement; import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.select.Select; public class DescribeStatement implements Statement { private Table table; + private Select select; private String describeType; public DescribeStatement() { @@ -32,9 +34,24 @@ public void setTable(Table table) { this.table = table; } + /** DuckDB also describes a query: {@code DESCRIBE SELECT ...}. */ + public Select getSelect() { + return select; + } + + public void setSelect(Select select) { + this.select = select; + } + + public DescribeStatement withSelect(Select select) { + setSelect(select); + return this; + } + @Override public String toString() { - return this.describeType + " " + table.getFullyQualifiedName(); + return this.describeType + " " + + (select != null ? select.toString() : table.getFullyQualifiedName()); } @Override diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 3bf8df2e0b..c3a9ffaad7 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -2044,7 +2044,11 @@ public void visit(Values values) { @Override public Void visit(DescribeStatement describe, S context) { - describe.getTable().accept(this, context); + if (describe.getSelect() != null) { + describe.getSelect().accept((SelectVisitor) this, context); + } else { + describe.getTable().accept(this, context); + } return null; } diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java index 69cb9770cb..f7dcd29bac 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -444,7 +444,11 @@ public StringBuilder visit(Comment comment, S context) { public StringBuilder visit(DescribeStatement describe, S context) { builder.append(describe.getDescribeType()); builder.append(" "); - builder.append(describe.getTable()); + if (describe.getSelect() != null) { + describe.getSelect().accept(this, context); + } else { + builder.append(describe.getTable()); + } 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 63effbefd6..d9dd820019 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -4373,11 +4373,19 @@ PurgeStatement PurgeStatement(): { DescribeStatement Describe(): { Table table; + Select select; DescribeStatement stmt = new DescribeStatement(); Token tk = null; } { (tk= | tk=) - table = Table() { stmt.setDescribeType(tk.image).setTable(table); } + ( + // DuckDB describes the result of a query, not only a table + LOOKAHEAD({ getToken(1).kind == K_SELECT || getToken(1).kind == K_WITH + || getToken(1).kind == K_FROM || getToken(1).kind == OPENING_BRACKET }) + select = Select() { stmt.setDescribeType(tk.image).setSelect(select); } + | + table = Table() { stmt.setDescribeType(tk.image).setTable(table); } + ) { return stmt; } 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 cfd3b3444a..a7ba820298 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -13,6 +13,7 @@ import net.sf.jsqlparser.schema.Table; import net.sf.jsqlparser.statement.create.table.CreateTable; import net.sf.jsqlparser.statement.insert.Insert; +import net.sf.jsqlparser.statement.DescribeStatement; import net.sf.jsqlparser.test.TestUtils; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -176,4 +177,32 @@ void testUsingSampleReservoir() throws JSQLParserException { Assertions.assertEquals(SampleClause.SampleMethod.RESERVOIR, select.getFromItem().getSampleClause().getMethod()); } + + @Test + void testDescribeQuery() throws JSQLParserException { + String sqlStr = "DESCRIBE SELECT * FROM t"; + DescribeStatement describe = + (DescribeStatement) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertNotNull(describe.getSelect()); + Assertions.assertNull(describe.getTable()); + } + + @Test + void testDescribeQueryTablesNamesFinder() throws JSQLParserException { + String sqlStr = "DESCRIBE SELECT * FROM ds.t"; + + Assertions.assertEquals(java.util.Collections.singletonList("ds.t"), + new net.sf.jsqlparser.util.TablesNamesFinder() + .getTableList(net.sf.jsqlparser.parser.CCJSqlParserUtil.parse(sqlStr))); + } + + @Test + void testDescribeTableStillParses() throws JSQLParserException { + String sqlStr = "DESCRIBE t"; + DescribeStatement describe = + (DescribeStatement) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals("t", describe.getTable().getName()); + } } From d0b06421ebc8b8d1bde3cc5f65dc9512cfe59042 Mon Sep 17 00:00:00 2001 From: Hayssam Saleh Date: Tue, 15 Sep 2026 14:49:57 +0200 Subject: [PATCH 07/19] Support PRAGMA statement Adds PRAGMA name, PRAGMA name(arguments) and PRAGMA name = value, as used by DuckDB and SQLite to read and set database settings. PRAGMA stays a non-reserved keyword. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqinwHBKuAtPEmtXr3b5P2 --- .../jsqlparser/statement/PragmaStatement.java | 91 +++++++++++++++++++ .../statement/StatementFeatureVisitor.java | 7 ++ .../statement/StatementVisitor.java | 6 ++ .../statement/StatementVisitorAdapter.java | 5 + .../sf/jsqlparser/util/TablesNamesFinder.java | 12 +++ .../util/deparser/StatementDeParser.java | 7 ++ .../validator/StatementValidator.java | 11 +++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 28 ++++++ .../statement/select/DuckDBTest.java | 32 +++++++ 9 files changed, 199 insertions(+) create mode 100644 src/main/java/net/sf/jsqlparser/statement/PragmaStatement.java diff --git a/src/main/java/net/sf/jsqlparser/statement/PragmaStatement.java b/src/main/java/net/sf/jsqlparser/statement/PragmaStatement.java new file mode 100644 index 0000000000..c1692e6df9 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/PragmaStatement.java @@ -0,0 +1,91 @@ +/*- + * #%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.statement; + +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; + +/** + * {@code PRAGMA name}, {@code PRAGMA name(arguments)} and {@code PRAGMA name = value}, used by + * DuckDB and SQLite to read or set database settings. + * + * @see PRAGMA statements + */ +public class PragmaStatement implements Statement { + private String name; + private ExpressionList parameters; + private Expression value; + + public PragmaStatement() {} + + public PragmaStatement(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public PragmaStatement withName(String name) { + setName(name); + return this; + } + + public ExpressionList getParameters() { + return parameters; + } + + public void setParameters(ExpressionList parameters) { + this.parameters = parameters; + } + + public PragmaStatement withParameters(ExpressionList parameters) { + setParameters(parameters); + return this; + } + + public Expression getValue() { + return value; + } + + public void setValue(Expression value) { + this.value = value; + } + + public PragmaStatement withValue(Expression value) { + setValue(value); + return this; + } + + public StringBuilder appendTo(StringBuilder builder) { + builder.append("PRAGMA ").append(name); + if (parameters != null) { + builder.append("(").append(parameters).append(")"); + } + if (value != null) { + builder.append(" = ").append(value); + } + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index 861612fecd..75a4713dc9 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -521,6 +521,13 @@ public Void visit(Drop drop, S context) { return null; } + @Override + public Void visit(PragmaStatement pragmaStatement, S context) { + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SESSION); + return null; + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { analysis.claimTopLevel(); diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index 8ec9dc1bc0..0093131601 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -371,6 +371,12 @@ default void visit(RenameTableStatement renameTableStatement) { this.visit(renameTableStatement, null); } + T visit(PragmaStatement pragmaStatement, S context); + + default void visit(PragmaStatement pragmaStatement) { + this.visit(pragmaStatement, null); + } + T visit(PurgeStatement purgeStatement, S context); default void visit(PurgeStatement purgeStatement) { diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index ab05725ad5..bdad3247a2 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -618,6 +618,11 @@ public T visit(RenameTableStatement renameTableStatement, S context) { return null; } + @Override + public T visit(PragmaStatement pragmaStatement, S context) { + return null; + } + @Override public T visit(PurgeStatement purgeStatement, S context) { return null; diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index c3a9ffaad7..9a796b49b9 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -209,6 +209,7 @@ import net.sf.jsqlparser.statement.update.Update; import net.sf.jsqlparser.statement.update.UpdateSet; import net.sf.jsqlparser.statement.upsert.Upsert; +import net.sf.jsqlparser.statement.PragmaStatement; /** @@ -2468,6 +2469,17 @@ public void visit(RenameTableStatement renameTableStatement) { StatementVisitor.super.visit(renameTableStatement); } + @Override + public Void visit(PragmaStatement pragmaStatement, S context) { + // no tables involved in this statement + return null; + } + + @Override + public void visit(PragmaStatement pragmaStatement) { + StatementVisitor.super.visit(pragmaStatement); + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { if (purgeStatement.getPurgeObjectType() == PurgeObjectType.TABLE) { diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java index f7dcd29bac..ca438defa6 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -97,6 +97,7 @@ import net.sf.jsqlparser.statement.update.ParenthesedUpdate; import net.sf.jsqlparser.statement.update.Update; import net.sf.jsqlparser.statement.upsert.Upsert; +import net.sf.jsqlparser.statement.PragmaStatement; public class StatementDeParser extends AbstractDeParser implements StatementVisitor { @@ -556,6 +557,12 @@ public StringBuilder visit(RenameTableStatement renameTableStatement, S cont return builder; } + @Override + public StringBuilder visit(PragmaStatement pragmaStatement, S context) { + pragmaStatement.appendTo(builder); + return builder; + } + @Override public StringBuilder visit(PurgeStatement purgeStatement, S context) { purgeStatement.appendTo(builder); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java index 6764ef740d..5a9fae85e3 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java @@ -99,6 +99,7 @@ import net.sf.jsqlparser.statement.upsert.Upsert; import net.sf.jsqlparser.util.validation.ValidationCapability; import net.sf.jsqlparser.util.validation.metadata.NamedObject; +import net.sf.jsqlparser.statement.PragmaStatement; /** * @author gitmotte @@ -477,6 +478,12 @@ public Void visit(RenameTableStatement renameTableStatement, S context) { return null; } + @Override + public Void visit(PragmaStatement pragmaStatement, S context) { + // TODO: not yet implemented + return null; + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { // TODO: not yet implemented @@ -693,6 +700,10 @@ public void visit(RenameTableStatement renameTableStatement) { visit(renameTableStatement, null); } + public void visit(PragmaStatement pragmaStatement) { + visit(pragmaStatement, null); + } + public void visit(PurgeStatement purgeStatement) { visit(purgeStatement, null); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index d9dd820019..afa907afc9 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -2201,6 +2201,7 @@ String NonReservedWord() : | tk= | tk= | tk= + | tk= | tk= | tk= | tk= @@ -2939,6 +2940,8 @@ Statement SingleStatement() : | LOOKAHEAD({ isKeywordAhead("REVOKE") }) stm = Revoke(false) | + LOOKAHEAD({ getToken(1).kind == K_PRAGMA }) stm = PragmaStatement() + | stm = PurgeStatement() | stm = SessionStatement() @@ -4340,6 +4343,31 @@ RenameTableStatement RenameTableStatement(): { } } +/** + * DuckDB/SQLite PRAGMA name [(arguments) | = value]. + */ +PragmaStatement PragmaStatement() #PragmaStatement: +{ + PragmaStatement pragmaStatement = new PragmaStatement(); + ObjectNames name; + ExpressionList parameters; + Expression value; +} +{ + name=RelObjectNames() + { pragmaStatement.setName(String.join(".", name.getNames())); } + // greedy: parentheses right after the pragma name are its arguments + [ + LOOKAHEAD(2) + ( + "(" parameters=ExpressionList() ")" { pragmaStatement.setParameters(parameters); } + | + "=" value=SimpleExpression() { pragmaStatement.setValue(value); } + ) + ] + { return pragmaStatement; } +} + PurgeStatement PurgeStatement(): { PurgeStatement purgeStatement = null; Table table; 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 a7ba820298..746ee75959 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -14,6 +14,7 @@ import net.sf.jsqlparser.statement.create.table.CreateTable; import net.sf.jsqlparser.statement.insert.Insert; import net.sf.jsqlparser.statement.DescribeStatement; +import net.sf.jsqlparser.statement.PragmaStatement; import net.sf.jsqlparser.test.TestUtils; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -205,4 +206,35 @@ void testDescribeTableStillParses() throws JSQLParserException { Assertions.assertEquals("t", describe.getTable().getName()); } + + @Test + void testPragmaWithArguments() throws JSQLParserException { + String sqlStr = "PRAGMA table_info('t')"; + PragmaStatement pragma = + (PragmaStatement) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals("table_info", pragma.getName()); + Assertions.assertEquals(1, pragma.getParameters().size()); + } + + @Test + void testPragmaAssignment() throws JSQLParserException { + String sqlStr = "PRAGMA memory_limit = '1GB'"; + PragmaStatement pragma = + (PragmaStatement) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals("'1GB'", pragma.getValue().toString()); + } + + @Test + void testPragmaWithoutArguments() throws JSQLParserException { + String sqlStr = "PRAGMA database_list"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } + + @Test + void testPragmaRemainsUsableAsIdentifier() throws JSQLParserException { + String sqlStr = "SELECT pragma FROM t"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } } From 7d4eac75a1f9793f9feb0f5ae382abf032f15b02 Mon Sep 17 00:00:00 2001 From: Hayssam Saleh Date: Tue, 15 Sep 2026 14:52:31 +0200 Subject: [PATCH 08/19] Support DuckDB INSTALL and LOAD extension statements Adds [FORCE] INSTALL extension [FROM repository] and LOAD extension, where the extension and repository may be an identifier or a string literal. INSTALL and LOAD stay non-reserved keywords. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqinwHBKuAtPEmtXr3b5P2 --- .../statement/ExtensionStatement.java | 107 ++++++++++++++++++ .../statement/StatementFeatureVisitor.java | 7 ++ .../statement/StatementVisitor.java | 6 + .../statement/StatementVisitorAdapter.java | 5 + .../sf/jsqlparser/util/TablesNamesFinder.java | 12 ++ .../util/deparser/StatementDeParser.java | 7 ++ .../validator/StatementValidator.java | 11 ++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 58 ++++++++-- .../statement/select/DuckDBTest.java | 43 +++++++ 9 files changed, 249 insertions(+), 7 deletions(-) create mode 100644 src/main/java/net/sf/jsqlparser/statement/ExtensionStatement.java diff --git a/src/main/java/net/sf/jsqlparser/statement/ExtensionStatement.java b/src/main/java/net/sf/jsqlparser/statement/ExtensionStatement.java new file mode 100644 index 0000000000..6219273667 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/ExtensionStatement.java @@ -0,0 +1,107 @@ +/*- + * #%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.statement; + +/** + * DuckDB's extension management statements {@code [FORCE] INSTALL extension [FROM repository]} and + * {@code LOAD extension}. + * + * @see Extensions + */ +public class ExtensionStatement implements Statement { + private Operation operation; + private boolean force; + private String extensionName; + private String repository; + + public ExtensionStatement() {} + + public ExtensionStatement(Operation operation, String extensionName) { + this.operation = operation; + this.extensionName = extensionName; + } + + public Operation getOperation() { + return operation; + } + + public void setOperation(Operation operation) { + this.operation = operation; + } + + public ExtensionStatement withOperation(Operation operation) { + setOperation(operation); + return this; + } + + public boolean isForce() { + return force; + } + + public void setForce(boolean force) { + this.force = force; + } + + public ExtensionStatement withForce(boolean force) { + setForce(force); + return this; + } + + public String getExtensionName() { + return extensionName; + } + + public void setExtensionName(String extensionName) { + this.extensionName = extensionName; + } + + public ExtensionStatement withExtensionName(String extensionName) { + setExtensionName(extensionName); + return this; + } + + public String getRepository() { + return repository; + } + + public void setRepository(String repository) { + this.repository = repository; + } + + public ExtensionStatement withRepository(String repository) { + setRepository(repository); + return this; + } + + public StringBuilder appendTo(StringBuilder builder) { + if (force) { + builder.append("FORCE "); + } + builder.append(operation).append(" ").append(extensionName); + if (repository != null) { + builder.append(" FROM ").append(repository); + } + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } + + public enum Operation { + INSTALL, LOAD + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index 75a4713dc9..3165642b57 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -528,6 +528,13 @@ public Void visit(PragmaStatement pragmaStatement, S context) { return null; } + @Override + public Void visit(ExtensionStatement extensionStatement, S context) { + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SESSION); + return null; + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { analysis.claimTopLevel(); diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index 0093131601..8c6af205e3 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -377,6 +377,12 @@ default void visit(PragmaStatement pragmaStatement) { this.visit(pragmaStatement, null); } + T visit(ExtensionStatement extensionStatement, S context); + + default void visit(ExtensionStatement extensionStatement) { + this.visit(extensionStatement, null); + } + T visit(PurgeStatement purgeStatement, S context); default void visit(PurgeStatement purgeStatement) { diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index bdad3247a2..72d9147f28 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -623,6 +623,11 @@ public T visit(PragmaStatement pragmaStatement, S context) { return null; } + @Override + public T visit(ExtensionStatement extensionStatement, S context) { + return null; + } + @Override public T visit(PurgeStatement purgeStatement, S context) { return null; diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 9a796b49b9..bcaeb1bd16 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -210,6 +210,7 @@ import net.sf.jsqlparser.statement.update.UpdateSet; import net.sf.jsqlparser.statement.upsert.Upsert; import net.sf.jsqlparser.statement.PragmaStatement; +import net.sf.jsqlparser.statement.ExtensionStatement; /** @@ -2480,6 +2481,17 @@ public void visit(PragmaStatement pragmaStatement) { StatementVisitor.super.visit(pragmaStatement); } + @Override + public Void visit(ExtensionStatement extensionStatement, S context) { + // no tables involved in this statement + return null; + } + + @Override + public void visit(ExtensionStatement extensionStatement) { + StatementVisitor.super.visit(extensionStatement); + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { if (purgeStatement.getPurgeObjectType() == PurgeObjectType.TABLE) { diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java index ca438defa6..ffba44348b 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -98,6 +98,7 @@ import net.sf.jsqlparser.statement.update.Update; import net.sf.jsqlparser.statement.upsert.Upsert; import net.sf.jsqlparser.statement.PragmaStatement; +import net.sf.jsqlparser.statement.ExtensionStatement; public class StatementDeParser extends AbstractDeParser implements StatementVisitor { @@ -563,6 +564,12 @@ public StringBuilder visit(PragmaStatement pragmaStatement, S context) { return builder; } + @Override + public StringBuilder visit(ExtensionStatement extensionStatement, S context) { + extensionStatement.appendTo(builder); + return builder; + } + @Override public StringBuilder visit(PurgeStatement purgeStatement, S context) { purgeStatement.appendTo(builder); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java index 5a9fae85e3..52da684d73 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java @@ -100,6 +100,7 @@ import net.sf.jsqlparser.util.validation.ValidationCapability; import net.sf.jsqlparser.util.validation.metadata.NamedObject; import net.sf.jsqlparser.statement.PragmaStatement; +import net.sf.jsqlparser.statement.ExtensionStatement; /** * @author gitmotte @@ -484,6 +485,12 @@ public Void visit(PragmaStatement pragmaStatement, S context) { return null; } + @Override + public Void visit(ExtensionStatement extensionStatement, S context) { + // TODO: not yet implemented + return null; + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { // TODO: not yet implemented @@ -704,6 +711,10 @@ public void visit(PragmaStatement pragmaStatement) { visit(pragmaStatement, null); } + public void visit(ExtensionStatement extensionStatement) { + visit(extensionStatement, null); + } + public void visit(PurgeStatement purgeStatement) { visit(purgeStatement, null); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index afa907afc9..088fbd3319 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -2099,6 +2099,7 @@ String NonReservedWord() : | tk= | tk= | tk= + | tk= | tk= | tk= | tk= @@ -2127,6 +2128,7 @@ String NonReservedWord() : | tk= | tk= | tk= + | tk= | tk= | tk= | tk= @@ -2942,6 +2944,10 @@ Statement SingleStatement() : | LOOKAHEAD({ getToken(1).kind == K_PRAGMA }) stm = PragmaStatement() | + LOOKAHEAD({ getToken(1).kind == K_INSTALL || getToken(1).kind == K_LOAD + || (getToken(1).kind == K_FORCE && getToken(2).kind == K_INSTALL) }) + stm = ExtensionStatement() + | stm = PurgeStatement() | stm = SessionStatement() @@ -4343,6 +4349,38 @@ RenameTableStatement RenameTableStatement(): { } } +/** + * DuckDB's [FORCE] INSTALL extension [FROM repository] and LOAD extension. + */ +ExtensionStatement ExtensionStatement() #ExtensionStatement: +{ + ExtensionStatement extensionStatement = new ExtensionStatement(); + ObjectNames name; + Token token; +} +{ + [ { extensionStatement.setForce(true); } ] + ( + { extensionStatement.setOperation(ExtensionStatement.Operation.INSTALL); } + | + { extensionStatement.setOperation(ExtensionStatement.Operation.LOAD); } + ) + ( + token= { extensionStatement.setExtensionName(token.image); } + | + name=RelObjectNames() { extensionStatement.setExtensionName(String.join(".", name.getNames())); } + ) + [ + LOOKAHEAD(2) + ( + token= { extensionStatement.setRepository(token.image); } + | + name=RelObjectNames() { extensionStatement.setRepository(String.join(".", name.getNames())); } + ) + ] + { return extensionStatement; } +} + /** * DuckDB/SQLite PRAGMA name [(arguments) | = value]. */ @@ -16428,14 +16466,20 @@ RollbackStatement RollbackStatement(): { { rollbackStatement = new RollbackStatement(); } [ { rollbackStatement.setUsingWorkKeyword(true); } ] - [ ( - [ { rollbackStatement.setUsingSavepointKeyword(true); }] - token= { rollbackStatement.setSavepointName(token.image); } - ) - | + // explicit lookahead: FORCE INSTALL starts a new statement, FORCE 'id' belongs here + [ + LOOKAHEAD(2) ( - token= { rollbackStatement.setForceDistributedTransactionIdentifier(token.image); } - ) ] + ( + [ { rollbackStatement.setUsingSavepointKeyword(true); }] + token= { rollbackStatement.setSavepointName(token.image); } + ) + | + ( + token= { rollbackStatement.setForceDistributedTransactionIdentifier(token.image); } + ) + ) + ] { return rollbackStatement; 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 746ee75959..161e3f4954 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -15,6 +15,7 @@ import net.sf.jsqlparser.statement.insert.Insert; import net.sf.jsqlparser.statement.DescribeStatement; import net.sf.jsqlparser.statement.PragmaStatement; +import net.sf.jsqlparser.statement.ExtensionStatement; import net.sf.jsqlparser.test.TestUtils; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -237,4 +238,46 @@ void testPragmaRemainsUsableAsIdentifier() throws JSQLParserException { String sqlStr = "SELECT pragma FROM t"; TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); } + + @Test + void testLoadExtension() throws JSQLParserException { + String sqlStr = "LOAD httpfs"; + ExtensionStatement extension = + (ExtensionStatement) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals(ExtensionStatement.Operation.LOAD, extension.getOperation()); + Assertions.assertEquals("httpfs", extension.getExtensionName()); + } + + @Test + void testInstallExtension() throws JSQLParserException { + String sqlStr = "INSTALL spatial"; + ExtensionStatement extension = + (ExtensionStatement) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals(ExtensionStatement.Operation.INSTALL, extension.getOperation()); + Assertions.assertFalse(extension.isForce()); + } + + @Test + void testForceInstallExtensionFromRepository() throws JSQLParserException { + String sqlStr = "FORCE INSTALL h3 FROM community"; + ExtensionStatement extension = + (ExtensionStatement) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertTrue(extension.isForce()); + Assertions.assertEquals("community", extension.getRepository()); + } + + @Test + void testInstallExtensionFromUrl() throws JSQLParserException { + String sqlStr = "INSTALL 'path/to/ext.duckdb_extension'"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } + + @Test + void testLoadRemainsUsableAsIdentifier() throws JSQLParserException { + String sqlStr = "SELECT load, install FROM t"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } } From 068fd29f2249b5254f6b3bb8d30240fc381a05f3 Mon Sep 17 00:00:00 2001 From: Hayssam Saleh Date: Tue, 15 Sep 2026 14:54:48 +0200 Subject: [PATCH 09/19] Support DuckDB ATTACH and DETACH statements Adds ATTACH [DATABASE] [IF NOT EXISTS] path [AS alias] [(options)] and DETACH [DATABASE] [IF EXISTS] name, with options such as READ_ONLY or TYPE SQLITE kept as strings. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqinwHBKuAtPEmtXr3b5P2 --- .../jsqlparser/statement/AttachStatement.java | 121 ++++++++++++++++++ .../jsqlparser/statement/DetachStatement.java | 88 +++++++++++++ .../statement/StatementFeatureVisitor.java | 14 ++ .../statement/StatementVisitor.java | 12 ++ .../statement/StatementVisitorAdapter.java | 10 ++ .../sf/jsqlparser/util/TablesNamesFinder.java | 24 ++++ .../util/deparser/StatementDeParser.java | 14 ++ .../validator/StatementValidator.java | 22 ++++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 69 ++++++++++ .../statement/select/DuckDBTest.java | 48 +++++++ 10 files changed, 422 insertions(+) create mode 100644 src/main/java/net/sf/jsqlparser/statement/AttachStatement.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/DetachStatement.java diff --git a/src/main/java/net/sf/jsqlparser/statement/AttachStatement.java b/src/main/java/net/sf/jsqlparser/statement/AttachStatement.java new file mode 100644 index 0000000000..7ad71dd546 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/AttachStatement.java @@ -0,0 +1,121 @@ +/*- + * #%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.statement; + +import net.sf.jsqlparser.statement.select.PlainSelect; + +import java.util.List; + +/** + * DuckDB's {@code ATTACH [DATABASE] [IF NOT EXISTS] path [AS alias] [(options)]}, which adds + * another database file or remote database to the session. + * + * @see ATTACH + */ +public class AttachStatement implements Statement { + private String databasePath; + private String alias; + private boolean usingDatabaseKeyword; + private boolean ifNotExists; + private List options; + + public String getDatabasePath() { + return databasePath; + } + + public void setDatabasePath(String databasePath) { + this.databasePath = databasePath; + } + + public AttachStatement withDatabasePath(String databasePath) { + setDatabasePath(databasePath); + return this; + } + + public String getAlias() { + return alias; + } + + public void setAlias(String alias) { + this.alias = alias; + } + + public AttachStatement withAlias(String alias) { + setAlias(alias); + return this; + } + + public boolean isUsingDatabaseKeyword() { + return usingDatabaseKeyword; + } + + public void setUsingDatabaseKeyword(boolean usingDatabaseKeyword) { + this.usingDatabaseKeyword = usingDatabaseKeyword; + } + + public AttachStatement withUsingDatabaseKeyword(boolean usingDatabaseKeyword) { + setUsingDatabaseKeyword(usingDatabaseKeyword); + return this; + } + + public boolean isIfNotExists() { + return ifNotExists; + } + + public void setIfNotExists(boolean ifNotExists) { + this.ifNotExists = ifNotExists; + } + + public AttachStatement withIfNotExists(boolean ifNotExists) { + setIfNotExists(ifNotExists); + return this; + } + + public List getOptions() { + return options; + } + + public void setOptions(List options) { + this.options = options; + } + + public AttachStatement withOptions(List options) { + setOptions(options); + return this; + } + + public StringBuilder appendTo(StringBuilder builder) { + builder.append("ATTACH "); + if (usingDatabaseKeyword) { + builder.append("DATABASE "); + } + if (ifNotExists) { + builder.append("IF NOT EXISTS "); + } + builder.append(databasePath); + if (alias != null) { + builder.append(" AS ").append(alias); + } + if (options != null && !options.isEmpty()) { + builder.append(" ").append(PlainSelect.getStringList(options, true, true)); + } + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/DetachStatement.java b/src/main/java/net/sf/jsqlparser/statement/DetachStatement.java new file mode 100644 index 0000000000..80fe5780a9 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/DetachStatement.java @@ -0,0 +1,88 @@ +/*- + * #%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.statement; + +/** + * DuckDB's {@code DETACH [DATABASE] [IF EXISTS] name}, the counterpart of {@link AttachStatement}. + * + * @see DETACH + */ +public class DetachStatement implements Statement { + private String databaseName; + private boolean usingDatabaseKeyword; + private boolean ifExists; + + public DetachStatement() {} + + public DetachStatement(String databaseName) { + this.databaseName = databaseName; + } + + public String getDatabaseName() { + return databaseName; + } + + public void setDatabaseName(String databaseName) { + this.databaseName = databaseName; + } + + public DetachStatement withDatabaseName(String databaseName) { + setDatabaseName(databaseName); + return this; + } + + public boolean isUsingDatabaseKeyword() { + return usingDatabaseKeyword; + } + + public void setUsingDatabaseKeyword(boolean usingDatabaseKeyword) { + this.usingDatabaseKeyword = usingDatabaseKeyword; + } + + public DetachStatement withUsingDatabaseKeyword(boolean usingDatabaseKeyword) { + setUsingDatabaseKeyword(usingDatabaseKeyword); + return this; + } + + public boolean isIfExists() { + return ifExists; + } + + public void setIfExists(boolean ifExists) { + this.ifExists = ifExists; + } + + public DetachStatement withIfExists(boolean ifExists) { + setIfExists(ifExists); + return this; + } + + public StringBuilder appendTo(StringBuilder builder) { + builder.append("DETACH "); + if (usingDatabaseKeyword) { + builder.append("DATABASE "); + } + if (ifExists) { + builder.append("IF EXISTS "); + } + builder.append(databaseName); + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index 3165642b57..f4703222ef 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -535,6 +535,20 @@ public Void visit(ExtensionStatement extensionStatement, S context) { return null; } + @Override + public Void visit(AttachStatement attachStatement, S context) { + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SESSION); + return null; + } + + @Override + public Void visit(DetachStatement detachStatement, S context) { + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SESSION); + return null; + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { analysis.claimTopLevel(); diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index 8c6af205e3..346ebf8169 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -383,6 +383,18 @@ default void visit(ExtensionStatement extensionStatement) { this.visit(extensionStatement, null); } + T visit(AttachStatement attachStatement, S context); + + default void visit(AttachStatement attachStatement) { + this.visit(attachStatement, null); + } + + T visit(DetachStatement detachStatement, S context); + + default void visit(DetachStatement detachStatement) { + this.visit(detachStatement, null); + } + T visit(PurgeStatement purgeStatement, S context); default void visit(PurgeStatement purgeStatement) { diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index 72d9147f28..b9583c780e 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -628,6 +628,16 @@ public T visit(ExtensionStatement extensionStatement, S context) { return null; } + @Override + public T visit(AttachStatement attachStatement, S context) { + return null; + } + + @Override + public T visit(DetachStatement detachStatement, S context) { + return null; + } + @Override public T visit(PurgeStatement purgeStatement, S context) { return null; diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index bcaeb1bd16..bea20edaf9 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -211,6 +211,8 @@ import net.sf.jsqlparser.statement.upsert.Upsert; import net.sf.jsqlparser.statement.PragmaStatement; import net.sf.jsqlparser.statement.ExtensionStatement; +import net.sf.jsqlparser.statement.AttachStatement; +import net.sf.jsqlparser.statement.DetachStatement; /** @@ -2492,6 +2494,28 @@ public void visit(ExtensionStatement extensionStatement) { StatementVisitor.super.visit(extensionStatement); } + @Override + public Void visit(AttachStatement attachStatement, S context) { + // no tables involved in this statement + return null; + } + + @Override + public void visit(AttachStatement attachStatement) { + StatementVisitor.super.visit(attachStatement); + } + + @Override + public Void visit(DetachStatement detachStatement, S context) { + // no tables involved in this statement + return null; + } + + @Override + public void visit(DetachStatement detachStatement) { + StatementVisitor.super.visit(detachStatement); + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { if (purgeStatement.getPurgeObjectType() == PurgeObjectType.TABLE) { diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java index ffba44348b..c2820d7175 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -99,6 +99,8 @@ import net.sf.jsqlparser.statement.upsert.Upsert; import net.sf.jsqlparser.statement.PragmaStatement; import net.sf.jsqlparser.statement.ExtensionStatement; +import net.sf.jsqlparser.statement.AttachStatement; +import net.sf.jsqlparser.statement.DetachStatement; public class StatementDeParser extends AbstractDeParser implements StatementVisitor { @@ -570,6 +572,18 @@ public StringBuilder visit(ExtensionStatement extensionStatement, S context) return builder; } + @Override + public StringBuilder visit(AttachStatement attachStatement, S context) { + attachStatement.appendTo(builder); + return builder; + } + + @Override + public StringBuilder visit(DetachStatement detachStatement, S context) { + detachStatement.appendTo(builder); + return builder; + } + @Override public StringBuilder visit(PurgeStatement purgeStatement, S context) { purgeStatement.appendTo(builder); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java index 52da684d73..d371288261 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java @@ -101,6 +101,8 @@ import net.sf.jsqlparser.util.validation.metadata.NamedObject; import net.sf.jsqlparser.statement.PragmaStatement; import net.sf.jsqlparser.statement.ExtensionStatement; +import net.sf.jsqlparser.statement.AttachStatement; +import net.sf.jsqlparser.statement.DetachStatement; /** * @author gitmotte @@ -491,6 +493,18 @@ public Void visit(ExtensionStatement extensionStatement, S context) { return null; } + @Override + public Void visit(AttachStatement attachStatement, S context) { + // TODO: not yet implemented + return null; + } + + @Override + public Void visit(DetachStatement detachStatement, S context) { + // TODO: not yet implemented + return null; + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { // TODO: not yet implemented @@ -715,6 +729,14 @@ public void visit(ExtensionStatement extensionStatement) { visit(extensionStatement, null); } + public void visit(AttachStatement attachStatement) { + visit(attachStatement, null); + } + + public void visit(DetachStatement detachStatement) { + visit(detachStatement, null); + } + public void visit(PurgeStatement purgeStatement) { visit(purgeStatement, null); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 088fbd3319..c216dc39ae 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -2948,6 +2948,10 @@ Statement SingleStatement() : || (getToken(1).kind == K_FORCE && getToken(2).kind == K_INSTALL) }) stm = ExtensionStatement() | + LOOKAHEAD({ getToken(1).kind == K_ATTACH }) stm = AttachStatement() + | + LOOKAHEAD({ getToken(1).kind == K_DETACH }) stm = DetachStatement() + | stm = PurgeStatement() | stm = SessionStatement() @@ -4349,6 +4353,71 @@ RenameTableStatement RenameTableStatement(): { } } +/** + * DuckDB's ATTACH [DATABASE] [IF NOT EXISTS] path [AS alias] [(options)]. + */ +AttachStatement AttachStatement() #AttachStatement: +{ + AttachStatement attachStatement = new AttachStatement(); + List options = new ArrayList(); + String option; + ObjectNames name; + Token token; +} +{ + + // greedy: DATABASE right after ATTACH is the keyword, not the database name + [ LOOKAHEAD(2) { attachStatement.setUsingDatabaseKeyword(true); } ] + [ LOOKAHEAD(2) { attachStatement.setIfNotExists(true); } ] + ( + token= { attachStatement.setDatabasePath(token.image); } + | + name=RelObjectNames() { attachStatement.setDatabasePath(String.join(".", name.getNames())); } + ) + [ name=RelObjectNames() { attachStatement.setAlias(String.join(".", name.getNames())); } ] + [ + LOOKAHEAD(2) "(" option=DatabaseOption() { options.add(option); } + ( "," option=DatabaseOption() { options.add(option); } )* + ")" { attachStatement.setOptions(options); } + ] + { return attachStatement; } +} + +/** One ATTACH option, such as READ_ONLY or TYPE SQLITE. */ +String DatabaseOption(): +{ + StringBuilder builder = new StringBuilder(); + String name; + Token token; +} +{ + ( + name=RelObjectName() { if (builder.length() > 0) builder.append(" "); builder.append(name); } + | + token= { if (builder.length() > 0) builder.append(" "); builder.append(token.image); } + | + token= { if (builder.length() > 0) builder.append(" "); builder.append(token.image); } + )+ + { return builder.toString(); } +} + +/** + * DuckDB's DETACH [DATABASE] [IF EXISTS] name. + */ +DetachStatement DetachStatement() #DetachStatement: +{ + DetachStatement detachStatement = new DetachStatement(); + ObjectNames name; +} +{ + + // greedy: DATABASE right after DETACH is the keyword, not the database name + [ LOOKAHEAD(2) { detachStatement.setUsingDatabaseKeyword(true); } ] + [ LOOKAHEAD(2) { detachStatement.setIfExists(true); } ] + name=RelObjectNames() { detachStatement.setDatabaseName(String.join(".", name.getNames())); } + { return detachStatement; } +} + /** * DuckDB's [FORCE] INSTALL extension [FROM repository] and LOAD extension. */ 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 161e3f4954..f36b10f5fc 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -16,6 +16,8 @@ import net.sf.jsqlparser.statement.DescribeStatement; import net.sf.jsqlparser.statement.PragmaStatement; import net.sf.jsqlparser.statement.ExtensionStatement; +import net.sf.jsqlparser.statement.AttachStatement; +import net.sf.jsqlparser.statement.DetachStatement; import net.sf.jsqlparser.test.TestUtils; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -280,4 +282,50 @@ void testLoadRemainsUsableAsIdentifier() throws JSQLParserException { String sqlStr = "SELECT load, install FROM t"; TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); } + + @Test + void testAttachDatabaseWithAlias() throws JSQLParserException { + String sqlStr = "ATTACH 'file.db' AS mydb"; + AttachStatement attach = + (AttachStatement) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals("'file.db'", attach.getDatabasePath()); + Assertions.assertEquals("mydb", attach.getAlias()); + } + + @Test + void testAttachDatabaseWithOptions() throws JSQLParserException { + String sqlStr = "ATTACH DATABASE IF NOT EXISTS 'file.db' AS mydb (TYPE SQLITE, READ_ONLY)"; + AttachStatement attach = + (AttachStatement) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertTrue(attach.isIfNotExists()); + Assertions.assertTrue(attach.isUsingDatabaseKeyword()); + Assertions.assertEquals(java.util.Arrays.asList("TYPE SQLITE", "READ_ONLY"), + attach.getOptions()); + } + + @Test + void testDetachDatabase() throws JSQLParserException { + String sqlStr = "DETACH mydb"; + DetachStatement detach = + (DetachStatement) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals("mydb", detach.getDatabaseName()); + } + + @Test + void testDetachDatabaseIfExists() throws JSQLParserException { + String sqlStr = "DETACH DATABASE IF EXISTS mydb"; + DetachStatement detach = + (DetachStatement) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertTrue(detach.isIfExists()); + } + + @Test + void testAttachRemainsUsableAsIdentifier() throws JSQLParserException { + String sqlStr = "SELECT attach, detach FROM t"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } } From 804fb5f0895a5623e5c854dfecf82d7cf4771ea4 Mon Sep 17 00:00:00 2001 From: Hayssam Saleh Date: Tue, 15 Sep 2026 14:56:34 +0200 Subject: [PATCH 10/19] Support DuckDB 2.0 CONNECT and DISCONNECT statements Adds CONNECT target [AS alias] and DISCONNECT [name], which route a session's queries to a remote database. Oracle's CONNECT BY is unaffected. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqinwHBKuAtPEmtXr3b5P2 --- .../statement/ConnectStatement.java | 69 +++++++++++++++++++ .../statement/DisconnectStatement.java | 54 +++++++++++++++ .../statement/StatementFeatureVisitor.java | 14 ++++ .../statement/StatementVisitor.java | 12 ++++ .../statement/StatementVisitorAdapter.java | 10 +++ .../sf/jsqlparser/util/TablesNamesFinder.java | 24 +++++++ .../util/deparser/StatementDeParser.java | 14 ++++ .../validator/StatementValidator.java | 22 ++++++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 43 ++++++++++++ .../statement/select/DuckDBTest.java | 45 ++++++++++++ 10 files changed, 307 insertions(+) create mode 100644 src/main/java/net/sf/jsqlparser/statement/ConnectStatement.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/DisconnectStatement.java diff --git a/src/main/java/net/sf/jsqlparser/statement/ConnectStatement.java b/src/main/java/net/sf/jsqlparser/statement/ConnectStatement.java new file mode 100644 index 0000000000..a5ae350c64 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/ConnectStatement.java @@ -0,0 +1,69 @@ +/*- + * #%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.statement; + +/** + * DuckDB's {@code CONNECT target [AS alias]}, which routes the session's queries to a remote + * database. + */ +public class ConnectStatement implements Statement { + private String target; + private String alias; + + public ConnectStatement() {} + + public ConnectStatement(String target) { + this.target = target; + } + + public String getTarget() { + return target; + } + + public void setTarget(String target) { + this.target = target; + } + + public ConnectStatement withTarget(String target) { + setTarget(target); + return this; + } + + public String getAlias() { + return alias; + } + + public void setAlias(String alias) { + this.alias = alias; + } + + public ConnectStatement withAlias(String alias) { + setAlias(alias); + return this; + } + + public StringBuilder appendTo(StringBuilder builder) { + builder.append("CONNECT ").append(target); + if (alias != null) { + builder.append(" AS ").append(alias); + } + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/DisconnectStatement.java b/src/main/java/net/sf/jsqlparser/statement/DisconnectStatement.java new file mode 100644 index 0000000000..fdbe6f4e71 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/DisconnectStatement.java @@ -0,0 +1,54 @@ +/*- + * #%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.statement; + +/** + * DuckDB's {@code DISCONNECT [name]}, the counterpart of {@link ConnectStatement}. + */ +public class DisconnectStatement implements Statement { + private String name; + + public DisconnectStatement() {} + + public DisconnectStatement(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public DisconnectStatement withName(String name) { + setName(name); + return this; + } + + public StringBuilder appendTo(StringBuilder builder) { + builder.append("DISCONNECT"); + if (name != null) { + builder.append(" ").append(name); + } + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index f4703222ef..de7c44a689 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -549,6 +549,20 @@ public Void visit(DetachStatement detachStatement, S context) { return null; } + @Override + public Void visit(ConnectStatement connectStatement, S context) { + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SESSION); + return null; + } + + @Override + public Void visit(DisconnectStatement disconnectStatement, S context) { + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SESSION); + return null; + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { analysis.claimTopLevel(); diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index 346ebf8169..c14b06a0fe 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -395,6 +395,18 @@ default void visit(DetachStatement detachStatement) { this.visit(detachStatement, null); } + T visit(ConnectStatement connectStatement, S context); + + default void visit(ConnectStatement connectStatement) { + this.visit(connectStatement, null); + } + + T visit(DisconnectStatement disconnectStatement, S context); + + default void visit(DisconnectStatement disconnectStatement) { + this.visit(disconnectStatement, null); + } + T visit(PurgeStatement purgeStatement, S context); default void visit(PurgeStatement purgeStatement) { diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index b9583c780e..a02d71a574 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -638,6 +638,16 @@ public T visit(DetachStatement detachStatement, S context) { return null; } + @Override + public T visit(ConnectStatement connectStatement, S context) { + return null; + } + + @Override + public T visit(DisconnectStatement disconnectStatement, S context) { + return null; + } + @Override public T visit(PurgeStatement purgeStatement, S context) { return null; diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index bea20edaf9..1b50a16d0a 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -213,6 +213,8 @@ import net.sf.jsqlparser.statement.ExtensionStatement; import net.sf.jsqlparser.statement.AttachStatement; import net.sf.jsqlparser.statement.DetachStatement; +import net.sf.jsqlparser.statement.ConnectStatement; +import net.sf.jsqlparser.statement.DisconnectStatement; /** @@ -2516,6 +2518,28 @@ public void visit(DetachStatement detachStatement) { StatementVisitor.super.visit(detachStatement); } + @Override + public Void visit(ConnectStatement connectStatement, S context) { + // no tables involved in this statement + return null; + } + + @Override + public void visit(ConnectStatement connectStatement) { + StatementVisitor.super.visit(connectStatement); + } + + @Override + public Void visit(DisconnectStatement disconnectStatement, S context) { + // no tables involved in this statement + return null; + } + + @Override + public void visit(DisconnectStatement disconnectStatement) { + StatementVisitor.super.visit(disconnectStatement); + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { if (purgeStatement.getPurgeObjectType() == PurgeObjectType.TABLE) { diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java index c2820d7175..83e47ab3b2 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -101,6 +101,8 @@ import net.sf.jsqlparser.statement.ExtensionStatement; import net.sf.jsqlparser.statement.AttachStatement; import net.sf.jsqlparser.statement.DetachStatement; +import net.sf.jsqlparser.statement.ConnectStatement; +import net.sf.jsqlparser.statement.DisconnectStatement; public class StatementDeParser extends AbstractDeParser implements StatementVisitor { @@ -584,6 +586,18 @@ public StringBuilder visit(DetachStatement detachStatement, S context) { return builder; } + @Override + public StringBuilder visit(ConnectStatement connectStatement, S context) { + connectStatement.appendTo(builder); + return builder; + } + + @Override + public StringBuilder visit(DisconnectStatement disconnectStatement, S context) { + disconnectStatement.appendTo(builder); + return builder; + } + @Override public StringBuilder visit(PurgeStatement purgeStatement, S context) { purgeStatement.appendTo(builder); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java index d371288261..5c19ad8ec0 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java @@ -103,6 +103,8 @@ import net.sf.jsqlparser.statement.ExtensionStatement; import net.sf.jsqlparser.statement.AttachStatement; import net.sf.jsqlparser.statement.DetachStatement; +import net.sf.jsqlparser.statement.ConnectStatement; +import net.sf.jsqlparser.statement.DisconnectStatement; /** * @author gitmotte @@ -505,6 +507,18 @@ public Void visit(DetachStatement detachStatement, S context) { return null; } + @Override + public Void visit(ConnectStatement connectStatement, S context) { + // TODO: not yet implemented + return null; + } + + @Override + public Void visit(DisconnectStatement disconnectStatement, S context) { + // TODO: not yet implemented + return null; + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { // TODO: not yet implemented @@ -737,6 +751,14 @@ public void visit(DetachStatement detachStatement) { visit(detachStatement, null); } + public void visit(ConnectStatement connectStatement) { + visit(connectStatement, null); + } + + public void visit(DisconnectStatement disconnectStatement) { + visit(disconnectStatement, null); + } + public void visit(PurgeStatement purgeStatement) { visit(purgeStatement, null); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index c216dc39ae..1bd1f7dbc2 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -2952,6 +2952,12 @@ Statement SingleStatement() : | LOOKAHEAD({ getToken(1).kind == K_DETACH }) stm = DetachStatement() | + LOOKAHEAD({ getToken(1).kind == K_CONNECT && getToken(2).kind != K_BY }) + stm = ConnectStatement() + | + LOOKAHEAD({ getToken(1).kind == K_DISCONNECT && getToken(2).kind != K_SESSION }) + stm = DisconnectStatement() + | stm = PurgeStatement() | stm = SessionStatement() @@ -4353,6 +4359,43 @@ RenameTableStatement RenameTableStatement(): { } } +/** + * DuckDB's CONNECT target [AS alias], routing the session to a remote database. + */ +ConnectStatement ConnectStatement() #ConnectStatement: +{ + ConnectStatement connectStatement = new ConnectStatement(); + ObjectNames name; + Token token; +} +{ + + ( + token= { connectStatement.setTarget(token.image); } + | + name=RelObjectNames() { connectStatement.setTarget(String.join(".", name.getNames())); } + ) + [ name=RelObjectNames() { connectStatement.setAlias(String.join(".", name.getNames())); } ] + { return connectStatement; } +} + +/** + * DuckDB's DISCONNECT [name]. + */ +DisconnectStatement DisconnectStatement() #DisconnectStatement: +{ + DisconnectStatement disconnectStatement = new DisconnectStatement(); + ObjectNames name; +} +{ + + [ + LOOKAHEAD(2) + name=RelObjectNames() { disconnectStatement.setName(String.join(".", name.getNames())); } + ] + { return disconnectStatement; } +} + /** * DuckDB's ATTACH [DATABASE] [IF NOT EXISTS] path [AS alias] [(options)]. */ 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 f36b10f5fc..e1f354d5bc 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -18,6 +18,8 @@ import net.sf.jsqlparser.statement.ExtensionStatement; import net.sf.jsqlparser.statement.AttachStatement; import net.sf.jsqlparser.statement.DetachStatement; +import net.sf.jsqlparser.statement.ConnectStatement; +import net.sf.jsqlparser.statement.DisconnectStatement; import net.sf.jsqlparser.test.TestUtils; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -328,4 +330,47 @@ void testAttachRemainsUsableAsIdentifier() throws JSQLParserException { String sqlStr = "SELECT attach, detach FROM t"; TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); } + + @Test + void testConnectToRemoteDatabase() throws JSQLParserException { + String sqlStr = "CONNECT 'postgres://localhost/mydb'"; + ConnectStatement connect = + (ConnectStatement) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals("'postgres://localhost/mydb'", connect.getTarget()); + Assertions.assertNull(connect.getAlias()); + } + + @Test + void testConnectWithAlias() throws JSQLParserException { + String sqlStr = "CONNECT 'postgres://localhost/mydb' AS remote"; + ConnectStatement connect = + (ConnectStatement) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals("remote", connect.getAlias()); + } + + @Test + void testDisconnect() throws JSQLParserException { + String sqlStr = "DISCONNECT"; + DisconnectStatement disconnect = + (DisconnectStatement) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertNull(disconnect.getName()); + } + + @Test + void testDisconnectNamedConnection() throws JSQLParserException { + String sqlStr = "DISCONNECT remote"; + DisconnectStatement disconnect = + (DisconnectStatement) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals("remote", disconnect.getName()); + } + + @Test + void testConnectByStillParses() throws JSQLParserException { + String sqlStr = "SELECT * FROM t START WITH id = 1 CONNECT BY PRIOR id = parent_id"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } } From 4b73b317d6aa6f7a7b0c350dfc9a3236d28267bb Mon Sep 17 00:00:00 2001 From: Hayssam Saleh Date: Tue, 15 Sep 2026 15:06:30 +0200 Subject: [PATCH 11/19] Support PREPARE and DEALLOCATE statements Adds PREPARE name AS statement and DEALLOCATE [PREPARE] name. EXECUTE of a prepared statement already parsed. The prepared statement is a nested Statement, so TablesNamesFinder reaches its tables. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqinwHBKuAtPEmtXr3b5P2 --- .../statement/DeallocateStatement.java | 69 ++++++++++++++++++ .../statement/PrepareStatement.java | 70 +++++++++++++++++++ .../statement/StatementFeatureVisitor.java | 14 ++++ .../statement/StatementVisitor.java | 12 ++++ .../statement/StatementVisitorAdapter.java | 10 +++ .../sf/jsqlparser/util/TablesNamesFinder.java | 24 +++++++ .../util/deparser/StatementDeParser.java | 14 ++++ .../validator/StatementValidator.java | 22 ++++++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 37 ++++++++++ .../statement/select/DuckDBTest.java | 43 ++++++++++++ 10 files changed, 315 insertions(+) create mode 100644 src/main/java/net/sf/jsqlparser/statement/DeallocateStatement.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/PrepareStatement.java diff --git a/src/main/java/net/sf/jsqlparser/statement/DeallocateStatement.java b/src/main/java/net/sf/jsqlparser/statement/DeallocateStatement.java new file mode 100644 index 0000000000..25d375e38b --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/DeallocateStatement.java @@ -0,0 +1,69 @@ +/*- + * #%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.statement; + +/** + * {@code DEALLOCATE [PREPARE] name}, which drops a statement prepared by {@link PrepareStatement}. + */ +public class DeallocateStatement implements Statement { + private String name; + private boolean usingPrepareKeyword; + + public DeallocateStatement() {} + + public DeallocateStatement(String name) { + this.name = name; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public DeallocateStatement withName(String name) { + setName(name); + return this; + } + + public boolean isUsingPrepareKeyword() { + return usingPrepareKeyword; + } + + public void setUsingPrepareKeyword(boolean usingPrepareKeyword) { + this.usingPrepareKeyword = usingPrepareKeyword; + } + + public DeallocateStatement withUsingPrepareKeyword(boolean usingPrepareKeyword) { + setUsingPrepareKeyword(usingPrepareKeyword); + return this; + } + + public StringBuilder appendTo(StringBuilder builder) { + builder.append("DEALLOCATE "); + if (usingPrepareKeyword) { + builder.append("PREPARE "); + } + builder.append(name); + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/PrepareStatement.java b/src/main/java/net/sf/jsqlparser/statement/PrepareStatement.java new file mode 100644 index 0000000000..e4675b3400 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/PrepareStatement.java @@ -0,0 +1,70 @@ +/*- + * #%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.statement; + +/** + * {@code PREPARE name AS statement}, which stores a parameterised statement for later + * {@code EXECUTE}. + * + * @see Prepared + * statements + */ +public class PrepareStatement implements Statement { + private String name; + private Statement statement; + + public PrepareStatement() {} + + public PrepareStatement(String name, Statement statement) { + this.name = name; + this.statement = statement; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public PrepareStatement withName(String name) { + setName(name); + return this; + } + + public Statement getStatement() { + return statement; + } + + public void setStatement(Statement statement) { + this.statement = statement; + } + + public PrepareStatement withStatement(Statement statement) { + setStatement(statement); + return this; + } + + public StringBuilder appendTo(StringBuilder builder) { + builder.append("PREPARE ").append(name).append(" AS ").append(statement); + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index de7c44a689..d7fe7c7bfd 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -563,6 +563,20 @@ public Void visit(DisconnectStatement disconnectStatement, S context) { return null; } + @Override + public Void visit(PrepareStatement prepareStatement, S context) { + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SESSION); + return null; + } + + @Override + public Void visit(DeallocateStatement deallocateStatement, S context) { + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SESSION); + return null; + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { analysis.claimTopLevel(); diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index c14b06a0fe..e130361489 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -407,6 +407,18 @@ default void visit(DisconnectStatement disconnectStatement) { this.visit(disconnectStatement, null); } + T visit(PrepareStatement prepareStatement, S context); + + default void visit(PrepareStatement prepareStatement) { + this.visit(prepareStatement, null); + } + + T visit(DeallocateStatement deallocateStatement, S context); + + default void visit(DeallocateStatement deallocateStatement) { + this.visit(deallocateStatement, null); + } + T visit(PurgeStatement purgeStatement, S context); default void visit(PurgeStatement purgeStatement) { diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index a02d71a574..a42255ea58 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -648,6 +648,16 @@ public T visit(DisconnectStatement disconnectStatement, S context) { return null; } + @Override + public T visit(PrepareStatement prepareStatement, S context) { + return null; + } + + @Override + public T visit(DeallocateStatement deallocateStatement, S context) { + return null; + } + @Override public T visit(PurgeStatement purgeStatement, S context) { return null; diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 1b50a16d0a..bcd03098ba 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -215,6 +215,8 @@ import net.sf.jsqlparser.statement.DetachStatement; import net.sf.jsqlparser.statement.ConnectStatement; import net.sf.jsqlparser.statement.DisconnectStatement; +import net.sf.jsqlparser.statement.PrepareStatement; +import net.sf.jsqlparser.statement.DeallocateStatement; /** @@ -2540,6 +2542,28 @@ public void visit(DisconnectStatement disconnectStatement) { StatementVisitor.super.visit(disconnectStatement); } + @Override + public Void visit(PrepareStatement prepareStatement, S context) { + prepareStatement.getStatement().accept(this, context); + return null; + } + + @Override + public void visit(PrepareStatement prepareStatement) { + StatementVisitor.super.visit(prepareStatement); + } + + @Override + public Void visit(DeallocateStatement deallocateStatement, S context) { + // no tables involved in this statement + return null; + } + + @Override + public void visit(DeallocateStatement deallocateStatement) { + StatementVisitor.super.visit(deallocateStatement); + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { if (purgeStatement.getPurgeObjectType() == PurgeObjectType.TABLE) { diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java index 83e47ab3b2..28532d6065 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -103,6 +103,8 @@ import net.sf.jsqlparser.statement.DetachStatement; import net.sf.jsqlparser.statement.ConnectStatement; import net.sf.jsqlparser.statement.DisconnectStatement; +import net.sf.jsqlparser.statement.PrepareStatement; +import net.sf.jsqlparser.statement.DeallocateStatement; public class StatementDeParser extends AbstractDeParser implements StatementVisitor { @@ -598,6 +600,18 @@ public StringBuilder visit(DisconnectStatement disconnectStatement, S contex return builder; } + @Override + public StringBuilder visit(PrepareStatement prepareStatement, S context) { + prepareStatement.appendTo(builder); + return builder; + } + + @Override + public StringBuilder visit(DeallocateStatement deallocateStatement, S context) { + deallocateStatement.appendTo(builder); + return builder; + } + @Override public StringBuilder visit(PurgeStatement purgeStatement, S context) { purgeStatement.appendTo(builder); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java index 5c19ad8ec0..bead264d45 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java @@ -105,6 +105,8 @@ import net.sf.jsqlparser.statement.DetachStatement; import net.sf.jsqlparser.statement.ConnectStatement; import net.sf.jsqlparser.statement.DisconnectStatement; +import net.sf.jsqlparser.statement.PrepareStatement; +import net.sf.jsqlparser.statement.DeallocateStatement; /** * @author gitmotte @@ -519,6 +521,18 @@ public Void visit(DisconnectStatement disconnectStatement, S context) { return null; } + @Override + public Void visit(PrepareStatement prepareStatement, S context) { + // TODO: not yet implemented + return null; + } + + @Override + public Void visit(DeallocateStatement deallocateStatement, S context) { + // TODO: not yet implemented + return null; + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { // TODO: not yet implemented @@ -759,6 +773,14 @@ public void visit(DisconnectStatement disconnectStatement) { visit(disconnectStatement, null); } + public void visit(PrepareStatement prepareStatement) { + visit(prepareStatement, null); + } + + public void visit(DeallocateStatement deallocateStatement) { + visit(deallocateStatement, null); + } + public void visit(PurgeStatement purgeStatement) { visit(purgeStatement, null); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 1bd1f7dbc2..9a410928b8 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -2020,6 +2020,7 @@ String NonReservedWord() : | tk= | tk= | tk= + | tk= | tk= | tk= | tk= @@ -2204,6 +2205,7 @@ String NonReservedWord() : | tk= | tk= | tk= + | tk= | tk= | tk= | tk= @@ -2958,6 +2960,10 @@ Statement SingleStatement() : LOOKAHEAD({ getToken(1).kind == K_DISCONNECT && getToken(2).kind != K_SESSION }) stm = DisconnectStatement() | + LOOKAHEAD({ getToken(1).kind == K_PREPARE }) stm = PrepareStatement() + | + LOOKAHEAD({ getToken(1).kind == K_DEALLOCATE }) stm = DeallocateStatement() + | stm = PurgeStatement() | stm = SessionStatement() @@ -4359,6 +4365,37 @@ RenameTableStatement RenameTableStatement(): { } } +/** + * PREPARE name AS statement, storing a parameterised statement for later EXECUTE. + */ +PrepareStatement PrepareStatement() #PrepareStatement: +{ + PrepareStatement prepareStatement = new PrepareStatement(); + ObjectNames name; + Statement statement; +} +{ + name=RelObjectNames() + { prepareStatement.setName(String.join(".", name.getNames())); } + statement=SingleStatement() { prepareStatement.setStatement(statement); } + { return prepareStatement; } +} + +/** + * DEALLOCATE [PREPARE] name. + */ +DeallocateStatement DeallocateStatement() #DeallocateStatement: +{ + DeallocateStatement deallocateStatement = new DeallocateStatement(); + ObjectNames name; +} +{ + + [ LOOKAHEAD(2) { deallocateStatement.setUsingPrepareKeyword(true); } ] + name=RelObjectNames() { deallocateStatement.setName(String.join(".", name.getNames())); } + { return deallocateStatement; } +} + /** * DuckDB's CONNECT target [AS alias], routing the session to a remote database. */ 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 e1f354d5bc..eb691e6546 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -20,6 +20,8 @@ import net.sf.jsqlparser.statement.DetachStatement; import net.sf.jsqlparser.statement.ConnectStatement; import net.sf.jsqlparser.statement.DisconnectStatement; +import net.sf.jsqlparser.statement.PrepareStatement; +import net.sf.jsqlparser.statement.DeallocateStatement; import net.sf.jsqlparser.test.TestUtils; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -373,4 +375,45 @@ void testConnectByStillParses() throws JSQLParserException { String sqlStr = "SELECT * FROM t START WITH id = 1 CONNECT BY PRIOR id = parent_id"; TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); } + + @Test + void testPrepareStatement() throws JSQLParserException { + String sqlStr = "PREPARE q AS SELECT * FROM t WHERE a = $1"; + PrepareStatement prepare = + (PrepareStatement) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals("q", prepare.getName()); + Assertions.assertNotNull(prepare.getStatement()); + } + + @Test + void testPrepareInsertStatement() throws JSQLParserException { + String sqlStr = "PREPARE ins AS INSERT INTO t VALUES ($1, $2)"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } + + @Test + void testPrepareTablesNamesFinder() throws JSQLParserException { + String sqlStr = "PREPARE q AS SELECT * FROM ds.t"; + + Assertions.assertEquals(java.util.Collections.singletonList("ds.t"), + new net.sf.jsqlparser.util.TablesNamesFinder() + .getTableList(net.sf.jsqlparser.parser.CCJSqlParserUtil.parse(sqlStr))); + } + + @Test + void testDeallocatePreparedStatement() throws JSQLParserException { + String sqlStr = "DEALLOCATE PREPARE q"; + DeallocateStatement deallocate = + (DeallocateStatement) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals("q", deallocate.getName()); + Assertions.assertTrue(deallocate.isUsingPrepareKeyword()); + } + + @Test + void testExecutePreparedStatement() throws JSQLParserException { + String sqlStr = "EXECUTE q (1)"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } } From 588e4a503461e4248b77a3f2899c31a08b2d6ea1 Mon Sep 17 00:00:00 2001 From: Hayssam Saleh Date: Tue, 15 Sep 2026 15:09:57 +0200 Subject: [PATCH 12/19] Support DuckDB COPY statement Adds COPY table [(columns)] FROM path [(options)] and COPY {table | (query)} TO path [(options)]. Options such as FORMAT PARQUET or AUTO_DETECT true are free-form keywords, so they are collected verbatim. COPY stays valid as MySQL's ALGORITHM value. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqinwHBKuAtPEmtXr3b5P2 --- .../jsqlparser/statement/CopyStatement.java | 139 ++++++++++++++++++ .../statement/StatementFeatureVisitor.java | 7 + .../statement/StatementVisitor.java | 6 + .../statement/StatementVisitorAdapter.java | 5 + .../sf/jsqlparser/util/TablesNamesFinder.java | 16 ++ .../util/deparser/StatementDeParser.java | 7 + .../validator/StatementValidator.java | 11 ++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 90 ++++++++++-- .../statement/select/DuckDBTest.java | 49 ++++++ 9 files changed, 314 insertions(+), 16 deletions(-) create mode 100644 src/main/java/net/sf/jsqlparser/statement/CopyStatement.java diff --git a/src/main/java/net/sf/jsqlparser/statement/CopyStatement.java b/src/main/java/net/sf/jsqlparser/statement/CopyStatement.java new file mode 100644 index 0000000000..0dbc2892b4 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/CopyStatement.java @@ -0,0 +1,139 @@ +/*- + * #%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.statement; + +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.select.PlainSelect; +import net.sf.jsqlparser.statement.select.Select; + +import java.util.List; + +/** + * {@code COPY table [(columns)] FROM path [(options)]} and + * {@code COPY {table | (query)} TO path [(options)]}, DuckDB's bulk import and export statement. + * + * @see COPY + */ +public class CopyStatement implements Statement { + private Table table; + private ExpressionList columns; + private Select select; + private boolean from; + private String path; + private List options; + + public Table getTable() { + return table; + } + + public void setTable(Table table) { + this.table = table; + } + + public CopyStatement withTable(Table table) { + setTable(table); + return this; + } + + public ExpressionList getColumns() { + return columns; + } + + public void setColumns(ExpressionList columns) { + this.columns = columns; + } + + public CopyStatement withColumns(ExpressionList columns) { + setColumns(columns); + return this; + } + + public Select getSelect() { + return select; + } + + public void setSelect(Select select) { + this.select = select; + } + + public CopyStatement withSelect(Select select) { + setSelect(select); + return this; + } + + /** {@code true} for COPY ... FROM (import), {@code false} for COPY ... TO (export). */ + public boolean isFrom() { + return from; + } + + public void setFrom(boolean from) { + this.from = from; + } + + public CopyStatement withFrom(boolean from) { + setFrom(from); + return this; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } + + public CopyStatement withPath(String path) { + setPath(path); + return this; + } + + public List getOptions() { + return options; + } + + public void setOptions(List options) { + this.options = options; + } + + public CopyStatement withOptions(List options) { + setOptions(options); + return this; + } + + public StringBuilder appendTo(StringBuilder builder) { + builder.append("COPY "); + if (select != null) { + builder.append("(").append(select).append(")"); + } else { + builder.append(table); + if (columns != null && !columns.isEmpty()) { + builder.append(" (").append(columns).append(")"); + } + } + builder.append(from ? " FROM " : " TO ").append(path); + if (options != null && !options.isEmpty()) { + builder.append(" ").append(PlainSelect.getStringList(options, true, true)); + } + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index d7fe7c7bfd..e0ea9fb874 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -577,6 +577,13 @@ public Void visit(DeallocateStatement deallocateStatement, S context) { return null; } + @Override + public Void visit(CopyStatement copyStatement, S context) { + analysis.claimTopLevel(); + analysis.certain(StmtFeature.READS_DATA, StmtFeature.MODIFIES_DATA); + return null; + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { analysis.claimTopLevel(); diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index e130361489..23d92f7ee4 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -419,6 +419,12 @@ default void visit(DeallocateStatement deallocateStatement) { this.visit(deallocateStatement, null); } + T visit(CopyStatement copyStatement, S context); + + default void visit(CopyStatement copyStatement) { + this.visit(copyStatement, null); + } + T visit(PurgeStatement purgeStatement, S context); default void visit(PurgeStatement purgeStatement) { diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index a42255ea58..9fc45524ba 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -658,6 +658,11 @@ public T visit(DeallocateStatement deallocateStatement, S context) { return null; } + @Override + public T visit(CopyStatement copyStatement, S context) { + return null; + } + @Override public T visit(PurgeStatement purgeStatement, S context) { return null; diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index bcd03098ba..8565fc473c 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -217,6 +217,7 @@ import net.sf.jsqlparser.statement.DisconnectStatement; import net.sf.jsqlparser.statement.PrepareStatement; import net.sf.jsqlparser.statement.DeallocateStatement; +import net.sf.jsqlparser.statement.CopyStatement; /** @@ -2564,6 +2565,21 @@ public void visit(DeallocateStatement deallocateStatement) { StatementVisitor.super.visit(deallocateStatement); } + @Override + public Void visit(CopyStatement copyStatement, S context) { + if (copyStatement.getSelect() != null) { + copyStatement.getSelect().accept((SelectVisitor) this, context); + } else if (copyStatement.getTable() != null) { + copyStatement.getTable().accept(this, context); + } + return null; + } + + @Override + public void visit(CopyStatement copyStatement) { + StatementVisitor.super.visit(copyStatement); + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { if (purgeStatement.getPurgeObjectType() == PurgeObjectType.TABLE) { diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java index 28532d6065..84db233e5d 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -105,6 +105,7 @@ import net.sf.jsqlparser.statement.DisconnectStatement; import net.sf.jsqlparser.statement.PrepareStatement; import net.sf.jsqlparser.statement.DeallocateStatement; +import net.sf.jsqlparser.statement.CopyStatement; public class StatementDeParser extends AbstractDeParser implements StatementVisitor { @@ -612,6 +613,12 @@ public StringBuilder visit(DeallocateStatement deallocateStatement, S contex return builder; } + @Override + public StringBuilder visit(CopyStatement copyStatement, S context) { + copyStatement.appendTo(builder); + return builder; + } + @Override public StringBuilder visit(PurgeStatement purgeStatement, S context) { purgeStatement.appendTo(builder); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java index bead264d45..021a7d3351 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java @@ -107,6 +107,7 @@ import net.sf.jsqlparser.statement.DisconnectStatement; import net.sf.jsqlparser.statement.PrepareStatement; import net.sf.jsqlparser.statement.DeallocateStatement; +import net.sf.jsqlparser.statement.CopyStatement; /** * @author gitmotte @@ -533,6 +534,12 @@ public Void visit(DeallocateStatement deallocateStatement, S context) { return null; } + @Override + public Void visit(CopyStatement copyStatement, S context) { + // TODO: not yet implemented + return null; + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { // TODO: not yet implemented @@ -781,6 +788,10 @@ public void visit(DeallocateStatement deallocateStatement) { visit(deallocateStatement, null); } + public void visit(CopyStatement copyStatement) { + visit(copyStatement, null); + } + public void visit(PurgeStatement purgeStatement) { visit(purgeStatement, null); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 9a410928b8..76de9fda88 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -2003,6 +2003,7 @@ String NonReservedWord() : | tk= | tk= | tk= + | tk= | tk= | tk= | tk= @@ -2964,6 +2965,8 @@ Statement SingleStatement() : | LOOKAHEAD({ getToken(1).kind == K_DEALLOCATE }) stm = DeallocateStatement() | + LOOKAHEAD({ getToken(1).kind == K_COPY }) stm = CopyStatement() + | stm = PurgeStatement() | stm = SessionStatement() @@ -4365,6 +4368,46 @@ RenameTableStatement RenameTableStatement(): { } } +/** + * DuckDB's COPY table [(columns)] FROM path [(options)] and + * COPY {table | (query)} TO path [(options)]. + */ +CopyStatement CopyStatement() #CopyStatement: +{ + CopyStatement copyStatement = new CopyStatement(); + ExpressionList columns = null; + List options = new ArrayList(); + String option; + Select select; + Table table; + Token token; +} +{ + + ( + LOOKAHEAD(2) "(" select=Select() ")" { copyStatement.setSelect(select); } + | + table=Table() { copyStatement.setTable(table); } + [ LOOKAHEAD(2) "(" columns=ColumnList() ")" { copyStatement.setColumns(columns); } ] + ) + ( + { copyStatement.setFrom(true); } + | + + ) + ( + token= { copyStatement.setPath(token.image); } + | + token= { copyStatement.setPath(token.image); } + ) + [ + LOOKAHEAD(2) "(" option=DatabaseOption() { options.add(option); } + ( "," option=DatabaseOption() { options.add(option); } )* + ")" { copyStatement.setOptions(options); } + ] + { return copyStatement; } +} + /** * PREPARE name AS statement, storing a parameterised statement for later EXECUTE. */ @@ -4463,22 +4506,35 @@ AttachStatement AttachStatement() #AttachStatement: { return attachStatement; } } -/** One ATTACH option, such as READ_ONLY or TYPE SQLITE. */ -String DatabaseOption(): -{ +/** + * One ATTACH or COPY option, such as READ_ONLY, TYPE SQLITE or FORMAT PARQUET. Option names and + * values are free-form keywords, so the tokens are collected verbatim up to the next comma or the + * closing bracket. + */ +JAVACODE +String DatabaseOption() { StringBuilder builder = new StringBuilder(); - String name; - Token token; -} -{ - ( - name=RelObjectName() { if (builder.length() > 0) builder.append(" "); builder.append(name); } - | - token= { if (builder.length() > 0) builder.append(" "); builder.append(token.image); } - | - token= { if (builder.length() > 0) builder.append(" "); builder.append(token.image); } - )+ - { return builder.toString(); } + int depth = 0; + while (true) { + Token token = getToken(1); + if (token == null || token.kind == EOF) { + break; + } + if (depth == 0 && (",".equals(token.image) || ")".equals(token.image))) { + break; + } + if ("(".equals(token.image)) { + depth++; + } else if (")".equals(token.image)) { + depth--; + } + getNextToken(); + if (builder.length() > 0) { + builder.append(" "); + } + builder.append(token.image); + } + return builder.toString(); } /** @@ -12890,7 +12946,9 @@ List IndexAlgorithmOrLockOption(): { ( keyword= | keyword= ) { option.add(keyword.image); } [ "=" { option.add("="); } ] - ( value= | value= | value= | value= ) + // COPY is one of MySQL's ALGORITHM values and also starts the COPY statement + ( value= | value= | value= | value= + | value= ) { option.add(value.image); return option; } } 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 eb691e6546..2153b24ac2 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -22,6 +22,7 @@ import net.sf.jsqlparser.statement.DisconnectStatement; import net.sf.jsqlparser.statement.PrepareStatement; import net.sf.jsqlparser.statement.DeallocateStatement; +import net.sf.jsqlparser.statement.CopyStatement; import net.sf.jsqlparser.test.TestUtils; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -416,4 +417,52 @@ void testExecutePreparedStatement() throws JSQLParserException { String sqlStr = "EXECUTE q (1)"; TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); } + + @Test + void testCopyFromFile() throws JSQLParserException { + String sqlStr = "COPY t FROM 'in.csv'"; + CopyStatement copy = + (CopyStatement) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertTrue(copy.isFrom()); + Assertions.assertEquals("t", copy.getTable().getName()); + Assertions.assertEquals("'in.csv'", copy.getPath()); + } + + @Test + void testCopyColumnsFromFileWithOptions() throws JSQLParserException { + String sqlStr = "COPY t (a, b) FROM 'in.csv' (AUTO_DETECT true)"; + CopyStatement copy = + (CopyStatement) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals(2, copy.getColumns().size()); + Assertions.assertEquals(java.util.Collections.singletonList("AUTO_DETECT true"), + copy.getOptions()); + } + + @Test + void testCopyQueryToFile() throws JSQLParserException { + String sqlStr = + "COPY (SELECT * FROM t) TO 'out.parquet' (FORMAT PARQUET, COMPRESSION ZSTD)"; + CopyStatement copy = + (CopyStatement) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertFalse(copy.isFrom()); + Assertions.assertNotNull(copy.getSelect()); + } + + @Test + void testCopyTableToFile() throws JSQLParserException { + String sqlStr = "COPY t TO 'out.csv' (FORMAT CSV, DELIMITER '|')"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } + + @Test + void testCopyTablesNamesFinder() throws JSQLParserException { + String sqlStr = "COPY ds.t FROM 'in.csv'"; + + Assertions.assertEquals(java.util.Collections.singletonList("ds.t"), + new net.sf.jsqlparser.util.TablesNamesFinder() + .getTableList(net.sf.jsqlparser.parser.CCJSqlParserUtil.parse(sqlStr))); + } } From 9af64f89c7459dc70e874794f955bace81a8c7f8 Mon Sep 17 00:00:00 2001 From: Hayssam Saleh Date: Tue, 15 Sep 2026 15:12:15 +0200 Subject: [PATCH 13/19] Support DuckDB CREATE MACRO Adds CREATE [OR REPLACE] [TEMPORARY] MACRO name (parameters) AS body for both scalar macros and table macros (AS TABLE SELECT ...). Parameters may carry a default value, as in add(a, b := 5). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqinwHBKuAtPEmtXr3b5P2 --- .../statement/StatementFeatureVisitor.java | 8 + .../statement/StatementVisitor.java | 7 + .../statement/StatementVisitorAdapter.java | 6 + .../statement/create/macro/CreateMacro.java | 178 ++++++++++++++++++ .../sf/jsqlparser/util/TablesNamesFinder.java | 14 ++ .../util/deparser/StatementDeParser.java | 7 + .../validator/StatementValidator.java | 11 ++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 52 +++++ .../statement/select/DuckDBTest.java | 34 ++++ 9 files changed, 317 insertions(+) create mode 100644 src/main/java/net/sf/jsqlparser/statement/create/macro/CreateMacro.java diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index e0ea9fb874..9f55ca36b6 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -93,6 +93,7 @@ import java.util.Locale; import java.util.Set; import java.util.function.Predicate; +import net.sf.jsqlparser.statement.create.macro.CreateMacro; /** * Derives a {@link StatementFeatures} verdict from a statement tree. @@ -584,6 +585,13 @@ public Void visit(CopyStatement copyStatement, S context) { return null; } + @Override + public Void visit(CreateMacro createMacro, S context) { + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SCHEMA); + return null; + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { analysis.claimTopLevel(); diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index 23d92f7ee4..2bf9419b9b 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -68,6 +68,7 @@ import net.sf.jsqlparser.statement.update.ParenthesedUpdate; import net.sf.jsqlparser.statement.update.Update; import net.sf.jsqlparser.statement.upsert.Upsert; +import net.sf.jsqlparser.statement.create.macro.CreateMacro; public interface StatementVisitor { @@ -425,6 +426,12 @@ default void visit(CopyStatement copyStatement) { this.visit(copyStatement, null); } + T visit(CreateMacro createMacro, S context); + + default void visit(CreateMacro createMacro) { + this.visit(createMacro, null); + } + T visit(PurgeStatement purgeStatement, S context); default void visit(PurgeStatement purgeStatement) { diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index 9fc45524ba..08505d4ff1 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -89,6 +89,7 @@ import java.util.List; import net.sf.jsqlparser.util.TableDefinitionTraversal; +import net.sf.jsqlparser.statement.create.macro.CreateMacro; @SuppressWarnings({"PMD.UncommentedEmptyMethodBody"}) public class StatementVisitorAdapter implements StatementVisitor { @@ -663,6 +664,11 @@ public T visit(CopyStatement copyStatement, S context) { return null; } + @Override + public T visit(CreateMacro createMacro, S context) { + return null; + } + @Override public T visit(PurgeStatement purgeStatement, S context) { return null; diff --git a/src/main/java/net/sf/jsqlparser/statement/create/macro/CreateMacro.java b/src/main/java/net/sf/jsqlparser/statement/create/macro/CreateMacro.java new file mode 100644 index 0000000000..c08b866bbb --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/macro/CreateMacro.java @@ -0,0 +1,178 @@ +/*- + * #%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.statement.create.macro; + +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; +import net.sf.jsqlparser.statement.select.Select; + +import java.io.Serializable; +import java.util.List; + +/** + * DuckDB's {@code CREATE [OR REPLACE] [TEMPORARY] MACRO name (parameters) AS [TABLE] body}, a named + * expression or query that is inlined at call sites. + * + * @see CREATE MACRO + */ +public class CreateMacro implements Statement { + private boolean orReplace; + private boolean temporary; + private String name; + private List parameters; + private Expression expression; + private Select select; + + public boolean isOrReplace() { + return orReplace; + } + + public void setOrReplace(boolean orReplace) { + this.orReplace = orReplace; + } + + public CreateMacro withOrReplace(boolean orReplace) { + setOrReplace(orReplace); + return this; + } + + public boolean isTemporary() { + return temporary; + } + + public void setTemporary(boolean temporary) { + this.temporary = temporary; + } + + public CreateMacro withTemporary(boolean temporary) { + setTemporary(temporary); + return this; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public CreateMacro withName(String name) { + setName(name); + return this; + } + + public List getParameters() { + return parameters; + } + + public void setParameters(List parameters) { + this.parameters = parameters; + } + + public CreateMacro withParameters(List parameters) { + setParameters(parameters); + return this; + } + + public Expression getExpression() { + return expression; + } + + public void setExpression(Expression expression) { + this.expression = expression; + } + + public CreateMacro withExpression(Expression expression) { + setExpression(expression); + return this; + } + + public Select getSelect() { + return select; + } + + public void setSelect(Select select) { + this.select = select; + } + + public CreateMacro withSelect(Select select) { + setSelect(select); + return this; + } + + /** A table macro returns a relation, a scalar macro an expression. */ + public boolean isTable() { + return select != null; + } + + public StringBuilder appendTo(StringBuilder builder) { + builder.append("CREATE "); + if (orReplace) { + builder.append("OR REPLACE "); + } + if (temporary) { + builder.append("TEMPORARY "); + } + builder.append("MACRO ").append(name).append(" ("); + for (int i = 0; i < parameters.size(); i++) { + if (i > 0) { + builder.append(", "); + } + builder.append(parameters.get(i)); + } + builder.append(") AS "); + if (select != null) { + builder.append("TABLE ").append(select); + } else { + builder.append(expression); + } + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } + + /** A macro parameter, optionally carrying a default value ({@code b := 5}). */ + public static class Parameter implements Serializable { + private final String name; + private final Expression defaultValue; + + public Parameter(String name) { + this(name, null); + } + + public Parameter(String name, Expression defaultValue) { + this.name = name; + this.defaultValue = defaultValue; + } + + public String getName() { + return name; + } + + public Expression getDefaultValue() { + return defaultValue; + } + + @Override + public String toString() { + return defaultValue == null ? name : name + " := " + defaultValue; + } + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 8565fc473c..128e2b78a4 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -218,6 +218,7 @@ import net.sf.jsqlparser.statement.PrepareStatement; import net.sf.jsqlparser.statement.DeallocateStatement; import net.sf.jsqlparser.statement.CopyStatement; +import net.sf.jsqlparser.statement.create.macro.CreateMacro; /** @@ -2580,6 +2581,19 @@ public void visit(CopyStatement copyStatement) { StatementVisitor.super.visit(copyStatement); } + @Override + public Void visit(CreateMacro createMacro, S context) { + if (createMacro.getSelect() != null) { + createMacro.getSelect().accept((SelectVisitor) this, context); + } + return null; + } + + @Override + public void visit(CreateMacro createMacro) { + StatementVisitor.super.visit(createMacro); + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { if (purgeStatement.getPurgeObjectType() == PurgeObjectType.TABLE) { diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java index 84db233e5d..9cf64fb420 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -106,6 +106,7 @@ import net.sf.jsqlparser.statement.PrepareStatement; import net.sf.jsqlparser.statement.DeallocateStatement; import net.sf.jsqlparser.statement.CopyStatement; +import net.sf.jsqlparser.statement.create.macro.CreateMacro; public class StatementDeParser extends AbstractDeParser implements StatementVisitor { @@ -619,6 +620,12 @@ public StringBuilder visit(CopyStatement copyStatement, S context) { return builder; } + @Override + public StringBuilder visit(CreateMacro createMacro, S context) { + createMacro.appendTo(builder); + return builder; + } + @Override public StringBuilder visit(PurgeStatement purgeStatement, S context) { purgeStatement.appendTo(builder); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java index 021a7d3351..a8755432d8 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java @@ -108,6 +108,7 @@ import net.sf.jsqlparser.statement.PrepareStatement; import net.sf.jsqlparser.statement.DeallocateStatement; import net.sf.jsqlparser.statement.CopyStatement; +import net.sf.jsqlparser.statement.create.macro.CreateMacro; /** * @author gitmotte @@ -540,6 +541,12 @@ public Void visit(CopyStatement copyStatement, S context) { return null; } + @Override + public Void visit(CreateMacro createMacro, S context) { + // TODO: not yet implemented + return null; + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { // TODO: not yet implemented @@ -792,6 +799,10 @@ public void visit(CopyStatement copyStatement) { visit(copyStatement, null); } + public void visit(CreateMacro createMacro) { + visit(createMacro, null); + } + public void visit(PurgeStatement purgeStatement) { visit(purgeStatement, null); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 76de9fda88..df8c7276ad 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -60,6 +60,7 @@ import net.sf.jsqlparser.statement.create.extension.*; import net.sf.jsqlparser.statement.create.event.*; import net.sf.jsqlparser.statement.create.function.*; import net.sf.jsqlparser.statement.create.index.*; +import net.sf.jsqlparser.statement.create.macro.*; import net.sf.jsqlparser.statement.create.policy.*; import net.sf.jsqlparser.statement.create.procedure.*; import net.sf.jsqlparser.statement.create.schema.*; @@ -1211,6 +1212,16 @@ public class CCJSqlParser extends AbstractJSqlParser { return isReservedKeywordSafeByFollower(); } + /** CREATE [TEMP|TEMPORARY] MACRO, DuckDB's inlined function definition. */ + private boolean isCreateMacroAhead() { + int offset = 1; + int kind = getToken(offset).kind; + if (kind == K_TEMP || kind == K_TEMPORARY) { + offset++; + } + return getToken(offset).kind == K_MACRO; + } + /** DuckDB sampling methods, which are ordinary identifiers except for SYSTEM/BERNOULLI. */ private boolean isSampleMethod(Token token) { return token.kind == K_SYSTEM || token.kind == K_BERNOULLI @@ -2147,6 +2158,7 @@ String NonReservedWord() : | tk= | tk= | tk= + | tk= | tk= | tk= | tk= @@ -4368,6 +4380,44 @@ RenameTableStatement RenameTableStatement(): { } } +/** + * DuckDB's CREATE [OR REPLACE] [TEMPORARY] MACRO name (parameters) AS [TABLE] body. + */ +CreateMacro CreateMacro(boolean isUsingOrReplace) #CreateMacro: +{ + CreateMacro createMacro = new CreateMacro(); + List parameters = new ArrayList(); + ObjectNames name; + Expression expression; + Select select; + String parameterName; + Expression defaultValue; +} +{ + { createMacro.setOrReplace(isUsingOrReplace); } + [ ( | ) { createMacro.setTemporary(true); } ] + name=RelObjectNames() { createMacro.setName(String.join(".", name.getNames())); } + "(" + [ + parameterName=RelObjectName() { defaultValue = null; } + [ ":=" defaultValue=Expression() ] + { parameters.add(new CreateMacro.Parameter(parameterName, defaultValue)); } + ( + "," parameterName=RelObjectName() { defaultValue = null; } + [ ":=" defaultValue=Expression() ] + { parameters.add(new CreateMacro.Parameter(parameterName, defaultValue)); } + )* + ] + ")" { createMacro.setParameters(parameters); } + + ( + LOOKAHEAD(2) select=Select() { createMacro.setSelect(select); } + | + expression=Expression() { createMacro.setExpression(expression); } + ) + { return createMacro; } +} + /** * DuckDB's COPY table [(columns)] FROM path [(options)] and * COPY {table | (query)} TO path [(options)]. @@ -17285,6 +17335,8 @@ Statement Create(): | statement = CreateSynonym(isUsingOrReplace) | + LOOKAHEAD({ isCreateMacroAhead() }) statement = CreateMacro(isUsingOrReplace) + | LOOKAHEAD(3) statement = CreateTable(isUsingOrReplace) | LOOKAHEAD(2) statement = CreateView(isUsingOrReplace) 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 2153b24ac2..f226def984 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -23,6 +23,7 @@ import net.sf.jsqlparser.statement.PrepareStatement; import net.sf.jsqlparser.statement.DeallocateStatement; import net.sf.jsqlparser.statement.CopyStatement; +import net.sf.jsqlparser.statement.create.macro.CreateMacro; import net.sf.jsqlparser.test.TestUtils; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -465,4 +466,37 @@ void testCopyTablesNamesFinder() throws JSQLParserException { new net.sf.jsqlparser.util.TablesNamesFinder() .getTableList(net.sf.jsqlparser.parser.CCJSqlParserUtil.parse(sqlStr))); } + + @Test + void testCreateScalarMacro() throws JSQLParserException { + String sqlStr = "CREATE MACRO add_one(a) AS a + 1"; + CreateMacro createMacro = + (CreateMacro) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals("add_one", createMacro.getName()); + Assertions.assertEquals(1, createMacro.getParameters().size()); + Assertions.assertFalse(createMacro.isTable()); + } + + @Test + void testCreateOrReplaceTemporaryMacroWithDefault() throws JSQLParserException { + String sqlStr = "CREATE OR REPLACE TEMPORARY MACRO add(a, b := 5) AS a + b"; + CreateMacro createMacro = + (CreateMacro) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertTrue(createMacro.isOrReplace()); + Assertions.assertTrue(createMacro.isTemporary()); + Assertions.assertEquals("5", + createMacro.getParameters().get(1).getDefaultValue().toString()); + } + + @Test + void testCreateTableMacro() throws JSQLParserException { + String sqlStr = "CREATE MACRO get_t(i) AS TABLE SELECT * FROM t WHERE id = i"; + CreateMacro createMacro = + (CreateMacro) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertTrue(createMacro.isTable()); + Assertions.assertNotNull(createMacro.getSelect()); + } } From 1df00335b6057f6edc2abb0871ba5621741358fe Mon Sep 17 00:00:00 2001 From: Hayssam Saleh Date: Tue, 15 Sep 2026 15:14:11 +0200 Subject: [PATCH 14/19] Support DuckDB 2.0 CREATE EXTENSION REPOSITORY Adds CREATE EXTENSION REPOSITORY [IF NOT EXISTS] name [WITH PREFIX prefix], which registers a custom repository to install extensions from. PostgreSQL's CREATE EXTENSION is unaffected. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqinwHBKuAtPEmtXr3b5P2 --- .../statement/StatementFeatureVisitor.java | 8 ++ .../statement/StatementVisitor.java | 7 ++ .../statement/StatementVisitorAdapter.java | 6 ++ .../extension/CreateExtensionRepository.java | 85 +++++++++++++++++++ .../sf/jsqlparser/util/TablesNamesFinder.java | 12 +++ .../util/deparser/StatementDeParser.java | 7 ++ .../validator/StatementValidator.java | 11 +++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 28 ++++++ .../statement/select/DuckDBTest.java | 28 ++++++ 9 files changed, 192 insertions(+) create mode 100644 src/main/java/net/sf/jsqlparser/statement/create/extension/CreateExtensionRepository.java diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index 9f55ca36b6..305b91a9c0 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -94,6 +94,7 @@ import java.util.Set; import java.util.function.Predicate; import net.sf.jsqlparser.statement.create.macro.CreateMacro; +import net.sf.jsqlparser.statement.create.extension.CreateExtensionRepository; /** * Derives a {@link StatementFeatures} verdict from a statement tree. @@ -592,6 +593,13 @@ public Void visit(CreateMacro createMacro, S context) { return null; } + @Override + public Void visit(CreateExtensionRepository createExtensionRepository, S context) { + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SCHEMA); + return null; + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { analysis.claimTopLevel(); diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index 2bf9419b9b..d0a01a4a17 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -69,6 +69,7 @@ import net.sf.jsqlparser.statement.update.Update; import net.sf.jsqlparser.statement.upsert.Upsert; import net.sf.jsqlparser.statement.create.macro.CreateMacro; +import net.sf.jsqlparser.statement.create.extension.CreateExtensionRepository; public interface StatementVisitor { @@ -432,6 +433,12 @@ default void visit(CreateMacro createMacro) { this.visit(createMacro, null); } + T visit(CreateExtensionRepository createExtensionRepository, S context); + + default void visit(CreateExtensionRepository createExtensionRepository) { + this.visit(createExtensionRepository, null); + } + T visit(PurgeStatement purgeStatement, S context); default void visit(PurgeStatement purgeStatement) { diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index 08505d4ff1..a64b219604 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -90,6 +90,7 @@ import java.util.List; import net.sf.jsqlparser.util.TableDefinitionTraversal; import net.sf.jsqlparser.statement.create.macro.CreateMacro; +import net.sf.jsqlparser.statement.create.extension.CreateExtensionRepository; @SuppressWarnings({"PMD.UncommentedEmptyMethodBody"}) public class StatementVisitorAdapter implements StatementVisitor { @@ -669,6 +670,11 @@ public T visit(CreateMacro createMacro, S context) { return null; } + @Override + public T visit(CreateExtensionRepository createExtensionRepository, S context) { + return null; + } + @Override public T visit(PurgeStatement purgeStatement, S context) { return null; diff --git a/src/main/java/net/sf/jsqlparser/statement/create/extension/CreateExtensionRepository.java b/src/main/java/net/sf/jsqlparser/statement/create/extension/CreateExtensionRepository.java new file mode 100644 index 0000000000..f3b85ccee4 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/extension/CreateExtensionRepository.java @@ -0,0 +1,85 @@ +/*- + * #%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.statement.create.extension; + +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; + +/** + * DuckDB 2.0's {@code CREATE EXTENSION REPOSITORY [IF NOT EXISTS] name [WITH PREFIX prefix]}, which + * registers a custom repository extensions may be installed from. + */ +public class CreateExtensionRepository implements Statement { + private String name; + private boolean ifNotExists; + private Expression prefix; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public CreateExtensionRepository withName(String name) { + setName(name); + return this; + } + + public boolean isIfNotExists() { + return ifNotExists; + } + + public void setIfNotExists(boolean ifNotExists) { + this.ifNotExists = ifNotExists; + } + + public CreateExtensionRepository withIfNotExists(boolean ifNotExists) { + setIfNotExists(ifNotExists); + return this; + } + + public Expression getPrefix() { + return prefix; + } + + public void setPrefix(Expression prefix) { + this.prefix = prefix; + } + + public CreateExtensionRepository withPrefix(Expression prefix) { + setPrefix(prefix); + return this; + } + + public StringBuilder appendTo(StringBuilder builder) { + builder.append("CREATE EXTENSION REPOSITORY "); + if (ifNotExists) { + builder.append("IF NOT EXISTS "); + } + builder.append(name); + if (prefix != null) { + builder.append(" WITH PREFIX ").append(prefix); + } + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 128e2b78a4..269ce17dbe 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -219,6 +219,7 @@ import net.sf.jsqlparser.statement.DeallocateStatement; import net.sf.jsqlparser.statement.CopyStatement; import net.sf.jsqlparser.statement.create.macro.CreateMacro; +import net.sf.jsqlparser.statement.create.extension.CreateExtensionRepository; /** @@ -2594,6 +2595,17 @@ public void visit(CreateMacro createMacro) { StatementVisitor.super.visit(createMacro); } + @Override + public Void visit(CreateExtensionRepository createExtensionRepository, S context) { + // no tables involved in this statement + return null; + } + + @Override + public void visit(CreateExtensionRepository createExtensionRepository) { + StatementVisitor.super.visit(createExtensionRepository); + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { if (purgeStatement.getPurgeObjectType() == PurgeObjectType.TABLE) { diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java index 9cf64fb420..63e33a4445 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -107,6 +107,7 @@ import net.sf.jsqlparser.statement.DeallocateStatement; import net.sf.jsqlparser.statement.CopyStatement; import net.sf.jsqlparser.statement.create.macro.CreateMacro; +import net.sf.jsqlparser.statement.create.extension.CreateExtensionRepository; public class StatementDeParser extends AbstractDeParser implements StatementVisitor { @@ -626,6 +627,12 @@ public StringBuilder visit(CreateMacro createMacro, S context) { return builder; } + @Override + public StringBuilder visit(CreateExtensionRepository createExtensionRepository, S context) { + createExtensionRepository.appendTo(builder); + return builder; + } + @Override public StringBuilder visit(PurgeStatement purgeStatement, S context) { purgeStatement.appendTo(builder); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java index a8755432d8..13d48fd187 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java @@ -109,6 +109,7 @@ import net.sf.jsqlparser.statement.DeallocateStatement; import net.sf.jsqlparser.statement.CopyStatement; import net.sf.jsqlparser.statement.create.macro.CreateMacro; +import net.sf.jsqlparser.statement.create.extension.CreateExtensionRepository; /** * @author gitmotte @@ -547,6 +548,12 @@ public Void visit(CreateMacro createMacro, S context) { return null; } + @Override + public Void visit(CreateExtensionRepository createExtensionRepository, S context) { + // TODO: not yet implemented + return null; + } + @Override public Void visit(PurgeStatement purgeStatement, S context) { // TODO: not yet implemented @@ -803,6 +810,10 @@ public void visit(CreateMacro createMacro) { visit(createMacro, null); } + public void visit(CreateExtensionRepository createExtensionRepository) { + visit(createExtensionRepository, null); + } + public void visit(PurgeStatement purgeStatement) { visit(purgeStatement, null); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index df8c7276ad..4dd4ecb0fd 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -17307,6 +17307,12 @@ Statement Create(): | LOOKAHEAD() { requireTypeDdlSyntax(!isUsingOrReplace, "OR REPLACE is not supported for CREATE DOMAIN"); } statement = CreateDomain() | + LOOKAHEAD({ isKeywordAhead("EXTENSION") + && "REPOSITORY".equalsIgnoreCase(getToken(2).image) }) + { requireTypeDdlSyntax(!isUsingOrReplace, + "OR REPLACE is not supported for CREATE EXTENSION REPOSITORY"); } + statement = CreateExtensionRepository() + | LOOKAHEAD({ isKeywordAhead("EXTENSION") }) { requireTypeDdlSyntax(!isUsingOrReplace, "OR REPLACE is not supported for CREATE EXTENSION"); } statement = CreateExtension() | LOOKAHEAD({ isKeywordAhead("PUBLICATION") }) @@ -17710,6 +17716,28 @@ AlterDomain AlterDomain(): { return result; } } +/** + * DuckDB 2.0's CREATE EXTENSION REPOSITORY [IF NOT EXISTS] name [WITH PREFIX prefix]. + */ +CreateExtensionRepository CreateExtensionRepository() #CreateExtensionRepository: +{ + CreateExtensionRepository createExtensionRepository = new CreateExtensionRepository(); + Expression prefix; + String name; +} +{ + TypeDdlKeyword("EXTENSION") TypeDdlKeyword("REPOSITORY") + [ LOOKAHEAD(3) + { createExtensionRepository.setIfNotExists(true); } ] + name=RelObjectName() { createExtensionRepository.setName(name); } + // greedy: WITH PREFIX belongs to the repository definition + [ + LOOKAHEAD(2) TypeDdlKeyword("PREFIX") prefix=SimpleExpression() + { createExtensionRepository.setPrefix(prefix); } + ] + { return createExtensionRepository; } +} + CreateExtension CreateExtension(): { CreateExtension result = new CreateExtension(); String name; Expression version; } { 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 f226def984..4effe86552 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -24,6 +24,7 @@ import net.sf.jsqlparser.statement.DeallocateStatement; import net.sf.jsqlparser.statement.CopyStatement; import net.sf.jsqlparser.statement.create.macro.CreateMacro; +import net.sf.jsqlparser.statement.create.extension.CreateExtensionRepository; import net.sf.jsqlparser.test.TestUtils; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -499,4 +500,31 @@ void testCreateTableMacro() throws JSQLParserException { Assertions.assertTrue(createMacro.isTable()); Assertions.assertNotNull(createMacro.getSelect()); } + + @Test + void testCreateExtensionRepository() throws JSQLParserException { + String sqlStr = + "CREATE EXTENSION REPOSITORY my_repo WITH PREFIX 'https://extensions.example.org'"; + CreateExtensionRepository createRepository = + (CreateExtensionRepository) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals("my_repo", createRepository.getName()); + Assertions.assertEquals("'https://extensions.example.org'", + createRepository.getPrefix().toString()); + } + + @Test + void testCreateExtensionRepositoryIfNotExists() throws JSQLParserException { + String sqlStr = "CREATE EXTENSION REPOSITORY IF NOT EXISTS my_repo"; + CreateExtensionRepository createRepository = + (CreateExtensionRepository) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertTrue(createRepository.isIfNotExists()); + } + + @Test + void testCreatePostgresExtensionStillParses() throws JSQLParserException { + String sqlStr = "CREATE EXTENSION IF NOT EXISTS hstore WITH SCHEMA public CASCADE"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } } From 154bb245cdf60ec019623c5dafa09748d64838f8 Mon Sep 17 00:00:00 2001 From: Hayssam Saleh Date: Tue, 15 Sep 2026 15:43:12 +0200 Subject: [PATCH 15/19] Support DuckDB 2.0 triggers with transition tables and a statement body DuckDB writes CREATE TRIGGER ... REFERENCING OLD TABLE AS o NEW TABLE AS n FOR EACH STATEMENT , i.e. the standard header with a SQL statement instead of EXECUTE FUNCTION. The standard-form parser now accepts a statement body, and such triggers are recognised and rendered as standard rather than MySQL-style triggers. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqinwHBKuAtPEmtXr3b5P2 --- .../create/trigger/CreateTrigger.java | 13 ++++++-- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 22 ++++++++++---- .../statement/select/DuckDBTest.java | 30 +++++++++++++++++++ 3 files changed, 56 insertions(+), 9 deletions(-) diff --git a/src/main/java/net/sf/jsqlparser/statement/create/trigger/CreateTrigger.java b/src/main/java/net/sf/jsqlparser/statement/create/trigger/CreateTrigger.java index 105115683b..4c3b8f3f80 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/trigger/CreateTrigger.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/trigger/CreateTrigger.java @@ -241,7 +241,9 @@ public void visit(Consumer tables, Consumer expressions) { } public void appendTo(StringBuilder sql, Consumer visitor) { - if (routine == null) { + // MySQL's shorter form has neither a routine nor any of the standard clauses + if (routine == null && transitionRelations.isEmpty() && !constraint + && orientation != Orientation.STATEMENT) { sql.append(mysqlSql()); return; } @@ -252,8 +254,13 @@ public void appendTo(StringBuilder sql, Consumer visitor) { visitor.accept(whenExpression); sql.append(')'); } - sql.append(" EXECUTE ").append(executeKeyword).append(' '); - visitor.accept(routine); + if (routine != null) { + sql.append(" EXECUTE ").append(executeKeyword).append(' '); + visitor.accept(routine); + } else { + // DuckDB 2.0 runs a statement rather than a function + sql.append(' ').append(body); + } } private void appendPostgreSqlHeader(StringBuilder sql, Consumer visitor) { diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 4dd4ecb0fd..a630ed830d 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1515,6 +1515,8 @@ public class CCJSqlParser extends AbstractJSqlParser { Token token = getToken(i); if (token.kind == EOF || ";".equals(token.image) || token.kind == K_BEGIN) { return false; } if (token.kind == K_EXECUTE && (getToken(i + 1).kind == K_FUNCTION || getToken(i + 1).kind == K_PROCEDURE)) { return true; } + // DuckDB 2.0 uses the standard header with a statement body instead of EXECUTE + if ("REFERENCING".equalsIgnoreCase(token.image) || token.kind == K_STATEMENT) { return true; } } } @@ -17092,6 +17094,7 @@ CreateTrigger PostgreSqlTrigger(boolean replace): { CreateTrigger result = new CreateTrigger(); String name; Table table; CreateTrigger.Timing timing; TriggerEvent event; TransitionRelation transition; Expression expression; Token token; boolean negative = false; + Statement body = null; Function routine = new Function(); ExpressionList arguments = new ExpressionList(); } { @@ -17104,7 +17107,8 @@ CreateTrigger PostgreSqlTrigger(boolean replace): event=PostgreSqlTriggerEvent() { result.getEvents().add(event); } ( event=PostgreSqlTriggerEvent() { result.getEvents().add(event); } )* table=Table() { result.setTable(table); } - [ table=Table() { result.setReferencedTable(table); } ] + // greedy: FROM here names the referenced table, it never starts the trigger body + [ LOOKAHEAD(2) table=Table() { result.setReferencedTable(table); } ] [ [ { negative = true; } ] { result.getConstraintAttributes().setDeferrable(!negative); } ] [ LOOKAHEAD({ isAccessKeywordAhead("INITIALLY") }) AccessKeyword("INITIALLY") ( token= ) @@ -17117,12 +17121,18 @@ CreateTrigger PostgreSqlTrigger(boolean replace): ( { result.setOrientation(CreateTrigger.Orientation.ROW); } | { result.setOrientation(CreateTrigger.Orientation.STATEMENT); } ) ] [ "(" expression=Expression() ")" { result.setWhenExpression(expression); } ] - ( | { result.setExecuteKeyword(CreateTrigger.ExecuteKeyword.PROCEDURE); } ) - name=AccessQualifiedName() { routine.setName(name); } - "(" [ expression=PostgreSqlTriggerArgument() { arguments.add(expression); } - ( "," expression=PostgreSqlTriggerArgument() { arguments.add(expression); } )* ] ")" + ( + LOOKAHEAD(2) + ( | { result.setExecuteKeyword(CreateTrigger.ExecuteKeyword.PROCEDURE); } ) + name=AccessQualifiedName() { routine.setName(name); } + "(" [ expression=PostgreSqlTriggerArgument() { arguments.add(expression); } + ( "," expression=PostgreSqlTriggerArgument() { arguments.add(expression); } )* ] ")" + { routine.setParameters(arguments); result.setRoutine(routine); } + | + // DuckDB 2.0 runs a statement rather than a function + body=SingleStatement() { result.setBody(body); } + ) { - routine.setParameters(arguments); result.setRoutine(routine); requireAccessSyntax(!result.isConstraint() || !replace && result.getTiming() == CreateTrigger.Timing.AFTER && result.getOrientation() == CreateTrigger.Orientation.ROW, "A constraint trigger requires AFTER FOR EACH ROW without OR REPLACE"); requireAccessSyntax(result.isConstraint() || result.getReferencedTable() == null 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 4effe86552..45d3e1b5df 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -25,6 +25,7 @@ import net.sf.jsqlparser.statement.CopyStatement; import net.sf.jsqlparser.statement.create.macro.CreateMacro; import net.sf.jsqlparser.statement.create.extension.CreateExtensionRepository; +import net.sf.jsqlparser.statement.create.trigger.CreateTrigger; import net.sf.jsqlparser.test.TestUtils; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -527,4 +528,33 @@ void testCreatePostgresExtensionStillParses() throws JSQLParserException { String sqlStr = "CREATE EXTENSION IF NOT EXISTS hstore WITH SCHEMA public CASCADE"; TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); } + + @Test + void testCreateTriggerWithTransitionTablesAndStatementBody() throws JSQLParserException { + String sqlStr = "CREATE TRIGGER trg_audit AFTER UPDATE ON target " + + "REFERENCING OLD TABLE AS o NEW TABLE AS n FOR EACH STATEMENT " + + "INSERT INTO audit SELECT n.id, o.val, n.val FROM o JOIN n ON o.id = n.id"; + CreateTrigger createTrigger = + (CreateTrigger) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals(2, createTrigger.getTransitionRelations().size()); + Assertions.assertEquals(CreateTrigger.Orientation.STATEMENT, + createTrigger.getOrientation()); + Assertions.assertNotNull(createTrigger.getBody()); + } + + @Test + void testCreateTriggerBeforeDeleteWithStatementBody() throws JSQLParserException { + String sqlStr = "CREATE TRIGGER trg BEFORE DELETE ON t REFERENCING OLD TABLE AS o " + + "FOR EACH STATEMENT DELETE FROM audit WHERE id IN (SELECT id FROM o)"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } + + @Test + void testPostgreSqlTriggerStillParses() throws JSQLParserException { + String sqlStr = "CREATE TRIGGER trg AFTER UPDATE ON target " + + "REFERENCING OLD TABLE AS o NEW TABLE AS n FOR EACH STATEMENT " + + "EXECUTE FUNCTION f()"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } } From 0f685b80c8fb17fa8f80f9f16a70e1162f90e34f Mon Sep 17 00:00:00 2001 From: Hayssam Saleh Date: Tue, 15 Sep 2026 15:48:54 +0200 Subject: [PATCH 16/19] Support DuckDB FROM-first queries DuckDB lets the FROM clause lead, with the SELECT clause optional: FROM tbl SELECT a and FROM tbl WHERE a > 1. PlainSelect records the order in a fromFirst flag, and both the AST's own rendering and SelectDeParser emit the clauses accordingly. Bare FROM queries and the pipe syntax are unaffected. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqinwHBKuAtPEmtXr3b5P2 --- .../statement/select/PlainSelect.java | 123 ++++++++--- .../util/deparser/SelectDeParser.java | 205 ++++++++++-------- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 87 ++++++++ .../statement/select/DuckDBTest.java | 46 ++++ 4 files changed, 331 insertions(+), 130 deletions(-) diff --git a/src/main/java/net/sf/jsqlparser/statement/select/PlainSelect.java b/src/main/java/net/sf/jsqlparser/statement/select/PlainSelect.java index f84bf6ea97..58510e04fc 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/PlainSelect.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/PlainSelect.java @@ -66,6 +66,7 @@ public class PlainSelect extends Select { private boolean isUsingFinal = false; private boolean isUsingOnly = false; private boolean useWithNoLog = false; + private boolean fromFirst = false; private Table intoTempTable = null; private List settings = null; @@ -543,9 +544,83 @@ public void setWindowDefinitions(List windowDefinitions) { @SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.ExcessiveMethodLength", "PMD.NPathComplexity"}) + /** + * DuckDB allows the FROM clause to come first, with the SELECT clause optional: + * {@code FROM tbl SELECT a} and {@code FROM tbl WHERE a > 1}. + */ + public boolean isFromFirst() { + return fromFirst; + } + + public void setFromFirst(boolean fromFirst) { + this.fromFirst = fromFirst; + } + + public PlainSelect withFromFirst(boolean fromFirst) { + setFromFirst(fromFirst); + return this; + } + public StringBuilder appendSelectBodyTo(StringBuilder builder) { + if (fromFirst) { + // DuckDB allows the FROM clause to lead: FROM tbl [SELECT ...] [WHERE ...] + appendFromClauseTo(builder); + if (selectItems != null && !selectItems.isEmpty()) { + builder.append(" "); + appendSelectClauseTo(builder); + appendIntoClausesTo(builder); + } + } else { + appendSelectClauseTo(builder); + appendIntoClausesTo(builder); + appendFromClauseTo(builder); + } + + if (preWhere != null) { + builder.append(" PREWHERE ").append(preWhere); + } + if (where != null) { + builder.append(" WHERE ").append(where); + } + if (oracleHierarchical != null) { + builder.append(oracleHierarchical); + } + if (preferringClause != null) { + builder.append(" ").append(preferringClause); + } + if (groupBy != null) { + builder.append(" ").append(groupBy); + } + if (having != null) { + builder.append(" HAVING ").append(having); + } + if (qualify != null) { + builder.append(" QUALIFY ").append(qualify); + } + if (windowDefinitions != null) { + builder.append(" WINDOW "); + builder.append(windowDefinitions.stream().map(WindowDefinition::toString) + .collect(joining(", "))); + } + appendEmitClauseTo(builder); + if (intoTempTable != null) { + builder.append(" INTO TEMP ").append(intoTempTable); + } + if (useWithNoLog) { + builder.append(" WITH NO LOG"); + } + return builder; + } + + private void appendSelectClauseTo(StringBuilder builder) { builder.append("SELECT "); + appendSelectHintsTo(builder); + appendSelectQualifiersTo(builder); + appendMySqlSelectFlagsTo(builder); + builder.append(getStringList(selectItems)); + } + private void appendSelectHintsTo(StringBuilder builder) { if (this.mySqlHintStraightJoin) { builder.append("STRAIGHT_JOIN "); } @@ -561,7 +636,9 @@ public StringBuilder appendSelectBodyTo(StringBuilder builder) { if (first != null) { builder.append(first).append(" "); } + } + private void appendSelectQualifiersTo(StringBuilder builder) { if (distinct != null) { builder.append(distinct).append(" "); } @@ -580,14 +657,19 @@ public StringBuilder appendSelectBodyTo(StringBuilder builder) { if (top != null) { builder.append(top).append(" "); } + } + + private void appendMySqlSelectFlagsTo(StringBuilder builder) { if (mySqlCacheFlag != null) { builder.append(mySqlCacheFlag.name()).append(" "); } + if (mySqlSqlCalcFoundRows) { builder.append("SQL_CALC_FOUND_ROWS").append(" "); } - builder.append(getStringList(selectItems)); + } + private void appendIntoClausesTo(StringBuilder builder) { if (intoTables != null) { builder.append(" INTO "); for (Iterator
iter = intoTables.iterator(); iter.hasNext();) { @@ -603,9 +685,11 @@ public StringBuilder appendSelectBodyTo(StringBuilder builder) { .getPosition() == MySqlSelectIntoClause.Position.BEFORE_FROM) { builder.append(" ").append(mySqlSelectIntoClause); } + } + private void appendFromClauseTo(StringBuilder builder) { if (fromItem != null) { - builder.append(" FROM "); + builder.append(fromFirst ? "FROM " : " FROM "); if (isUsingOnly) { builder.append("ONLY "); } @@ -633,42 +717,9 @@ public StringBuilder appendSelectBodyTo(StringBuilder builder) { builder.append(" WINDOW ").append(ksqlWindow); } } - if (preWhere != null) { - builder.append(" PREWHERE ").append(preWhere); - } - if (where != null) { - builder.append(" WHERE ").append(where); - } - if (oracleHierarchical != null) { - builder.append(oracleHierarchical); - } - if (preferringClause != null) { - builder.append(" ").append(preferringClause); - } - if (groupBy != null) { - builder.append(" ").append(groupBy); - } - if (having != null) { - builder.append(" HAVING ").append(having); - } - if (qualify != null) { - builder.append(" QUALIFY ").append(qualify); - } - if (windowDefinitions != null) { - builder.append(" WINDOW "); - builder.append(windowDefinitions.stream().map(WindowDefinition::toString) - .collect(joining(", "))); - } - appendEmitClauseTo(builder); - if (intoTempTable != null) { - builder.append(" INTO TEMP ").append(intoTempTable); - } - if (useWithNoLog) { - builder.append(" WITH NO LOG"); - } - return builder; } + @Override @SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.ExcessiveMethodLength", "PMD.NPathComplexity"}) diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java index 0dc7d84b82..c7a4700a5d 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java @@ -198,101 +198,16 @@ public StringBuilder visit(PlainSelect plainSelect, S context) { } } - builder.append("SELECT "); - - if (plainSelect.getMySqlHintStraightJoin()) { - builder.append("STRAIGHT_JOIN "); - } - - OracleHint hint = plainSelect.getOracleHint(); - if (hint != null) { - builder.append(hint).append(" "); - } - - Skip skip = plainSelect.getSkip(); - if (skip != null) { - builder.append(skip).append(" "); - } - - First first = plainSelect.getFirst(); - if (first != null) { - builder.append(first).append(" "); - } - - deparseDistinctClause(plainSelect.getDistinct()); - - if (plainSelect.getBigQuerySelectQualifier() != null) { - switch (plainSelect.getBigQuerySelectQualifier()) { - case AS_STRUCT: - builder.append("AS STRUCT "); - break; - case AS_VALUE: - builder.append("AS VALUE "); - break; - } - } - - Top top = plainSelect.getTop(); - if (top != null) { - visit(top); - } - - if (plainSelect.getMySqlSqlCacheFlag() != null) { - builder.append(plainSelect.getMySqlSqlCacheFlag().name()).append(" "); - } - - if (plainSelect.getMySqlSqlCalcFoundRows()) { - builder.append("SQL_CALC_FOUND_ROWS").append(" "); - } - - deparseSelectItemsClause(plainSelect.getSelectItems()); - - if (plainSelect.getIntoTables() != null) { - builder.append(" INTO "); - for (Iterator
iter = plainSelect.getIntoTables().iterator(); iter.hasNext();) { - visit(iter.next(), context); - if (iter.hasNext()) { - builder.append(", "); - } - } - } - - deparseMySqlSelectInto(plainSelect, MySqlSelectIntoClause.Position.BEFORE_FROM, context); - - if (plainSelect.getFromItem() != null) { - builder.append(" FROM "); - if (plainSelect.isUsingOnly()) { - builder.append("ONLY "); - } - plainSelect.getFromItem().accept(this, context); - - if (plainSelect.getFromItem() instanceof Table) { - Table table = (Table) plainSelect.getFromItem(); - if (table.getSampleClause() != null) { - table.getSampleClause().appendTo(builder); - } - } - } - - if (plainSelect.getLateralViews() != null) { - for (LateralView lateralView : plainSelect.getLateralViews()) { - deparseLateralView(lateralView); - } - } - - if (plainSelect.getJoins() != null) { - for (Join join : plainSelect.getJoins()) { - deparseJoin(join); + if (plainSelect.isFromFirst()) { + // DuckDB allows the FROM clause to lead, with an optional SELECT clause behind it + deparseFromClause(plainSelect, context); + if (plainSelect.getSelectItems() != null && !plainSelect.getSelectItems().isEmpty()) { + builder.append(" "); + deparseSelectClause(plainSelect, context); } - } - - if (plainSelect.isUsingFinal()) { - builder.append(" FINAL"); - } - - if (plainSelect.getKsqlWindow() != null) { - builder.append(" WINDOW "); - builder.append(plainSelect.getKsqlWindow().toString()); + } else { + deparseSelectClause(plainSelect, context); + deparseFromClause(plainSelect, context); } deparsePreWhereClause(plainSelect); @@ -419,6 +334,108 @@ public StringBuilder visit(PlainSelect plainSelect, S context) { return builder; } + private void deparseSelectClause(PlainSelect plainSelect, S context) { + builder.append("SELECT "); + + if (plainSelect.getMySqlHintStraightJoin()) { + builder.append("STRAIGHT_JOIN "); + } + + OracleHint hint = plainSelect.getOracleHint(); + if (hint != null) { + builder.append(hint).append(" "); + } + + Skip skip = plainSelect.getSkip(); + if (skip != null) { + builder.append(skip).append(" "); + } + + First first = plainSelect.getFirst(); + if (first != null) { + builder.append(first).append(" "); + } + + deparseDistinctClause(plainSelect.getDistinct()); + + if (plainSelect.getBigQuerySelectQualifier() != null) { + switch (plainSelect.getBigQuerySelectQualifier()) { + case AS_STRUCT: + builder.append("AS STRUCT "); + break; + case AS_VALUE: + builder.append("AS VALUE "); + break; + } + } + + Top top = plainSelect.getTop(); + if (top != null) { + visit(top); + } + + if (plainSelect.getMySqlSqlCacheFlag() != null) { + builder.append(plainSelect.getMySqlSqlCacheFlag().name()).append(" "); + } + + if (plainSelect.getMySqlSqlCalcFoundRows()) { + builder.append("SQL_CALC_FOUND_ROWS").append(" "); + } + + deparseSelectItemsClause(plainSelect.getSelectItems()); + + if (plainSelect.getIntoTables() != null) { + builder.append(" INTO "); + for (Iterator
iter = plainSelect.getIntoTables().iterator(); iter.hasNext();) { + visit(iter.next(), context); + if (iter.hasNext()) { + builder.append(", "); + } + } + } + + deparseMySqlSelectInto(plainSelect, MySqlSelectIntoClause.Position.BEFORE_FROM, context); + } + + private void deparseFromClause(PlainSelect plainSelect, S context) { + if (plainSelect.getFromItem() != null) { + builder.append(plainSelect.isFromFirst() ? "FROM " : " FROM "); + if (plainSelect.isUsingOnly()) { + builder.append("ONLY "); + } + plainSelect.getFromItem().accept(this, context); + + if (plainSelect.getFromItem() instanceof Table) { + Table table = (Table) plainSelect.getFromItem(); + if (table.getSampleClause() != null) { + table.getSampleClause().appendTo(builder); + } + } + } + + if (plainSelect.getLateralViews() != null) { + for (LateralView lateralView : plainSelect.getLateralViews()) { + deparseLateralView(lateralView); + } + } + + if (plainSelect.getJoins() != null) { + for (Join join : plainSelect.getJoins()) { + deparseJoin(join); + } + } + + if (plainSelect.isUsingFinal()) { + builder.append(" FINAL"); + } + + if (plainSelect.getKsqlWindow() != null) { + builder.append(" WINDOW "); + builder.append(plainSelect.getKsqlWindow().toString()); + } + } + + @Override public StringBuilder visit(PivotQuery pivotQuery, S context) { List> withItemsList = pivotQuery.getWithItemsList(); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index a630ed830d..02cac04d1b 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1212,6 +1212,44 @@ public class CCJSqlParser extends AbstractJSqlParser { return isReservedKeywordSafeByFollower(); } + /** + * DuckDB's FROM-first query: a FROM clause followed by query clauses the pipe syntax cannot + * consume. Scanning stops at a pipe operator or the end of the statement, and clause keywords + * nested in brackets (a sub-select, for instance) do not count. + */ + private boolean isFromFirstSelectAhead() { + if (getToken(1).kind != K_FROM) { + return false; + } + int depth = 0; + for (int i = 2;; i++) { + Token token = getToken(i); + if (token == null || token.kind == EOF || ";".equals(token.image) + || "|>".equals(token.image)) { + return false; + } + if ("(".equals(token.image)) { + depth++; + } else if (")".equals(token.image)) { + depth--; + } else if (depth == 0) { + switch (token.kind) { + case K_SELECT: + case K_WHERE: + case K_GROUP: + case K_HAVING: + case K_QUALIFY: + case K_ORDER: + case K_LIMIT: + case K_OFFSET: + return true; + default: + break; + } + } + } + } + /** CREATE [TEMP|TEMPORARY] MACRO, DuckDB's inlined function definition. */ private boolean isCreateMacroAhead() { int offset = 1; @@ -6310,6 +6348,8 @@ Select Select() #Select: { [ with=WithList() ] ( + LOOKAHEAD({ isFromFirstSelectAhead() }) select = FromFirstSelect() + | LOOKAHEAD(3) select = FromQuery() | ( @@ -6341,6 +6381,53 @@ Select Select() #Select: } } +/** + * DuckDB's FROM-first query: FROM tbl [SELECT ...] [WHERE ...] [GROUP BY ...] [HAVING ...] + * [QUALIFY ...] [ORDER BY ...] [LIMIT ...] [OFFSET ...]. Without a SELECT clause all columns + * are returned. + */ +PlainSelect FromFirstSelect() #FromFirstSelect: +{ + PlainSelect plainSelect = new PlainSelect(); + List> selectItems = null; + FromItem fromItem; + List joins = null; + Expression where = null; + GroupByElement groupBy = null; + Expression having = null; + Expression qualify = null; + List orderByElements = null; + Limit limit = null; + Offset offset = null; +} +{ + fromItem=FromItem() + { + plainSelect.setFromItem(fromItem); + plainSelect.setFromFirst(true); + } + [ LOOKAHEAD(2) joins=JoinsList() { plainSelect.setJoins(joins); } ] + // greedy: a SELECT right after the FROM clause belongs to this query + [ + LOOKAHEAD(2) + + [ LOOKAHEAD(2) ( | { plainSelect.setDistinct(new Distinct()); } ) ] + selectItems=SelectItemsList() { plainSelect.setSelectItems(selectItems); } + ] + [ LOOKAHEAD(2) where=WhereClause() { plainSelect.setWhere(where); } ] + [ LOOKAHEAD(2) groupBy=GroupByColumnReferences() { plainSelect.setGroupByElement(groupBy); } ] + [ LOOKAHEAD(2) having=Having() { plainSelect.setHaving(having); } ] + [ LOOKAHEAD(2) qualify=Qualify() { plainSelect.setQualify(qualify); } ] + [ LOOKAHEAD( ) orderByElements=OrderByElements() + { plainSelect.setOrderByElements(orderByElements); } ] + [ LOOKAHEAD() limit=LimitWithOffset() { plainSelect.setLimit(limit); } ] + [ LOOKAHEAD() offset=Offset() { plainSelect.setOffset(offset); } ] + { + linkAST(plainSelect, jjtThis); + return plainSelect; + } +} + FromQuery FromQuery() #FromQuery: { FromQuery fromQuery; 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 45d3e1b5df..84257479d9 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -557,4 +557,50 @@ void testPostgreSqlTriggerStillParses() throws JSQLParserException { + "EXECUTE FUNCTION f()"; TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); } + + @Test + void testFromFirstWithSelect() throws JSQLParserException { + String sqlStr = "FROM t SELECT a, b"; + PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertTrue(select.isFromFirst()); + Assertions.assertEquals(2, select.getSelectItems().size()); + } + + @Test + void testFromFirstWithWhere() throws JSQLParserException { + String sqlStr = "FROM t WHERE a > 1"; + PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertTrue(select.isFromFirst()); + Assertions.assertNull(select.getSelectItems()); + } + + @Test + void testFromFirstWithClauses() throws JSQLParserException { + String sqlStr = "FROM t SELECT a, count(*) WHERE a > 1 GROUP BY a HAVING count(*) > 2 " + + "ORDER BY a LIMIT 10"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } + + @Test + void testFromFirstWithJoin() throws JSQLParserException { + String sqlStr = "FROM t JOIN u ON t.id = u.id SELECT t.a"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } + + @Test + void testFromFirstTablesNamesFinder() throws JSQLParserException { + String sqlStr = "FROM ds.t WHERE a > 1"; + + Assertions.assertEquals(java.util.Collections.singletonList("ds.t"), + new net.sf.jsqlparser.util.TablesNamesFinder() + .getTableList(net.sf.jsqlparser.parser.CCJSqlParserUtil.parse(sqlStr))); + } + + @Test + void testBarePipeQueryStillParses() throws JSQLParserException { + String sqlStr = "FROM t |> WHERE a > 1"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } } From 6f6dea41d43e17e2f6ec085ddf9555a19d98c66e Mon Sep 17 00:00:00 2001 From: Hayssam Saleh Date: Tue, 15 Sep 2026 15:51:53 +0200 Subject: [PATCH 17/19] Support DuckDB UNPIVOT statement Adds the simplified statement form UNPIVOT tbl ON columns [INTO NAME name VALUE values] as UnPivotQuery, the counterpart of the existing PivotQuery. The FROM-clause form UNPIVOT (...) is unaffected. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqinwHBKuAtPEmtXr3b5P2 --- .../statement/select/FromItemVisitor.java | 8 ++ .../select/FromItemVisitorAdapter.java | 5 + .../statement/select/SelectVisitor.java | 8 ++ .../select/SelectVisitorAdapter.java | 10 ++ .../statement/select/UnPivotQuery.java | 115 ++++++++++++++++++ .../sf/jsqlparser/util/TablesNamesFinder.java | 15 +++ .../util/deparser/SelectDeParser.java | 12 ++ .../validation/validator/SelectValidator.java | 14 +++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 24 ++++ .../statement/select/DuckDBTest.java | 35 ++++++ 10 files changed, 246 insertions(+) create mode 100644 src/main/java/net/sf/jsqlparser/statement/select/UnPivotQuery.java diff --git a/src/main/java/net/sf/jsqlparser/statement/select/FromItemVisitor.java b/src/main/java/net/sf/jsqlparser/statement/select/FromItemVisitor.java index 69af24f551..f5c6235774 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/FromItemVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/FromItemVisitor.java @@ -92,6 +92,14 @@ default void visit(PivotQuery pivotQuery) { this.visit(pivotQuery, null); } + default T visit(UnPivotQuery unPivotQuery, S context) { + return null; + } + + default void visit(UnPivotQuery unPivotQuery) { + this.visit(unPivotQuery, null); + } + T visit(SetOperationList setOperationList, S context); default void visit(SetOperationList setOperationList) { diff --git a/src/main/java/net/sf/jsqlparser/statement/select/FromItemVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/select/FromItemVisitorAdapter.java index 3c88d63c41..c7ce999010 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/FromItemVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/FromItemVisitorAdapter.java @@ -129,6 +129,11 @@ public T visit(PivotQuery pivotQuery, S context) { return pivotQuery.accept(selectVisitor, context); } + @Override + public T visit(UnPivotQuery unPivotQuery, S context) { + return unPivotQuery.accept(selectVisitor, context); + } + @Override public T visit(SetOperationList setOperationList, S context) { ArrayList results = new ArrayList<>(); diff --git a/src/main/java/net/sf/jsqlparser/statement/select/SelectVisitor.java b/src/main/java/net/sf/jsqlparser/statement/select/SelectVisitor.java index f5b88bd357..5591f4bf83 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/SelectVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/SelectVisitor.java @@ -47,6 +47,14 @@ default void visit(PivotQuery pivotQuery) { this.visit(pivotQuery, null); } + default T visit(UnPivotQuery unPivotQuery, S context) { + return null; + } + + default void visit(UnPivotQuery unPivotQuery) { + this.visit(unPivotQuery, null); + } + T visit(FromQuery fromQuery, S context); T visit(SetOperationList setOpList, S context); diff --git a/src/main/java/net/sf/jsqlparser/statement/select/SelectVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/select/SelectVisitorAdapter.java index 051f3929e0..28e9525bc3 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/SelectVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/SelectVisitorAdapter.java @@ -272,6 +272,16 @@ public T visit(PivotQuery pivotQuery, S context) { return null; } + @Override + public T visit(UnPivotQuery unPivotQuery, S context) { + visitWithItems(unPivotQuery.getWithItemsList(), context); + + fromItemVisitor.visitFromItem(unPivotQuery.getFromItem(), context); + expressionVisitor.visitExpressions(unPivotQuery.getOnExpressions(), context); + expressionVisitor.visitExpressions(unPivotQuery.getValueColumns(), context); + return null; + } + @Override public T visit(FromQuery fromQuery, S context) { return null; diff --git a/src/main/java/net/sf/jsqlparser/statement/select/UnPivotQuery.java b/src/main/java/net/sf/jsqlparser/statement/select/UnPivotQuery.java new file mode 100644 index 0000000000..799e6b6123 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/select/UnPivotQuery.java @@ -0,0 +1,115 @@ +/*- + * #%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.statement.select; + +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.schema.Column; + +/** + * DuckDB's simplified UNPIVOT statement: + * {@code UNPIVOT tbl ON columns [INTO NAME name VALUE values]}, the counterpart of + * {@link PivotQuery}. + * + * @see UNPIVOT + */ +public class UnPivotQuery extends Select { + private FromItem fromItem; + private ExpressionList onExpressions; + private String nameColumn; + private ExpressionList valueColumns; + + public FromItem getFromItem() { + return fromItem; + } + + public void setFromItem(FromItem fromItem) { + this.fromItem = fromItem; + } + + public UnPivotQuery withFromItem(FromItem fromItem) { + setFromItem(fromItem); + return this; + } + + public ExpressionList getOnExpressions() { + return onExpressions; + } + + public void setOnExpressions(ExpressionList onExpressions) { + this.onExpressions = onExpressions; + } + + public UnPivotQuery withOnExpressions(ExpressionList onExpressions) { + setOnExpressions(onExpressions); + return this; + } + + public String getNameColumn() { + return nameColumn; + } + + public void setNameColumn(String nameColumn) { + this.nameColumn = nameColumn; + } + + public UnPivotQuery withNameColumn(String nameColumn) { + setNameColumn(nameColumn); + return this; + } + + public ExpressionList getValueColumns() { + return valueColumns; + } + + public void setValueColumns(ExpressionList valueColumns) { + this.valueColumns = valueColumns; + } + + public UnPivotQuery withValueColumns(ExpressionList valueColumns) { + setValueColumns(valueColumns); + return this; + } + + @Override + public StringBuilder appendSelectBodyTo(StringBuilder builder) { + builder.append("UNPIVOT ").append(fromItem); + if (onExpressions != null) { + builder.append(" ON ").append(onExpressions); + } + if (nameColumn != null) { + builder.append(" INTO NAME ").append(nameColumn); + if (valueColumns != null) { + builder.append(" VALUE ").append(valueColumns); + } + } + return builder; + } + + @Override + public T accept(SelectVisitor selectVisitor, S context) { + return selectVisitor.visit(this, context); + } + + @Override + public T accept(FromItemVisitor fromItemVisitor, S context) { + return fromItemVisitor.visit(this, context); + } + + @Override + public SampleClause getSampleClause() { + return null; + } + + @Override + public FromItem setSampleClause(SampleClause sampleClause) { + return null; + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 269ce17dbe..489297ce7e 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -189,6 +189,7 @@ import net.sf.jsqlparser.statement.select.ParenthesedSelect; import net.sf.jsqlparser.statement.select.Pivot; import net.sf.jsqlparser.statement.select.PivotQuery; +import net.sf.jsqlparser.statement.select.UnPivotQuery; import net.sf.jsqlparser.statement.select.PivotVisitor; import net.sf.jsqlparser.statement.select.PivotXml; import net.sf.jsqlparser.statement.select.PlainSelect; @@ -494,6 +495,20 @@ public Void visit(PivotQuery pivotQuery, S context) { return null; } + @Override + public Void visit(UnPivotQuery unPivotQuery, S context) { + visitWithItems(unPivotQuery.getWithItemsList(), context); + visitFromItem(unPivotQuery.getFromItem(), context); + visitExpressions(unPivotQuery.getOnExpressions(), context); + visitExpressions(unPivotQuery.getValueColumns(), context); + return null; + } + + @Override + public void visit(UnPivotQuery unPivotQuery) { + SelectVisitor.super.visit(unPivotQuery); + } + private void visitSelectItems(List> selectItems, S context) { if (selectItems != null) { for (SelectItem item : selectItems) { diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java index c7a4700a5d..8531a5addc 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java @@ -63,6 +63,7 @@ import net.sf.jsqlparser.statement.select.ParenthesedSelect; import net.sf.jsqlparser.statement.select.Pivot; import net.sf.jsqlparser.statement.select.PivotQuery; +import net.sf.jsqlparser.statement.select.UnPivotQuery; import net.sf.jsqlparser.statement.select.PivotVisitor; import net.sf.jsqlparser.statement.select.PivotXml; import net.sf.jsqlparser.statement.select.PlainSelect; @@ -436,6 +437,17 @@ private void deparseFromClause(PlainSelect plainSelect, S context) { } + @Override + public StringBuilder visit(UnPivotQuery unPivotQuery, S context) { + unPivotQuery.appendSelectBodyTo(builder); + return builder; + } + + @Override + public void visit(UnPivotQuery unPivotQuery) { + SelectVisitor.super.visit(unPivotQuery); + } + @Override public StringBuilder visit(PivotQuery pivotQuery, S context) { List> withItemsList = pivotQuery.getWithItemsList(); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/SelectValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/SelectValidator.java index 4961fd7cf0..a5295db776 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/SelectValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/SelectValidator.java @@ -34,6 +34,7 @@ import net.sf.jsqlparser.statement.select.ParenthesedSelect; import net.sf.jsqlparser.statement.select.Pivot; import net.sf.jsqlparser.statement.select.PivotQuery; +import net.sf.jsqlparser.statement.select.UnPivotQuery; import net.sf.jsqlparser.statement.select.PivotVisitor; import net.sf.jsqlparser.statement.select.PivotXml; import net.sf.jsqlparser.statement.select.PlainSelect; @@ -160,6 +161,14 @@ public Void visit(PlainSelect plainSelect, S context) { return null; } + @Override + public Void visit(UnPivotQuery unPivotQuery, S context) { + validateFeature(Feature.pivot); + validateOptionalFromItem(unPivotQuery.getFromItem()); + validateOptionalExpressions(unPivotQuery.getOnExpressions()); + return null; + } + @Override public Void visit(PivotQuery pivotQuery, S context) { if (isNotEmpty(pivotQuery.getWithItemsList())) { @@ -464,6 +473,11 @@ public void visit(PivotQuery pivotQuery) { visit(pivotQuery, null); } + @Override + public void visit(UnPivotQuery unPivotQuery) { + visit(unPivotQuery, null); + } + public void visit(SelectItem selectExpressionItem) { visit(selectExpressionItem, null); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 02cac04d1b..0c66df5887 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -6356,6 +6356,8 @@ Select Select() #Select: ( LOOKAHEAD(2) select = PivotQuery() | + LOOKAHEAD(2) select = UnPivotQuery() + | LOOKAHEAD(3) select = PlainSelect() | LOOKAHEAD(3) select = Values() @@ -7728,6 +7730,28 @@ List> PivotFunctionItems(): { return functionItems; } } +/** + * DuckDB's simplified UNPIVOT statement: + * UNPIVOT tbl ON columns [INTO NAME name VALUE values]. + */ +UnPivotQuery UnPivotQuery(): +{ + UnPivotQuery unPivotQuery = new UnPivotQuery(); + ExpressionList onExpressions; + ExpressionList valueColumns; + FromItem fromItem; + String nameColumn; +} +{ + fromItem=FromItem() { unPivotQuery.setFromItem(fromItem); } + onExpressions=ExpressionList() { unPivotQuery.setOnExpressions(onExpressions); } + [ + nameColumn=RelObjectName() { unPivotQuery.setNameColumn(nameColumn); } + valueColumns=ColumnList() { unPivotQuery.setValueColumns(valueColumns); } + ] + { return unPivotQuery; } +} + PivotQuery PivotQuery(): { PivotQuery pivotQuery = new PivotQuery(); 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 84257479d9..cb7c724fca 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -603,4 +603,39 @@ void testBarePipeQueryStillParses() throws JSQLParserException { String sqlStr = "FROM t |> WHERE a > 1"; TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); } + + @Test + void testUnPivotStatement() throws JSQLParserException { + String sqlStr = "UNPIVOT monthly_sales ON jan, feb INTO NAME month VALUE sales"; + UnPivotQuery unPivotQuery = + (UnPivotQuery) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertEquals("month", unPivotQuery.getNameColumn()); + Assertions.assertEquals(2, unPivotQuery.getOnExpressions().size()); + Assertions.assertEquals(1, unPivotQuery.getValueColumns().size()); + } + + @Test + void testUnPivotStatementWithoutIntoClause() throws JSQLParserException { + String sqlStr = "UNPIVOT monthly_sales ON jan, feb"; + UnPivotQuery unPivotQuery = + (UnPivotQuery) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertNull(unPivotQuery.getNameColumn()); + } + + @Test + void testUnPivotStatementTablesNamesFinder() throws JSQLParserException { + String sqlStr = "UNPIVOT ds.monthly_sales ON jan, feb INTO NAME month VALUE sales"; + + Assertions.assertEquals(java.util.Collections.singletonList("ds.monthly_sales"), + new net.sf.jsqlparser.util.TablesNamesFinder() + .getTableList(net.sf.jsqlparser.parser.CCJSqlParserUtil.parse(sqlStr))); + } + + @Test + void testUnPivotInFromClauseStillParses() throws JSQLParserException { + String sqlStr = "SELECT * FROM monthly_sales UNPIVOT (sales FOR month IN (jan, feb))"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } } From eb0415596160a1c8ef4c5f21a19a79eae22b4484 Mon Sep 17 00:00:00 2001 From: Hayssam Saleh Date: Tue, 15 Sep 2026 15:58:09 +0200 Subject: [PATCH 18/19] Support DuckDB 2.0 APPROX NEAREST similarity joins Adds JOIN t APPROX NEAREST n BY SIMILARITY expression, the vector similarity join of DuckDB 2.0. APPROX no longer binds as a table alias in that position. Also adds the lookahead the UNPIVOT statement's INTO clause needed to keep the grammar free of choice conflicts. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqinwHBKuAtPEmtXr3b5P2 --- .../sf/jsqlparser/statement/select/Join.java | 37 +++++++++++++++++++ .../util/deparser/SelectDeParser.java | 6 +++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 19 +++++++++- .../statement/select/DuckDBTest.java | 27 ++++++++++++++ 4 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/main/java/net/sf/jsqlparser/statement/select/Join.java b/src/main/java/net/sf/jsqlparser/statement/select/Join.java index 935c5ae38f..d06a5df1f8 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/Join.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/Join.java @@ -37,6 +37,8 @@ public class Join extends ASTNodeAccessImpl { private boolean cross = false; private boolean semi = false; private boolean anti = false; + private Long approxNearest; + private Expression similarity; private boolean any = false; private boolean all = false; private boolean straight = false; @@ -195,6 +197,36 @@ public Join withFetch(boolean b) { * * @return true if is a "SEMI" join */ + /** + * DuckDB 2.0's similarity join: + * {@code JOIN products APPROX NEAREST 2 BY SIMILARITY array_cosine_similarity(...)}. + */ + public Long getApproxNearest() { + return approxNearest; + } + + public void setApproxNearest(Long approxNearest) { + this.approxNearest = approxNearest; + } + + public Join withApproxNearest(Long approxNearest) { + setApproxNearest(approxNearest); + return this; + } + + public Expression getSimilarity() { + return similarity; + } + + public void setSimilarity(Expression similarity) { + this.similarity = similarity; + } + + public Join withSimilarity(Expression similarity) { + setSimilarity(similarity); + return this; + } + public boolean isAnti() { return anti; } @@ -577,6 +609,11 @@ public String toString() { builder.append(fromItem).append((joinWindow != null) ? " WITHIN " + joinWindow : ""); } + if (approxNearest != null) { + builder.append(" APPROX NEAREST ").append(approxNearest) + .append(" BY SIMILARITY ").append(similarity); + } + for (Expression onExpression : onExpressions) { builder.append(" ON ").append(onExpression); } diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java index 8531a5addc..da0f5fcec5 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java @@ -773,6 +773,12 @@ public void deparseJoin(Join join) { builder.append(" WITHIN "); builder.append(join.getJoinWindow().toString()); } + if (join.getApproxNearest() != null) { + builder.append(" APPROX NEAREST ").append(join.getApproxNearest()) + .append(" BY SIMILARITY "); + join.getSimilarity().accept(expressionVisitor, null); + } + for (Expression onExpression : join.getOnExpressions()) { builder.append(" ON "); onExpression.accept(expressionVisitor, null); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 0c66df5887..c33efd7807 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1273,6 +1273,11 @@ public class CCJSqlParser extends AbstractJSqlParser { || "POSITION".equalsIgnoreCase(getToken(2).image)); } + /** DuckDB 2.0's APPROX NEAREST similarity join, never a table alias. */ + private boolean isApproxNearestAhead() { + return getToken(1).kind == K_APPROX && getToken(2).kind == K_NEAREST; + } + /** SEMI/ANTI stay contextual identifiers unless they prefix a JOIN. */ private boolean isSemiAntiJoinAhead() { int kind = getToken(1).kind; @@ -1311,7 +1316,7 @@ public class CCJSqlParser extends AbstractJSqlParser { * reserved keywords. */ private boolean isAliasAhead() { - if (isAsOfJoinAhead() || isSemiAntiJoinAhead()) { + if (isAsOfJoinAhead() || isSemiAntiJoinAhead() || isApproxNearestAhead()) { return false; } Token t = getToken(1); @@ -2008,6 +2013,7 @@ String NonReservedWord() : | tk= | tk= | tk= + | tk= | tk= | tk= | tk= @@ -2216,6 +2222,7 @@ String NonReservedWord() : | tk= | tk= | tk= + | tk= | tk= | tk= | tk= @@ -2321,6 +2328,7 @@ String NonReservedWord() : | tk= | tk= | tk= + | tk= | tk= | tk= | tk= @@ -7745,7 +7753,9 @@ UnPivotQuery UnPivotQuery(): { fromItem=FromItem() { unPivotQuery.setFromItem(fromItem); } onExpressions=ExpressionList() { unPivotQuery.setOnExpressions(onExpressions); } + // greedy: INTO after the ON list belongs to this UNPIVOT [ + LOOKAHEAD(2) nameColumn=RelObjectName() { unPivotQuery.setNameColumn(nameColumn); } valueColumns=ColumnList() { unPivotQuery.setValueColumns(valueColumns); } ] @@ -8234,6 +8244,7 @@ Join JoinerExpression() #JoinerExpression: List columns = null; KSQLJoinWindow joinWindow = null; JoinHint joinHint = null; + Token tk = null; } { [ LOOKAHEAD({ isAsOfJoinAhead() }) { join.setAsOf(true); } ] @@ -8289,6 +8300,12 @@ Join JoinerExpression() #JoinerExpression: right=FromItem() + // DuckDB 2.0: JOIN products APPROX NEAREST 2 BY SIMILARITY array_cosine_similarity(...) + [ + LOOKAHEAD({ isApproxNearestAhead() }) + tk= { join.setApproxNearest(Long.valueOf(tk.image)); } + onExpression=Expression() { join.setSimilarity(onExpression); } + ] [ LOOKAHEAD(2) ( [ joinWindow = KSQLJoinWindowClause() {join.setJoinWindow(joinWindow);} ] 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 cb7c724fca..4a43b1a444 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -638,4 +638,31 @@ void testUnPivotInFromClauseStillParses() throws JSQLParserException { String sqlStr = "SELECT * FROM monthly_sales UNPIVOT (sales FOR month IN (jan, feb))"; TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); } + + @Test + void testApproxNearestJoin() throws JSQLParserException { + String sqlStr = "SELECT * FROM queries q INNER JOIN products t " + + "APPROX NEAREST 2 BY SIMILARITY array_cosine_similarity(q.vec, t.vec)"; + PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + Join join = select.getJoins().get(0); + + Assertions.assertEquals(2L, join.getApproxNearest()); + Assertions.assertEquals("array_cosine_similarity(q.vec, t.vec)", + join.getSimilarity().toString()); + } + + @Test + void testApproxNearestJoinWithoutAlias() throws JSQLParserException { + String sqlStr = "SELECT * FROM queries JOIN products " + + "APPROX NEAREST 5 BY SIMILARITY array_distance(queries.vec, products.vec)"; + PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertNull(((Table) select.getJoins().get(0).getFromItem()).getAlias()); + } + + @Test + void testApproxRemainsUsableAsIdentifier() throws JSQLParserException { + String sqlStr = "SELECT approx, nearest, similarity FROM t"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } } From c04286da53ef5a6b0d29d819383fff6720232156 Mon Sep 17 00:00:00 2001 From: Hayssam Saleh Date: Tue, 15 Sep 2026 16:00:38 +0200 Subject: [PATCH 19/19] Support DuckDB 2.0 recursive CTEs with USING KEY Adds WITH RECURSIVE tbl (a, b) USING KEY (a, avg(b)) AS (...), which aggregates a recursive CTE by key instead of accumulating every row. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01EqinwHBKuAtPEmtXr3b5P2 --- .../jsqlparser/statement/select/WithItem.java | 22 +++++++++++++++++ .../util/deparser/DmlDeParserSupport.java | 3 +++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 4 ++++ .../statement/select/DuckDBTest.java | 24 +++++++++++++++++++ 4 files changed, 53 insertions(+) diff --git a/src/main/java/net/sf/jsqlparser/statement/select/WithItem.java b/src/main/java/net/sf/jsqlparser/statement/select/WithItem.java index 767f96846a..49fcff0cb9 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/WithItem.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/WithItem.java @@ -11,6 +11,7 @@ import java.util.function.Consumer; import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; import java.io.Serializable; import java.util.ArrayList; @@ -35,6 +36,7 @@ public class WithItem implements Serializable { private boolean recursive = false; private boolean usingNot = false; private boolean materialized = false; + private ExpressionList usingKeyExpressions; public WithItem(K statement, Alias alias) { this.statement = statement; @@ -166,6 +168,23 @@ public WithItem withCycleClause(WithCycleClause cycleClause) { return this; } + /** + * DuckDB 2.0 aggregates a recursive CTE by key: + * {@code WITH RECURSIVE tbl (a, b) USING KEY (a, avg(b)) AS (...)}. + */ + public ExpressionList getUsingKeyExpressions() { + return usingKeyExpressions; + } + + public void setUsingKeyExpressions(ExpressionList usingKeyExpressions) { + this.usingKeyExpressions = usingKeyExpressions; + } + + public WithItem withUsingKeyExpressions(ExpressionList usingKeyExpressions) { + setUsingKeyExpressions(usingKeyExpressions); + return this; + } + public StringBuilder appendRecursiveClausesTo(StringBuilder builder, Consumer expressionPrinter) { if (searchClause != null) { @@ -197,6 +216,9 @@ public String toString() { } builder.append(")"); } + if (usingKeyExpressions != null) { + builder.append(" USING KEY (").append(usingKeyExpressions).append(")"); + } builder.append(" AS "); if (materialized) { builder.append(usingNot diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/DmlDeParserSupport.java b/src/main/java/net/sf/jsqlparser/util/deparser/DmlDeParserSupport.java index 9231d2b0e8..e11b265864 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/DmlDeParserSupport.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/DmlDeParserSupport.java @@ -93,6 +93,9 @@ StringBuilder deparseWithItem(WithItem item, S context) { builder.append(' ') .append(PlainSelect.getStringList(item.getWithItemList(), true, true)); } + if (item.getUsingKeyExpressions() != null) { + builder.append(" USING KEY (").append(item.getUsingKeyExpressions()).append(")"); + } builder.append(" AS "); if (item.isMaterialized()) { builder.append(item.isUsingNot() ? "NOT MATERIALIZED " : "MATERIALIZED "); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index c33efd7807..1d4e9cea85 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -7374,6 +7374,7 @@ WithItem WithItem() #WithItem: ParenthesedStatement statement = null; WithSearchClause withSearchClause = null; WithCycleClause withCycleClause = null; + ExpressionList usingKeyExpressions = null; WithItem withItem; } { @@ -7388,6 +7389,8 @@ WithItem WithItem() #WithItem: [ LOOKAHEAD(2) { recursive = true; } ] name=RelObjectName() [ "(" selectItems=SelectItemsList() ")" ] + // DuckDB 2.0: WITH RECURSIVE tbl (a, b) USING KEY (a, avg(b)) AS (...) + [ LOOKAHEAD(2) "(" usingKeyExpressions=ExpressionList() ")" ] [ LOOKAHEAD(2) [ { usingNot = true; } ] { materialized = true; } ] ( @@ -7403,6 +7406,7 @@ WithItem WithItem() #WithItem: withItem = new WithItem(statement, new Alias(name, false)) .withRecursive(recursive, usingNot, materialized) .withWithItemList(selectItems); + withItem.setUsingKeyExpressions(usingKeyExpressions); } ) ) 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 4a43b1a444..68e98f1019 100644 --- a/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java @@ -665,4 +665,28 @@ void testApproxRemainsUsableAsIdentifier() throws JSQLParserException { String sqlStr = "SELECT approx, nearest, similarity FROM t"; TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); } + + @Test + void testRecursiveCteUsingKey() throws JSQLParserException { + String sqlStr = "WITH RECURSIVE tbl (a, b) USING KEY (a, avg(b)) AS " + + "(SELECT 1, 2 UNION ALL SELECT a + 1, b FROM tbl WHERE a < 3) SELECT * FROM tbl"; + PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + WithItem withItem = select.getWithItemsList().get(0); + + Assertions.assertEquals(2, withItem.getUsingKeyExpressions().size()); + } + + @Test + void testRecursiveCteUsingKeySingleColumn() throws JSQLParserException { + String sqlStr = "WITH RECURSIVE tbl USING KEY (a) AS (SELECT 1 AS a) SELECT * FROM tbl"; + TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + } + + @Test + void testRecursiveCteWithoutUsingKey() throws JSQLParserException { + String sqlStr = "WITH RECURSIVE tbl (a) AS (SELECT 1) SELECT * FROM tbl"; + PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true); + + Assertions.assertNull(select.getWithItemsList().get(0).getUsingKeyExpressions()); + } }