Skip to content

Commit 6860852

Browse files
authored
Preserve ASOF joins through shared join rendering (#2621)
1 parent 73bd3c4 commit 6860852

7 files changed

Lines changed: 199 additions & 64 deletions

File tree

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,9 @@ public enum Feature {
195195
*/
196196
joinApply,
197197

198+
/** Nearest-match ASOF join. */
199+
joinAsOf,
200+
198201
joinWindow, joinUsingColumns,
199202

200203
/**

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

Lines changed: 57 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ public class Join extends ASTNodeAccessImpl {
2525

2626
private final LinkedList<Expression> onExpressions = new LinkedList<>();
2727
private final LinkedList<Column> usingColumns = new LinkedList<>();
28+
private boolean asOf = false;
2829
private boolean outer = false;
2930
private boolean right = false;
3031
private boolean left = false;
@@ -46,6 +47,20 @@ public class Join extends ASTNodeAccessImpl {
4647

4748
private JoinHint joinHint = null;
4849

50+
/** Whether the join uses nearest-match ASOF semantics. */
51+
public boolean isAsOf() {
52+
return asOf;
53+
}
54+
55+
public void setAsOf(boolean asOf) {
56+
this.asOf = asOf;
57+
}
58+
59+
public Join withAsOf(boolean asOf) {
60+
setAsOf(asOf);
61+
return this;
62+
}
63+
4964
public boolean isSimple() {
5065
return simple;
5166
}
@@ -464,6 +479,47 @@ public Join setJoinHint(JoinHint joinHint) {
464479
return this;
465480
}
466481

482+
/** Appends the join modifiers shared by model rendering and expression deparsers. */
483+
@SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.NPathComplexity"})
484+
public StringBuilder appendJoinTypeTo(StringBuilder builder) {
485+
if (isAsOf()) {
486+
builder.append("ASOF ");
487+
}
488+
if (isNatural()) {
489+
builder.append("NATURAL ");
490+
}
491+
492+
if (isAny()) {
493+
builder.append("ANY ");
494+
} else if (isAll()) {
495+
builder.append("ALL ");
496+
}
497+
498+
if (isRight()) {
499+
builder.append("RIGHT ");
500+
} else if (isFull()) {
501+
builder.append("FULL ");
502+
} else if (isLeft()) {
503+
builder.append("LEFT ");
504+
} else if (isCross()) {
505+
builder.append("CROSS ");
506+
}
507+
508+
if (isOuter()) {
509+
builder.append("OUTER ");
510+
} else if (isInner()) {
511+
builder.append("INNER ");
512+
} else if (isSemi()) {
513+
builder.append("SEMI ");
514+
}
515+
516+
if (isArray()) {
517+
builder.append("ARRAY ");
518+
}
519+
520+
return builder;
521+
}
522+
467523
/** Appends the join keyword, hint and FETCH modifier, followed by a space. */
468524
public StringBuilder appendJoinKeywordTo(StringBuilder builder) {
469525
if (isStraight()) {
@@ -499,38 +555,7 @@ public String toString() {
499555
} else if (isSimple()) {
500556
builder.append(fromItem);
501557
} else {
502-
if (isNatural()) {
503-
builder.append("NATURAL ");
504-
}
505-
506-
if (isAny()) {
507-
builder.append("ANY ");
508-
} else if (isAll()) {
509-
builder.append("ALL ");
510-
}
511-
512-
if (isRight()) {
513-
builder.append("RIGHT ");
514-
} else if (isFull()) {
515-
builder.append("FULL ");
516-
} else if (isLeft()) {
517-
builder.append("LEFT ");
518-
} else if (isCross()) {
519-
builder.append("CROSS ");
520-
}
521-
522-
if (isOuter()) {
523-
builder.append("OUTER ");
524-
} else if (isInner()) {
525-
builder.append("INNER ");
526-
} else if (isSemi()) {
527-
builder.append("SEMI ");
528-
}
529-
530-
if (isArray()) {
531-
builder.append("ARRAY ");
532-
}
533-
558+
appendJoinTypeTo(builder);
534559
appendJoinKeywordTo(builder);
535560

536561
builder.append(fromItem).append((joinWindow != null) ? " WITHIN " + joinWindow : "");

‎src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java‎

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -732,39 +732,8 @@ public void deparseJoin(Join join) {
732732
builder.append(", ");
733733
} else {
734734

735-
if (join.isNatural()) {
736-
builder.append(" NATURAL");
737-
}
738-
739-
if (join.isAny()) {
740-
builder.append(" ANY");
741-
} else if (join.isAll()) {
742-
builder.append(" ALL");
743-
}
744-
745-
if (join.isRight()) {
746-
builder.append(" RIGHT");
747-
} else if (join.isFull()) {
748-
builder.append(" FULL");
749-
} else if (join.isLeft()) {
750-
builder.append(" LEFT");
751-
} else if (join.isCross()) {
752-
builder.append(" CROSS");
753-
}
754-
755-
if (join.isOuter()) {
756-
builder.append(" OUTER");
757-
} else if (join.isInner()) {
758-
builder.append(" INNER");
759-
} else if (join.isSemi()) {
760-
builder.append(" SEMI");
761-
}
762-
763-
if (join.isArray()) {
764-
builder.append(" ARRAY");
765-
}
766-
767735
builder.append(' ');
736+
join.appendJoinTypeTo(builder);
768737
join.appendJoinKeywordTo(builder);
769738

770739
}

‎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
@@ -62,6 +62,7 @@ public class FeaturesAllowed implements FeatureSetValidation, ModifyableFeatureS
6262
Feature.joinInner,
6363
Feature.joinStraight,
6464
Feature.joinApply,
65+
Feature.joinAsOf,
6566
Feature.joinWindow,
6667
Feature.joinUsingColumns,
6768

‎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
@@ -330,6 +330,7 @@ public void validateOptionalJoin(Join join) {
330330
validateFeature(c, join.isSemi(), Feature.joinSemi);
331331
validateFeature(c, join.isStraight(), Feature.joinStraight);
332332
validateFeature(c, join.isApply(), Feature.joinApply);
333+
validateFeature(c, join.isAsOf(), Feature.joinAsOf);
333334
validateFeature(c, join.isWindowJoin(), Feature.joinWindow);
334335
validateOptionalFeature(c, join.getUsingColumns(), Feature.joinUsingColumns);
335336
}

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1209,6 +1209,24 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
12091209
return isReservedKeywordSafeByFollower();
12101210
}
12111211

1212+
/** ASOF stays a contextual identifier outside a join prefix. */
1213+
private boolean isAsOfJoinAhead() {
1214+
if (!isKeywordAhead("ASOF")) {
1215+
return false;
1216+
}
1217+
int offset = 2;
1218+
int kind = getToken(offset).kind;
1219+
if (kind == K_LEFT || kind == K_RIGHT || kind == K_FULL) {
1220+
offset++;
1221+
if (getToken(offset).kind == K_OUTER) {
1222+
offset++;
1223+
}
1224+
} else if (kind == K_INNER) {
1225+
offset++;
1226+
}
1227+
return getToken(offset).kind == K_JOIN;
1228+
}
1229+
12121230
/**
12131231
* Checks whether the next token(s) can plausibly start an {@code Alias}.
12141232
*
@@ -1220,6 +1238,9 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
12201238
* reserved keywords.
12211239
*/
12221240
private boolean isAliasAhead() {
1241+
if (isAsOfJoinAhead()) {
1242+
return false;
1243+
}
12231244
Token t = getToken(1);
12241245
int kind = t.kind;
12251246

@@ -7684,6 +7705,7 @@ Join JoinerExpression() #JoinerExpression:
76847705
JoinHint joinHint = null;
76857706
}
76867707
{
7708+
[ LOOKAHEAD({ isAsOfJoinAhead() }) <S_IDENTIFIER> { join.setAsOf(true); } ]
76877709
[ <K_GLOBAL> { join.setGlobal(true); } ]
76887710
[ <K_ANY> { join.setAny(true); } | <K_ALL> { join.setAll(true); } ]
76897711
[ <K_NATURAL> { join.setNatural(true); } ]
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
/*-
2+
* #%L
3+
* JSQLParser library
4+
* %%
5+
* Copyright (C) 2004 - 2026 JSQLParser
6+
* %%
7+
* Dual licensed under GNU LGPL 2.1 or Apache License 2.0
8+
* #L%
9+
*/
10+
package net.sf.jsqlparser.statement.select;
11+
12+
import static org.junit.jupiter.api.Assertions.assertEquals;
13+
import static org.junit.jupiter.api.Assertions.assertFalse;
14+
import static org.junit.jupiter.api.Assertions.assertTrue;
15+
import java.util.Set;
16+
import net.sf.jsqlparser.expression.LongValue;
17+
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
18+
import net.sf.jsqlparser.parser.feature.Feature;
19+
import net.sf.jsqlparser.test.TestUtils;
20+
import net.sf.jsqlparser.util.TablesNamesFinder;
21+
import net.sf.jsqlparser.util.deparser.ExpressionDeParser;
22+
import net.sf.jsqlparser.util.deparser.SelectDeParser;
23+
import net.sf.jsqlparser.util.validation.ValidationTestAsserts;
24+
import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed;
25+
import org.junit.jupiter.api.Test;
26+
import org.junit.jupiter.params.ParameterizedTest;
27+
import org.junit.jupiter.params.provider.ValueSource;
28+
29+
class AsOfJoinTest extends ValidationTestAsserts {
30+
@ParameterizedTest
31+
@ValueSource(strings = {"ASOF JOIN", "ASOF INNER JOIN", "ASOF LEFT JOIN",
32+
"ASOF LEFT OUTER JOIN", "ASOF RIGHT JOIN", "ASOF FULL OUTER JOIN"})
33+
void preservesAsOfAndOrdinaryJoinQualifiers(String joinType) throws Exception {
34+
String sql = "SELECT * FROM trades t " + joinType
35+
+ " prices p ON t.symbol = p.symbol AND t.ts >= p.ts";
36+
PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sql);
37+
Join join = select.getJoins().get(0);
38+
assertTrue(join.isAsOf());
39+
assertEquals(joinType.contains("LEFT"), join.isLeft());
40+
assertEquals(joinType.contains("OUTER"), join.isOuter());
41+
assertTrue(((PlainSelect) CCJSqlParserUtil.parse(select.toString())).getJoins().get(0)
42+
.isAsOf());
43+
assertEquals(Set.of("trades", "prices"), TablesNamesFinder.findTables(sql));
44+
}
45+
46+
@ParameterizedTest
47+
@ValueSource(strings = {
48+
"SELECT * FROM trades ASOF JOIN prices USING (symbol, ts)",
49+
"SELECT * FROM trades ASOF /* nearest */ LEFT JOIN prices USING (symbol, ts)",
50+
"SELECT * FROM (SELECT * FROM trades) ASOF JOIN prices ON trades.ts >= prices.ts",
51+
"SELECT * FROM trades t ASOF JOIN prices p ON t.ts >= p.ts JOIN symbols s ON s.id = t.id",
52+
"FROM trades t |> ASOF JOIN prices p ON t.ts >= p.ts |> SELECT *"})
53+
void worksWithoutAliasesAndWithinExistingJoinPaths(String sql) throws Exception {
54+
TestUtils.assertSqlCanBeParsedAndDeparsed(sql, true);
55+
}
56+
57+
@Test
58+
void keepsUsingColumnsStructuredAndAllowsChangingTheJoinKind() throws Exception {
59+
PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse(
60+
"SELECT * FROM trades ASOF LEFT JOIN prices USING (symbol, ts)");
61+
Join join = select.getJoins().get(0);
62+
assertEquals("symbol", join.getUsingColumns().get(0).getColumnName());
63+
assertEquals("ts", join.getUsingColumns().get(1).getColumnName());
64+
join.setAsOf(false);
65+
assertEquals("SELECT * FROM trades LEFT JOIN prices USING (symbol, ts)", select.toString());
66+
assertTrue(join.withAsOf(true).isAsOf());
67+
}
68+
69+
@Test
70+
void visitsPredicatesThroughTheConfiguredDeparser() throws Exception {
71+
PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse(
72+
"SELECT * FROM trades t ASOF LEFT JOIN prices p ON t.ts >= p.ts + 1");
73+
StringBuilder output = new StringBuilder();
74+
int[] visits = {0};
75+
ExpressionDeParser expressions = new ExpressionDeParser(null, output) {
76+
@Override
77+
public <S> StringBuilder visit(LongValue value, S context) {
78+
visits[0]++;
79+
return getBuilder().append(value.getValue() + 100);
80+
}
81+
};
82+
SelectDeParser deparser = new SelectDeParser(expressions, output);
83+
expressions.setSelectVisitor(deparser);
84+
select.accept((SelectVisitor<StringBuilder>) deparser, null);
85+
assertEquals("SELECT * FROM trades t ASOF LEFT JOIN prices p ON t.ts >= p.ts + 101",
86+
output.toString());
87+
assertEquals(1, visits[0]);
88+
assertTrue(select.toString().endsWith("p.ts + 1"));
89+
}
90+
91+
@ParameterizedTest
92+
@ValueSource(strings = {"SELECT asof FROM trades", "SELECT * FROM asof",
93+
"SELECT * FROM trades asof WHERE asof.ts > 0",
94+
"SELECT * FROM trades AS asof JOIN prices p ON asof.ts = p.ts",
95+
"SELECT * FROM trades \"ASOF\" JOIN prices p ON \"ASOF\".ts = p.ts"})
96+
void keepsAsOfUsableAsAnIdentifier(String sql) throws Exception {
97+
PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sql);
98+
if (select.getJoins() != null) {
99+
assertFalse(select.getJoins().get(0).isAsOf());
100+
}
101+
}
102+
103+
@Test
104+
void validatesTheAsOfFeatureAndPredicateExpressions() throws Exception {
105+
String sql = "SELECT * FROM trades t ASOF JOIN prices p ON t.ts >= p.ts";
106+
validateNoErrors(sql, 1, FeaturesAllowed.SELECT);
107+
validateNotAllowed(sql, 1, 1,
108+
new FeaturesAllowed().add(FeaturesAllowed.SELECT).remove(Feature.joinAsOf),
109+
Feature.joinAsOf);
110+
validateNotAllowed(sql + " + ?", 1, 1,
111+
new FeaturesAllowed().add(FeaturesAllowed.SELECT).remove(Feature.jdbcParameter),
112+
Feature.jdbcParameter);
113+
}
114+
}

0 commit comments

Comments
 (0)