diff --git a/src/main/java/net/sf/jsqlparser/parser/AbstractJSqlParser.java b/src/main/java/net/sf/jsqlparser/parser/AbstractJSqlParser.java index 050401b59..a6b3432c5 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, SPANNER; + Feature.allowBackslashEscapeCharacter), INFORMIX, SPANNER, 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 c8d675cec..5cb252120 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 099f73042..43083de1c 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 c9cb4667a..3ba748312 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java @@ -765,19 +765,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 99698d75b..f848960a3 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -7352,6 +7352,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(); @@ -7391,6 +7406,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 1f090e2bb..a1c72477e 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -715,6 +715,8 @@ One grammar covers every supported RDBMS, but a few pieces of syntax mean differ - Informix ``ALTER TABLE ... ADD CONSTRAINT`` definitions with optional trailing constraint names * - ``SPANNER`` - GoogleSQL ``CREATE [UNIQUE] NULL_FILTERED INDEX`` with a separate null-filtering flag + * - ``DORIS`` + - ``JOIN [shuffle]`` and ``JOIN [broadcast]`` distribution hints Features set explicitly *after* the preset win over it. @@ -724,6 +726,11 @@ MySQL user-variable targets in ``SELECT ... INTO @variable`` require expressions, with the clause position preserved before ``FROM`` or at the end of the query. They are not table targets in ``getIntoTables()``. +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. + With ``Dialect.SQLSERVER``, ``PRIMARY KEY NONCLUSTERED (id)`` and ``UNIQUE CLUSTERED (id)`` store their clustering option in ``Index.getClustering()`` for both ``CREATE TABLE`` and ``ALTER TABLE``. Without that dialect, these words 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 000000000..958d2f878 --- /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); + } + } +}