From 130418979a34c4376a169a7b2a021361f84d7875 Mon Sep 17 00:00:00 2001 From: minleejae Date: Sat, 12 Sep 2026 20:05:13 +0900 Subject: [PATCH] Preserve ASOF joins through shared join rendering --- .../sf/jsqlparser/parser/feature/Feature.java | 3 + .../sf/jsqlparser/statement/select/Join.java | 89 +++++++++----- .../util/deparser/SelectDeParser.java | 33 +---- .../validation/feature/FeaturesAllowed.java | 1 + .../validation/validator/SelectValidator.java | 1 + .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 22 ++++ .../statement/select/AsOfJoinTest.java | 114 ++++++++++++++++++ 7 files changed, 199 insertions(+), 64 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/statement/select/AsOfJoinTest.java 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 ebe600b76..7d0643e0e 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -195,6 +195,9 @@ public enum Feature { */ joinApply, + /** Nearest-match ASOF join. */ + joinAsOf, + joinWindow, joinUsingColumns, /** 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 5cb252120..d134689f6 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/Join.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/Join.java @@ -25,6 +25,7 @@ public class Join extends ASTNodeAccessImpl { private final LinkedList onExpressions = new LinkedList<>(); private final LinkedList usingColumns = new LinkedList<>(); + private boolean asOf = false; private boolean outer = false; private boolean right = false; private boolean left = false; @@ -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; } @@ -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()) { @@ -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 : ""); 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 3ba748312..4fd3e94f3 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java @@ -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); } 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 92ea4e0f4..ee0101117 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 @@ -62,6 +62,7 @@ public class FeaturesAllowed implements FeatureSetValidation, ModifyableFeatureS Feature.joinInner, Feature.joinStraight, Feature.joinApply, + Feature.joinAsOf, Feature.joinWindow, Feature.joinUsingColumns, 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 4bba95395..71c33f902 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 @@ -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); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 92f99568d..0ed0821e5 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1209,6 +1209,24 @@ public class CCJSqlParser extends AbstractJSqlParser { 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}. * @@ -1220,6 +1238,9 @@ public class CCJSqlParser extends AbstractJSqlParser { * reserved keywords. */ private boolean isAliasAhead() { + if (isAsOfJoinAhead()) { + return false; + } Token t = getToken(1); int kind = t.kind; @@ -7667,6 +7688,7 @@ Join JoinerExpression() #JoinerExpression: JoinHint joinHint = null; } { + [ LOOKAHEAD({ isAsOfJoinAhead() }) { join.setAsOf(true); } ] [ { join.setGlobal(true); } ] [ { join.setAny(true); } | { join.setAll(true); } ] [ { join.setNatural(true); } ] diff --git a/src/test/java/net/sf/jsqlparser/statement/select/AsOfJoinTest.java b/src/test/java/net/sf/jsqlparser/statement/select/AsOfJoinTest.java new file mode 100644 index 000000000..1d327ee0c --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/select/AsOfJoinTest.java @@ -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 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) 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); + } +}