Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions src/main/java/net/sf/jsqlparser/parser/feature/Feature.java
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,9 @@ public enum Feature {
*/
joinApply,

/** Nearest-match ASOF join. */
joinAsOf,

joinWindow, joinUsingColumns,

/**
Expand Down
89 changes: 57 additions & 32 deletions src/main/java/net/sf/jsqlparser/statement/select/Join.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public class Join extends ASTNodeAccessImpl {

private final LinkedList<Expression> onExpressions = new LinkedList<>();
private final LinkedList<Column> usingColumns = new LinkedList<>();
private boolean asOf = false;
private boolean outer = false;
private boolean right = false;
private boolean left = false;
Expand All @@ -46,6 +47,20 @@ public class Join extends ASTNodeAccessImpl {

private JoinHint joinHint = null;

/** Whether the join uses nearest-match ASOF semantics. */
public boolean isAsOf() {
return asOf;
}

public void setAsOf(boolean asOf) {
this.asOf = asOf;
}

public Join withAsOf(boolean asOf) {
setAsOf(asOf);
return this;
}

public boolean isSimple() {
return simple;
}
Expand Down Expand Up @@ -464,6 +479,47 @@ public Join setJoinHint(JoinHint joinHint) {
return this;
}

/** Appends the join modifiers shared by model rendering and expression deparsers. */
@SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.NPathComplexity"})
public StringBuilder appendJoinTypeTo(StringBuilder builder) {
if (isAsOf()) {
builder.append("ASOF ");
}
if (isNatural()) {
builder.append("NATURAL ");
}

if (isAny()) {
builder.append("ANY ");
} else if (isAll()) {
builder.append("ALL ");
}

if (isRight()) {
builder.append("RIGHT ");
} else if (isFull()) {
builder.append("FULL ");
} else if (isLeft()) {
builder.append("LEFT ");
} else if (isCross()) {
builder.append("CROSS ");
}

if (isOuter()) {
builder.append("OUTER ");
} else if (isInner()) {
builder.append("INNER ");
} else if (isSemi()) {
builder.append("SEMI ");
}

if (isArray()) {
builder.append("ARRAY ");
}

return builder;
}

/** Appends the join keyword, hint and FETCH modifier, followed by a space. */
public StringBuilder appendJoinKeywordTo(StringBuilder builder) {
if (isStraight()) {
Expand Down Expand Up @@ -499,38 +555,7 @@ public String toString() {
} else if (isSimple()) {
builder.append(fromItem);
} else {
if (isNatural()) {
builder.append("NATURAL ");
}

if (isAny()) {
builder.append("ANY ");
} else if (isAll()) {
builder.append("ALL ");
}

if (isRight()) {
builder.append("RIGHT ");
} else if (isFull()) {
builder.append("FULL ");
} else if (isLeft()) {
builder.append("LEFT ");
} else if (isCross()) {
builder.append("CROSS ");
}

if (isOuter()) {
builder.append("OUTER ");
} else if (isInner()) {
builder.append("INNER ");
} else if (isSemi()) {
builder.append("SEMI ");
}

if (isArray()) {
builder.append("ARRAY ");
}

appendJoinTypeTo(builder);
appendJoinKeywordTo(builder);

builder.append(fromItem).append((joinWindow != null) ? " WITHIN " + joinWindow : "");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -733,39 +733,8 @@ public void deparseJoin(Join join) {
builder.append(", ");
} else {

if (join.isNatural()) {
builder.append(" NATURAL");
}

if (join.isAny()) {
builder.append(" ANY");
} else if (join.isAll()) {
builder.append(" ALL");
}

if (join.isRight()) {
builder.append(" RIGHT");
} else if (join.isFull()) {
builder.append(" FULL");
} else if (join.isLeft()) {
builder.append(" LEFT");
} else if (join.isCross()) {
builder.append(" CROSS");
}

if (join.isOuter()) {
builder.append(" OUTER");
} else if (join.isInner()) {
builder.append(" INNER");
} else if (join.isSemi()) {
builder.append(" SEMI");
}

if (join.isArray()) {
builder.append(" ARRAY");
}

builder.append(' ');
join.appendJoinTypeTo(builder);
join.appendJoinKeywordTo(builder);

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ public class FeaturesAllowed implements FeatureSetValidation, ModifyableFeatureS
Feature.joinInner,
Feature.joinStraight,
Feature.joinApply,
Feature.joinAsOf,
Feature.joinWindow,
Feature.joinUsingColumns,

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@ public void validateOptionalJoin(Join join) {
validateFeature(c, join.isSemi(), Feature.joinSemi);
validateFeature(c, join.isStraight(), Feature.joinStraight);
validateFeature(c, join.isApply(), Feature.joinApply);
validateFeature(c, join.isAsOf(), Feature.joinAsOf);
validateFeature(c, join.isWindowJoin(), Feature.joinWindow);
validateOptionalFeature(c, join.getUsingColumns(), Feature.joinUsingColumns);
}
Expand Down
22 changes: 22 additions & 0 deletions src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt
Original file line number Diff line number Diff line change
Expand Up @@ -1209,6 +1209,24 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
return isReservedKeywordSafeByFollower();
}

/** ASOF stays a contextual identifier outside a join prefix. */
private boolean isAsOfJoinAhead() {
if (!isKeywordAhead("ASOF")) {
return false;
}
int offset = 2;
int kind = getToken(offset).kind;
if (kind == K_LEFT || kind == K_RIGHT || kind == K_FULL) {
offset++;
if (getToken(offset).kind == K_OUTER) {
offset++;
}
} else if (kind == K_INNER) {
offset++;
}
return getToken(offset).kind == K_JOIN;
}

/**
* Checks whether the next token(s) can plausibly start an {@code Alias}.
*
Expand All @@ -1220,6 +1238,9 @@ public class CCJSqlParser extends AbstractJSqlParser<CCJSqlParser> {
* reserved keywords.
*/
private boolean isAliasAhead() {
if (isAsOfJoinAhead()) {
return false;
}
Token t = getToken(1);
int kind = t.kind;

Expand Down Expand Up @@ -7667,6 +7688,7 @@ Join JoinerExpression() #JoinerExpression:
JoinHint joinHint = null;
}
{
[ LOOKAHEAD({ isAsOfJoinAhead() }) <S_IDENTIFIER> { join.setAsOf(true); } ]
[ <K_GLOBAL> { join.setGlobal(true); } ]
[ <K_ANY> { join.setAny(true); } | <K_ALL> { join.setAll(true); } ]
[ <K_NATURAL> { join.setNatural(true); } ]
Expand Down
114 changes: 114 additions & 0 deletions src/test/java/net/sf/jsqlparser/statement/select/AsOfJoinTest.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*-
* #%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 static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.util.Set;
import net.sf.jsqlparser.expression.LongValue;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.parser.feature.Feature;
import net.sf.jsqlparser.test.TestUtils;
import net.sf.jsqlparser.util.TablesNamesFinder;
import net.sf.jsqlparser.util.deparser.ExpressionDeParser;
import net.sf.jsqlparser.util.deparser.SelectDeParser;
import net.sf.jsqlparser.util.validation.ValidationTestAsserts;
import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;

class AsOfJoinTest extends ValidationTestAsserts {
@ParameterizedTest
@ValueSource(strings = {"ASOF JOIN", "ASOF INNER JOIN", "ASOF LEFT JOIN",
"ASOF LEFT OUTER JOIN", "ASOF RIGHT JOIN", "ASOF FULL OUTER JOIN"})
void preservesAsOfAndOrdinaryJoinQualifiers(String joinType) throws Exception {
String sql = "SELECT * FROM trades t " + joinType
+ " prices p ON t.symbol = p.symbol AND t.ts >= p.ts";
PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sql);
Join join = select.getJoins().get(0);
assertTrue(join.isAsOf());
assertEquals(joinType.contains("LEFT"), join.isLeft());
assertEquals(joinType.contains("OUTER"), join.isOuter());
assertTrue(((PlainSelect) CCJSqlParserUtil.parse(select.toString())).getJoins().get(0)
.isAsOf());
assertEquals(Set.of("trades", "prices"), TablesNamesFinder.findTables(sql));
}

@ParameterizedTest
@ValueSource(strings = {
"SELECT * FROM trades ASOF JOIN prices USING (symbol, ts)",
"SELECT * FROM trades ASOF /* nearest */ LEFT JOIN prices USING (symbol, ts)",
"SELECT * FROM (SELECT * FROM trades) ASOF JOIN prices ON trades.ts >= prices.ts",
"SELECT * FROM trades t ASOF JOIN prices p ON t.ts >= p.ts JOIN symbols s ON s.id = t.id",
"FROM trades t |> ASOF JOIN prices p ON t.ts >= p.ts |> SELECT *"})
void worksWithoutAliasesAndWithinExistingJoinPaths(String sql) throws Exception {
TestUtils.assertSqlCanBeParsedAndDeparsed(sql, true);
}

@Test
void keepsUsingColumnsStructuredAndAllowsChangingTheJoinKind() throws Exception {
PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse(
"SELECT * FROM trades ASOF LEFT JOIN prices USING (symbol, ts)");
Join join = select.getJoins().get(0);
assertEquals("symbol", join.getUsingColumns().get(0).getColumnName());
assertEquals("ts", join.getUsingColumns().get(1).getColumnName());
join.setAsOf(false);
assertEquals("SELECT * FROM trades LEFT JOIN prices USING (symbol, ts)", select.toString());
assertTrue(join.withAsOf(true).isAsOf());
}

@Test
void visitsPredicatesThroughTheConfiguredDeparser() throws Exception {
PlainSelect select = (PlainSelect) CCJSqlParserUtil.parse(
"SELECT * FROM trades t ASOF LEFT JOIN prices p ON t.ts >= p.ts + 1");
StringBuilder output = new StringBuilder();
int[] visits = {0};
ExpressionDeParser expressions = new ExpressionDeParser(null, output) {
@Override
public <S> StringBuilder visit(LongValue value, S context) {
visits[0]++;
return getBuilder().append(value.getValue() + 100);
}
};
SelectDeParser deparser = new SelectDeParser(expressions, output);
expressions.setSelectVisitor(deparser);
select.accept((SelectVisitor<StringBuilder>) deparser, null);
assertEquals("SELECT * FROM trades t ASOF LEFT JOIN prices p ON t.ts >= p.ts + 101",
output.toString());
assertEquals(1, visits[0]);
assertTrue(select.toString().endsWith("p.ts + 1"));
}

@ParameterizedTest
@ValueSource(strings = {"SELECT asof FROM trades", "SELECT * FROM asof",
"SELECT * FROM trades asof WHERE asof.ts > 0",
"SELECT * FROM trades AS asof JOIN prices p ON asof.ts = p.ts",
"SELECT * FROM trades \"ASOF\" JOIN prices p ON \"ASOF\".ts = p.ts"})
void keepsAsOfUsableAsAnIdentifier(String sql) throws Exception {
PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sql);
if (select.getJoins() != null) {
assertFalse(select.getJoins().get(0).isAsOf());
}
}

@Test
void validatesTheAsOfFeatureAndPredicateExpressions() throws Exception {
String sql = "SELECT * FROM trades t ASOF JOIN prices p ON t.ts >= p.ts";
validateNoErrors(sql, 1, FeaturesAllowed.SELECT);
validateNotAllowed(sql, 1, 1,
new FeaturesAllowed().add(FeaturesAllowed.SELECT).remove(Feature.joinAsOf),
Feature.joinAsOf);
validateNotAllowed(sql + " + ?", 1, 1,
new FeaturesAllowed().add(FeaturesAllowed.SELECT).remove(Feature.jdbcParameter),
Feature.jdbcParameter);
}
}
Loading