From 4f44ca491a69f0ce03d9bea1ce97f5a6728db44d Mon Sep 17 00:00:00 2001 From: minleejae Date: Thu, 5 Nov 2026 12:49:02 +0900 Subject: [PATCH] feat: parse MySQL index hint scopes, row and table insert sources and DELETE USING joins MySQL 8.4 index hints with FOR JOIN/ORDER BY/GROUP BY, several hints on one table, USE INDEX () and IGNORE KEY failed, as did WEIGHT_STRING(x AS CHAR(n)), INSERT ... VALUES ROW(...), INSERT ... TABLE t, INSERT INTO t () VALUES () and DELETE FROM t1 USING t1 JOIN t2. INSERT ... PARTITION (p) (cols) rendered PARTITION after the column list, and toString() and the deparsers disagreed on several INSERT and REPLACE forms. Keep every index hint in Table.getIndexHints() with a typed MySQLIndexHint.Scope, parsed by one list production and rendered by one helper; getIndexHint() returns the first. Store the WEIGHT_STRING cast in Function.getCastType(), a ColDataType built as in CAST from exactly CHAR(n) or BINARY(n) after the single argument. Let VALUES ROW( and TABLE name reach the existing Values and TableStatement sources, and share an InsertColumnList production that accepts () between INSERT and REPLACE. Hold the joins of a DELETE USING clause in Delete.getUsingJoins() and render the target's joins before USING. The cast type and the REPLACE column list carry nodes spanning their source. Render INSERT through one ordered Insert.appendTo with renderer callbacks, used by toString() and InsertDeParser, with callback appendTo methods on InsertDuplicateAction, InsertConflictAction and OracleMultiInsertClause. The deparser now keeps ON DUPLICATE KEY UPDATE ... WHERE and visits the ON CONFLICT action; the unused Insert.duplicateUpdateSets field is gone. Signed-off-by: minleejae --- .../sf/jsqlparser/expression/Function.java | 33 +++ .../jsqlparser/expression/MySQLIndexHint.java | 69 ++++- .../java/net/sf/jsqlparser/schema/Table.java | 53 +++- .../statement/StatementVisitorAdapter.java | 9 +- .../jsqlparser/statement/delete/Delete.java | 52 +++- .../jsqlparser/statement/insert/Insert.java | 224 ++++++++-------- .../insert/InsertConflictAction.java | 17 +- .../insert/InsertDuplicateAction.java | 28 +- .../insert/OracleMultiInsertClause.java | 28 +- .../statement/update/UpdateSet.java | 41 +++ .../sf/jsqlparser/util/TablesNamesFinder.java | 1 + .../util/deparser/DeleteDeParser.java | 6 +- .../util/deparser/DmlDeParserSupport.java | 6 + .../util/deparser/ExpressionDeParser.java | 1 + .../util/deparser/InsertDeParser.java | 186 ++------------ .../util/deparser/SelectDeParser.java | 6 +- .../util/deparser/UpsertDeParser.java | 16 +- .../deparser/ValuesStatementDeParser.java | 3 +- .../validation/validator/DeleteValidator.java | 1 + .../validation/validator/SelectValidator.java | 9 +- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 163 ++++++++++-- src/site/sphinx/usage.rst | 26 ++ .../expression/WeightStringFunctionTest.java | 141 +++++++++++ .../parser/ASTNodeCoverageTest.java | 13 +- .../statement/delete/DeleteUsingJoinTest.java | 228 +++++++++++++++++ .../statement/insert/InsertRenderingTest.java | 205 +++++++++++++++ .../insert/MySqlInsertSourceTest.java | 209 +++++++++++++++ .../statement/select/MySqlIndexHintTest.java | 239 ++++++++++++++++++ 28 files changed, 1647 insertions(+), 366 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/expression/WeightStringFunctionTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/delete/DeleteUsingJoinTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/insert/InsertRenderingTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/insert/MySqlInsertSourceTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/select/MySqlIndexHintTest.java diff --git a/src/main/java/net/sf/jsqlparser/expression/Function.java b/src/main/java/net/sf/jsqlparser/expression/Function.java index e411bab60f..0a0ddd86fb 100644 --- a/src/main/java/net/sf/jsqlparser/expression/Function.java +++ b/src/main/java/net/sf/jsqlparser/expression/Function.java @@ -17,6 +17,7 @@ import net.sf.jsqlparser.statement.select.Limit; import net.sf.jsqlparser.statement.select.OrderByElement; +import net.sf.jsqlparser.statement.create.table.ColDataType; import net.sf.jsqlparser.statement.create.table.ColumnDefinition; import net.sf.jsqlparser.statement.select.PlainSelect; @@ -59,6 +60,12 @@ public class Function extends ASTNodeAccessImpl implements Expression { */ private List keywordArguments = null; + /** + * The {@code AS type} cast of the argument, as in MySQL's {@code WEIGHT_STRING(str AS CHAR(3))} + * or {@code WEIGHT_STRING(str AS BINARY(4))}. + */ + private ColDataType castType; + /** Column definitions supplied for a record-returning function inside ROWS FROM. */ public List getResultColumnDefinitions() { return resultColumnDefinitions; @@ -83,6 +90,31 @@ public Function(String name, Expression... parameters) { this.parameters = new ExpressionList<>(parameters); } + /** + * Returns the {@code AS type} cast written after the argument, as in MySQL's + * {@code WEIGHT_STRING(str AS CHAR(3))}, or {@code null}. + */ + public ColDataType getCastType() { + return castType; + } + + public void setCastType(ColDataType castType) { + this.castType = castType; + } + + public Function withCastType(ColDataType castType) { + setCastType(castType); + return this; + } + + /** Appends {@code " AS type"} when the argument has a {@link #getCastType() cast type}. */ + public StringBuilder appendCastTypeTo(StringBuilder builder) { + if (castType != null) { + builder.append(" AS ").append(castType); + } + return builder; + } + @Override public T accept(ExpressionVisitor expressionVisitor, S context) { return expressionVisitor.visit(this, context); @@ -385,6 +417,7 @@ public String toString() { } b.append(parameters); + appendCastTypeTo(b); if (havingClause != null) { havingClause.appendTo(b); diff --git a/src/main/java/net/sf/jsqlparser/expression/MySQLIndexHint.java b/src/main/java/net/sf/jsqlparser/expression/MySQLIndexHint.java index 7360a4b2e5..17044a9fb3 100644 --- a/src/main/java/net/sf/jsqlparser/expression/MySQLIndexHint.java +++ b/src/main/java/net/sf/jsqlparser/expression/MySQLIndexHint.java @@ -12,15 +12,35 @@ import java.io.Serializable; import java.util.List; +/** + * A MySQL index hint after a table reference: + * {@code {USE|IGNORE|FORCE} {INDEX|KEY} [FOR {JOIN|ORDER BY|GROUP BY}] (index_list)}. + *

+ * A table may carry several hints, see {@code Table.getIndexHints()}. {@code USE INDEX ()} has an + * empty index list. + */ public class MySQLIndexHint implements Serializable { private final String action; private final String indexQualifier; + private final Scope scope; private final List indexNames; public MySQLIndexHint(String action, String indexQualifier, List indexNames) { + this(action, indexQualifier, null, indexNames); + } + + /** + * @param action {@code USE}, {@code IGNORE} or {@code FORCE} + * @param indexQualifier {@code INDEX} or {@code KEY} + * @param scope the {@code FOR JOIN|ORDER BY|GROUP BY} scope, or {@code null} for every use + * @param indexNames the index names, empty for {@code USE INDEX ()} + */ + public MySQLIndexHint(String action, String indexQualifier, Scope scope, + List indexNames) { this.action = action; this.indexQualifier = indexQualifier; + this.scope = scope; this.indexNames = indexNames; } @@ -32,22 +52,53 @@ public String getIndexQualifier() { return indexQualifier; } + /** + * Returns the part of the query the hint applies to, or {@code null} when no {@code FOR} clause + * was given. + */ + public Scope getScope() { + return scope; + } + public List getIndexNames() { return indexNames; } - @Override - public String toString() { - // use|ignore|force key|index (index1,...,indexN) - StringBuilder buffer = new StringBuilder(); - buffer.append(" ").append(action).append(" ").append(indexQualifier).append(" ("); + /** Appends {@code " USE INDEX [FOR scope] (i1,i2)"}, with a leading space. */ + public StringBuilder appendTo(StringBuilder builder) { + // use|ignore|force key|index [for join|order by|group by] (index1,...,indexN) + builder.append(" ").append(action).append(" ").append(indexQualifier); + if (scope != null) { + builder.append(" FOR ").append(scope.getKeywords()); + } + builder.append(" ("); for (int i = 0; i < indexNames.size(); i++) { if (i > 0) { - buffer.append(","); + builder.append(","); } - buffer.append(indexNames.get(i)); + builder.append(indexNames.get(i)); + } + return builder.append(")"); + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + /** The {@code FOR} clause of an index hint. */ + public enum Scope { + JOIN("JOIN"), ORDER_BY("ORDER BY"), GROUP_BY("GROUP BY"); + + private final String keywords; + + Scope(String keywords) { + this.keywords = keywords; + } + + /** Returns the keywords after {@code FOR}, for example {@code ORDER BY}. */ + public String getKeywords() { + return keywords; } - buffer.append(")"); - return buffer.toString(); } } diff --git a/src/main/java/net/sf/jsqlparser/schema/Table.java b/src/main/java/net/sf/jsqlparser/schema/Table.java index 0e23b28a9c..bbf9f4536a 100644 --- a/src/main/java/net/sf/jsqlparser/schema/Table.java +++ b/src/main/java/net/sf/jsqlparser/schema/Table.java @@ -60,7 +60,7 @@ public class Table extends ASTNodeAccessImpl private UnPivot unpivot; - private MySQLIndexHint mysqlHints; + private List mysqlIndexHints; private SQLServerHints sqlServerHints; @@ -411,8 +411,13 @@ public void setUnPivot(UnPivot unpivot) { this.unpivot = unpivot; } + /** + * Returns the first MySQL index hint, or {@code null}. Use {@link #getIndexHints()} for a table + * with several hints. + */ public MySQLIndexHint getIndexHint() { - return mysqlHints; + return mysqlIndexHints == null || mysqlIndexHints.isEmpty() ? null + : mysqlIndexHints.get(0); } public Table withHint(MySQLIndexHint hint) { @@ -420,8 +425,46 @@ public Table withHint(MySQLIndexHint hint) { return this; } + /** + * Replaces the MySQL index hints with the given hint, or removes them for {@code null}. + */ public void setHint(MySQLIndexHint hint) { - this.mysqlHints = hint; + this.mysqlIndexHints = hint == null ? null : new ArrayList<>(Collections.singleton(hint)); + } + + /** + * Returns the MySQL index hints in their order, for example + * {@code USE INDEX (i1) IGNORE INDEX FOR ORDER BY (i2)}, or {@code null} when there are none. + */ + public List getIndexHints() { + return mysqlIndexHints; + } + + public void setIndexHints(List indexHints) { + this.mysqlIndexHints = indexHints; + } + + public Table withIndexHints(List indexHints) { + setIndexHints(indexHints); + return this; + } + + public Table addIndexHint(MySQLIndexHint hint) { + if (mysqlIndexHints == null) { + mysqlIndexHints = new ArrayList<>(); + } + mysqlIndexHints.add(hint); + return this; + } + + /** Appends the MySQL index hints, each with a leading space, in their order. */ + public StringBuilder appendIndexHintsTo(StringBuilder builder) { + if (mysqlIndexHints != null) { + for (MySQLIndexHint hint : mysqlIndexHints) { + hint.appendTo(builder); + } + } + return builder; } public SQLServerHints getSqlServerHints() { @@ -468,9 +511,7 @@ public StringBuilder appendTo(StringBuilder builder) { builder.append(" ").append(unpivot); } - if (mysqlHints != null) { - builder.append(mysqlHints); - } + appendIndexHintsTo(builder); if (sqlServerHints != null) { builder.append(sqlServerHints); diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index adc28b2b56..9e0e8dbfaa 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -103,6 +103,7 @@ import net.sf.jsqlparser.statement.refresh.RefreshMaterializedViewStatement; import net.sf.jsqlparser.statement.reindex.Reindex; import net.sf.jsqlparser.statement.securitylabel.SecurityLabel; +import net.sf.jsqlparser.statement.select.FromItem; import net.sf.jsqlparser.statement.select.FromItemVisitor; import net.sf.jsqlparser.statement.select.FromItemVisitorAdapter; import net.sf.jsqlparser.statement.select.PivotVisitor; @@ -255,8 +256,14 @@ public T visit(Delete delete, S context) { fromItemVisitor.visitTables(delete.getTables(), context); selectVisitor.visitOutputClause(delete.getOutputClause(), context); fromItemVisitor.visitFromItem(delete.getTable(), context); - fromItemVisitor.visitTables(delete.getUsingList(), context); fromItemVisitor.visitJoins(delete.getJoins(), context); + // USING may name subqueries, so visit the items rather than getUsingList()'s tables + if (delete.getUsingFromItemList() != null) { + for (FromItem usingFromItem : delete.getUsingFromItemList()) { + fromItemVisitor.visitFromItem(usingFromItem, context); + } + } + fromItemVisitor.visitJoins(delete.getUsingJoins(), context); expressionVisitor.visitExpression(delete.getWhere(), context); expressionVisitor.visitPreferringClause(delete.getPreferringClause(), context); diff --git a/src/main/java/net/sf/jsqlparser/statement/delete/Delete.java b/src/main/java/net/sf/jsqlparser/statement/delete/Delete.java index c988eaf70b..d82cafe8be 100644 --- a/src/main/java/net/sf/jsqlparser/statement/delete/Delete.java +++ b/src/main/java/net/sf/jsqlparser/statement/delete/Delete.java @@ -45,6 +45,7 @@ public class Delete extends ASTNodeAccessImpl implements Statement { private OracleHint oracleHint = null; private List tables; private List usingFromItemList; + private List usingJoins; private List joins; private Expression where; private PreferringClause preferringClause; @@ -281,21 +282,14 @@ public String toString() { b.append(' ').append(forPortionClause); } + appendJoinsTo(b, joins); + if (usingFromItemList != null && !usingFromItemList.isEmpty()) { b.append(" USING "); b.append(usingFromItemList.stream() .map(Object::toString) .collect(joining(", "))); - } - - if (joins != null) { - for (Join join : joins) { - if (join.isSimple()) { - b.append(", ").append(join); - } else { - b.append(" ").append(join); - } - } + appendJoinsTo(b, usingJoins); } if (where != null) { @@ -542,4 +536,42 @@ public List getUsingFromItemList() { public void setUsingFromItemList(List usingFromItemList) { this.usingFromItemList = usingFromItemList; } + + /** + * Returns the joins after the USING items, as in MySQL's + * {@code DELETE FROM t1 USING t1 JOIN t2 ON t1.id = t2.id} or PostgreSQL's + * {@code DELETE FROM t USING s JOIN u ON s.id = u.id WHERE ...}, or {@code null}. They are + * rendered only when {@link #getUsingFromItemList()} is not empty. {@link #getJoins()} holds + * the joins of the target table, written before USING. + */ + public List getUsingJoins() { + return usingJoins; + } + + public void setUsingJoins(List usingJoins) { + this.usingJoins = usingJoins; + } + + public Delete withUsingJoins(List usingJoins) { + setUsingJoins(usingJoins); + return this; + } + + public Delete addUsingJoins(Join... usingJoins) { + List collection = Optional.ofNullable(getUsingJoins()).orElseGet(ArrayList::new); + Collections.addAll(collection, usingJoins); + return this.withUsingJoins(collection); + } + + private static void appendJoinsTo(StringBuilder b, List joins) { + if (joins != null) { + for (Join join : joins) { + if (join.isSimple()) { + b.append(", ").append(join); + } else { + b.append(" ").append(join); + } + } + } + } } diff --git a/src/main/java/net/sf/jsqlparser/statement/insert/Insert.java b/src/main/java/net/sf/jsqlparser/statement/insert/Insert.java index 7d32939f01..94ec2ab5a7 100644 --- a/src/main/java/net/sf/jsqlparser/statement/insert/Insert.java +++ b/src/main/java/net/sf/jsqlparser/statement/insert/Insert.java @@ -11,10 +11,11 @@ import java.util.Arrays; import java.util.Collection; -import java.util.Iterator; import java.util.List; import java.util.Optional; +import java.util.function.Consumer; import net.sf.jsqlparser.expression.Alias; +import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.OracleHint; import net.sf.jsqlparser.expression.operators.relational.ExpressionList; import net.sf.jsqlparser.parser.ASTNodeAccessImpl; @@ -27,6 +28,7 @@ import net.sf.jsqlparser.statement.StatementVisitor; import net.sf.jsqlparser.statement.select.PlainSelect; import net.sf.jsqlparser.statement.select.Select; +import net.sf.jsqlparser.statement.select.SelectItem; import net.sf.jsqlparser.statement.select.SetOperationList; import net.sf.jsqlparser.statement.select.Values; import net.sf.jsqlparser.statement.select.WithItem; @@ -47,7 +49,6 @@ public enum OverridingMode { } private OverridingMode overridingMode = OverridingMode.NONE; - private List duplicateUpdateSets = null; private InsertModifierPriority modifierPriority = null; private boolean modifierIgnore = false; private boolean overwrite = false; @@ -66,10 +67,7 @@ public enum OverridingMode { private List oracleMultiInsertBranches; public List getDuplicateUpdateSets() { - if (duplicateAction != null) { - return duplicateAction.getUpdateSets(); - } - return duplicateUpdateSets; + return duplicateAction != null ? duplicateAction.getUpdateSets() : null; } public List getSetUpdateSets() { @@ -356,139 +354,176 @@ public void setOracleMultiInsertBranches( } @Override - @SuppressWarnings({"PMD.CyclomaticComplexity", "PMD.NPathComplexity"}) public String toString() { - StringBuilder sql = new StringBuilder(); - appendWithItems(sql); - appendInsertPrefix(sql); - if (appendOracleMultiInsert(sql)) { - return sql.toString(); + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append, builder::append, builder::append, + builder::append).toString(); + } + + /** + * Appends the statement in clause order. {@link #toString()} renders every part as text, while + * {@code InsertDeParser} passes renderers that visit the WITH items, queries, expressions and + * RETURNING items, so both produce the same SQL. + * + * @param builder the target + * @param withItemRenderer renders one WITH item + * @param selectRenderer renders the source query and the queries of Oracle's multi-table insert + * @param expressionRenderer renders expressions, including the assignment lists + * @param selectItemRenderer renders one RETURNING item + * @return the builder + */ + public StringBuilder appendTo(StringBuilder builder, Consumer> withItemRenderer, + Consumer selectRenderer, + Consumer expressionRenderer) { + builder.append(oracleMultiInsertFirst ? " FIRST" : " ALL"); + if (oracleMultiInsertBranches != null) { for (OracleMultiInsertBranch branch : oracleMultiInsertBranches) { - appendOracleMultiInsertBranch(sql, branch); + appendOracleMultiInsertBranch(builder, branch, selectRenderer, expressionRenderer); } } if (select != null) { - sql.append(" ").append(select); + builder.append(" "); + selectRenderer.accept(select); } - return true; } - private void appendInsertTargetAndValues(StringBuilder sql) { - if (overwrite) { - sql.append("OVERWRITE "); - } else { - sql.append("INTO "); + private static void appendOracleMultiInsertBranch(StringBuilder builder, + OracleMultiInsertBranch branch, Consumer selectRenderer, + Consumer expressionRenderer) { + if (onlyDefaultValues) { + builder.append(" DEFAULT VALUES"); + } if (select != null) { - sql.append(select); + builder.append(" "); + selectRenderer.accept(select); } - } - - private void appendInsertActions(StringBuilder sql) { if (setUpdateSets != null && !setUpdateSets.isEmpty()) { - sql.append("SET "); - UpdateSet.appendUpdateSetsTo(sql, setUpdateSets); + builder.append(" SET "); + UpdateSet.appendUpdateSetsTo(builder, setUpdateSets, expressionRenderer); if (rowAlias != null) { - sql.append(" ").append(rowAlias); + builder.append(rowAlias); } } + } + private void appendActions(StringBuilder builder, Consumer expressionRenderer) { if (duplicateAction != null) { - sql.append(" ON DUPLICATE KEY UPDATE "); - duplicateAction.appendTo(sql); + builder.append(" ON DUPLICATE KEY UPDATE "); + duplicateAction.appendTo(builder, expressionRenderer); } - if (conflictAction != null) { - sql.append(" ON CONFLICT"); - + builder.append(" ON CONFLICT"); if (conflictTarget != null) { - conflictTarget.appendTo(sql); + conflictTarget.appendTo(builder, expressionRenderer); } - conflictAction.appendTo(sql); + conflictAction.appendTo(builder, expressionRenderer); } + } - if (returningClause != null) { - returningClause.appendTo(sql); + /** Appends {@code (a, b)} with the plain column names, as INSERT column lists allow. */ + static StringBuilder appendColumnNamesTo(StringBuilder builder, List columns) { + builder.append("("); + for (int i = 0; i < columns.size(); i++) { + if (i > 0) { + builder.append(", "); + } + builder.append(columns.get(i).getColumnName()); } + return builder.append(")"); } public Insert withWithItemsList(List> withList) { @@ -563,21 +598,4 @@ public Insert withOracleMultiInsertBranches( this.setOracleMultiInsertBranches(oracleMultiInsertBranches); return this; } - - private void appendOracleMultiInsertBranch(StringBuilder sql, - OracleMultiInsertBranch branch) { - if (branch == null || branch.getClauses() == null || branch.getClauses().isEmpty()) { - return; - } - - if (branch.getWhenExpression() != null) { - sql.append(" WHEN ").append(branch.getWhenExpression()).append(" THEN"); - } else if (branch.isElseClause()) { - sql.append(" ELSE"); - } - - for (OracleMultiInsertClause clause : branch.getClauses()) { - sql.append(" ").append(clause); - } - } } diff --git a/src/main/java/net/sf/jsqlparser/statement/insert/InsertConflictAction.java b/src/main/java/net/sf/jsqlparser/statement/insert/InsertConflictAction.java index 76781bbccc..2eddef3b69 100644 --- a/src/main/java/net/sf/jsqlparser/statement/insert/InsertConflictAction.java +++ b/src/main/java/net/sf/jsqlparser/statement/insert/InsertConflictAction.java @@ -18,6 +18,7 @@ import java.util.Collection; import java.util.List; import java.util.Objects; +import java.util.function.Consumer; /** * https://www.postgresql.org/docs/current/sql-insert.html @@ -101,18 +102,26 @@ public InsertConflictAction withWhereExpression(Expression whereExpression) { return this; } - @SuppressWarnings("PMD.SwitchStmtsShouldHaveDefault") public StringBuilder appendTo(StringBuilder builder) { + return appendTo(builder, builder::append); + } + + /** + * Appends {@code " DO NOTHING"} or {@code " DO UPDATE SET ... [WHERE ...]"}, letting a deparser + * visit the expressions. + */ + @SuppressWarnings("PMD.SwitchStmtsShouldHaveDefault") + public StringBuilder appendTo(StringBuilder builder, Consumer expressionRenderer) { switch (conflictActionType) { case DO_NOTHING: builder.append(" DO NOTHING"); break; case DO_UPDATE: builder.append(" DO UPDATE SET "); - UpdateSet.appendUpdateSetsTo(builder, updateSets); - + UpdateSet.appendUpdateSetsTo(builder, updateSets, expressionRenderer); if (whereExpression != null) { - builder.append(" WHERE ").append(whereExpression); + builder.append(" WHERE "); + expressionRenderer.accept(whereExpression); } break; } diff --git a/src/main/java/net/sf/jsqlparser/statement/insert/InsertDuplicateAction.java b/src/main/java/net/sf/jsqlparser/statement/insert/InsertDuplicateAction.java index 4a106a6f79..727032819e 100644 --- a/src/main/java/net/sf/jsqlparser/statement/insert/InsertDuplicateAction.java +++ b/src/main/java/net/sf/jsqlparser/statement/insert/InsertDuplicateAction.java @@ -18,6 +18,7 @@ import java.util.Collection; import java.util.List; import java.util.Objects; +import java.util.function.Consumer; /** * on duplicate key is one of: @@ -96,19 +97,22 @@ public InsertDuplicateAction withWhereExpression(Expression whereExpression) { return this; } - @SuppressWarnings("PMD.SwitchStmtsShouldHaveDefault") public StringBuilder appendTo(StringBuilder builder) { - switch (conflictActionType) { - case NOTHING: - builder.append(" NOTHING "); - break; - default: - UpdateSet.appendUpdateSetsTo(builder, updateSets); - - if (whereExpression != null) { - builder.append(" WHERE ").append(whereExpression); - } - break; + return appendTo(builder, builder::append); + } + + /** + * Appends the action after {@code ON DUPLICATE KEY UPDATE}: {@code NOTHING}, or the assignments + * and an optional {@code WHERE}, letting a deparser visit the expressions. + */ + public StringBuilder appendTo(StringBuilder builder, Consumer expressionRenderer) { + if (conflictActionType == ConflictActionType.NOTHING) { + return builder.append("NOTHING"); + } + UpdateSet.appendUpdateSetsTo(builder, updateSets, expressionRenderer); + if (whereExpression != null) { + builder.append(" WHERE "); + expressionRenderer.accept(whereExpression); } return builder; } diff --git a/src/main/java/net/sf/jsqlparser/statement/insert/OracleMultiInsertClause.java b/src/main/java/net/sf/jsqlparser/statement/insert/OracleMultiInsertClause.java index 1a7bdc6d36..456be08a88 100644 --- a/src/main/java/net/sf/jsqlparser/statement/insert/OracleMultiInsertClause.java +++ b/src/main/java/net/sf/jsqlparser/statement/insert/OracleMultiInsertClause.java @@ -10,7 +10,7 @@ package net.sf.jsqlparser.statement.insert; import java.io.Serializable; -import java.util.Iterator; +import java.util.function.Consumer; import net.sf.jsqlparser.expression.operators.relational.ExpressionList; import net.sf.jsqlparser.schema.Column; import net.sf.jsqlparser.schema.Table; @@ -48,26 +48,22 @@ public void setSelect(Select select) { @Override public String toString() { - StringBuilder sql = new StringBuilder("INTO "); - sql.append(table); + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } + /** Appends {@code INTO table [(columns)] query}, letting a deparser visit the query. */ + public StringBuilder appendTo(StringBuilder builder, Consumer selects = selectVisitor == null ? builder::append + : select -> select.accept(selectVisitor, null); + Consumer expressions = expressionVisitor == null ? builder::append + : expression -> expression.accept(expressionVisitor, null); + Consumer> selectItems = builder::append; + if (expressionVisitor != null && insert.getReturningClause() != null) { + SelectDeParser returningItems = new SelectDeParser(expressionVisitor, builder); + selectItems = item -> item.accept(returningItems, null); + } + insert.appendTo(builder, withItems, selects, expressions, selectItems); } public ExpressionVisitor getExpressionVisitor() { @@ -175,46 +69,4 @@ public SelectVisitor getSelectVisitor() { public void setSelectVisitor(SelectVisitor visitor) { selectVisitor = visitor; } - - private void appendOracleIntoClause(OracleMultiInsertClause clause) { - builder.append("INTO ").append(clause.getTable().toString()); - if (clause.getColumns() != null && !clause.getColumns().isEmpty()) { - builder.append(" ("); - for (Iterator iter = clause.getColumns().iterator(); iter.hasNext();) { - Column column = iter.next(); - builder.append(column.getColumnName()); - if (iter.hasNext()) { - builder.append(", "); - } - } - builder.append(")"); - } - if (clause.getSelect() != null) { - builder.append(" "); - clause.getSelect().accept(selectVisitor, null); - } - } - - private void appendOracleMultiInsertBranch(OracleMultiInsertBranch branch) { - if (branch == null || branch.getClauses() == null || branch.getClauses().isEmpty()) { - return; - } - - if (branch.getWhenExpression() != null) { - builder.append(" WHEN "); - if (expressionVisitor != null) { - branch.getWhenExpression().accept(expressionVisitor, null); - } else { - builder.append(branch.getWhenExpression().toString()); - } - builder.append(" THEN"); - } else if (branch.isElseClause()) { - builder.append(" ELSE"); - } - - for (OracleMultiInsertClause clause : branch.getClauses()) { - builder.append(" "); - appendOracleIntoClause(clause); - } - } } 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 3fee43edf6..d844f3ec59 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/SelectDeParser.java @@ -21,7 +21,6 @@ import net.sf.jsqlparser.expression.Alias; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.ExpressionVisitor; -import net.sf.jsqlparser.expression.MySQLIndexHint; import net.sf.jsqlparser.expression.OracleHint; import net.sf.jsqlparser.expression.SQLServerHints; import net.sf.jsqlparser.expression.WindowDefinition; @@ -647,10 +646,7 @@ public StringBuilder visit(Table table, S context) { if (unpivot != null) { unpivot.accept(this, context); } - MySQLIndexHint indexHint = table.getIndexHint(); - if (indexHint != null) { - builder.append(indexHint); - } + table.appendIndexHintsTo(builder); SQLServerHints sqlServerHints = table.getSqlServerHints(); if (sqlServerHints != null) { builder.append(sqlServerHints); diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/UpsertDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/UpsertDeParser.java index 218ca1db30..9048a2cc3a 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/UpsertDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/UpsertDeParser.java @@ -10,7 +10,6 @@ package net.sf.jsqlparser.util.deparser; import net.sf.jsqlparser.expression.ExpressionVisitor; -import net.sf.jsqlparser.statement.insert.ConflictActionType; import net.sf.jsqlparser.statement.select.SelectVisitor; import net.sf.jsqlparser.statement.upsert.Upsert; @@ -67,6 +66,7 @@ public void deParse(Upsert upsert) { deparseUpdateSets(upsert.getUpdateSets(), builder, expressionVisitor); } else { if (upsert.getColumns() != null) { + builder.append(" "); upsert.getColumns().accept(expressionVisitor, null); } @@ -78,16 +78,12 @@ public void deParse(Upsert upsert) { builder.append(" "); upsert.getSelect().accept((SelectVisitor) selectVisitor, null); } + } - if (upsert.getDuplicateAction() != null) { - builder.append(" ON DUPLICATE KEY UPDATE "); - if (ConflictActionType.DO_UPDATE - .equals(upsert.getDuplicateAction().getConflictActionType())) { - deparseUpdateSets(upsert.getDuplicateUpdateSets(), builder, expressionVisitor); - } else { - upsert.getDuplicateAction().appendTo(builder); - } - } + if (upsert.getDuplicateAction() != null) { + builder.append(" ON DUPLICATE KEY UPDATE "); + upsert.getDuplicateAction().appendTo(builder, + expression -> expression.accept(expressionVisitor, null)); } } diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/ValuesStatementDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/ValuesStatementDeParser.java index 8c8ffe967a..1f921d12e9 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/ValuesStatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/ValuesStatementDeParser.java @@ -40,7 +40,8 @@ public void deParse(Values values, S context) { builder.append("VALUES "); values.getExpressions().accept(expressionVisitor, context); if (values.getAlias() != null) { - builder.append(" ").append(values.getAlias()); + // the alias renders its own leading space, as in Values.toString() + builder.append(values.getAlias()); } } } diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/DeleteValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/DeleteValidator.java index d030b9f309..85f41c7b8b 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/DeleteValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/DeleteValidator.java @@ -45,6 +45,7 @@ public void validate(Delete delete) { validateOptionalOrderByElements(delete.getOrderByElements()); v.validateOptionalJoins(delete.getJoins()); + v.validateOptionalJoins(delete.getUsingJoins()); if (delete.getLimit() != null) { getValidator(LimitValidator.class).validate(delete.getLimit()); 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 a042c50983..1798e211f5 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 @@ -301,9 +301,12 @@ public Void visit(Table table, S context) { validateOptional(table.getPivot(), p -> p.accept(this, context)); validateOptional(table.getUnPivot(), up -> up.accept(this, context)); - MySQLIndexHint indexHint = table.getIndexHint(); - if (indexHint != null && isNotEmpty(indexHint.getIndexNames())) { - indexHint.getIndexNames().forEach(i -> validateName(NamedObject.index, i)); + if (table.getIndexHints() != null) { + for (MySQLIndexHint indexHint : table.getIndexHints()) { + if (isNotEmpty(indexHint.getIndexNames())) { + indexHint.getIndexNames().forEach(i -> validateName(NamedObject.index, i)); + } + } } SQLServerHints sqlServerHints = table.getSqlServerHints(); if (sqlServerHints != null) { diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 76fc4f7041..ef5c4258f5 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -1528,6 +1528,39 @@ public class CCJSqlParser extends AbstractJSqlParser { } } + /** + * Whether MySQL's {@code TABLE name} follows an INSERT target, so that TABLE starts the + * source query instead of naming an alias of the target. + */ + private boolean isInsertTableSourceAhead() { + if (getToken(1).kind != K_TABLE) { + return false; + } + int kind = getToken(2).kind; + if (kind == S_IDENTIFIER || kind == S_QUOTED_IDENTIFIER) { + return true; + } + return kind >= MIN_NON_RESERVED_WORD && kind <= MAX_NON_RESERVED_WORD + && kind != K_OVERRIDING && kind != K_BY && kind != K_TABLE; + } + + /** Whether {@code AS CHAR(} or {@code AS BINARY(} follows a function argument. */ + private boolean isFunctionCastTypeAhead() { + int type = getToken(2).kind; + return getToken(1).kind == K_AS && (type == K_CHAR || type == K_BINARY) + && getToken(3).kind == OPENING_BRACKET; + } + + /** + * Whether a function takes MySQL's {@code AS CHAR(n)} or {@code AS BINARY(n)} cast: only + * {@code WEIGHT_STRING} does, after its single argument. + */ + private static boolean isWeightStringCall(Function function, ExpressionList arguments) { + return "WEIGHT_STRING".equalsIgnoreCase(function.getName()) && arguments != null + && arguments.size() == 1 && !function.isDistinct() && !function.isAllColumns() + && !function.isUnique(); + } + private boolean isKeywordArgumentAhead() { Token t = getToken(1); if (t.kind == EOF || t.image.equals(")")) return false; @@ -1822,9 +1855,9 @@ public class CCJSqlParser extends AbstractJSqlParser { case K_NEXTVAL: return nextKind != K_VALUE; // IGNORE: blocks NULLS (IGNORE NULLS), FROM (DELETE IGNORE FROM), - // INDEX (IGNORE INDEX hint) + // INDEX and KEY (IGNORE INDEX and IGNORE KEY hints) case K_IGNORE: return nextKind != K_NULLS && nextKind != K_FROM - && nextKind != K_INDEX; + && nextKind != K_INDEX && nextKind != K_KEY; // GLOBAL: blocks IN (GLOBAL IN), TEMPORARY, JOIN (GLOBAL JOIN) case K_GLOBAL: return nextKind != K_IN && nextKind != K_TEMPORARY @@ -1835,10 +1868,13 @@ public class CCJSqlParser extends AbstractJSqlParser { case K_LIMIT: case K_OFFSET: return false; - // VALUE/VALUES: safe as alias when not starting VALUES(...) + // VALUE/VALUES: safe as alias when not starting VALUES(...), nor MySQL's + // VALUES ROW(...), which only VALUES starts case K_VALUE: case K_VALUES: return nextKind != OPENING_BRACKET && nextKind != S_IDENTIFIER - && nextKind != S_QUOTED_IDENTIFIER && nextKind != S_CHAR_LITERAL; + && nextKind != S_QUOTED_IDENTIFIER && nextKind != S_CHAR_LITERAL + && !(kind == K_VALUES && nextKind == K_ROW + && getToken(3).kind == OPENING_BRACKET); default: return false; @@ -2445,6 +2481,10 @@ public class CCJSqlParser extends AbstractJSqlParser { return false; } + private static void requireSyntax(boolean valid, String message) throws ParseException { + if (!valid) { throw new ParseException(message); } + } + private static void requireTypeDdlSyntax(boolean valid, String message) throws ParseException { if (!valid) { throw new ParseException(message); } } @@ -8077,7 +8117,7 @@ Insert Insert(): Table table = null; List> with = null; Column tableColumn = null; - ExpressionList columns = new ExpressionList(); + ExpressionList columns = null; List partitions = new ArrayList(); Expression exp = null; ReturningClause returningClause; @@ -8171,12 +8211,12 @@ Insert Insert(): ] table=TableReference() [ LOOKAHEAD(2) "(" partitions=Partitions() ")" ] - [ LOOKAHEAD({ isAliasAhead() && !isColumnMatchingAhead() + [ LOOKAHEAD({ isAliasAhead() && !isColumnMatchingAhead() && !isInsertTableSourceAhead() && !(getToken(1).kind == K_OVERRIDING && (getToken(2).kind == K_SYSTEM || getToken(2).kind == K_USER)) }) [ { useAs = true; } ] name=RelObjectName() { table.setAlias(new Alias(name,useAs)); }] - [ LOOKAHEAD(2) "(" columns=ColumnList() ")" ] + [ LOOKAHEAD(2) columns=InsertColumnList() ] // DuckDB: INSERT INTO tbl BY NAME|POSITION SELECT ... [ @@ -8230,7 +8270,7 @@ Insert Insert(): ) { - if (!columns.isEmpty()) { + if (columns != null) { insert.setColumns(columns); } if (!partitions.isEmpty()) { @@ -8244,6 +8284,36 @@ Insert Insert(): } } +/** + * The target column list of INSERT and REPLACE, without its parentheses. MySQL accepts an empty + * list, as in INSERT INTO t () VALUES (). + */ +ExpressionList InsertColumnList(): +{ + ExpressionList columns = new ExpressionList(); +} +{ + "(" [ columns = ColumnList() ] ")" + { + return columns; + } +} + +/** {@link #InsertColumnList()} with its parentheses, as REPLACE keeps it. */ +ParenthesedExpressionList ParenthesedInsertColumnList(): +{ + ExpressionList columns; + Token first = getToken(1); +} +{ + columns = InsertColumnList() + { + ParenthesedExpressionList result = new ParenthesedExpressionList(columns); + linkAST(result, first); + return result; + } +} + OracleMultiInsertClause OracleMultiInsertClause(): { OracleMultiInsertClause clause = new OracleMultiInsertClause(); @@ -8456,7 +8526,7 @@ Upsert Upsert(): table=Table() { upsert.setTable(table); } - [ LOOKAHEAD(2) columns = ParenthesedColumnList() { upsert.setColumns(columns); } ] + [ LOOKAHEAD(2) columns = ParenthesedInsertColumnList() { upsert.setColumns(columns); } ] ( ( @@ -8497,6 +8567,7 @@ Delete Delete(): List> with = null; FromItem usingFromItem = null; List usingFromItemList = new ArrayList(); + List usingJoins = null; List joins = null; Expression where = null; PreferringClause preferringClause = null; @@ -8526,7 +8597,10 @@ Delete Delete(): [ forPortionClause=ForPortionClause() { delete.setForPortionClause(forPortionClause); } ] [ LOOKAHEAD(2) joins=JoinsList() ] ] [ usingFromItem=FromItem() { usingFromItemList.add(usingFromItem); } - ("," usingFromItem=FromItem() { usingFromItemList.add(usingFromItem); } )*] + ( LOOKAHEAD(2) "," usingFromItem=FromItem() { usingFromItemList.add(usingFromItem); } )* + // MySQL's table references and PostgreSQL's from items may join further tables + [ LOOKAHEAD(2) usingJoins=JoinsList() { delete.setUsingJoins(usingJoins); } ] + ] [where=WhereClause() { delete.setWhere(where); } ] [preferringClause=PreferringClause() { delete.setPreferringClause(preferringClause);} ] [orderByElements = OrderByElements() { delete.setOrderByElements(orderByElements); } ] @@ -8889,11 +8963,11 @@ Table TableWithAlias(): Table TableWithAliasAndMysqlIndexHint(): { Table table = null; - MySQLIndexHint indexHint = null; + List indexHints = null; } { table=TableWithAlias() - [ LOOKAHEAD(2) indexHint=MySQLIndexHint() { table.setHint(indexHint); } ] + [ LOOKAHEAD(2) indexHints=MySQLIndexHints() { table.setIndexHints(indexHints); } ] { return table; } } @@ -10686,6 +10760,7 @@ MySQLIndexHint MySQLIndexHint(): { Token actionToken = null; Token indexToken = null; + MySQLIndexHint.Scope scope = null; String indexName = null; List indexNameList = new ArrayList(); } @@ -10702,12 +10777,39 @@ MySQLIndexHint MySQLIndexHint(): | indexToken = ) + [ + + ( + { scope = MySQLIndexHint.Scope.JOIN; } + | { scope = MySQLIndexHint.Scope.ORDER_BY; } + | { scope = MySQLIndexHint.Scope.GROUP_BY; } + ) + ] + "(" + [ indexName = RelObjectName() { indexNameList.add(indexName); } ("," indexName= RelObjectName() { indexNameList.add(indexName); })* + ] ")" { - return new MySQLIndexHint(actionToken.image, indexToken.image, indexNameList); + // only USE INDEX () may name no index + requireSyntax(!indexNameList.isEmpty() || actionToken.kind == K_USE, + actionToken.image + " " + indexToken.image + " requires at least one index name"); + return new MySQLIndexHint(actionToken.image, indexToken.image, scope, indexNameList); + } +} + +List MySQLIndexHints(): +{ + List hints = new ArrayList(); + MySQLIndexHint hint; +} +{ + hint = MySQLIndexHint() { hints.add(hint); } + ( LOOKAHEAD(2) hint = MySQLIndexHint() { hints.add(hint); } )* + { + return hints; } } @@ -11130,7 +11232,7 @@ FromItem FromItem() #FromItem: Pivot pivot = null; UnPivot unpivot = null; Alias alias = null; - MySQLIndexHint indexHint = null; + List indexHints = null; SQLServerHints sqlServerHints = null; Select select; @@ -11210,9 +11312,9 @@ FromItem FromItem() #FromItem: [ LOOKAHEAD(2) ( - indexHint = MySQLIndexHint() { + indexHints = MySQLIndexHints() { if (fromItem instanceof Table) - ((Table) fromItem).setHint(indexHint); + ((Table) fromItem).setIndexHints(indexHints); } | sqlServerHints = SQLServerHints() { @@ -15736,6 +15838,7 @@ Function InternalFunction(boolean escaped): Limit limit; List orderByList = null; List keywordArgs = null; + ColDataType castType = null; } { [ LOOKAHEAD(2) prefixToken = ] @@ -15766,6 +15869,16 @@ Function InternalFunction(boolean escaped): ) ] + // MySQL: WEIGHT_STRING(str AS CHAR(n)) and WEIGHT_STRING(str AS BINARY(n)) + [ + LOOKAHEAD({ isFunctionCastTypeAhead() }) castType = WeightStringCastType() + { + requireSyntax(isWeightStringCall(retval, expressionList), + "AS " + castType + " is only supported after the argument of WEIGHT_STRING"); + retval.setCastType(castType); + } + ] + [ ( expr = Expression() { retval.setHavingClause( "MIN", expr ); } @@ -19426,6 +19539,24 @@ String SignedIntegerTypeArgument(): { return (sign == null ? "" : sign.image) + number.image; } } +/** + * The type of MySQL's WEIGHT_STRING(str AS {CHAR | BINARY}(n)): one of the two type names and a + * length, which the server requires and allows nothing else with. It is built like the same type + * in CAST. + */ +ColDataType WeightStringCastType(): +{ + Token type; + Token length; + Token first = getToken(1); +} +{ + ( type= | type= ) "(" length= ")" + { + return linked(ColDataType.fromTypeParameters(type.image, length.image, null), first); + } +} + ColDataType ColDataType(): { ColDataType colDataType = new ColDataType(); diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index f56cb9ea95..e5e826a253 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -232,6 +232,32 @@ The fastest way to learn the object model is to look at it. Paste your SQL into Read that as a map: each line is a getter away. ``select.getSelectItems()``, ``select.getFromItem()``, ``select.getWhere()``. Once the tree gets deeper than a couple of levels, stop casting by hand and use :ref:`Use the Visitor Patterns`. +MySQL index hints, insert sources and DELETE ... USING +------------------------------------------------------ + +A table reference keeps every MySQL index hint in ``Table.getIndexHints()``, in +source order, for example ``t USE INDEX (i1) IGNORE INDEX FOR ORDER BY (i2)``. +``MySQLIndexHint.getScope()`` returns ``JOIN``, ``ORDER_BY`` or ``GROUP_BY`` for a +``FOR`` clause and ``null`` without one. ``USE INDEX ()`` has an empty index list; +``IGNORE`` and ``FORCE`` require at least one index. ``getIndexHint()`` still returns +the first hint, and ``setHint()`` replaces all hints with one. + +``WEIGHT_STRING(str AS CHAR(n))`` and ``WEIGHT_STRING(str AS BINARY(n))`` keep the cast +in ``Function.getCastType()``, a ``ColDataType`` as in ``CAST``. + +``INSERT INTO t VALUES ROW(1, 2), ROW(3, 4)`` and ``INSERT INTO t TABLE s`` use the same +``Values`` and ``TableStatement`` models as the queries ``VALUES ROW(...)`` and +``TABLE s``. ``INSERT INTO t () VALUES ()`` has an empty, non-null +``getColumns()``; REPLACE accepts the same forms. ``toString()`` and +``InsertDeParser`` both render through ``Insert.appendTo``, which writes ``PARTITION`` +before the column list. + +``DELETE FROM t1 USING t1 JOIN t2 ON t1.id = t2.id`` keeps the joins of the USING +clause in ``Delete.getUsingJoins()``, after ``getUsingFromItemList()``. PostgreSQL's +``DELETE FROM t USING s JOIN u ON ...`` uses the same properties. ``getJoins()`` +holds the joins of the target, as in ``DELETE FROM t1, t2 USING ...``, and is +rendered before USING. + PostgreSQL CHECK inheritance ---------------------------- diff --git a/src/test/java/net/sf/jsqlparser/expression/WeightStringFunctionTest.java b/src/test/java/net/sf/jsqlparser/expression/WeightStringFunctionTest.java new file mode 100644 index 0000000000..1c72fbbeb9 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/expression/WeightStringFunctionTest.java @@ -0,0 +1,141 @@ +/*- + * #%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.expression; + +import static net.sf.jsqlparser.test.TestUtils.assertDeparse; +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Collections; +import java.util.function.Consumer; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.Node; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.create.table.ColDataType; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.util.TablesNamesFinder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class WeightStringFunctionTest { + + private static final Consumer MYSQL = parser -> parser.withDialect(Dialect.MYSQL); + + private static Function selectFunction(String sql, int index, + Consumer consumer) throws JSQLParserException { + PlainSelect select = (PlainSelect) assertSqlCanBeParsedAndDeparsed(sql, true, consumer); + return (Function) select.getSelectItem(index).getExpression(); + } + + @Test + void castToCharWithMySqlDialect() throws JSQLParserException { + String sql = "SELECT CHAR(77 USING utf8mb4), WEIGHT_STRING('x' AS CHAR(3))"; + Function function = selectFunction(sql, 1, MYSQL); + + assertEquals("WEIGHT_STRING", function.getName()); + assertEquals("'x'", function.getParameters().get(0).toString()); + // the same ColDataType as in CAST('x' AS CHAR(3)) + assertEquals("CHAR (3)", function.getCastType().getDataType()); + CastExpression cast = + (CastExpression) CCJSqlParserUtil.parseExpression("CAST('x' AS CHAR(3))"); + assertEquals(cast.getColDataType().toString(), function.getCastType().toString()); + assertEquals("WEIGHT_STRING('x' AS CHAR (3))", function.toString()); + assertEquals(3, function.getCastType().getNumericPrecision().intValue()); + + // the type spans its own text, inside the call + Node node = function.getCastType().getASTNode(); + assertEquals("CHAR(3)", sql.substring(node.jjtGetFirstToken().absoluteBegin - 1, + node.jjtGetLastToken().absoluteEnd - 1)); + } + + @Test + void castToBinaryWithoutDialect() throws JSQLParserException { + Function function = selectFunction("SELECT weight_string(c AS BINARY(4)) FROM t", 0, null); + + assertEquals("BINARY (4)", function.getCastType().getDataType()); + assertInstanceOf(Column.class, function.getParameters().get(0)); + } + + @Test + void plainCallHasNoCastType() throws JSQLParserException { + Function function = selectFunction("SELECT WEIGHT_STRING('x')", 0, MYSQL); + + assertNull(function.getCastType()); + assertEquals("WEIGHT_STRING('x')", function.toString()); + } + + @Test + void charUsingKeepsItsKeywordArgument() throws JSQLParserException { + Function function = selectFunction("SELECT CHAR(77 USING utf8mb4)", 0, MYSQL); + + assertNull(function.getCastType()); + assertEquals("USING", function.getKeywordArguments().get(0).getKeyword()); + } + + @Test + void nestedInExpressionsAndSubqueries() throws JSQLParserException { + String sql = "SELECT HEX(WEIGHT_STRING(a AS CHAR(2))) FROM t" + + " WHERE WEIGHT_STRING(b AS BINARY(8)) = (SELECT WEIGHT_STRING(c) FROM s)"; + assertSqlCanBeParsedAndDeparsed(sql, true, MYSQL); + + assertEquals(2, TablesNamesFinder.findTables(sql).size()); + } + + @ParameterizedTest + @ValueSource(strings = { + "SELECT WEIGHT_STRING('x' AS CHAR)", + "SELECT WEIGHT_STRING('x' AS VARCHAR(3))", + "SELECT WEIGHT_STRING('x' AS CHAR(3)", + "SELECT LOWER('x' AS CHAR(3))", + "SELECT WEIGHT_STRING('x' AS CHAR(3) AS BINARY(3))", + "SELECT WEIGHT_STRING('x' AS CHAR(3) CHARACTER SET utf8mb4)", + "SELECT WEIGHT_STRING('x' AS CHAR(n))", + "SELECT WEIGHT_STRING('x' AS BINARY(3, 2))", + "SELECT WEIGHT_STRING('x' AS CHAR(3) ARRAY)", + "SELECT WEIGHT_STRING('x', 'y' AS CHAR(3))", + "SELECT WEIGHT_STRING(DISTINCT 'x' AS CHAR(3))", + "SELECT WEIGHT_STRING(AS CHAR(3))", + "SELECT db.WEIGHT_STRING('x' AS CHAR(3))" + }) + void rejectsOtherCastForms(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, MYSQL)); + } + + @Test + void statementBoundary() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "SELECT WEIGHT_STRING('x' AS CHAR(3)); SELECT 1", MYSQL); + + assertEquals(2, statements.size()); + } + + @Test + void buildCastTypeOnTheModel() throws JSQLParserException { + ColDataType binary = new ColDataType("BINARY"); + binary.setArgumentsStringList(Collections.singletonList("4")); + Function function = new Function().withName("WEIGHT_STRING") + .withParameters(new StringValue("x")).withCastType(binary); + + assertEquals("WEIGHT_STRING('x' AS BINARY (4))", function.toString()); + assertDeparse(new PlainSelect().addSelectItem(function), + "SELECT WEIGHT_STRING('x' AS BINARY (4))"); + + function.setCastType(null); + assertEquals("WEIGHT_STRING('x')", function.toString()); + } +} diff --git a/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java b/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java index 0ac7a4e84a..c991cbb198 100644 --- a/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java +++ b/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java @@ -146,7 +146,18 @@ public void testStatementsTheCorpusLacks() throws JSQLParserException { "CLUSTER (VERBOSE) t USING i", "CLUSTER VERBOSE i ON t", "ANALYSE (\"verbose\", PARALLEL -1) t", - "CHECKPOINT"}) { + "CHECKPOINT", + "SELECT a FROM t AS x USE KEY (i1) FORCE INDEX FOR JOIN (i2, i3) WHERE a = 1", + "SELECT a FROM t USE INDEX () JOIN s IGNORE INDEX FOR ORDER BY (i1) ON t.a = s.a", + "UPDATE t USE INDEX (i1) IGNORE INDEX FOR ORDER BY (i2) SET a = 1", + "SELECT WEIGHT_STRING('x' AS CHAR(3)), WEIGHT_STRING(a AS BINARY(4))", + "INSERT INTO t (a, b) VALUES ROW(1, 2), ROW(3, 4)", + "INSERT INTO t PARTITION (p0) (a) TABLE s", + "INSERT INTO t () VALUES ()", + "REPLACE INTO t () VALUES ()", + "REPLACE INTO t (a, b) VALUES ROW(1, 2)", + "DELETE FROM t1, t2 USING t1 INNER JOIN t2 INNER JOIN t3 WHERE t1.id = t2.id", + "DELETE FROM t USING s JOIN u ON s.id = u.id, v WHERE t.id = s.id"}) { new Walk(sql, sql, problems).check(CCJSqlParserUtil.parseStatements(sql)); } String block = "BEGIN x := 1; NULL; foo(1); END;"; diff --git a/src/test/java/net/sf/jsqlparser/statement/delete/DeleteUsingJoinTest.java b/src/test/java/net/sf/jsqlparser/statement/delete/DeleteUsingJoinTest.java new file mode 100644 index 0000000000..884790d7f8 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/delete/DeleteUsingJoinTest.java @@ -0,0 +1,228 @@ +/*- + * #%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.delete; + +import static net.sf.jsqlparser.test.TestUtils.assertDeparse; +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.function.Consumer; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Alias; +import net.sf.jsqlparser.expression.ExpressionVisitorAdapter; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.StatementVisitorAdapter; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.select.FromItemVisitorAdapter; +import net.sf.jsqlparser.statement.select.Join; +import net.sf.jsqlparser.statement.select.ParenthesedSelect; +import net.sf.jsqlparser.statement.select.SelectVisitorAdapter; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.validation.ValidationTestAsserts; +import net.sf.jsqlparser.util.validation.feature.MySqlVersion; +import net.sf.jsqlparser.util.validation.feature.PostgresqlVersion; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class DeleteUsingJoinTest { + + private static final Consumer MYSQL = parser -> parser.withDialect(Dialect.MYSQL); + private static final Consumer POSTGRESQL = + parser -> parser.withDialect(Dialect.POSTGRESQL); + + /** Parses exactly with and without the given dialect and returns the dialect's result. */ + private static Delete parse(String sql, Consumer dialect) + throws JSQLParserException { + assertSqlCanBeParsedAndDeparsed(sql, false); + return (Delete) assertSqlCanBeParsedAndDeparsed(sql, false, dialect); + } + + private static String name(Object fromItem) { + return ((Table) fromItem).getFullyQualifiedName(); + } + + @Test + void mySqlUsingJoin() throws JSQLParserException { + Delete delete = parse("DELETE FROM t1 USING t1 JOIN t2 ON t1.id = t2.id", MYSQL); + + assertEquals("t1", delete.getTable().getName()); + assertTrue(delete.getTables().isEmpty()); + assertNull(delete.getJoins()); + assertEquals(1, delete.getUsingFromItemList().size()); + assertEquals("t1", name(delete.getUsingFromItemList().get(0))); + Join join = delete.getUsingJoins().get(0); + assertEquals("t2", name(join.getFromItem())); + assertEquals("t1.id = t2.id", join.getOnExpressions().iterator().next().toString()); + } + + @Test + void mySqlSeveralTargets() throws JSQLParserException { + Delete delete = parse("DELETE FROM t1, t2 USING t1 INNER JOIN t2 INNER JOIN t3" + + " WHERE t1.id = t2.id AND t2.id = t3.id", MYSQL); + + // the targets keep the existing FROM t1, t2 reading + assertEquals("t1", delete.getTable().getName()); + assertTrue(delete.getJoins().get(0).isSimple()); + assertEquals("t2", name(delete.getJoins().get(0).getFromItem())); + assertEquals("t1", name(delete.getUsingFromItemList().get(0))); + assertEquals(2, delete.getUsingJoins().size()); + assertTrue(delete.getUsingJoins().get(1).isInner()); + assertEquals("t3", name(delete.getUsingJoins().get(1).getFromItem())); + } + + @Test + void targetsBeforeUsingKeepTheirPlace() throws JSQLParserException { + // the target joins were rendered after USING before + Delete delete = parse("DELETE FROM t1, t2 USING t1, t2 WHERE t1.id = t2.id", MYSQL); + + assertEquals(1, delete.getJoins().size()); + assertEquals(2, delete.getUsingFromItemList().size()); + assertNull(delete.getUsingJoins()); + } + + @Test + void commaListFollowedByJoins() throws JSQLParserException { + Delete delete = parse("DELETE FROM t1 USING t1, t2 LEFT JOIN t3 ON t2.a = t3.a, t4" + + " WHERE t1.id = t2.id", MYSQL); + + assertEquals(2, delete.getUsingFromItemList().size()); + assertEquals(2, delete.getUsingJoins().size()); + assertTrue(delete.getUsingJoins().get(0).isLeft()); + assertTrue(delete.getUsingJoins().get(1).isSimple()); + } + + @Test + void postgreSqlUsingJoinWithSubquery() throws JSQLParserException { + Delete delete = parse("DELETE FROM t USING (SELECT id FROM s) AS x JOIN u ON x.id = u.id" + + " WHERE t.id = x.id RETURNING t.id", POSTGRESQL); + + assertInstanceOf(ParenthesedSelect.class, delete.getUsingFromItemList().get(0)); + assertEquals("u", name(delete.getUsingJoins().get(0).getFromItem())); + assertEquals(1, delete.getReturningClause().size()); + } + + @Test + void plainUsingListIsUnchanged() throws JSQLParserException { + Delete delete = parse("DELETE FROM t USING s, u WHERE t.id = s.id", POSTGRESQL); + + assertEquals(2, delete.getUsingFromItemList().size()); + assertNull(delete.getUsingJoins()); + assertNull(delete.getJoins()); + } + + @Test + void existingMultiTableFormIsUnchanged() throws JSQLParserException { + Delete delete = parse("DELETE t1 FROM t1 JOIN t2 ON t1.id = t2.id", MYSQL); + + assertEquals(1, delete.getTables().size()); + assertEquals(1, delete.getJoins().size()); + assertTrue(delete.getUsingFromItemList().isEmpty()); + assertNull(delete.getUsingJoins()); + } + + @Test + void targetAliasesRenderInBothRenderers() throws JSQLParserException { + // the deparser dropped the alias of a target table, which the grammar accepts although + // MySQL takes aliases only after FROM + Delete delete = (Delete) assertSqlCanBeParsedAndDeparsed( + "DELETE t1 x FROM t1 x WHERE x.a = 1", false); + + assertEquals("x", delete.getTables().get(0).getAlias().getName()); + } + + @ParameterizedTest + @ValueSource(strings = { + "DELETE FROM t1 USING", + "DELETE FROM t1 USING t1 JOIN", + "DELETE FROM t1 USING t1 JOIN t2 ON", + "DELETE FROM t1 USING , t1" + }) + void rejectsIncompleteForms(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, MYSQL)); + } + + @Test + void statementBoundary() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "DELETE FROM t1 USING t1 JOIN t2 ON t1.id = t2.id; SELECT 1", MYSQL); + + assertEquals(2, statements.size()); + assertEquals("DELETE FROM t1 USING t1 JOIN t2 ON t1.id = t2.id", + statements.get(0).toString()); + } + + @Test + void tablesNamesFinderSeesUsingJoins() throws JSQLParserException { + assertEquals(new HashSet<>(Arrays.asList("t1", "t2", "t3")), TablesNamesFinder.findTables( + "DELETE FROM t1 USING t1 JOIN t2 ON t1.id = t2.id JOIN t3 ON t2.id = t3.id")); + assertEquals(new HashSet<>(Arrays.asList("t", "s", "u")), TablesNamesFinder.findTables( + "DELETE FROM t USING (SELECT id FROM s) x JOIN u ON x.id = u.id")); + } + + @Test + void statementVisitorAdapterVisitsUsingItemsAndJoins() throws JSQLParserException { + Delete delete = (Delete) CCJSqlParserUtil.parse( + "DELETE FROM t USING (SELECT id FROM s) x JOIN u ON x.id = u.id"); + List tables = new ArrayList<>(); + ExpressionVisitorAdapter expressions = new ExpressionVisitorAdapter<>(); + FromItemVisitorAdapter fromItems = new FromItemVisitorAdapter() { + @Override + public Void visit(Table table, S context) { + tables.add(table.getName()); + return null; + } + }; + SelectVisitorAdapter selects = new SelectVisitorAdapter<>(expressions, fromItems); + expressions.setSelectVisitor(selects); + fromItems.setSelectVisitor(selects).setExpressionVisitor(expressions); + + // a subquery in USING no longer fails the cast in getUsingList() + delete.accept(new StatementVisitorAdapter(selects), null); + + assertEquals(Arrays.asList("t", "s", "u"), tables); + } + + @Test + void validatesForMySqlAndPostgreSql() throws JSQLParserException { + ValidationTestAsserts.validateNoErrors("DELETE FROM t1 USING t1 JOIN t2 ON t1.id = t2.id", + 1, MySqlVersion.V8_0); + ValidationTestAsserts.validateNoErrors( + "DELETE FROM t USING s JOIN u ON s.id = u.id WHERE t.id = s.id", 1, + PostgresqlVersion.V14); + } + + @Test + void buildUsingJoinsOnTheModel() { + Delete delete = new Delete().withTable(new Table("t1")) + .addUsingFromItemList(new Table("t1")); + delete.addUsingJoins(new Join().withInner(true) + .setFromItem(new Table("t2").withAlias(new Alias("b", false)))); + + assertEquals("DELETE FROM t1 USING t1 INNER JOIN t2 b", delete.toString()); + assertDeparse(delete, "DELETE FROM t1 USING t1 INNER JOIN t2 b"); + + // the joins of USING are rendered only with USING items + delete.setUsingFromItemList(null); + assertEquals("DELETE FROM t1", delete.toString()); + assertDeparse(delete, "DELETE FROM t1"); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/insert/InsertRenderingTest.java b/src/test/java/net/sf/jsqlparser/statement/insert/InsertRenderingTest.java new file mode 100644 index 0000000000..10dd5e3eb8 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/insert/InsertRenderingTest.java @@ -0,0 +1,205 @@ +/*- + * #%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.insert; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.schema.Partition; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.update.UpdateSet; +import net.sf.jsqlparser.util.deparser.ExpressionDeParser; +import net.sf.jsqlparser.util.deparser.InsertDeParser; +import net.sf.jsqlparser.util.deparser.SelectDeParser; +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; + +/** toString() and the deparsers share Insert.appendTo, so both render the same text. */ +class InsertRenderingTest { + + private static final Consumer MYSQL = parser -> parser.withDialect(Dialect.MYSQL); + + private static String deparse(Statement statement) { + StringBuilder builder = new StringBuilder(); + statement.accept(new StatementDeParser(builder)); + return builder.toString(); + } + + /** Asserts that toString(), the deparser and a reparse give back exactly {@code sql}. */ + private static Statement assertExact(String sql, Consumer consumer) + throws JSQLParserException { + Statement statement = CCJSqlParserUtil.parse(sql, consumer); + assertEquals(sql, statement.toString(), "toString()"); + assertEquals(sql, deparse(statement), "StatementDeParser"); + assertEquals(sql, CCJSqlParserUtil.parse(statement.toString(), consumer).toString(), + "reparse"); + return statement; + } + + /** Renders column names in brackets, to see which parts the deparser visits. */ + private static String deparseWithMarkedColumns(Insert insert) { + StringBuilder builder = new StringBuilder(); + SelectDeParser selects = new SelectDeParser(builder); + ExpressionDeParser expressions = new ExpressionDeParser(selects, builder) { + @Override + public StringBuilder visit(Column column, S context) { + return builder.append('[').append(column.getColumnName()).append(']'); + } + }; + selects.setExpressionVisitor(expressions); + new InsertDeParser(expressions, selects, builder).deParse(insert); + return builder.toString(); + } + + @ParameterizedTest + @ValueSource(strings = { + // MySQL and Hive write PARTITION before the column list + "INSERT INTO t PARTITION (p0) (a) VALUES (1)", + "INSERT INTO t PARTITION (p0, p1) VALUES (1), (2)", + "INSERT IGNORE INTO t PARTITION (p0) (a, b) SELECT a, b FROM s", + "INSERT OVERWRITE TABLE t PARTITION (pt1 = 'pt1', pt2) SELECT * FROM a", + "INSERT INTO TABLE t PARTITION (dt = 1) SELECT * FROM a", + // row aliases + "INSERT INTO t (a, b) VALUES (1, 2) AS new ON DUPLICATE KEY UPDATE b = new.b", + "INSERT INTO t (a, b) VALUES (1, 2) AS new(x, y) ON DUPLICATE KEY UPDATE b = y", + "INSERT INTO t SET a = 1, b = 2 AS new ON DUPLICATE KEY UPDATE b = new.b", + // ON DUPLICATE KEY UPDATE keeps its WHERE in both renderers + "INSERT INTO t (a) VALUES (1) ON DUPLICATE KEY UPDATE a = 2 WHERE a > 1", + "INSERT INTO t (a) VALUES (1) ON DUPLICATE KEY UPDATE NOTHING", + "INSERT LOW_PRIORITY IGNORE INTO t VALUES (1)", + "INSERT INTO t DEFAULT VALUES", + "INSERT INTO t1 OUTPUT inserted.* INTO t2 VALUES (1)", + "INSERT INTO t1 OUTPUT inserted.* DEFAULT VALUES", + "INSERT INTO t AS x (a) OVERRIDING SYSTEM VALUE VALUES (1)" + + " ON CONFLICT ON CONSTRAINT c DO NOTHING RETURNING *", + "INSERT INTO t (a) VALUES (1) ON CONFLICT (a) WHERE a > 0" + + " DO UPDATE SET a = excluded.a WHERE t.a < 5 RETURNING a, b AS c", + "INSERT INTO t BY NAME SELECT * FROM s", + "WITH w AS (SELECT 1 AS a) INSERT INTO t SELECT a FROM w", + "INSERT ALL INTO t1 (a) VALUES (x) INTO t2 VALUES (y) SELECT x, y FROM s", + "INSERT FIRST WHEN x > 1 THEN INTO t1 VALUES (x) ELSE INTO t2 VALUES (y)" + + " SELECT x, y FROM s" + }) + void insertRendersIdentically(String sql) throws JSQLParserException { + assertExact(sql, null); + } + + @ParameterizedTest + @ValueSource(strings = { + "REPLACE INTO t (a, b) VALUES (1, 2)", + "REPLACE t (a) SELECT a FROM s", + "REPLACE INTO t SET a = 1", + "UPSERT INTO t (a) VALUES (1) ON DUPLICATE KEY UPDATE NOTHING", + "UPSERT INTO t (a) VALUES (1) ON DUPLICATE KEY UPDATE a = 2 WHERE a > 1" + }) + void replaceRendersIdentically(String sql) throws JSQLParserException { + assertExact(sql, MYSQL); + } + + @Test + void partitionFollowsTheTable() throws JSQLParserException { + Insert insert = (Insert) assertExact("INSERT INTO t PARTITION (p0) (a) VALUES (1)", MYSQL); + + assertEquals("p0", insert.getPartitions().get(0).getColumn().getColumnName()); + assertEquals("a", insert.getColumns().get(0).getColumnName()); + } + + @Test + void optimizerHintComesBeforeThePriority() throws JSQLParserException { + Insert insert = (Insert) CCJSqlParserUtil + .parse("INSERT /*+ APPEND */ LOW_PRIORITY IGNORE INTO t VALUES (1)"); + + assertEquals("INSERT /*+ APPEND */ LOW_PRIORITY IGNORE INTO t VALUES (1)", + insert.toString()); + assertEquals(insert.toString(), deparse(insert)); + } + + @Test + void deparserVisitsConflictAndDuplicateActions() throws JSQLParserException { + Insert conflict = (Insert) CCJSqlParserUtil.parse("INSERT INTO t (a) VALUES (1)" + + " ON CONFLICT (a) WHERE a > 0 DO UPDATE SET a = excluded.a WHERE t.a < 5" + + " RETURNING a"); + // the conflict target names index columns; its predicate and the action are visited + assertEquals("INSERT INTO t (a) VALUES (1) ON CONFLICT (a) WHERE [a] > 0" + + " DO UPDATE SET [a] = [a] WHERE [a] < 5 RETURNING [a]", + deparseWithMarkedColumns(conflict)); + + Insert duplicate = (Insert) CCJSqlParserUtil.parse( + "INSERT INTO t (a) VALUES (b) ON DUPLICATE KEY UPDATE a = c WHERE d > 1"); + assertEquals("INSERT INTO t (a) VALUES ([b]) ON DUPLICATE KEY UPDATE [a] = [c]" + + " WHERE [d] > 1", deparseWithMarkedColumns(duplicate)); + + Insert set = (Insert) CCJSqlParserUtil.parse("INSERT INTO t SET a = b, (c, d) = (1, e)"); + assertEquals("INSERT INTO t SET [a] = [b], ([c], [d]) = (1, [e])", + deparseWithMarkedColumns(set)); + } + + @Test + void appendToTakesRenderers() throws JSQLParserException { + Insert insert = (Insert) CCJSqlParserUtil.parse( + "WITH w AS (SELECT 1) INSERT INTO t SELECT * FROM w ON DUPLICATE KEY UPDATE a = 1" + + " RETURNING a"); + StringBuilder builder = new StringBuilder(); + + insert.appendTo(builder, item -> builder.append(""), + select -> builder.append(""), expression -> builder.append(""), + item -> builder.append("")); + + assertEquals("WITH INSERT INTO t ON DUPLICATE KEY UPDATE = " + + " RETURNING ", builder.toString()); + } + + @Test + void duplicateUpdateSetsComeFromTheAction() throws JSQLParserException { + Insert insert = (Insert) CCJSqlParserUtil.parse("INSERT INTO t (a) VALUES (1)"); + assertNull(insert.getDuplicateUpdateSets()); + + List sets = + Collections.singletonList(new UpdateSet(new Column("a"), new LongValue(2))); + insert.withDuplicateUpdateSets(sets); + assertSame(sets, insert.getDuplicateUpdateSets()); + assertSame(sets, insert.getDuplicateAction().getUpdateSets()); + assertEquals("INSERT INTO t (a) VALUES (1) ON DUPLICATE KEY UPDATE a = 2", + insert.toString()); + assertEquals(insert.toString(), deparse(insert)); + } + + @Test + void builtInsertRendersPartitionsFirst() { + Insert insert = new Insert().withTable(new Table("t")).addColumns(new Column("a")); + insert.setPartitions(Collections.singletonList(new Partition(new Column("p0"), null))); + insert.setOnlyDefaultValues(true); + + assertEquals("INSERT INTO t PARTITION (p0) (a) DEFAULT VALUES", insert.toString()); + assertEquals(insert.toString(), deparse(insert)); + } + + @Test + void actionsRenderWithoutStraySpaces() { + InsertDuplicateAction nothing = new InsertDuplicateAction(ConflictActionType.NOTHING); + assertEquals("NOTHING", nothing.toString()); + + InsertConflictAction doNothing = new InsertConflictAction(ConflictActionType.DO_NOTHING); + assertEquals(" DO NOTHING", doNothing.toString()); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/insert/MySqlInsertSourceTest.java b/src/test/java/net/sf/jsqlparser/statement/insert/MySqlInsertSourceTest.java new file mode 100644 index 0000000000..3ac2a0873b --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/insert/MySqlInsertSourceTest.java @@ -0,0 +1,209 @@ +/*- + * #%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.insert; + +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.function.Consumer; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Function; +import net.sf.jsqlparser.expression.operators.relational.ParenthesedExpressionList; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.Node; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.select.PlainSelect; +import net.sf.jsqlparser.statement.select.TableStatement; +import net.sf.jsqlparser.statement.select.Values; +import net.sf.jsqlparser.statement.upsert.Upsert; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.validation.ValidationTestAsserts; +import net.sf.jsqlparser.util.validation.feature.MySqlVersion; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class MySqlInsertSourceTest { + + private static final Consumer MYSQL = parser -> parser.withDialect(Dialect.MYSQL); + + /** Parses exactly with and without the MySQL dialect and returns the MySQL result. */ + private static Statement parse(String sql) throws JSQLParserException { + assertSqlCanBeParsedAndDeparsed(sql, false); + return assertSqlCanBeParsedAndDeparsed(sql, false, MYSQL); + } + + @Test + void valuesRowConstructors() throws JSQLParserException { + Insert insert = (Insert) parse("INSERT INTO t VALUES ROW(1, 2), ROW(3, 4)"); + + assertEquals("t", insert.getTable().getName()); + assertNull(insert.getTable().getAlias()); + assertNull(insert.getColumns()); + Values values = assertInstanceOf(Values.class, insert.getSelect()); + assertEquals(2, values.getExpressions().size()); + Function row = assertInstanceOf(Function.class, values.getExpressions().get(0)); + assertEquals("ROW", row.getName()); + assertEquals(2, row.getParameters().size()); + + // the same model as the query VALUES ROW(1, 2), ROW(3, 4) + Values query = (Values) CCJSqlParserUtil.parse("VALUES ROW(1, 2), ROW(3, 4)", MYSQL); + assertEquals(query.toString(), values.toString()); + } + + @Test + void valuesRowWithColumnsAndDuplicateKeyUpdate() throws JSQLParserException { + Insert insert = (Insert) parse("INSERT INTO t (a, b) VALUES ROW(1, 2), ROW(3, 4)" + + " ON DUPLICATE KEY UPDATE b = 5"); + + assertEquals(2, insert.getColumns().size()); + assertInstanceOf(Values.class, insert.getSelect()); + assertEquals(1, insert.getDuplicateUpdateSets().size()); + } + + @Test + void tableStatementSource() throws JSQLParserException { + Insert insert = (Insert) parse("INSERT INTO t TABLE s"); + + assertNull(insert.getTable().getAlias()); + TableStatement source = assertInstanceOf(TableStatement.class, insert.getSelect()); + assertEquals("s", source.getTable().getName()); + } + + @Test + void tableStatementSourceWithColumnsAndClauses() throws JSQLParserException { + Insert insert = (Insert) parse("INSERT INTO t (a, b) TABLE db.s ORDER BY a LIMIT 2"); + + assertEquals(2, insert.getColumns().size()); + TableStatement source = assertInstanceOf(TableStatement.class, insert.getSelect()); + assertEquals("db.s", source.getTable().getFullyQualifiedName()); + assertNotNull(source.getLimit()); + } + + @Test + void tableStillNamesAnAliasBeforeOtherSources() throws JSQLParserException { + Insert values = (Insert) assertSqlCanBeParsedAndDeparsed("INSERT INTO t table VALUES (1)"); + assertEquals("table", values.getTable().getAlias().getName()); + + Insert columns = + (Insert) assertSqlCanBeParsedAndDeparsed("INSERT INTO t table (a) VALUES (1)"); + assertEquals("table", columns.getTable().getAlias().getName()); + + Insert explicit = + (Insert) assertSqlCanBeParsedAndDeparsed("INSERT INTO t AS table SELECT 1"); + assertEquals("table", explicit.getTable().getAlias().getName()); + } + + @Test + void emptyColumnListAndRow() throws JSQLParserException { + Insert insert = (Insert) parse("INSERT INTO t () VALUES ()"); + + assertNotNull(insert.getColumns()); + assertTrue(insert.getColumns().isEmpty()); + Values values = assertInstanceOf(Values.class, insert.getSelect()); + assertInstanceOf(ParenthesedExpressionList.class, values.getExpressions()); + assertTrue(values.getExpressions().isEmpty()); + + // without a column list the columns stay null + Insert noColumns = (Insert) parse("INSERT INTO t VALUES ()"); + assertNull(noColumns.getColumns()); + } + + @Test + void emptyColumnListWithOtherSources() throws JSQLParserException { + parse("INSERT INTO t () VALUES (), ()"); + parse("INSERT INTO t () TABLE s"); + parse("INSERT IGNORE INTO t PARTITION (p0) () VALUES ()"); + } + + @Test + void replaceSharesTheSources() throws JSQLParserException { + String sql = "REPLACE INTO t () VALUES ()"; + Upsert empty = (Upsert) parse(sql); + assertTrue(empty.getColumns().isEmpty()); + assertInstanceOf(ParenthesedExpressionList.class, empty.getColumns()); + // the list prints its parentheses, so its node spans them + Node node = empty.getColumns().getASTNode(); + assertEquals("()", sql.substring(node.jjtGetFirstToken().absoluteBegin - 1, + node.jjtGetLastToken().absoluteEnd - 1)); + + Upsert rows = (Upsert) parse("REPLACE INTO t VALUES ROW(1, 2), ROW(3, 4)"); + assertInstanceOf(Values.class, rows.getSelect()); + + Upsert table = (Upsert) parse("REPLACE INTO t (a, b) TABLE s"); + assertInstanceOf(TableStatement.class, table.getSelect()); + } + + @Test + void otherDialectsParseTheSameSources() throws JSQLParserException { + Consumer postgres = parser -> parser.withDialect(Dialect.POSTGRESQL); + Insert table = (Insert) assertSqlCanBeParsedAndDeparsed("INSERT INTO t TABLE s", false, + postgres); + assertInstanceOf(TableStatement.class, table.getSelect()); + + // VALUES before something other than ROW( is still read as before + assertInstanceOf(PlainSelect.class, assertSqlCanBeParsedAndDeparsed( + "SELECT values.a FROM t values WHERE a = 1", false, postgres)); + } + + @ParameterizedTest + @ValueSource(strings = { + "INSERT INTO t (,) VALUES ()", + "INSERT INTO t () VALUES", + "INSERT INTO t TABLE", + "INSERT INTO t VALUES ROW", + "INSERT INTO t VALUES ROW(1, 2) ROW(3, 4)", + // a row constructor list starts with VALUES only + "INSERT INTO t VALUE ROW(1, 2)" + }) + void rejectsIncompleteForms(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, MYSQL)); + } + + @Test + void statementBoundaries() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "INSERT INTO t TABLE s; INSERT INTO t () VALUES (); " + + "INSERT INTO t VALUES ROW(1, 2); SELECT 1", + MYSQL); + + assertEquals(4, statements.size()); + assertEquals("INSERT INTO t TABLE s", statements.get(0).toString()); + assertEquals("INSERT INTO t VALUES ROW(1, 2)", statements.get(2).toString()); + } + + @Test + void tablesNamesFinderSeesTheSources() throws JSQLParserException { + assertEquals(new HashSet<>(Arrays.asList("t", "s")), + TablesNamesFinder.findTables("INSERT INTO t TABLE s")); + assertEquals(new HashSet<>(Arrays.asList("t", "s")), TablesNamesFinder.findTables( + "INSERT INTO t VALUES ROW((SELECT max(a) FROM s), 2)")); + assertEquals(new HashSet<>(Arrays.asList("t")), + TablesNamesFinder.findTables("INSERT INTO t () VALUES ()")); + } + + @Test + void validatesForMySql() throws JSQLParserException { + for (String sql : new String[] {"INSERT INTO t VALUES ROW(1, 2), ROW(3, 4)", + "INSERT INTO t TABLE s", "INSERT INTO t () VALUES ()"}) { + ValidationTestAsserts.validateNoErrors(sql, 1, MySqlVersion.V8_0); + } + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/select/MySqlIndexHintTest.java b/src/test/java/net/sf/jsqlparser/statement/select/MySqlIndexHintTest.java new file mode 100644 index 0000000000..0f1af0d6f1 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/select/MySqlIndexHintTest.java @@ -0,0 +1,239 @@ +/*- + * #%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 net.sf.jsqlparser.test.TestUtils.assertDeparse; +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.function.Consumer; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.MySQLIndexHint; +import net.sf.jsqlparser.expression.MySQLIndexHint.Scope; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.update.Update; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.validation.ValidationTestAsserts; +import net.sf.jsqlparser.util.validation.feature.MySqlVersion; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class MySqlIndexHintTest { + + private static final Consumer MYSQL = parser -> parser.withDialect(Dialect.MYSQL); + + private static PlainSelect parse(String sql) throws JSQLParserException { + // exact output: single spaces, and the same text from toString() and the deparser + assertSqlCanBeParsedAndDeparsed(sql, false); + return (PlainSelect) assertSqlCanBeParsedAndDeparsed(sql, false, MYSQL); + } + + private static void assertHint(MySQLIndexHint hint, String action, String qualifier, + Scope scope, String... names) { + assertEquals(action, hint.getAction()); + assertEquals(qualifier, hint.getIndexQualifier()); + assertEquals(scope, hint.getScope()); + assertEquals(Arrays.asList(names), hint.getIndexNames()); + } + + @Test + void ignoreIndexForOrderBy() throws JSQLParserException { + PlainSelect select = parse("SELECT a FROM t IGNORE INDEX FOR ORDER BY (i1) ORDER BY a"); + + Table table = (Table) select.getFromItem(); + assertEquals(1, table.getIndexHints().size()); + assertHint(table.getIndexHint(), "IGNORE", "INDEX", Scope.ORDER_BY, "i1"); + assertEquals(1, select.getOrderByElements().size()); + } + + @Test + void forceIndexForJoin() throws JSQLParserException { + PlainSelect select = parse("SELECT a FROM t FORCE INDEX FOR JOIN (i1) JOIN s ON t.a = s.a"); + + assertHint(((Table) select.getFromItem()).getIndexHint(), "FORCE", "INDEX", Scope.JOIN, + "i1"); + assertEquals("s", ((Table) select.getJoins().get(0).getFromItem()).getName()); + assertNull(((Table) select.getJoins().get(0).getFromItem()).getIndexHints()); + } + + @Test + void useKeyForGroupBy() throws JSQLParserException { + PlainSelect select = parse("SELECT a FROM t USE KEY FOR GROUP BY (i1,i2) GROUP BY a"); + + assertHint(((Table) select.getFromItem()).getIndexHint(), "USE", "KEY", Scope.GROUP_BY, + "i1", "i2"); + } + + @Test + void ignoreKeyIsAHintNotAnAlias() throws JSQLParserException { + // IGNORE before KEY used to be read as an alias of t + PlainSelect select = parse("SELECT a FROM t IGNORE KEY (i1)"); + Table table = (Table) select.getFromItem(); + assertNull(table.getAlias()); + assertHint(table.getIndexHint(), "IGNORE", "KEY", null, "i1"); + + Update update = (Update) assertSqlCanBeParsedAndDeparsed( + "UPDATE t IGNORE KEY FOR ORDER BY (i1) SET a = 1 ORDER BY a LIMIT 1", false, MYSQL); + assertHint(update.getTable().getIndexHint(), "IGNORE", "KEY", Scope.ORDER_BY, "i1"); + } + + @Test + void emptyUseIndexList() throws JSQLParserException { + PlainSelect select = parse("SELECT a FROM t USE INDEX ()"); + + assertHint(((Table) select.getFromItem()).getIndexHint(), "USE", "INDEX", null); + assertTrue(((Table) select.getFromItem()).getIndexHint().getIndexNames().isEmpty()); + } + + @Test + void severalHintsKeepTheirOrder() throws JSQLParserException { + PlainSelect select = parse("SELECT a FROM t AS x USE INDEX (i1) IGNORE INDEX FOR ORDER BY" + + " (i2) FORCE KEY FOR JOIN (i3,PRIMARY) WHERE a = 1"); + + Table table = (Table) select.getFromItem(); + assertEquals("x", table.getAlias().getName()); + List hints = table.getIndexHints(); + assertEquals(3, hints.size()); + assertHint(hints.get(0), "USE", "INDEX", null, "i1"); + assertHint(hints.get(1), "IGNORE", "INDEX", Scope.ORDER_BY, "i2"); + assertHint(hints.get(2), "FORCE", "KEY", Scope.JOIN, "i3", "PRIMARY"); + assertSame(hints.get(0), table.getIndexHint()); + } + + @Test + void hintsOnJoinedTables() throws JSQLParserException { + PlainSelect select = parse("SELECT a FROM t1 USE INDEX (i1) JOIN t2 IGNORE INDEX (i2)" + + " USE INDEX FOR JOIN (i3) ON t1.a = t2.a"); + + Table joined = (Table) select.getJoins().get(0).getFromItem(); + assertEquals(2, joined.getIndexHints().size()); + assertHint(joined.getIndexHints().get(1), "USE", "INDEX", Scope.JOIN, "i3"); + } + + @Test + void updateTargetWithSeveralHints() throws JSQLParserException { + String sql = "UPDATE t USE INDEX (i1) IGNORE INDEX FOR ORDER BY (i2) SET a = 1"; + Update update = (Update) assertSqlCanBeParsedAndDeparsed(sql, false, MYSQL); + + assertEquals(2, update.getTable().getIndexHints().size()); + assertHint(update.getTable().getIndexHints().get(1), "IGNORE", "INDEX", Scope.ORDER_BY, + "i2"); + } + + @Test + void lockingClauseAfterHintsKeepsItsMeaning() throws JSQLParserException { + PlainSelect select = parse("SELECT a FROM t USE INDEX (i1) FOR UPDATE"); + + assertEquals(ForMode.UPDATE, select.getForMode()); + assertNull(((Table) select.getFromItem()).getIndexHint().getScope()); + + PlainSelect plain = parse("SELECT a FROM t FOR UPDATE"); + assertEquals(ForMode.UPDATE, plain.getForMode()); + assertNull(((Table) plain.getFromItem()).getIndexHints()); + } + + @Test + void otherDialectsParseTheSameHints() throws JSQLParserException { + String sql = "SELECT a FROM t IGNORE INDEX FOR ORDER BY (i1) ORDER BY a"; + PlainSelect select = (PlainSelect) assertSqlCanBeParsedAndDeparsed(sql, false, + parser -> parser.withDialect(Dialect.POSTGRESQL)); + + assertEquals(Scope.ORDER_BY, ((Table) select.getFromItem()).getIndexHint().getScope()); + } + + @ParameterizedTest + @ValueSource(strings = { + "SELECT a FROM t IGNORE INDEX ()", + "SELECT a FROM t FORCE KEY ()", + "SELECT a FROM t IGNORE KEY ()", + "SELECT a FROM t USE INDEX", + "SELECT a FROM t USE INDEX FOR (i1)", + "SELECT a FROM t USE INDEX FOR UPDATE (i1)", + "SELECT a FROM t USE INDEX FOR ORDER (i1)", + "SELECT a FROM t USE INDEX (i1,)" + }) + void rejectsIncompleteHints(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, MYSQL)); + } + + @Test + void statementBoundary() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "SELECT a FROM t USE INDEX FOR JOIN (i1); SELECT 1", MYSQL); + + assertEquals(2, statements.size()); + assertEquals("SELECT a FROM t USE INDEX FOR JOIN (i1)", statements.get(0).toString()); + } + + @Test + void tablesNamesFinderIgnoresIndexNames() throws JSQLParserException { + assertEquals(new HashSet<>(Arrays.asList("t", "s")), TablesNamesFinder.findTables( + "SELECT a FROM t FORCE INDEX FOR JOIN (i1) JOIN s USE INDEX () ON t.a = s.a")); + } + + @Test + void validatesForMySql() throws JSQLParserException { + ValidationTestAsserts.validateNoErrors( + "SELECT a FROM t USE INDEX (i1) IGNORE INDEX FOR ORDER BY (i2) ORDER BY a", 1, + MySqlVersion.V8_0); + } + + @Test + void editHintsOnTheModel() throws JSQLParserException { + Table table = new Table("t"); + MySQLIndexHint legacy = new MySQLIndexHint("USE", "INDEX", Arrays.asList("i1")); + assertNull(legacy.getScope()); + + table.setHint(legacy); + assertEquals("t USE INDEX (i1)", table.toString()); + + table.addIndexHint(new MySQLIndexHint("IGNORE", "KEY", Scope.GROUP_BY, + Arrays.asList("i2", "i3"))); + assertEquals("t USE INDEX (i1) IGNORE KEY FOR GROUP BY (i2,i3)", table.toString()); + assertEquals(" USE INDEX (i1) IGNORE KEY FOR GROUP BY (i2,i3)", + table.appendIndexHintsTo(new StringBuilder()).toString()); + + PlainSelect select = new PlainSelect().addSelectItems(new AllColumns()).withFromItem(table); + assertEquals("SELECT * FROM t USE INDEX (i1) IGNORE KEY FOR GROUP BY (i2,i3)", + select.toString()); + assertDeparse(select, + "SELECT * FROM t USE INDEX (i1) IGNORE KEY FOR GROUP BY (i2,i3)"); + assertSqlCanBeParsedAndDeparsed(select.toString(), false, MYSQL); + + // setHint replaces every hint; null removes them + table.setHint(new MySQLIndexHint("FORCE", "INDEX", Scope.ORDER_BY, + Collections.singletonList("i4"))); + assertEquals("t FORCE INDEX FOR ORDER BY (i4)", table.toString()); + table.setHint(null); + assertNull(table.getIndexHint()); + assertNull(table.getIndexHints()); + assertEquals("t", table.toString()); + + table.setIndexHints(Collections.singletonList( + new MySQLIndexHint("USE", "INDEX", Scope.JOIN, Collections.emptyList()))); + assertEquals("t USE INDEX FOR JOIN ()", table.toString()); + assertInstanceOf(MySQLIndexHint.class, table.getIndexHint()); + assertEquals("ORDER BY", Scope.ORDER_BY.getKeywords()); + } +}