Skip to content

Commit 50a25f0

Browse files
hayssamsclaude
andcommitted
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqinwHBKuAtPEmtXr3b5P2
1 parent d9b97b9 commit 50a25f0

6 files changed

Lines changed: 80 additions & 5 deletions

File tree

‎src/main/java/net/sf/jsqlparser/parser/feature/Feature.java‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,10 @@ public enum Feature {
182182
* "SEMI" join
183183
*/
184184
joinSemi,
185+
/**
186+
* @see net.sf.jsqlparser.statement.select.Join#isAnti()
187+
*/
188+
joinAnti,
185189
/**
186190
* "INNER" join
187191
*/

‎src/main/java/net/sf/jsqlparser/statement/select/Join.java‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ public class Join extends ASTNodeAccessImpl {
3636
private boolean simple = false;
3737
private boolean cross = false;
3838
private boolean semi = false;
39+
private boolean anti = false;
3940
private boolean any = false;
4041
private boolean all = false;
4142
private boolean straight = false;
@@ -194,6 +195,19 @@ public Join withFetch(boolean b) {
194195
*
195196
* @return true if is a "SEMI" join
196197
*/
198+
public boolean isAnti() {
199+
return anti;
200+
}
201+
202+
public void setAnti(boolean b) {
203+
anti = b;
204+
}
205+
206+
public Join withAnti(boolean b) {
207+
this.setAnti(b);
208+
return this;
209+
}
210+
197211
public boolean isSemi() {
198212
return semi;
199213
}
@@ -511,6 +525,8 @@ public StringBuilder appendJoinTypeTo(StringBuilder builder) {
511525
builder.append("INNER ");
512526
} else if (isSemi()) {
513527
builder.append("SEMI ");
528+
} else if (isAnti()) {
529+
builder.append("ANTI ");
514530
}
515531

516532
if (isArray()) {

‎src/main/java/net/sf/jsqlparser/util/validation/feature/FeaturesAllowed.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ public class FeaturesAllowed implements FeatureSetValidation, ModifyableFeatureS
5959
Feature.joinCross,
6060
Feature.joinOuter,
6161
Feature.joinSemi,
62+
Feature.joinAnti,
6263
Feature.joinInner,
6364
Feature.joinStraight,
6465
Feature.joinApply,

‎src/main/java/net/sf/jsqlparser/util/validation/validator/SelectValidator.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,7 @@ public void validateOptionalJoin(Join join) {
328328
validateFeature(c, join.isOuter(), Feature.joinOuter);
329329
validateFeature(c, join.isInner(), Feature.joinInner);
330330
validateFeature(c, join.isSemi(), Feature.joinSemi);
331+
validateFeature(c, join.isAnti(), Feature.joinAnti);
331332
validateFeature(c, join.isStraight(), Feature.joinStraight);
332333
validateFeature(c, join.isApply(), Feature.joinApply);
333334
validateFeature(c, join.isAsOf(), Feature.joinAsOf);

‎src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1063,9 +1063,11 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
10631063
case K_CONNECT: return nextKind != K_BY;
10641064
case K_START: return nextKind != K_WITH;
10651065
case K_LEFT: return nextKind != K_JOIN && nextKind != K_OUTER
1066-
&& nextKind != K_SEMI && nextKind != K_ARRAY_LITERAL;
1066+
&& nextKind != K_SEMI && nextKind != K_ANTI
1067+
&& nextKind != K_ARRAY_LITERAL;
10671068
case K_RIGHT: return nextKind != K_JOIN && nextKind != K_OUTER
1068-
&& nextKind != K_SEMI && nextKind != K_ARRAY_LITERAL;
1069+
&& nextKind != K_SEMI && nextKind != K_ANTI
1070+
&& nextKind != K_ARRAY_LITERAL;
10691071
case K_ARRAY_LITERAL:
10701072
return nextKind != K_JOIN;
10711073
case K_ALL: return nextKind != K_JOIN;
@@ -1209,6 +1211,15 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
12091211
return isReservedKeywordSafeByFollower();
12101212
}
12111213

1214+
/** SEMI/ANTI stay contextual identifiers unless they prefix a JOIN. */
1215+
private boolean isSemiAntiJoinAhead() {
1216+
int kind = getToken(1).kind;
1217+
if (kind != K_SEMI && kind != K_ANTI) {
1218+
return false;
1219+
}
1220+
return getToken(2).kind == K_JOIN;
1221+
}
1222+
12121223
/** ASOF stays a contextual identifier outside a join prefix. */
12131224
private boolean isAsOfJoinAhead() {
12141225
if (!isKeywordAhead("ASOF")) {
@@ -1238,7 +1249,7 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
12381249
* reserved keywords.
12391250
*/
12401251
private boolean isAliasAhead() {
1241-
if (isAsOfJoinAhead()) {
1252+
if (isAsOfJoinAhead() || isSemiAntiJoinAhead()) {
12421253
return false;
12431254
}
12441255
Token t = getToken(1);
@@ -1930,6 +1941,7 @@ String NonReservedWord() :
19301941
| tk=<K_ALWAYS:"ALWAYS">
19311942
| tk=<K_ANALYSE:"ANALYSE">
19321943
| tk=<K_ANALYZE:"ANALYZE">
1944+
| tk=<K_ANTI:"ANTI">
19331945
| tk=<K_APPEND_ONLY:"APPEND_ONLY">
19341946
| tk=<K_APPLY:"APPLY">
19351947
| tk=<K_APPROXIMATE:"APPROXIMATE">
@@ -7735,15 +7747,19 @@ Join JoinerExpression() #JoinerExpression:
77357747

77367748
[
77377749
(
7738-
<K_LEFT> { join.setLeft(true); } [ <K_SEMI> { join.setSemi(true); } | <K_OUTER> { join.setOuter(true); } | <K_ANY> { join.setAny(true); } | <K_ALL> { join.setAll(true); } ]
7750+
<K_LEFT> { join.setLeft(true); } [ <K_SEMI> { join.setSemi(true); } | <K_ANTI> { join.setAnti(true); } | <K_OUTER> { join.setOuter(true); } | <K_ANY> { join.setAny(true); } | <K_ALL> { join.setAll(true); } ]
77397751
|
77407752
(
77417753
<K_RIGHT> { join.setRight(true); }
77427754
|
77437755
<K_FULL> { join.setFull(true); }
7744-
) [ <K_OUTER> { join.setOuter(true); } | <K_ANY> { join.setAny(true); } | <K_ALL> { join.setAll(true); } ]
7756+
) [ <K_SEMI> { join.setSemi(true); } | <K_ANTI> { join.setAnti(true); } | <K_OUTER> { join.setOuter(true); } | <K_ANY> { join.setAny(true); } | <K_ALL> { join.setAll(true); } ]
77457757
|
77467758
<K_INNER> { join.setInner(true); }
7759+
|
7760+
// DuckDB allows SEMI/ANTI without a LEFT prefix
7761+
LOOKAHEAD({ isSemiAntiJoinAhead() })
7762+
( <K_SEMI> { join.setSemi(true); } | <K_ANTI> { join.setAnti(true); } )
77477763
)
77487764
|
77497765
<K_CROSS> { join.setCross(true); }

‎src/test/java/net/sf/jsqlparser/statement/select/DuckDBTest.java‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,4 +54,41 @@ void testGlobRemainsUsableAsIdentifier() throws JSQLParserException {
5454
String sqlStr = "SELECT glob FROM t";
5555
TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true);
5656
}
57+
58+
@Test
59+
void testSemiJoinWithoutLeft() throws JSQLParserException {
60+
String sqlStr = "SELECT * FROM t SEMI JOIN u USING (id)";
61+
PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true);
62+
Join join = select.getJoins().get(0);
63+
64+
Assertions.assertTrue(join.isSemi());
65+
Assertions.assertFalse(join.isLeft());
66+
Assertions.assertNull(((Table) select.getFromItem()).getAlias());
67+
}
68+
69+
@Test
70+
void testAntiJoinWithoutLeft() throws JSQLParserException {
71+
String sqlStr = "SELECT * FROM t ANTI JOIN u ON t.id = u.id";
72+
PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true);
73+
Join join = select.getJoins().get(0);
74+
75+
Assertions.assertTrue(join.isAnti());
76+
Assertions.assertNull(((Table) select.getFromItem()).getAlias());
77+
}
78+
79+
@Test
80+
void testLeftAntiJoin() throws JSQLParserException {
81+
String sqlStr = "SELECT * FROM t LEFT ANTI JOIN u ON t.id = u.id";
82+
PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true);
83+
Join join = select.getJoins().get(0);
84+
85+
Assertions.assertTrue(join.isLeft());
86+
Assertions.assertTrue(join.isAnti());
87+
}
88+
89+
@Test
90+
void testAntiRemainsUsableAsIdentifier() throws JSQLParserException {
91+
String sqlStr = "SELECT anti FROM t anti";
92+
TestUtils.assertSqlCanBeParsedAndDeparsed(sqlStr, true);
93+
}
5794
}

0 commit comments

Comments
 (0)