From 9696444570b67b9e7ef9c6c3ce4897bcc15d1a4b Mon Sep 17 00:00:00 2001 From: minleejae Date: Fri, 11 Sep 2026 13:07:26 +0900 Subject: [PATCH] Support Doris distribution hints and share join keyword rendering --- .../jsqlparser/parser/AbstractJSqlParser.java | 2 +- .../sf/jsqlparser/statement/select/Join.java | 35 ++++-- .../jsqlparser/statement/select/JoinHint.java | 24 +++- .../util/deparser/SelectDeParser.java | 15 +-- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 23 ++++ src/site/sphinx/usage.rst | 7 ++ .../statement/select/DorisJoinHintTest.java | 119 ++++++++++++++++++ 7 files changed, 196 insertions(+), 29 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/statement/select/DorisJoinHintTest.java diff --git a/src/main/java/net/sf/jsqlparser/parser/AbstractJSqlParser.java b/src/main/java/net/sf/jsqlparser/parser/AbstractJSqlParser.java index 8ce5c93e86..c86de8d0d1 100644 --- a/src/main/java/net/sf/jsqlparser/parser/AbstractJSqlParser.java +++ b/src/main/java/net/sf/jsqlparser/parser/AbstractJSqlParser.java @@ -46,7 +46,7 @@ public enum Dialect { AdjacentStringLiterals.WHITESPACE, Feature.allowDoubleQuotedStrings, Feature.allowBackslashEscapeCharacter), SNOWFLAKE( - Feature.allowBackslashEscapeCharacter), INFORMIX; + Feature.allowBackslashEscapeCharacter), INFORMIX, DORIS; private final Set lexerFeatures; private final AdjacentStringLiterals adjacentStringLiterals; 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 c8d675cecb..5cb2521203 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/Join.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/Join.java @@ -464,6 +464,27 @@ public Join setJoinHint(JoinHint joinHint) { return this; } + /** Appends the join keyword, hint and FETCH modifier, followed by a space. */ + public StringBuilder appendJoinKeywordTo(StringBuilder builder) { + if (isStraight()) { + builder.append("STRAIGHT_JOIN "); + } else if (isApply()) { + builder.append("APPLY "); + } else { + if (joinHint != null && joinHint.getPosition() == JoinHint.Position.BEFORE_JOIN) { + builder.append(joinHint).append(' '); + } + builder.append("JOIN "); + if (joinHint != null && joinHint.getPosition() == JoinHint.Position.AFTER_JOIN) { + builder.append(joinHint).append(' '); + } + if (fetch) { + builder.append("FETCH "); + } + } + return builder; + } + @Override @SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.NPathComplexity"}) public String toString() { @@ -510,19 +531,7 @@ public String toString() { builder.append("ARRAY "); } - if (isStraight()) { - builder.append("STRAIGHT_JOIN "); - } else if (isApply()) { - builder.append("APPLY "); - } else { - if (joinHint != null) { - builder.append(joinHint).append(" "); - } - builder.append("JOIN "); - if (fetch) { - builder.append("FETCH "); - } - } + appendJoinKeywordTo(builder); builder.append(fromItem).append((joinWindow != null) ? " WITHIN " + joinWindow : ""); } diff --git a/src/main/java/net/sf/jsqlparser/statement/select/JoinHint.java b/src/main/java/net/sf/jsqlparser/statement/select/JoinHint.java index 099f73042f..43083de1cd 100644 --- a/src/main/java/net/sf/jsqlparser/statement/select/JoinHint.java +++ b/src/main/java/net/sf/jsqlparser/statement/select/JoinHint.java @@ -9,8 +9,10 @@ */ package net.sf.jsqlparser.statement.select; +import java.util.Objects; + /** - * Hints (Transact-SQL) - Join + * SQL Server join hints precede JOIN; Doris distribution hints follow it in square brackets. * * @link Hints @@ -18,14 +20,32 @@ */ public class JoinHint { + public enum Position { + BEFORE_JOIN, AFTER_JOIN + } + private final String keyword; + private final Position position; public JoinHint(String keyword) { + this(keyword, Position.BEFORE_JOIN); + } + + public JoinHint(String keyword, Position position) { this.keyword = keyword; + this.position = Objects.requireNonNull(position, "position"); + } + + public String getKeyword() { + return keyword; + } + + public Position getPosition() { + return position; } @Override public String toString() { - return keyword; + return position == Position.AFTER_JOIN ? "[" + keyword + "]" : keyword; } } 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 f78e3d71d6..0dc8803b8f 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java @@ -764,19 +764,8 @@ public void deparseJoin(Join join) { builder.append(" ARRAY"); } - if (join.isStraight()) { - builder.append(" STRAIGHT_JOIN "); - } else if (join.isApply()) { - builder.append(" APPLY "); - } else { - if (join.getJoinHint() != null) { - builder.append(" ").append(join.getJoinHint()); - } - builder.append(" JOIN "); - if (join.isFetch()) { - builder.append("FETCH "); - } - } + builder.append(' '); + join.appendJoinKeywordTo(builder); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 556e14f38f..0763980dbf 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -7201,6 +7201,21 @@ JoinHint JoinHint(): } } +JoinHint DorisJoinHint(): +{ + Token token; +} +{ + "[" + ( + LOOKAHEAD({ isKeywordAhead("SHUFFLE") }) token= + | + LOOKAHEAD({ isKeywordAhead("BROADCAST") }) token= + ) + "]" + { return new JoinHint(token.image, JoinHint.Position.AFTER_JOIN); } +} + Join JoinerExpression() #JoinerExpression: { Join join = new Join(); @@ -7240,6 +7255,14 @@ Join JoinerExpression() #JoinerExpression: ( [ joinHint=JoinHint() {join.setJoinHint(joinHint); } ] + [ LOOKAHEAD("[", { Dialect.DORIS.name().equals(getAsString(Feature.dialect)) }) + { + if (joinHint != null) { + throw new ParseException("Only one join hint is allowed per JOIN"); + } + } + joinHint=DorisJoinHint() { join.setJoinHint(joinHint); } + ] [ { join.setFetch(true); } ] ) | diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index ba3848122e..094522e8b8 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -713,9 +713,16 @@ One grammar covers every supported RDBMS, but a few pieces of syntax mean differ - ``withBackslashEscapeCharacter`` only, double quotes stay quoted identifiers * - ``INFORMIX`` - Informix ``ALTER TABLE ... ADD CONSTRAINT`` definitions with optional trailing constraint names + * - ``DORIS`` + - ``JOIN [shuffle]`` and ``JOIN [broadcast]`` distribution hints Features set explicitly *after* the preset win over it. +Doris distribution hints require ``parser.withDialect(Dialect.DORIS)``. +``Join.getJoinHint()`` exposes the keyword and ``Position.AFTER_JOIN``; +the existing SQL Server hints use ``Position.BEFORE_JOIN``. Rendering preserves +both the position and the brackets around a Doris hint. + Informix's constraint form requires an explicit dialect selection: .. code-block:: java diff --git a/src/test/java/net/sf/jsqlparser/statement/select/DorisJoinHintTest.java b/src/test/java/net/sf/jsqlparser/statement/select/DorisJoinHintTest.java new file mode 100644 index 0000000000..958d2f878c --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/select/DorisJoinHintTest.java @@ -0,0 +1,119 @@ +/*- + * #%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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import java.util.List; +import java.util.Set; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.test.TestUtils; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class DorisJoinHintTest { + private static PlainSelect parse(String sql, Dialect dialect) throws JSQLParserException { + return (PlainSelect) CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(dialect)); + } + + @Test + void parsesOriginalReproducerIssue1620() throws Exception { + String sql = "SELECT * FROM uba.events a LEFT JOIN [shuffle] uba.events b " + + "ON a.event_id = b.event_id"; + PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sql, false, + parser -> parser.withDialect(Dialect.DORIS)); + Join join = select.getJoins().get(0); + assertEquals("shuffle", join.getJoinHint().getKeyword()); + assertEquals(JoinHint.Position.AFTER_JOIN, join.getJoinHint().getPosition()); + assertEquals("uba.events", ((Table) join.getFromItem()).getFullyQualifiedName()); + assertEquals("b", join.getFromItem().getAlias().getName()); + assertEquals(Set.of("uba.events"), new TablesNamesFinder<>().getTables((Statement) select)); + } + + @ParameterizedTest + @ValueSource(strings = {"shuffle", "broadcast", "SHUFFLE", "BROADCAST"}) + void preservesHintPositionAndRoundTrips(String hint) throws Exception { + for (String kind : List.of("", "INNER ", "LEFT OUTER ", "RIGHT ", "LEFT SEMI ")) { + String sql = "SELECT a.id FROM a " + kind + "JOIN [" + hint + "] b USING (id)"; + PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sql, false, + parser -> parser.withDialect(Dialect.DORIS)); + StringBuilder output = new StringBuilder(); + select.accept(new StatementDeParser(output), null); + for (String rendered : List.of(select.toString(), output.toString())) { + JoinHint reparsed = parse(rendered, Dialect.DORIS).getJoins().get(0).getJoinHint(); + assertEquals(hint, reparsed.getKeyword()); + assertEquals(JoinHint.Position.AFTER_JOIN, reparsed.getPosition()); + } + } + } + + @Test + void keepsHintPerJoinAndSupportsAstMutation() throws Exception { + PlainSelect select = parse("SELECT * FROM a JOIN [shuffle] b ON a.id = b.id " + + "JOIN [broadcast] c ON b.id = c.id JOIN d ON c.id = d.id", Dialect.DORIS); + assertEquals("shuffle", select.getJoins().get(0).getJoinHint().getKeyword()); + assertEquals("broadcast", select.getJoins().get(1).getJoinHint().getKeyword()); + assertNull(select.getJoins().get(2).getJoinHint()); + select.getJoins().get(0) + .setJoinHint(new JoinHint("broadcast", JoinHint.Position.AFTER_JOIN)); + TestUtils.assertDeparse(select, "SELECT * FROM a JOIN [broadcast] b ON a.id = b.id " + + "JOIN [broadcast] c ON b.id = c.id JOIN d ON c.id = d.id"); + select.getJoins().get(0).setJoinHint(null); + assertEquals("JOIN b ON a.id = b.id", select.getJoins().get(0).toString()); + } + + @Test + void keepsSqlServerHintsAndQuotedTableNames() throws Exception { + for (String keyword : List.of("LOOP", "HASH", "MERGE", "REMOTE")) { + String sql = "SELECT * FROM a INNER " + keyword + " JOIN b ON a.id = b.id"; + PlainSelect select = (PlainSelect) TestUtils.assertSqlCanBeParsedAndDeparsed(sql); + assertEquals(JoinHint.Position.BEFORE_JOIN, + select.getJoins().get(0).getJoinHint().getPosition()); + assertEquals(keyword, new JoinHint(keyword).toString()); + } + PlainSelect quoted = parse("SELECT * FROM a JOIN [shuffle] b ON a.id = b.id", + Dialect.SQLSERVER); + assertNull(quoted.getJoins().get(0).getJoinHint()); + assertEquals("[shuffle]", ((Table) quoted.getJoins().get(0).getFromItem()).getName()); + PlainSelect dorisTable = parse("SELECT * FROM a JOIN `shuffle` b ON a.id = b.id", + Dialect.DORIS); + assertNull(dorisTable.getJoins().get(0).getJoinHint()); + } + + @Test + void requiresDorisDialect() { + String sql = "SELECT * FROM a JOIN [shuffle] db.b b ON a.id = b.id"; + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql)); + for (Dialect dialect : Dialect.values()) { + if (dialect != Dialect.DORIS) { + assertThrows(JSQLParserException.class, () -> parse(sql, dialect), dialect.name()); + } + } + } + + @Test + void rejectsUnknownAndDuplicateHints() { + for (String sql : List.of("SELECT * FROM a JOIN [unknown] b ON a.id = b.id", + "SELECT * FROM a JOIN [shuffle, broadcast] b ON a.id = b.id", + "SELECT * FROM a JOIN [shuffle] [broadcast] b ON a.id = b.id", + "SELECT * FROM a INNER HASH JOIN [shuffle] b ON a.id = b.id", + "SELECT * FROM a JOIN [shuffle b ON a.id = b.id")) { + assertThrows(JSQLParserException.class, () -> parse(sql, Dialect.DORIS), sql); + } + } +}