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()); + } +}