diff --git a/README.md b/README.md index e7a8db452..5946a44c2 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ and missing syntax gets added on demand — [open an issue](https://github.com/J | **MySQL table maintenance** | `ANALYZE` · `CHECK` · `CHECKSUM` · `OPTIMIZE` · `REPAIR TABLE` | | **PostgreSQL maintenance** | `VACUUM` · `ANALYZE` · `CLUSTER` · `CHECKPOINT` | | **MySQL locks** | `LOCK TABLES` · `UNLOCK TABLES` · `LOCK INSTANCE FOR BACKUP` · `UNLOCK INSTANCE` | +| **MySQL server administration** | `FLUSH` · `KILL` · `RESET PERSIST` · `HANDLER … OPEN`/`READ`/`CLOSE` | | **MySQL account management** | `CREATE`/`ALTER`/`RENAME USER` · `CREATE ROLE` · `SET ROLE` · `SET DEFAULT ROLE` · `SET PASSWORD` | | **Tablespaces** | MySQL and PostgreSQL `CREATE TABLESPACE` · `ALTER TABLESPACE` | | **PostgreSQL RLS** | `CREATE POLICY` · `ALTER TABLE … ENABLE`/`DISABLE`/`FORCE`/`NO FORCE ROW LEVEL SECURITY` | diff --git a/src/main/java/net/sf/jsqlparser/parser/RoutineBodyBoundary.java b/src/main/java/net/sf/jsqlparser/parser/RoutineBodyBoundary.java index c5ef37b5d..02b1c0b9c 100644 --- a/src/main/java/net/sf/jsqlparser/parser/RoutineBodyBoundary.java +++ b/src/main/java/net/sf/jsqlparser/parser/RoutineBodyBoundary.java @@ -248,7 +248,7 @@ private void acceptStatementToken(Token token, Token next) { if (statementStart) { statementKind = kind; } - if (statementKind == K_DECLARE && keyword(token, "HANDLER")) { + if (statementKind == K_DECLARE && token.kind == K_HANDLER) { handlerAction = true; } statementStart = false; diff --git a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java index dc3d137e3..f69ff2e46 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -28,6 +28,8 @@ import net.sf.jsqlparser.statement.ShowStatement; import net.sf.jsqlparser.statement.TableMaintenanceStatement; import net.sf.jsqlparser.statement.UseStatement; +import net.sf.jsqlparser.statement.admin.FlushStatement; +import net.sf.jsqlparser.statement.admin.KillStatement; import net.sf.jsqlparser.statement.alter.Alter; import net.sf.jsqlparser.statement.alter.AlterEventTrigger; import net.sf.jsqlparser.statement.alter.sequence.AlterSequence; @@ -51,6 +53,7 @@ import net.sf.jsqlparser.statement.drop.Drop; import net.sf.jsqlparser.statement.execute.Execute; import net.sf.jsqlparser.statement.grant.Grant; +import net.sf.jsqlparser.statement.handler.HandlerStatement; import net.sf.jsqlparser.statement.lock.BackupLockStatement; import net.sf.jsqlparser.statement.lock.UnlockStatement; import net.sf.jsqlparser.statement.merge.Merge; @@ -490,6 +493,27 @@ public enum Feature { */ tableMaintenance, + /** + * MySQL "FLUSH" statement is allowed + * + * @see FlushStatement + */ + flush, + + /** + * MySQL "KILL" statement is allowed + * + * @see KillStatement + */ + kill, + + /** + * MySQL "HANDLER ... OPEN", "HANDLER ... READ" and "HANDLER ... CLOSE" statements are allowed + * + * @see HandlerStatement + */ + handler, + /** * PostgreSQL "VACUUM" statement is allowed * @@ -930,6 +954,12 @@ public enum Feature { * @see ResetStatement */ reset, + /** + * MySQL "RESET PERSIST" statement is allowed + * + * @see ResetStatement#isPersist() + */ + resetPersist, /** * @see Pivot */ diff --git a/src/main/java/net/sf/jsqlparser/statement/ResetStatement.java b/src/main/java/net/sf/jsqlparser/statement/ResetStatement.java index 6d40879f8..a9cc1744e 100644 --- a/src/main/java/net/sf/jsqlparser/statement/ResetStatement.java +++ b/src/main/java/net/sf/jsqlparser/statement/ResetStatement.java @@ -11,9 +11,18 @@ import net.sf.jsqlparser.parser.ASTNodeAccessImpl; +/** + * PostgreSQL {@code RESET configuration_parameter}, {@code RESET ALL} and {@code RESET TIME ZONE}, + * and MySQL {@code RESET PERSIST [[IF EXISTS] system_var_name]}, which {@link #isPersist()} marks. + * + * @see PostgreSQL RESET + * @see MySQL RESET PERSIST + */ public final class ResetStatement extends ASTNodeAccessImpl implements Statement { private String name = ""; + private boolean persist; + private boolean ifExists; public ResetStatement() { // empty constructor @@ -27,6 +36,10 @@ public void add(String name) { this.name = name; } + /** + * Returns the parameter or, for RESET PERSIST, the system variable; null when RESET PERSIST + * names none and removes every persisted variable. + */ public String getName() { return name; } @@ -35,10 +48,52 @@ public void setName(String name) { this.name = name; } + /** Whether this is MySQL RESET PERSIST, which removes persisted global system variables. */ + public boolean isPersist() { + return persist; + } + + public void setPersist(boolean persist) { + this.persist = persist; + } + + public ResetStatement withPersist(boolean persist) { + setPersist(persist); + return this; + } + + /** Whether RESET PERSIST has IF EXISTS before the variable name. */ + public boolean isIfExists() { + return ifExists; + } + + public void setIfExists(boolean ifExists) { + this.ifExists = ifExists; + } + + public ResetStatement withIfExists(boolean ifExists) { + setIfExists(ifExists); + return this; + } + + public StringBuilder appendTo(StringBuilder builder) { + builder.append("RESET "); + if (!persist) { + return builder.append(name); + } + builder.append("PERSIST"); + if (ifExists) { + builder.append(" IF EXISTS"); + } + if (name != null && !name.isEmpty()) { + builder.append(' ').append(name); + } + return builder; + } + @Override public String toString() { - StringBuilder b = new StringBuilder("RESET ").append(name); - return b.toString(); + return appendTo(new StringBuilder()).toString(); } @Override diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index df67903b0..eba66018a 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -98,6 +98,9 @@ import net.sf.jsqlparser.statement.insert.Insert; import net.sf.jsqlparser.statement.insert.InsertBulk; import net.sf.jsqlparser.statement.load.LoadDataStatement; +import net.sf.jsqlparser.statement.admin.FlushStatement; +import net.sf.jsqlparser.statement.admin.KillStatement; +import net.sf.jsqlparser.statement.handler.HandlerStatement; import net.sf.jsqlparser.statement.lock.BackupLockStatement; import net.sf.jsqlparser.statement.lock.LockStatement; import net.sf.jsqlparser.statement.lock.UnlockStatement; @@ -783,6 +786,40 @@ public Void visit(TableMaintenanceStatement statement, S context) { return null; } + @Override + public Void visit(FlushStatement flush, S context) { + // a server operation like ALTER SYSTEM FLUSH; WITH READ LOCK and FOR EXPORT also lock + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SESSION); + if (flush.getTableLock() != null) { + analysis.certain(StmtFeature.MODIFIES_TRANSACTION); + } + return null; + } + + @Override + public Void visit(KillStatement kill, S context) { + // ends another session or its statement, like ALTER SYSTEM KILL SESSION + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SESSION); + return null; + } + + @Override + public Void visit(HandlerStatement handler, S context) { + if (handler.getAction() != HandlerStatement.Action.READ) { + // OPEN and CLOSE change the handlers of the session + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SESSION); + return null; + } + if (analysis.claimTopLevel()) { + analysis.certain(StmtFeature.RETURNS_RESULT_SET); + } + analysis.certain(StmtFeature.READS_DATA); + return super.visit(handler, context); + } + @Override public Void visit(VacuumStatement vacuum, S context) { // reclaims storage and, with ANALYZE, rewrites statistics; FULL rewrites the tables diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index 4063637f7..6a9aa337b 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -90,6 +90,9 @@ import net.sf.jsqlparser.statement.insert.Insert; import net.sf.jsqlparser.statement.insert.InsertBulk; import net.sf.jsqlparser.statement.insert.ParenthesedInsert; +import net.sf.jsqlparser.statement.admin.FlushStatement; +import net.sf.jsqlparser.statement.admin.KillStatement; +import net.sf.jsqlparser.statement.handler.HandlerStatement; import net.sf.jsqlparser.statement.lock.BackupLockStatement; import net.sf.jsqlparser.statement.lock.LockStatement; import net.sf.jsqlparser.statement.lock.UnlockStatement; @@ -376,6 +379,30 @@ default void visit(TableMaintenanceStatement statement) { this.visit(statement, null); } + default T visit(FlushStatement flush, S context) { + return null; + } + + default void visit(FlushStatement flush) { + this.visit(flush, null); + } + + default T visit(KillStatement kill, S context) { + return null; + } + + default void visit(KillStatement kill) { + this.visit(kill, null); + } + + default T visit(HandlerStatement handler, S context) { + return null; + } + + default void visit(HandlerStatement handler) { + this.visit(handler, null); + } + default T visit(VacuumStatement vacuum, S context) { return null; } diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index 9cb08129e..906f812de 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -93,6 +93,9 @@ import net.sf.jsqlparser.statement.insert.InsertBulk; import net.sf.jsqlparser.statement.insert.InsertConflictAction; import net.sf.jsqlparser.statement.insert.ParenthesedInsert; +import net.sf.jsqlparser.statement.admin.FlushStatement; +import net.sf.jsqlparser.statement.admin.KillStatement; +import net.sf.jsqlparser.statement.handler.HandlerStatement; import net.sf.jsqlparser.statement.lock.BackupLockStatement; import net.sf.jsqlparser.statement.lock.LockStatement; import net.sf.jsqlparser.statement.lock.UnlockStatement; @@ -723,6 +726,24 @@ public T visit(TableMaintenanceStatement statement, S context) { return null; } + @Override + public T visit(FlushStatement flush, S context) { + return null; + } + + @Override + public T visit(KillStatement kill, S context) { + expressionVisitor.visitExpression(kill.getProcesslistId(), context); + return null; + } + + @Override + public T visit(HandlerStatement handler, S context) { + expressionVisitor.visitExpression(handler.getKeyValues(), context); + expressionVisitor.visitExpression(handler.getWhere(), context); + return null; + } + @Override public T visit(VacuumStatement vacuum, S context) { return null; diff --git a/src/main/java/net/sf/jsqlparser/statement/admin/FlushOption.java b/src/main/java/net/sf/jsqlparser/statement/admin/FlushOption.java new file mode 100644 index 000000000..b3945aef6 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/admin/FlushOption.java @@ -0,0 +1,97 @@ +/*- + * #%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.admin; + +import java.util.Locale; +import java.util.Objects; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; + +/** + * One flush option of MySQL {@code FLUSH}, such as {@code BINARY LOGS} or + * {@code RELAY LOGS FOR CHANNEL 'c1'}. + * + * @see MySQL FLUSH + */ +public class FlushOption extends ASTNodeAccessImpl { + private Kind kind; + private StringValue channel; + + /** The flush options of MySQL 8.4. */ + public enum Kind { + BINARY_LOGS, ENGINE_LOGS, ERROR_LOGS, GENERAL_LOGS, LOGS, OPTIMIZER_COSTS, PRIVILEGES, RELAY_LOGS, SLOW_LOGS, STATUS, USER_RESOURCES; + + /** Returns the SQL spelling, for example {@code BINARY LOGS}. */ + public String getKeyword() { + return this == OPTIMIZER_COSTS || this == USER_RESOURCES ? name() + : name().replace('_', ' '); + } + + /** + * Returns the option spelled by the keyword, ignoring case, or null. + * + * @param keyword the words of the option separated by one space, such as + * {@code binary logs} + */ + public static Kind fromKeyword(String keyword) { + String upper = keyword == null ? null : keyword.toUpperCase(Locale.ROOT); + for (Kind kind : values()) { + if (kind.getKeyword().equals(upper)) { + return kind; + } + } + return null; + } + } + + public FlushOption(Kind kind) { + setKind(kind); + } + + public Kind getKind() { + return kind; + } + + public void setKind(Kind kind) { + this.kind = Objects.requireNonNull(kind); + } + + public FlushOption withKind(Kind kind) { + setKind(kind); + return this; + } + + /** Returns the channel of {@code RELAY LOGS FOR CHANNEL 'name'}, or null. */ + public StringValue getChannel() { + return channel; + } + + public void setChannel(StringValue channel) { + this.channel = channel; + } + + public FlushOption withChannel(StringValue channel) { + setChannel(channel); + return this; + } + + public StringBuilder appendTo(StringBuilder builder) { + builder.append(kind.getKeyword()); + if (channel != null) { + builder.append(" FOR CHANNEL ").append(channel); + } + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/admin/FlushStatement.java b/src/main/java/net/sf/jsqlparser/statement/admin/FlushStatement.java new file mode 100644 index 000000000..95957fe4d --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/admin/FlushStatement.java @@ -0,0 +1,181 @@ +/*- + * #%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.admin; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; +import net.sf.jsqlparser.statement.TableMaintenanceStatement; + +/** + * MySQL {@code FLUSH}, either with a list of {@link FlushOption flush options} or as + * {@code FLUSH TABLES}, which may name tables and lock them.
+ * Examples:
+ * FLUSH LOCAL BINARY LOGS, RELAY LOGS FOR CHANNEL 'c1'
+ * FLUSH TABLES t, u WITH READ LOCK
+ *
+ * Without options the statement is {@code FLUSH TABLES}: {@link #isFlushTables()} derives the form + * from {@link #getOptions()}, and the tables, the spelling and the lock apply only to that form. + * + * @see MySQL FLUSH + */ +public class FlushStatement extends ASTNodeAccessImpl implements Statement { + private TableMaintenanceStatement.Modifier modifier; + private final List options = new ArrayList<>(); + private boolean tablesKeyword = true; + private final List tables = new ArrayList<>(); + private TableLock tableLock; + + /** What {@code FLUSH TABLES} does to the tables after flushing them. */ + public enum TableLock { + /** {@code WITH READ LOCK}: the named tables, or every table, stay read locked. */ + WITH_READ_LOCK("WITH READ LOCK"), + /** {@code FOR EXPORT}: the named tables are made ready to copy their files. */ + FOR_EXPORT("FOR EXPORT"); + + private final String keyword; + + TableLock(String keyword) { + this.keyword = keyword; + } + + public String getKeyword() { + return keyword; + } + } + + /** Returns {@code NO_WRITE_TO_BINLOG} or its synonym {@code LOCAL}, or null. */ + public TableMaintenanceStatement.Modifier getModifier() { + return modifier; + } + + public void setModifier(TableMaintenanceStatement.Modifier modifier) { + this.modifier = modifier; + } + + public FlushStatement withModifier(TableMaintenanceStatement.Modifier modifier) { + setModifier(modifier); + return this; + } + + /** Returns the flush options in source order; empty for {@code FLUSH TABLES}. */ + public List getOptions() { + return Collections.unmodifiableList(options); + } + + public void setOptions(Collection options) { + this.options.clear(); + if (options != null) { + this.options.addAll(options); + } + } + + public FlushStatement withOptions(Collection options) { + setOptions(options); + return this; + } + + public FlushStatement addOptions(FlushOption... options) { + Collections.addAll(this.options, options); + return this; + } + + /** Whether the statement is {@code FLUSH TABLE} or {@code FLUSH TABLES}: it has no options. */ + public boolean isFlushTables() { + return options.isEmpty(); + } + + /** Whether {@code FLUSH TABLES} is spelled with {@code TABLES} instead of {@code TABLE}. */ + public boolean isTablesKeyword() { + return tablesKeyword; + } + + public void setTablesKeyword(boolean tablesKeyword) { + this.tablesKeyword = tablesKeyword; + } + + public FlushStatement withTablesKeyword(boolean tablesKeyword) { + setTablesKeyword(tablesKeyword); + return this; + } + + /** Returns the tables of {@code FLUSH TABLES}; empty when it flushes every table. */ + public List
getTables() { + return Collections.unmodifiableList(tables); + } + + public void setTables(Collection tables) { + this.tables.clear(); + if (tables != null) { + this.tables.addAll(tables); + } + } + + public FlushStatement withTables(Collection tables) { + setTables(tables); + return this; + } + + public FlushStatement addTables(Table... tables) { + Collections.addAll(this.tables, tables); + return this; + } + + /** Returns the lock of {@code FLUSH TABLES}, or null. */ + public TableLock getTableLock() { + return tableLock; + } + + public void setTableLock(TableLock tableLock) { + this.tableLock = tableLock; + } + + public FlushStatement withTableLock(TableLock tableLock) { + setTableLock(tableLock); + return this; + } + + public StringBuilder appendTo(StringBuilder builder) { + builder.append("FLUSH"); + if (modifier != null) { + builder.append(' ').append(modifier); + } + if (!options.isEmpty()) { + for (int i = 0; i < options.size(); i++) { + builder.append(i > 0 ? ", " : " "); + options.get(i).appendTo(builder); + } + return builder; + } + builder.append(tablesKeyword ? " TABLES" : " TABLE"); + for (int i = 0; i < tables.size(); i++) { + builder.append(i > 0 ? ", " : " ").append(tables.get(i).getFullyQualifiedName()); + } + if (tableLock != null) { + builder.append(' ').append(tableLock.getKeyword()); + } + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/admin/KillStatement.java b/src/main/java/net/sf/jsqlparser/statement/admin/KillStatement.java new file mode 100644 index 000000000..79d13dad8 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/admin/KillStatement.java @@ -0,0 +1,89 @@ +/*- + * #%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.admin; + +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; + +/** + * MySQL {@code KILL [CONNECTION | QUERY] processlist_id}, which ends a connection or the statement + * it runs. MySQL takes any expression for the identifier, such as {@code @id} or {@code 1 + 2}. + * + * @see MySQL KILL + */ +public class KillStatement extends ASTNodeAccessImpl implements Statement { + private Kind kind; + private Expression processlistId; + + /** What KILL ends. Without either keyword, KILL ends the connection. */ + public enum Kind { + CONNECTION, QUERY + } + + public KillStatement() { + // empty constructor + } + + public KillStatement(Kind kind, Expression processlistId) { + this.kind = kind; + this.processlistId = processlistId; + } + + /** Returns {@code CONNECTION} or {@code QUERY}, or null when neither keyword was given. */ + public Kind getKind() { + return kind; + } + + public void setKind(Kind kind) { + this.kind = kind; + } + + public KillStatement withKind(Kind kind) { + setKind(kind); + return this; + } + + public Expression getProcesslistId() { + return processlistId; + } + + public void setProcesslistId(Expression processlistId) { + this.processlistId = processlistId; + } + + public KillStatement withProcesslistId(Expression processlistId) { + setProcesslistId(processlistId); + return this; + } + + /** Shares the statement syntax with deparsers, which render the identifier expression. */ + public StringBuilder appendTo(StringBuilder builder, Consumer expressionRenderer) { + builder.append("KILL "); + if (kind != null) { + builder.append(kind).append(' '); + } + expressionRenderer.accept(processlistId); + return builder; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/handler/HandlerStatement.java b/src/main/java/net/sf/jsqlparser/statement/handler/HandlerStatement.java new file mode 100644 index 000000000..f70be46b3 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/handler/HandlerStatement.java @@ -0,0 +1,277 @@ +/*- + * #%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.handler; + +import java.util.Objects; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Alias; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; +import net.sf.jsqlparser.statement.select.Limit; +import net.sf.jsqlparser.statement.select.Offset; + +/** + * MySQL {@code HANDLER}, which opens a table for direct row access, reads rows from it and closes + * it again.
+ * Examples:
+ * HANDLER db.t OPEN AS h
+ * HANDLER h READ i1 >= (1, 2) WHERE a > 0 LIMIT 3
+ * HANDLER h READ i1 NEXT
+ * HANDLER h READ FIRST
+ * HANDLER h CLOSE
+ *
+ * {@link #getTable()} is the table that OPEN opens, with the handler name as its alias when one is + * given. READ and CLOSE name the handler, which is that alias or the unqualified table name, so + * their {@link #getTable()} holds the handler name rather than a table reference.
+ * A READ through an index either compares the index with a list of key values + * ({@link #getKeyComparison()} and {@link #getKeyValues()}) or moves in index order + * ({@link #getDirection()}). A READ without an index moves in storage order: FIRST or NEXT. + * + * @see MySQL HANDLER + */ +public class HandlerStatement extends ASTNodeAccessImpl implements Statement { + private Action action = Action.OPEN; + private Table table; + private String indexName; + private KeyComparison keyComparison; + private ExpressionList keyValues; + private Direction direction; + private Expression where; + private Limit limit; + private Offset offset; + + public enum Action { + OPEN, READ, CLOSE + } + + /** Where a READ continues: FIRST and NEXT, and through an index also PREV and LAST. */ + public enum Direction { + FIRST, NEXT, PREV, LAST + } + + /** How a READ through an index compares the index with the key values. */ + public enum KeyComparison { + /** {@code =} */ + EQUALS("="), + /** {@code <=} */ + LESS_THAN_OR_EQUALS("<="), + /** {@code >=} */ + GREATER_THAN_OR_EQUALS(">="), + /** {@code <} */ + LESS_THAN("<"), + /** {@code >} */ + GREATER_THAN(">"); + + private final String operator; + + KeyComparison(String operator) { + this.operator = operator; + } + + public String getOperator() { + return operator; + } + } + + public HandlerStatement() { + // empty constructor + } + + public HandlerStatement(Action action, Table table) { + setAction(action); + this.table = table; + } + + public Action getAction() { + return action; + } + + public void setAction(Action action) { + this.action = Objects.requireNonNull(action); + } + + public HandlerStatement withAction(Action action) { + setAction(action); + return this; + } + + /** + * Returns the table that OPEN opens, whose alias is the handler name, or the handler that READ + * and CLOSE use. + */ + public Table getTable() { + return table; + } + + public void setTable(Table table) { + this.table = table; + } + + public HandlerStatement withTable(Table table) { + setTable(table); + return this; + } + + /** Returns the name of the handler: the alias that OPEN gives, or else the table name. */ + public String getHandlerName() { + if (table == null) { + return null; + } + Alias alias = table.getAlias(); + return alias != null ? alias.getName() : table.getName(); + } + + /** Returns the index a READ uses, or null when it reads in storage order. */ + public String getIndexName() { + return indexName; + } + + public void setIndexName(String indexName) { + this.indexName = indexName; + } + + public HandlerStatement withIndexName(String indexName) { + setIndexName(indexName); + return this; + } + + /** Returns how a READ compares the index with {@link #getKeyValues()}, or null. */ + public KeyComparison getKeyComparison() { + return keyComparison; + } + + public void setKeyComparison(KeyComparison keyComparison) { + this.keyComparison = keyComparison; + } + + public HandlerStatement withKeyComparison(KeyComparison keyComparison) { + setKeyComparison(keyComparison); + return this; + } + + /** Returns the parenthesized key values a READ compares the index with, or null. */ + public ExpressionList getKeyValues() { + return keyValues; + } + + public void setKeyValues(ExpressionList keyValues) { + this.keyValues = keyValues; + } + + public HandlerStatement withKeyValues(ExpressionList keyValues) { + setKeyValues(keyValues); + return this; + } + + /** Returns where a READ continues, or null when it compares key values. */ + public Direction getDirection() { + return direction; + } + + public void setDirection(Direction direction) { + this.direction = direction; + } + + public HandlerStatement withDirection(Direction direction) { + setDirection(direction); + return this; + } + + public Expression getWhere() { + return where; + } + + public void setWhere(Expression where) { + this.where = where; + } + + public HandlerStatement withWhere(Expression where) { + setWhere(where); + return this; + } + + public Limit getLimit() { + return limit; + } + + public void setLimit(Limit limit) { + this.limit = limit; + } + + public HandlerStatement withLimit(Limit limit) { + setLimit(limit); + return this; + } + + /** Returns the OFFSET of {@code LIMIT row_count OFFSET offset}, or null. */ + public Offset getOffset() { + return offset; + } + + public void setOffset(Offset offset) { + this.offset = offset; + } + + public HandlerStatement withOffset(Offset offset) { + setOffset(offset); + return this; + } + + /** Shares the statement syntax with deparsers, which render the WHERE and key values. */ + public StringBuilder appendTo(StringBuilder builder, Consumer expressionRenderer) { + builder.append("HANDLER ").append(table.getFullyQualifiedName()).append(' ') + .append(action); + if (action == Action.OPEN) { + if (table.getAlias() != null) { + builder.append(table.getAlias()); + } + } else if (action == Action.READ) { + appendReadTo(builder, expressionRenderer); + } + return builder; + } + + private void appendReadTo(StringBuilder builder, Consumer expressionRenderer) { + if (indexName != null) { + builder.append(' ').append(indexName); + } + if (keyComparison != null) { + builder.append(' ').append(keyComparison.getOperator()).append(' '); + expressionRenderer.accept(keyValues); + } else { + builder.append(' ').append(direction); + } + if (where != null) { + builder.append(" WHERE "); + expressionRenderer.accept(where); + } + if (limit != null) { + builder.append(limit); + } + if (offset != null) { + builder.append(offset); + } + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index e1cf00f3f..11484a068 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -194,6 +194,9 @@ import net.sf.jsqlparser.statement.insert.OracleMultiInsertClause; import net.sf.jsqlparser.statement.insert.ParenthesedInsert; import net.sf.jsqlparser.statement.load.LoadDataStatement; +import net.sf.jsqlparser.statement.admin.FlushStatement; +import net.sf.jsqlparser.statement.admin.KillStatement; +import net.sf.jsqlparser.statement.handler.HandlerStatement; import net.sf.jsqlparser.statement.lock.BackupLockStatement; import net.sf.jsqlparser.statement.lock.LockStatement; import net.sf.jsqlparser.statement.lock.UnlockStatement; @@ -1732,6 +1735,35 @@ public void visit(TableMaintenanceStatement statement) { StatementVisitor.super.visit(statement); } + @Override + public Void visit(FlushStatement flush, S context) { + flush.getTables().forEach(table -> visit(table, context)); + return null; + } + + @Override + public Void visit(KillStatement kill, S context) { + if (kill.getProcesslistId() != null) { + kill.getProcesslistId().accept(this, context); + } + return null; + } + + @Override + public Void visit(HandlerStatement handler, S context) { + if (handler.getAction() == HandlerStatement.Action.OPEN) { + visit(handler.getTable(), context); + } + // READ and CLOSE name a handler, which may be an alias that OPEN gave the table + if (handler.getKeyValues() != null) { + handler.getKeyValues().accept(this, context); + } + if (handler.getWhere() != null) { + handler.getWhere().accept(this, context); + } + return null; + } + @Override public Void visit(VacuumStatement vacuum, S context) { // the column lists name columns of these tables, not tables @@ -2040,7 +2072,7 @@ public void visit(SetStatement set) { @Override public Void visit(ResetStatement reset, S context) { - throwUnsupported(reset); + // RESET names a configuration parameter or a system variable, never a table return null; } diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/ResetStatementDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/ResetStatementDeParser.java index 22eaca518..b65eabfcc 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/ResetStatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/ResetStatementDeParser.java @@ -24,8 +24,7 @@ public ResetStatementDeParser(ExpressionVisitor expressionVisitor @Override public void deParse(ResetStatement set) { - builder.append("RESET "); - builder.append(set.getName()); + set.appendTo(builder); } public ExpressionVisitor getExpressionVisitor() { diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java index 0e9adaba3..19777b82f 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -121,6 +121,9 @@ import net.sf.jsqlparser.statement.insert.Insert; import net.sf.jsqlparser.statement.insert.InsertBulk; import net.sf.jsqlparser.statement.insert.ParenthesedInsert; +import net.sf.jsqlparser.statement.admin.FlushStatement; +import net.sf.jsqlparser.statement.admin.KillStatement; +import net.sf.jsqlparser.statement.handler.HandlerStatement; import net.sf.jsqlparser.statement.lock.BackupLockStatement; import net.sf.jsqlparser.statement.lock.LockStatement; import net.sf.jsqlparser.statement.lock.UnlockStatement; @@ -393,6 +396,22 @@ public StringBuilder visit(TableMaintenanceStatement statement, S context) { return builder; } + @Override + public StringBuilder visit(FlushStatement flush, S context) { + return flush.appendTo(builder); + } + + @Override + public StringBuilder visit(KillStatement kill, S context) { + return kill.appendTo(builder, id -> id.accept(expressionDeParser, context)); + } + + @Override + public StringBuilder visit(HandlerStatement handler, S context) { + return handler.appendTo(builder, + expression -> expression.accept(expressionDeParser, context)); + } + @Override public StringBuilder visit(VacuumStatement vacuum, S context) { return vacuum.appendTo(builder); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java index 5ca009b15..c85cc390e 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java @@ -79,6 +79,14 @@ public enum MySqlVersion implements Version { // https://dev.mysql.com/doc/refman/8.0/en/table-maintenance-statements.html Feature.analyze, Feature.tableMaintenance, + // https://dev.mysql.com/doc/refman/8.0/en/flush.html + // https://dev.mysql.com/doc/refman/8.0/en/kill.html + // https://dev.mysql.com/doc/refman/8.0/en/reset-persist.html + Feature.flush, Feature.kill, Feature.resetPersist, + + // https://dev.mysql.com/doc/refman/8.0/en/handler.html + Feature.handler, + // https://dev.mysql.com/doc/refman/8.0/en/lock-tables.html Feature.lock, Feature.unlockTables, diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/ResetStatementValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/ResetStatementValidator.java index 3a4efb95b..83d3b80cb 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/ResetStatementValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/ResetStatementValidator.java @@ -18,7 +18,7 @@ public class ResetStatementValidator extends AbstractValidator { @Override public void validate(ResetStatement reset) { for (ValidationCapability c : getCapabilities()) { - validateFeature(c, Feature.reset); + validateFeature(c, reset.isPersist() ? Feature.resetPersist : Feature.reset); } } } diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java index ec1b85f29..9b87405ce 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java @@ -125,6 +125,9 @@ import net.sf.jsqlparser.statement.insert.Insert; import net.sf.jsqlparser.statement.insert.InsertBulk; import net.sf.jsqlparser.statement.insert.ParenthesedInsert; +import net.sf.jsqlparser.statement.admin.FlushStatement; +import net.sf.jsqlparser.statement.admin.KillStatement; +import net.sf.jsqlparser.statement.handler.HandlerStatement; import net.sf.jsqlparser.statement.lock.BackupLockStatement; import net.sf.jsqlparser.statement.lock.LockStatement; import net.sf.jsqlparser.statement.lock.UnlockStatement; @@ -649,6 +652,35 @@ public Void visit(TableMaintenanceStatement statement, S context) { return null; } + @Override + public Void visit(FlushStatement flush, S context) { + validateFeature(Feature.flush); + for (Table table : flush.getTables()) { + validateName(NamedObject.table, table.getFullyQualifiedName()); + } + return null; + } + + @Override + public Void visit(KillStatement kill, S context) { + validateFeature(Feature.kill); + validateOptionalExpression(kill.getProcesslistId()); + return null; + } + + @Override + public Void visit(HandlerStatement handler, S context) { + validateFeature(Feature.handler); + if (handler.getAction() == HandlerStatement.Action.OPEN) { + Table table = handler.getTable(); + validateNameWithAlias(NamedObject.table, table.getFullyQualifiedName(), + ValidationUtil.getAlias(table.getAlias())); + } + validateOptionalExpression(handler.getKeyValues()); + validateOptionalExpression(handler.getWhere()); + return null; + } + @Override public Void visit(VacuumStatement vacuum, S context) { getValidator(VacuumValidator.class).validate(vacuum); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 4cf410188..187818085 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -103,6 +103,8 @@ import net.sf.jsqlparser.statement.role.*; import net.sf.jsqlparser.statement.imprt.*; import net.sf.jsqlparser.statement.export.*; import net.sf.jsqlparser.statement.lock.*; +import net.sf.jsqlparser.statement.admin.*; +import net.sf.jsqlparser.statement.handler.HandlerStatement; import java.util.*; import java.util.AbstractMap.SimpleEntry; import java.util.concurrent.CancellationException; @@ -176,6 +178,14 @@ public class CCJSqlParser extends AbstractJSqlParser { return isPostgreSqlMaintenanceAhead(); case K_UNLOCK: return isUnlockTablesAhead() || isBackupLockAhead(); + case K_CHECK: case K_CHECKSUM: case K_OPTIMIZE: case K_REPAIR: + return isTableMaintenanceAhead(); + case K_FLUSH: + return isFlushAhead(); + case K_KILL: + return isKillAhead(); + case K_HANDLER: + return isHandlerAhead(); case K_IMPORT: case K_EXPORT: return Dialect.EXASOL.name().equals(getAsString(Feature.dialect)) || next.kind == K_EXPORT && getToken(2).kind == K_DATA; @@ -2570,6 +2580,122 @@ public class CCJSqlParser extends AbstractJSqlParser { && isMySqlLockDialect(false); } + /** MySQL's FLUSH, KILL, HANDLER and RESET PERSIST parse without a dialect and with MySQL's. */ + private boolean isMySqlServerAdminDialect() { + String dialect = getAsString(Feature.dialect); + return dialect == null || Dialect.MYSQL.name().equals(dialect); + } + + /** + * Whether MySQL FLUSH starts here: FLUSH, an optional NO_WRITE_TO_BINLOG or LOCAL, and then + * TABLE, TABLES or the first word of a flush option. Other FLUSH forms, such as the FLUSH + * HOSTS that MySQL 8.4 removed, remain unsupported statements. + */ + private boolean isFlushAhead() { + if (getToken(1).kind != K_FLUSH || !isMySqlServerAdminDialect()) { + return false; + } + int modifier = getToken(2).kind; + Token next = getToken(modifier == K_NO_WRITE_TO_BINLOG || modifier == K_LOCAL ? 3 : 2); + if (next.kind == K_TABLE || next.kind == K_TABLES) { + return true; + } + if (next.kind != S_IDENTIFIER && next.kind != K_BINARY && next.kind != K_ENGINE + && next.kind != K_ERROR) { + return false; + } + for (FlushOption.Kind kind : FlushOption.Kind.values()) { + if (kind.getKeyword().split(" ")[0].equalsIgnoreCase(next.image)) { + return true; + } + } + return false; + } + + /** Whether MySQL KILL [CONNECTION | QUERY] processlist_id starts here. */ + private boolean isKillAhead() { + int next = getToken(2).kind; + return getToken(1).kind == K_KILL && next != EOF && next != ST_SEMICOLON + && isMySqlServerAdminDialect(); + } + + /** Whether MySQL HANDLER starts here: a table or handler name, then OPEN, READ or CLOSE. */ + private boolean isHandlerAhead() { + if (getToken(1).kind != K_HANDLER || !isMySqlServerAdminDialect()) { + return false; + } + int offset = 2; + while (isIdentifierishNameToken(getToken(offset)) && ".".equals(getToken(offset + 1).image)) { + offset += 2; + } + int action = getToken(offset + 1).kind; + return isIdentifierishNameToken(getToken(offset)) + && (action == K_OPEN || action == K_READ || action == K_CLOSE); + } + + /** + * Whether HANDLER ... READ continues with an index name: a comparison or FIRST, NEXT, PREV or + * LAST follows the name. Otherwise FIRST or NEXT reads in storage order. + */ + private boolean isHandlerIndexAhead() { + Token next = getToken(2); + switch (next.kind) { + case K_FIRST: case K_NEXT: case K_LAST: + case OP_GREATERTHANEQUALS: case OP_MINORTHANEQUALS: + return isIdentifierishNameToken(getToken(1)); + default: + return isIdentifierishNameToken(getToken(1)) && ("=".equals(next.image) + || "<".equals(next.image) || ">".equals(next.image) + || next.kind == S_IDENTIFIER && "PREV".equalsIgnoreCase(next.image)); + } + } + + /** + * Whether MySQL RESET PERSIST starts here. Without a dialect only a variable name, which may + * have a component prefix, or IF EXISTS after PERSIST tells it from PostgreSQL's RESET of a + * parameter named persist, which a bare RESET PERSIST keeps. + */ + private boolean isResetPersistAhead() { + if (getToken(1).kind != K_RESET || !isWordSequenceAhead(2, "PERSIST")) { + return false; + } + String dialect = getAsString(Feature.dialect); + if (Dialect.MYSQL.name().equals(dialect)) { + return true; + } + Token next = getToken(3); + int end = getToken(4).kind; + return dialect == null && (next.kind == K_IF && end == K_EXISTS + || isIdentifierishNameToken(next) + && (end == EOF || end == ST_SEMICOLON || ".".equals(getToken(4).image))); + } + + /** Whether one of the expressions holds a subquery. */ + private static boolean containsSubquery(Expression... expressions) { + boolean[] found = new boolean[1]; + ExpressionVisitorAdapter finder = new ExpressionVisitorAdapter() { + @Override + public Void visit(Select select, S context) { + found[0] = true; + return null; + } + }; + for (Expression expression : expressions) { + if (expression != null) { + expression.accept(finder, null); + } + } + return found[0]; + } + + private static FlushOption.Kind flushOptionKind(String keyword) throws ParseException { + FlushOption.Kind kind = FlushOption.Kind.fromKeyword(keyword); + if (kind == null) { + throw new ParseException("Unknown FLUSH option: " + keyword); + } + return kind; + } + /** * Whether NO_WRITE_TO_BINLOG or LOCAL modifies a MySQL partition operation. Followed by a * comma, INTO or the end of the statement, the word remains a partition name. @@ -4331,6 +4457,7 @@ String NonReservedWordPart1() : | tk= | tk= | tk= + | tk= | tk= | tk= | tk= @@ -5217,6 +5344,12 @@ Statement SingleStatement() : | LOOKAHEAD({ isTableMaintenanceAhead() }) stm = TableMaintenanceStatement() | + LOOKAHEAD({ isFlushAhead() }) stm = FlushStatement() + | + LOOKAHEAD({ isKillAhead() }) stm = KillStatement() + | + LOOKAHEAD({ isHandlerAhead() }) stm = HandlerStatement() + | LOOKAHEAD({ isReindexAhead() }) stm = Reindex() | LOOKAHEAD({ getToken(1).kind == K_SECURITY && "LABEL".equalsIgnoreCase(getToken(2).image) }) @@ -5255,6 +5388,8 @@ Statement SingleStatement() : | stm = Set() | + LOOKAHEAD({ isResetPersistAhead() }) stm = ResetPersist() + | stm = Reset() | stm = Show() @@ -6910,6 +7045,33 @@ ResetStatement Reset(): { { reset = new ResetStatement(name); return reset; } } +// https://dev.mysql.com/doc/refman/8.4/en/reset-persist.html +ResetStatement ResetPersist(): +{ + ResetStatement reset = new ResetStatement((String) null).withPersist(true); + String name; + String part; + Token prefix; +} +{ + ContextualKeyword("PERSIST") + [ + // MySQL reserves IF, so IF without EXISTS starts the next statement + LOOKAHEAD({ getToken(1).kind == K_IF ? getToken(2).kind == K_EXISTS + : isIdentifierishNameToken(getToken(1)) }) + [ LOOKAHEAD(2) { reset.setIfExists(true); } ] + // MySQL's persisted_variable_ident: ident, ident.ident or DEFAULT.ident + ( + LOOKAHEAD(2) prefix= "." part=RelObjectName() { name = prefix.image + "." + part; } + | + LOOKAHEAD({ getToken(1).kind != K_DEFAULT }) + name=RelObjectName() [ "." part=RelObjectName() { name = name + "." + part; } ] + ) + { reset.setName(name); } + ] + { return reset; } +} + RenameTableStatement RenameTableStatement(): { RenameTableStatement renameTableStatement; Table oldName; @@ -19857,7 +20019,7 @@ TableMaintenanceStatement TableMaintenanceStatement(): TableMaintenanceStatement statement = new TableMaintenanceStatement(); TableMaintenanceStatement.Operation operation = null; TableMaintenanceStatement.Option option = null; - Token modifier = null; + TableMaintenanceStatement.Modifier modifier; Token word = null; Table table = null; } @@ -19872,11 +20034,7 @@ TableMaintenanceStatement TableMaintenanceStatement(): [ LOOKAHEAD({ operation.allowsModifier() && (getToken(1).kind == K_NO_WRITE_TO_BINLOG || getToken(1).kind == K_LOCAL) }) - ( modifier= | modifier= ) - { - statement.setModifier(TableMaintenanceStatement.Modifier.valueOf( - modifier.image.toUpperCase(Locale.ROOT))); - } + modifier=BinlogModifier() { statement.setModifier(modifier); } ] ( | { statement.setTablesKeyword(true); } ) table=Table() { statement.addTables(table); } @@ -19899,6 +20057,199 @@ TableMaintenanceStatement TableMaintenanceStatement(): } } +/** MySQL's opt_no_write_to_binlog: NO_WRITE_TO_BINLOG or its synonym LOCAL. */ +TableMaintenanceStatement.Modifier BinlogModifier(): +{ Token modifier; } +{ + ( modifier= | modifier= ) + { return TableMaintenanceStatement.Modifier.valueOf(modifier.image.toUpperCase(Locale.ROOT)); } +} + +/** + * MySQL FLUSH [NO_WRITE_TO_BINLOG | LOCAL] followed by flush options or by + * {TABLE | TABLES} [tbl_name [, tbl_name] ...] [WITH READ LOCK | FOR EXPORT]. + * https://dev.mysql.com/doc/refman/8.4/en/flush.html + */ +FlushStatement FlushStatement(): +{ + FlushStatement statement = new FlushStatement(); + TableMaintenanceStatement.Modifier modifier; + FlushOption option; + Table table; +} +{ + + [ modifier=BinlogModifier() { statement.setModifier(modifier); } ] + ( + ( { statement.setTablesKeyword(false); } | ) + [ + table=Table() { statement.addTables(table); } + ( "," table=Table() { statement.addTables(table); } )* + ] + [ + // WITH without READ LOCK starts the next statement + LOOKAHEAD({ getToken(1).kind == K_WITH && getToken(2).kind == K_READ + || getToken(1).kind == K_FOR }) + ( + + { statement.setTableLock(FlushStatement.TableLock.WITH_READ_LOCK); } + | + { + requireDdlSyntax(!statement.getTables().isEmpty(), "FOR EXPORT requires a table list"); + statement.setTableLock(FlushStatement.TableLock.FOR_EXPORT); + } + ) + ] + | + option=FlushOption() { statement.addOptions(option); } + ( "," option=FlushOption() { statement.addOptions(option); } )* + ) + { return statement; } +} + +/** A flush option of one word, or of a word and LOGS. Only RELAY LOGS names a channel. */ +FlushOption FlushOption(): +{ + Token first = getToken(1); + Token word; + Token channel; + FlushOption option; +} +{ + ( + LOOKAHEAD({ isWordSequenceAhead(2, "LOGS") }) + ( word= | word= | word= | word= ) + ContextualKeyword("LOGS") + { option = new FlushOption(flushOptionKind(word.image + " LOGS")); } + | + word= { option = new FlushOption(flushOptionKind(word.image)); } + ) + [ + ContextualKeyword("CHANNEL") channel= + { + requireDdlSyntax(option.getKind() == FlushOption.Kind.RELAY_LOGS, + "Only RELAY LOGS takes FOR CHANNEL"); + option.setChannel(stringValue(channel)); + } + ] + { linkAST(option, first); return option; } +} + +// https://dev.mysql.com/doc/refman/8.4/en/kill.html +KillStatement KillStatement(): +{ + KillStatement statement = new KillStatement(); + Expression id; +} +{ + + [ + LOOKAHEAD({ getToken(1).kind == K_QUERY || isKeywordAhead("CONNECTION") }) + ( + { statement.setKind(KillStatement.Kind.QUERY); } + | + ContextualKeyword("CONNECTION") { statement.setKind(KillStatement.Kind.CONNECTION); } + ) + ] + id=Expression() { statement.setProcesslistId(id); } + { return statement; } +} + +/** + * MySQL HANDLER tbl_name OPEN [[AS] alias], HANDLER name READ ... and HANDLER name CLOSE. READ + * and CLOSE take the handler name, which is unqualified. + * https://dev.mysql.com/doc/refman/8.4/en/handler.html + */ +HandlerStatement HandlerStatement(): +{ + HandlerStatement statement = new HandlerStatement(); + Table table; + String name; + boolean useAs = false; +} +{ + + ( + LOOKAHEAD({ getToken(2).kind != K_READ && getToken(2).kind != K_CLOSE }) + table=Table() + [ + [ { useAs = true; } ] + name=RelObjectName() { table.setAlias(new Alias(name, useAs)); } + ] + | + name=RelObjectName() { table = linkToLastToken(createTable(Arrays.asList(name))); } + ( + { statement.setAction(HandlerStatement.Action.CLOSE); } + | + { statement.setAction(HandlerStatement.Action.READ); } + HandlerRead(statement) + ) + ) + { statement.setTable(table); return statement; } +} + +void HandlerRead(HandlerStatement statement): +{ + String index; + ExpressionList values; + Expression expression; + Limit limit; +} +{ + ( + LOOKAHEAD({ isHandlerIndexAhead() }) + index=RelObjectName() { statement.setIndexName(index); } + ( + HandlerKeyComparison(statement) values=ParenthesedExpressionList() + { + requireDdlSyntax(!values.isEmpty(), "HANDLER READ requires key values"); + statement.setKeyValues(values); + } + | + { statement.setDirection(HandlerStatement.Direction.FIRST); } + | { statement.setDirection(HandlerStatement.Direction.NEXT); } + | ContextualKeyword("PREV") { statement.setDirection(HandlerStatement.Direction.PREV); } + | { statement.setDirection(HandlerStatement.Direction.LAST); } + ) + | + { statement.setDirection(HandlerStatement.Direction.FIRST); } + | + { statement.setDirection(HandlerStatement.Direction.NEXT); } + ) + [ expression=WhereClause() { statement.setWhere(expression); } ] + [ + limit=LimitWithOffset() { statement.setLimit(limit); } + [ + expression=Expression() + { + requireDdlSyntax(limit.getOffset() == null, "LIMIT offset, row_count takes no OFFSET"); + statement.setOffset(new Offset().withOffset(expression)); + } + ] + ] + { + // MySQL parses HANDLER READ with subqueries disallowed, like PURGE + requireDdlSyntax(!containsSubquery(statement.getKeyValues(), statement.getWhere(), + statement.getLimit() == null ? null : statement.getLimit().getOffset(), + statement.getLimit() == null ? null : statement.getLimit().getRowCount(), + statement.getOffset() == null ? null : statement.getOffset().getOffset()), + "HANDLER READ takes no subquery"); + } +} + +void HandlerKeyComparison(HandlerStatement statement): +{ HandlerStatement.KeyComparison comparison; } +{ + ( + "=" { comparison = HandlerStatement.KeyComparison.EQUALS; } + | { comparison = HandlerStatement.KeyComparison.LESS_THAN_OR_EQUALS; } + | { comparison = HandlerStatement.KeyComparison.GREATER_THAN_OR_EQUALS; } + | "<" { comparison = HandlerStatement.KeyComparison.LESS_THAN; } + | ">" { comparison = HandlerStatement.KeyComparison.GREATER_THAN; } + ) + { statement.setKeyComparison(comparison); } +} + /** * MySQL ANALYZE [NO_WRITE_TO_BINLOG | LOCAL] TABLE t, ... [histogram] and, with the PostgreSQL * dialect or without a dialect, PostgreSQL {ANALYZE | ANALYSE} [(options) | VERBOSE] [target, ...]. @@ -22321,14 +22672,10 @@ AlterExpressionPartition AddPartition(): /** Parses MySQL's opt_no_write_to_binlog of a partition operation. */ void PartitionBinlogModifier(AlterExpressionPartition alterExp): { - Token tk; + TableMaintenanceStatement.Modifier modifier; } { - ( tk= | tk= ) - { - alterExp.setModifier(TableMaintenanceStatement.Modifier.valueOf( - tk.image.toUpperCase(Locale.ROOT))); - } + modifier=BinlogModifier() { alterExp.setModifier(modifier); } } /** @@ -26248,10 +26595,10 @@ ForeignDataWrapperFunctions ForeignDataWrapperFunctions(): { name = null; } ( - ( LOOKAHEAD({ isKeywordAhead("HANDLER") }) ContextualKeyword("HANDLER") { handler = true; } + ( { handler = true; } | ContextualKeyword("VALIDATOR") { handler = false; } ) | - ( LOOKAHEAD({ isKeywordAhead("HANDLER") }) ContextualKeyword("HANDLER") { handler = true; } + ( { handler = true; } | ContextualKeyword("VALIDATOR") { handler = false; } ) name=AccessQualifiedName() ) @@ -26355,7 +26702,7 @@ CreateAccessMethod CreateAccessMethod(): { statement.setName(name); } ( { statement.setType(CreateAccessMethod.Type.TABLE); } | { statement.setType(CreateAccessMethod.Type.INDEX); } ) - ContextualKeyword("HANDLER") name=AccessQualifiedName() + name=AccessQualifiedName() { statement.setHandler(name); return statement; } } diff --git a/src/site/sphinx/index.rst b/src/site/sphinx/index.rst index e0ff2d8a4..e9aa7363e 100644 --- a/src/site/sphinx/index.rst +++ b/src/site/sphinx/index.rst @@ -164,6 +164,8 @@ One grammar covers the SQL standard plus all major RDBMS. Missing syntax gets ad - ``VACUUM`` · ``ANALYZE`` · ``CLUSTER`` · ``CHECKPOINT`` * - **MySQL locks** - ``LOCK TABLES`` · ``UNLOCK TABLES`` · ``LOCK INSTANCE FOR BACKUP`` · ``UNLOCK INSTANCE`` + * - **MySQL server administration** + - ``FLUSH`` · ``KILL`` · ``RESET PERSIST`` · ``HANDLER … OPEN``/``READ``/``CLOSE`` * - **MySQL account management** - ``CREATE``/``ALTER``/``RENAME USER`` · ``CREATE ROLE`` · ``SET ROLE`` · ``SET DEFAULT ROLE`` · ``SET PASSWORD`` * - **Tablespaces** diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 93bee3339..91da91378 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -2484,7 +2484,8 @@ records the ``TABLES`` spelling. ``getOptions()`` keeps the trailing options in order. CHECK accepts ``FOR UPGRADE``, ``QUICK``, ``FAST``, ``MEDIUM``, ``EXTENDED`` and ``CHANGED``; CHECKSUM accepts one of ``QUICK`` and ``EXTENDED``; REPAIR accepts ``QUICK``, ``EXTENDED`` and ``USE_FRM``. Other combinations are rejected, and the option words and -``CHECKSUM`` remain ordinary identifiers elsewhere. +``CHECKSUM`` remain ordinary identifiers elsewhere. With unsupported statements enabled, these +statements are parsed rather than captured as unsupported statements. .. code-block:: java @@ -2606,6 +2607,70 @@ Table visitors report the locked tables but not their aliases. Validators report three, MariaDB the first two, and PostgreSQL and Oracle ``lock``. Feature analysis reports ``MODIFIES_TRANSACTION``. +MySQL server administration statements +-------------------------------------- + +With ``Dialect.MYSQL`` and without a dialect, these MySQL 8.4 statements parse to typed +statements: + +* `FLUSH `_ parses to + ``FlushStatement``. ``getModifier()`` returns ``NO_WRITE_TO_BINLOG`` or ``LOCAL``, the + ``TableMaintenanceStatement.Modifier`` of OPTIMIZE, REPAIR and the partition operations. + ``getOptions()`` keeps the flush options in source order. Each is a ``FlushOption`` whose + ``getKind()`` is one of MySQL 8.4's options, from ``BINARY_LOGS`` to ``USER_RESOURCES``, + and whose ``getChannel()`` holds the string literal of ``RELAY LOGS FOR CHANNEL 'name'``. + Without options the statement is + ``FLUSH {TABLE | TABLES} [tbl_name, ...] [WITH READ LOCK | FOR EXPORT]`` + (``isFlushTables()``), with ``getTables()``, ``isTablesKeyword()`` and ``getTableLock()``. + As in MySQL, that form takes no flush option, ``FOR EXPORT`` requires a table list, only + ``RELAY LOGS`` names a channel, and ``FLUSH HOSTS``, which MySQL 8.4 removed, is rejected; + with unsupported statements enabled it remains an ``UnsupportedStatement``. +* `KILL [CONNECTION | QUERY] processlist_id + `_ parses to ``KillStatement``. + ``getKind()`` is ``CONNECTION``, ``QUERY`` or null when neither is given, and + ``getProcesslistId()`` is an expression, since MySQL accepts ``@id`` or ``1 + 2`` there. +* `RESET PERSIST [[IF EXISTS] system_var_name] + `_ extends ``ResetStatement`` + with ``isPersist()`` and ``isIfExists()``; ``getName()`` is the variable as written, with + a component or ``DEFAULT`` prefix such as ``validate_password.length``, or null when the + statement names none. Without a dialect only a variable name or ``IF EXISTS`` after + ``PERSIST`` selects this form, so a bare ``RESET PERSIST`` still resets a parameter named + ``persist``, as it does in the other dialects. +* `HANDLER `_ parses to + ``HandlerStatement``, whose ``getAction()`` is ``OPEN``, ``READ`` or ``CLOSE``. + ``getTable()`` is the table that OPEN opens, with the handler name as its alias, or the + unqualified handler name that READ and CLOSE take; ``getHandlerName()`` returns the name in + both cases. A READ has ``getIndexName()`` and either ``getKeyComparison()`` (``=``, + ``<=``, ``>=``, ``<`` or ``>``) with ``getKeyValues()``, or ``getDirection()`` + (``FIRST``, ``NEXT``, ``PREV`` or ``LAST``, and only ``FIRST`` or ``NEXT`` without an + index), followed by ``getWhere()``, ``getLimit()`` and ``getOffset()``. As in MySQL, a + READ takes no subquery. + +.. code-block:: java + + FlushStatement flush = (FlushStatement) CCJSqlParserUtil.parse( + "FLUSH LOCAL TABLES t1, t2 WITH READ LOCK"); + flush.getTables(); // [t1, t2] + flush.getTableLock(); // WITH_READ_LOCK + + HandlerStatement read = (HandlerStatement) CCJSqlParserUtil.parse( + "HANDLER h READ i1 >= (1, 2) WHERE a > 0 LIMIT 3"); + read.getKeyComparison(); // GREATER_THAN_OR_EQUALS + read.getKeyValues(); // (1, 2) + +Other dialects keep rejecting these statements. ``HANDLER`` becomes a non-reserved keyword +and remains usable as a name, like ``FLUSH``, ``KILL`` and the option words. + +Table visitors report the tables of ``FLUSH TABLES`` and the table that ``HANDLER ... OPEN`` +opens, but not the handler name of READ and CLOSE, which may be an alias. They find the +tables of subqueries in the KILL expression, and they now return no table for every +``RESET`` statement instead of throwing. Validators report the +``flush``, ``kill``, ``handler`` and ``resetPersist`` features, which the MySQL capabilities +enable; PostgreSQL's ``RESET`` keeps ``reset``. Feature analysis reports +``MODIFIES_SESSION`` for FLUSH, KILL and HANDLER OPEN and CLOSE, adds +``MODIFIES_TRANSACTION`` for the table locks of FLUSH TABLES, and reports ``READS_DATA`` and +``RETURNS_RESULT_SET`` for HANDLER READ. + MySQL account management statements ----------------------------------- diff --git a/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java b/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java index 25dd26d38..a6be61c1c 100644 --- a/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java +++ b/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java @@ -147,6 +147,13 @@ public void testStatementsTheCorpusLacks() throws JSQLParserException { "CLUSTER VERBOSE i ON t", "ANALYSE (\"verbose\", PARALLEL -1) t", "CHECKPOINT", + "FLUSH LOCAL TABLES db1.t, s WITH READ LOCK", + "FLUSH ERROR LOGS, RELAY LOGS FOR CHANNEL 'c1', STATUS", + "KILL QUERY 1 + 2", + "HANDLER db1.t OPEN AS h", + "HANDLER h READ i1 >= (1, 2) WHERE a > 0 LIMIT 3 OFFSET 1", + "HANDLER h CLOSE", + "RESET PERSIST IF EXISTS max_connections", "DROP CAST IF EXISTS (int[] AS public.t) CASCADE", "DROP OPERATOR public.=== (integer, integer), ~ (NONE, bigint)", "DROP TRANSFORM FOR public.hstore LANGUAGE plpython3u"}) { diff --git a/src/test/java/net/sf/jsqlparser/statement/ResetStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/ResetStatementTest.java index b3372ff73..6839c0a54 100644 --- a/src/test/java/net/sf/jsqlparser/statement/ResetStatementTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/ResetStatementTest.java @@ -11,11 +11,32 @@ import net.sf.jsqlparser.JSQLParserException; import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; 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.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import net.sf.jsqlparser.parser.ASTNodeAccess; +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.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.StatementDeParser; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; public class ResetStatementTest { + private static final Consumer MYSQL = parser -> parser.withDialect(Dialect.MYSQL); + private static final Consumer NO_DIALECT = parser -> { + }; @Test public void tesResetTZ() throws JSQLParserException { @@ -37,4 +58,129 @@ public void testObject() { assertEquals("somethingElse", resetStatement.getName()); } + @ParameterizedTest + @ValueSource(strings = {"RESET PERSIST", "RESET PERSIST max_connections", + "RESET PERSIST IF EXISTS max_connections", "RESET PERSIST `max_connections`", + "RESET PERSIST a.b", "RESET PERSIST `a`.`b`", "RESET PERSIST DEFAULT.x", + "RESET PERSIST IF EXISTS validate_password.length", + "RESET PERSIST IF EXISTS DEFAULT.x"}) + public void testResetPersistInMySql(String sql) throws JSQLParserException { + ResetStatement reset = (ResetStatement) assertSqlCanBeParsedAndDeparsed(sql, false, MYSQL); + assertTrue(reset.isPersist()); + assertEquals(sql, deparse(reset)); + } + + @Test + public void testResetPersistState() throws JSQLParserException { + ResetStatement all = (ResetStatement) CCJSqlParserUtil.parse("reset persist", MYSQL); + assertTrue(all.isPersist()); + assertFalse(all.isIfExists()); + assertNull(all.getName()); + assertEquals("RESET PERSIST", all.toString()); + + ResetStatement one = (ResetStatement) CCJSqlParserUtil + .parse("RESET PERSIST IF EXISTS max_connections", MYSQL); + assertTrue(one.isPersist()); + assertTrue(one.isIfExists()); + assertEquals("max_connections", one.getName()); + + // a component variable keeps its prefix in the name + ResetStatement component = (ResetStatement) CCJSqlParserUtil + .parse("RESET PERSIST IF EXISTS `validate_password`.length", MYSQL); + assertEquals("`validate_password`.length", component.getName()); + assertEquals("DEFAULT.x", + ((ResetStatement) CCJSqlParserUtil.parse("RESET PERSIST DEFAULT.x", MYSQL)) + .getName()); + } + + @ParameterizedTest + @ValueSource(strings = {"RESET PERSIST max_connections", + "RESET PERSIST IF EXISTS max_connections", "RESET PERSIST validate_password.length", + "RESET PERSIST DEFAULT.x"}) + public void testResetPersistWithANameWithoutDialect(String sql) throws JSQLParserException { + ResetStatement reset = + (ResetStatement) assertSqlCanBeParsedAndDeparsed(sql, false, NO_DIALECT); + assertTrue(reset.isPersist()); + assertEquals(sql.substring(sql.lastIndexOf(' ') + 1), reset.getName()); + } + + @Test + public void testBareResetPersistKeepsItsReadingOutsideMySql() throws JSQLParserException { + // PostgreSQL resets a parameter named persist; only MySQL reads the PERSIST keyword alone + for (Consumer dialect : List.of(NO_DIALECT, + (Consumer) parser -> parser.withDialect(Dialect.POSTGRESQL), + (Consumer) parser -> parser.withDialect(Dialect.MARIADB))) { + ResetStatement reset = + (ResetStatement) assertSqlCanBeParsedAndDeparsed("RESET persist", false, + dialect); + assertFalse(reset.isPersist()); + assertEquals("persist", reset.getName()); + } + ResetStatement script = (ResetStatement) CCJSqlParserUtil + .parseStatements("RESET persist; SELECT 1", NO_DIALECT).get(0); + assertFalse(script.isPersist()); + } + + @ParameterizedTest + @ValueSource(strings = {"RESET PERSIST max_connections", + "RESET PERSIST IF EXISTS max_connections", "RESET PERSIST a.b"}) + public void testOtherDialectsKeepRejectingResetPersistOfAName(String sql) { + for (Dialect dialect : List.of(Dialect.POSTGRESQL, Dialect.MARIADB, Dialect.ORACLE)) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(dialect))); + } + } + + @ParameterizedTest + @ValueSource(strings = {"RESET PERSIST IF EXISTS", "RESET PERSIST IF max_connections", + "RESET PERSIST a.b.c", "RESET PERSIST a.", "RESET PERSIST DEFAULT", + "RESET PERSIST a b", "RESET PERSIST 'max_connections'", + "RESET PERSIST max_connections, wait_timeout"}) + public void testRejectsResetPersistFormsMySqlRejects(String sql) { + for (Consumer dialect : List.of(NO_DIALECT, MYSQL)) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, dialect), + sql); + } + } + + @Test + public void testResetPersistEndsTheStatement() throws JSQLParserException { + List statements = CCJSqlParserUtil + .parseStatements("RESET PERSIST; RESET PERSIST IF EXISTS x; SELECT 1", MYSQL) + .stream().map(Object::toString).collect(Collectors.toList()); + assertEquals(List.of("RESET PERSIST", "RESET PERSIST IF EXISTS x", "SELECT 1"), + statements); + } + + @Test + public void testFindsNoTables() throws JSQLParserException { + for (String sql : new String[] {"RESET ALL", "RESET work_mem", "RESET PERSIST x"}) { + assertThat(new TablesNamesFinder<>().getTables(CCJSqlParserUtil.parse(sql))) + .isEmpty(); + } + } + + @Test + public void testNodeSpansTheStatement() throws JSQLParserException { + String sql = "RESET PERSIST IF EXISTS max_connections"; + Node node = ((ASTNodeAccess) CCJSqlParserUtil.parse(sql, MYSQL)).getASTNode(); + assertEquals(sql, sql.substring(node.jjtGetFirstToken().absoluteBegin - 1, + node.jjtGetLastToken().absoluteEnd - 1)); + } + + @Test + public void testBuildsResetPersistInCode() { + ResetStatement reset = new ResetStatement().withPersist(true); + assertEquals("RESET PERSIST", reset.toString()); + reset.withIfExists(true).setName("x"); + assertEquals("RESET PERSIST IF EXISTS x", deparse(reset)); + reset.setPersist(false); + assertEquals("RESET x", reset.toString()); + } + + private static String deparse(ResetStatement reset) { + StringBuilder builder = new StringBuilder(); + reset.accept(new StatementDeParser(builder), null); + return builder.toString(); + } } diff --git a/src/test/java/net/sf/jsqlparser/statement/TableMaintenanceStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/TableMaintenanceStatementTest.java index 0f7ad33af..77e6084c9 100644 --- a/src/test/java/net/sf/jsqlparser/statement/TableMaintenanceStatementTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/TableMaintenanceStatementTest.java @@ -213,4 +213,16 @@ void reportsStatementFeatures() throws JSQLParserException { assertThat(repair.getCertain()).contains(StmtFeature.MODIFIES_SCHEMA); assertThat(repair.getUncertain()).contains(StmtFeature.MODIFIES_DATA); } + + @Test + void isAStatementStartForUnsupportedStatements() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "FOO; CHECK TABLE t; OPTIMIZE TABLE t; CHECKSUM TABLE t; REPAIR TABLE t; CHECK x", + parser -> parser.withUnsupportedStatements(true)); + assertEquals(List.of("UnsupportedStatement", "TableMaintenanceStatement", + "TableMaintenanceStatement", "TableMaintenanceStatement", + "TableMaintenanceStatement", "UnsupportedStatement"), + statements.stream().map(statement -> statement.getClass().getSimpleName()) + .collect(Collectors.toList())); + } } diff --git a/src/test/java/net/sf/jsqlparser/statement/admin/MySqlFlushStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/admin/MySqlFlushStatementTest.java new file mode 100644 index 000000000..79566e6dd --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/admin/MySqlFlushStatementTest.java @@ -0,0 +1,300 @@ +/*- + * #%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.admin; + +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.ASTNodeAccess; +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.Table; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementFeatures; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.StmtFeature; +import net.sf.jsqlparser.statement.TableMaintenanceStatement.Modifier; +import net.sf.jsqlparser.statement.UnsupportedStatement; +import net.sf.jsqlparser.statement.alter.AlterSystemStatement; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class MySqlFlushStatementTest { + private static final Consumer MYSQL = parser -> parser.withDialect(Dialect.MYSQL); + private static final Consumer NO_DIALECT = parser -> { + }; + + @ParameterizedTest + @ValueSource(strings = { + "FLUSH TABLES", + "FLUSH TABLE", + "FLUSH TABLES WITH READ LOCK", + "FLUSH TABLES t, s WITH READ LOCK", + "FLUSH TABLES t FOR EXPORT", + "FLUSH TABLE t", + "FLUSH TABLES db1.t, `u`", + "FLUSH LOCAL TABLES t FOR EXPORT", + "FLUSH NO_WRITE_TO_BINLOG TABLE t WITH READ LOCK", + "FLUSH PRIVILEGES", + "FLUSH LOCAL BINARY LOGS", + "FLUSH NO_WRITE_TO_BINLOG LOGS", + "FLUSH ERROR LOGS, SLOW LOGS, STATUS", + "FLUSH RELAY LOGS", + "FLUSH RELAY LOGS FOR CHANNEL 'c1'", + "FLUSH LOGS, RELAY LOGS FOR CHANNEL 'c1'", + "FLUSH OPTIMIZER_COSTS", + "FLUSH USER_RESOURCES", + "FLUSH ENGINE LOGS, GENERAL LOGS", + "FLUSH BINARY LOGS, ENGINE LOGS, ERROR LOGS, GENERAL LOGS, LOGS, OPTIMIZER_COSTS, " + + "PRIVILEGES, RELAY LOGS, SLOW LOGS, STATUS, USER_RESOURCES"}) + void parsesMySqlFormsWithAndWithoutDialect(String sql) throws JSQLParserException { + for (Consumer dialect : List.of(NO_DIALECT, MYSQL)) { + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, false, dialect); + assertInstanceOf(FlushStatement.class, statement); + assertEquals(sql, deparse(statement)); + assertEquals(sql, CCJSqlParserUtil.parse(statement.toString(), dialect).toString()); + } + } + + @Test + void keepsTheOptionsInSourceOrder() throws JSQLParserException { + FlushStatement flush = + parse("flush local binary logs, relay logs for channel 'c1', status"); + assertEquals(Modifier.LOCAL, flush.getModifier()); + assertFalse(flush.isFlushTables()); + assertEquals(List.of(FlushOption.Kind.BINARY_LOGS, FlushOption.Kind.RELAY_LOGS, + FlushOption.Kind.STATUS), + flush.getOptions().stream().map(FlushOption::getKind) + .collect(Collectors.toList())); + assertNull(flush.getOptions().get(0).getChannel()); + assertEquals("c1", flush.getOptions().get(1).getChannel().getValue()); + assertTrue(flush.getTables().isEmpty()); + assertNull(flush.getTableLock()); + assertEquals("FLUSH LOCAL BINARY LOGS, RELAY LOGS FOR CHANNEL 'c1', STATUS", + flush.toString()); + } + + @Test + void keepsTheTablesAndTheirLock() throws JSQLParserException { + FlushStatement flush = parse("FLUSH NO_WRITE_TO_BINLOG TABLES db1.t, s WITH READ LOCK"); + assertTrue(flush.isFlushTables()); + assertTrue(flush.isTablesKeyword()); + assertEquals(Modifier.NO_WRITE_TO_BINLOG, flush.getModifier()); + assertEquals(List.of("db1.t", "s"), flush.getTables().stream() + .map(Table::getFullyQualifiedName).collect(Collectors.toList())); + assertEquals(FlushStatement.TableLock.WITH_READ_LOCK, flush.getTableLock()); + + FlushStatement export = parse("FLUSH TABLE t FOR EXPORT"); + assertFalse(export.isTablesKeyword()); + assertNull(export.getModifier()); + assertEquals(FlushStatement.TableLock.FOR_EXPORT, export.getTableLock()); + + FlushStatement all = parse("FLUSH TABLES"); + assertTrue(all.getTables().isEmpty()); + assertNull(all.getTableLock()); + assertTrue(all.getOptions().isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = { + "FLUSH", + "FLUSH LOCAL", + "FLUSH HOSTS", + "FLUSH QUERY CACHE", + "FLUSH DES_KEY_FILE", + "FLUSH LOGS,", + "FLUSH BINARY", + "FLUSH STATUS LOGS", + "FLUSH LOCAL NO_WRITE_TO_BINLOG LOGS", + "FLUSH TABLES FOR EXPORT", + "FLUSH TABLES t WITH READ LOCK, PRIVILEGES", + "FLUSH PRIVILEGES, TABLES", + "FLUSH TABLES t WITH READ", + "FLUSH TABLES t FOR UPDATE", + "FLUSH TABLES t WITH READ LOCK FOR EXPORT", + "FLUSH RELAY LOGS FOR CHANNEL c1", + "FLUSH RELAY LOGS FOR CHANNEL `c1`", + "FLUSH RELAY LOGS FOR CHANNEL", + "FLUSH BINARY LOGS FOR CHANNEL 'c1'", + "FLUSH TABLES t,"}) + void rejectsFormsMySqlRejects(String sql) { + for (Consumer dialect : List.of(NO_DIALECT, MYSQL)) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, dialect), + sql); + } + } + + @ParameterizedTest + @ValueSource(strings = {"FLUSH TABLES", "FLUSH PRIVILEGES", "FLUSH LOCAL LOGS"}) + void otherDialectsKeepRejectingFlush(String sql) { + for (Dialect dialect : List.of(Dialect.POSTGRESQL, Dialect.MARIADB, Dialect.ORACLE, + Dialect.SQLSERVER)) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(dialect)), + sql + " " + dialect); + } + } + + @Test + void oracleAlterSystemFlushIsUnchanged() throws JSQLParserException { + Statement statement = assertSqlCanBeParsedAndDeparsed("ALTER SYSTEM FLUSH SHARED_POOL", + true, parser -> parser.withDialect(Dialect.ORACLE)); + assertInstanceOf(AlterSystemStatement.class, statement); + } + + @Test + void wordsRemainUsableAsNames() throws JSQLParserException { + for (Consumer dialect : List.of(NO_DIALECT, MYSQL)) { + assertSqlCanBeParsedAndDeparsed( + "SELECT flush, logs, relay, slow, general, privileges, status, channel FROM flush", + true, dialect); + assertSqlCanBeParsedAndDeparsed("SELECT * FROM t AS flush", true, dialect); + } + } + + @Test + void endsTheStatementsAroundIt() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "SELECT 1; FLUSH TABLES WITH READ LOCK; FLUSH LOGS; UNLOCK TABLES", MYSQL); + assertEquals(List.of("PlainSelect", "FlushStatement", "FlushStatement", + "UnlockStatement"), names(statements)); + assertEquals("FLUSH TABLES WITH READ LOCK", statements.get(1).toString()); + + // WITH without READ LOCK starts the next statement + Statements next = CCJSqlParserUtil.parseStatements( + "SELECT 1; FLUSH TABLES t\nWITH x AS (SELECT 1) SELECT * FROM x", MYSQL); + assertEquals(List.of("PlainSelect", "FlushStatement", "PlainSelect"), names(next)); + assertEquals("FLUSH TABLES t", next.get(1).toString()); + } + + @Test + void isAStatementStartForUnsupportedStatements() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "FOO BAR; FLUSH TABLES; BAZ; FLUSH HOSTS", + parser -> parser.withUnsupportedStatements(true)); + assertEquals(List.of("UnsupportedStatement", "FlushStatement", "UnsupportedStatement", + "UnsupportedStatement"), names(statements)); + assertEquals("FLUSH HOSTS", statements.get(3).toString()); + + Statements postgres = CCJSqlParserUtil.parseStatements("FLUSH TABLES", + parser -> parser.withDialect(Dialect.POSTGRESQL).withUnsupportedStatements(true)); + assertInstanceOf(UnsupportedStatement.class, postgres.get(0)); + } + + @Test + void findsTheFlushedTables() throws JSQLParserException { + assertThat(new TablesNamesFinder<>() + .getTables(CCJSqlParserUtil.parse("FLUSH TABLES db1.t, s WITH READ LOCK"))) + .containsExactlyInAnyOrder("db1.t", "s"); + assertThat(new TablesNamesFinder<>().getTables(CCJSqlParserUtil.parse("FLUSH TABLES"))) + .isEmpty(); + assertThat(new TablesNamesFinder<>() + .getTables(CCJSqlParserUtil.parse("FLUSH PRIVILEGES, LOGS"))).isEmpty(); + } + + @Test + void reportsSessionFeaturesAndTheLock() throws JSQLParserException { + StatementFeatures logs = CCJSqlParserUtil.parse("FLUSH LOGS").getFeatures(); + assertThat(logs.getCertain()).containsExactly(StmtFeature.MODIFIES_SESSION); + + for (String sql : new String[] {"FLUSH TABLES WITH READ LOCK", + "FLUSH TABLE t FOR EXPORT"}) { + assertThat(CCJSqlParserUtil.parse(sql).getFeatures().getCertain()) + .containsExactlyInAnyOrder(StmtFeature.MODIFIES_SESSION, + StmtFeature.MODIFIES_TRANSACTION); + } + } + + @Test + void nodesSpanTheStatementTablesAndOptions() throws JSQLParserException { + String sql = "FLUSH LOCAL TABLES db1.t, s WITH READ LOCK"; + FlushStatement flush = parse(sql); + assertEquals(sql, text(sql, flush)); + assertEquals("db1.t", text(sql, flush.getTables().get(0))); + assertEquals("s", text(sql, flush.getTables().get(1))); + + sql = "FLUSH ERROR LOGS, RELAY LOGS FOR CHANNEL 'c1', STATUS"; + flush = parse(sql); + assertEquals(sql, text(sql, flush)); + assertEquals("ERROR LOGS", text(sql, flush.getOptions().get(0))); + assertEquals("RELAY LOGS FOR CHANNEL 'c1'", text(sql, flush.getOptions().get(1))); + assertEquals("'c1'", text(sql, flush.getOptions().get(1).getChannel())); + assertEquals("STATUS", text(sql, flush.getOptions().get(2))); + } + + @Test + void buildsStatementsInCode() { + FlushStatement flush = new FlushStatement(); + assertTrue(flush.isFlushTables()); + assertEquals("FLUSH TABLES", flush.toString()); + flush.withTablesKeyword(false).addTables(new Table("db", "t")) + .withTableLock(FlushStatement.TableLock.FOR_EXPORT) + .withModifier(Modifier.NO_WRITE_TO_BINLOG); + assertEquals("FLUSH NO_WRITE_TO_BINLOG TABLE db.t FOR EXPORT", flush.toString()); + assertEquals(flush.toString(), deparse(flush)); + + FlushOption relay = new FlushOption(FlushOption.Kind.RELAY_LOGS) + .withChannel(new StringValue("c1")); + FlushStatement options = new FlushStatement() + .withOptions(List.of(new FlushOption(FlushOption.Kind.OPTIMIZER_COSTS))) + .addOptions(relay); + assertFalse(options.isFlushTables()); + assertEquals("FLUSH OPTIMIZER_COSTS, RELAY LOGS FOR CHANNEL 'c1'", options.toString()); + assertEquals(options.toString(), deparse(options)); + options.setOptions(null); + options.setTables(List.of(new Table("t"))); + assertEquals("FLUSH TABLES t", options.toString()); + options.setTables(null); + assertEquals("FLUSH TABLES", options.toString()); + + assertEquals(FlushOption.Kind.SLOW_LOGS, FlushOption.Kind.fromKeyword("slow logs")); + assertNull(FlushOption.Kind.fromKeyword("HOSTS")); + assertNull(FlushOption.Kind.fromKeyword(null)); + assertThrows(NullPointerException.class, () -> relay.setKind(null)); + assertEquals("ENGINE LOGS", new FlushOption(FlushOption.Kind.ENGINE_LOGS).toString()); + } + + private static FlushStatement parse(String sql) throws JSQLParserException { + return (FlushStatement) assertSqlCanBeParsedAndDeparsed(sql, true, MYSQL); + } + + private static List names(Statements statements) { + return statements.stream().map(statement -> statement.getClass().getSimpleName()) + .collect(Collectors.toList()); + } + + static String deparse(Statement statement) { + StringBuilder builder = new StringBuilder(); + statement.accept(new StatementDeParser(builder), null); + return builder.toString(); + } + + static String text(String sql, ASTNodeAccess access) { + Node node = access.getASTNode(); + return sql.substring(node.jjtGetFirstToken().absoluteBegin - 1, + node.jjtGetLastToken().absoluteEnd - 1); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/admin/MySqlKillStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/admin/MySqlKillStatementTest.java new file mode 100644 index 000000000..2fe79d9ed --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/admin/MySqlKillStatementTest.java @@ -0,0 +1,182 @@ +/*- + * #%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.admin; + +import static net.sf.jsqlparser.statement.admin.MySqlFlushStatementTest.deparse; +import static net.sf.jsqlparser.statement.admin.MySqlFlushStatementTest.text; +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.assertj.core.api.Assertions.assertThat; +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.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Function; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.expression.UserVariable; +import net.sf.jsqlparser.expression.operators.arithmetic.Addition; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.StmtFeature; +import net.sf.jsqlparser.statement.UnsupportedStatement; +import net.sf.jsqlparser.statement.alter.AlterSystemStatement; +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 MySqlKillStatementTest { + private static final Consumer MYSQL = parser -> parser.withDialect(Dialect.MYSQL); + private static final Consumer NO_DIALECT = parser -> { + }; + + @ParameterizedTest + @ValueSource(strings = { + "KILL 123", + "KILL QUERY 123", + "KILL CONNECTION 123", + "KILL CONNECTION 1 + 2", + "KILL @x", + "KILL QUERY @id", + "KILL CONNECTION_ID()", + "KILL ?", + "KILL connection + 1"}) + void parsesMySqlFormsWithAndWithoutDialect(String sql) throws JSQLParserException { + for (Consumer dialect : List.of(NO_DIALECT, MYSQL)) { + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, true, dialect); + assertInstanceOf(KillStatement.class, statement); + assertEquals(statement.toString(), deparse(statement)); + assertEquals(statement.toString(), + CCJSqlParserUtil.parse(statement.toString(), dialect).toString()); + } + } + + @Test + void keepsWhatItEndsAndTheIdentifierExpression() throws JSQLParserException { + KillStatement kill = parse("KILL 123"); + assertNull(kill.getKind()); + assertEquals(123L, ((LongValue) kill.getProcesslistId()).getValue()); + + assertEquals(KillStatement.Kind.QUERY, parse("kill query 123").getKind()); + assertEquals(KillStatement.Kind.CONNECTION, parse("KILL CONNECTION 123").getKind()); + assertInstanceOf(Addition.class, parse("KILL CONNECTION 1 + 2").getProcesslistId()); + assertInstanceOf(UserVariable.class, parse("KILL @x").getProcesslistId()); + + // CONNECTION is the keyword only before the identifier, as in MySQL's grammar + KillStatement function = parse("KILL CONNECTION_ID()"); + assertNull(function.getKind()); + assertInstanceOf(Function.class, function.getProcesslistId()); + KillStatement plus = parse("KILL connection + 1"); + assertEquals(KillStatement.Kind.CONNECTION, plus.getKind()); + assertEquals("KILL CONNECTION +1", plus.toString()); + } + + @ParameterizedTest + @ValueSource(strings = { + "KILL", + "KILL QUERY", + "KILL CONNECTION", + "KILL QUERY CONNECTION 1", + "KILL 1 2", + "KILL 1, 2", + "KILL USER u"}) + void rejectsFormsMySqlRejects(String sql) { + for (Consumer dialect : List.of(NO_DIALECT, MYSQL)) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, dialect), + sql); + } + } + + @ParameterizedTest + @ValueSource(strings = {"KILL 1", "KILL QUERY 1"}) + void otherDialectsKeepRejectingKill(String sql) { + for (Dialect dialect : List.of(Dialect.POSTGRESQL, Dialect.MARIADB, Dialect.ORACLE, + Dialect.SQLSERVER)) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(dialect)), + sql + " " + dialect); + } + } + + @Test + void oracleAlterSystemKillSessionIsUnchanged() throws JSQLParserException { + assertInstanceOf(AlterSystemStatement.class, assertSqlCanBeParsedAndDeparsed( + "ALTER SYSTEM KILL SESSION '13, 8'", true, + parser -> parser.withDialect(Dialect.ORACLE))); + } + + @Test + void wordsRemainUsableAsNames() throws JSQLParserException { + // MySQL 8.4 reserves KILL, but the parser keeps accepting it as a name, as before + for (Consumer dialect : List.of(NO_DIALECT, MYSQL)) { + assertSqlCanBeParsedAndDeparsed("SELECT kill, query, connection FROM kill", true, + dialect); + } + } + + @Test + void endsTheStatementsAroundIt() throws JSQLParserException { + Statements statements = + CCJSqlParserUtil.parseStatements("SELECT 1; KILL QUERY 5; KILL 6", MYSQL); + assertEquals(List.of("PlainSelect", "KillStatement", "KillStatement"), + statements.stream().map(statement -> statement.getClass().getSimpleName()) + .collect(Collectors.toList())); + + Statements unsupported = CCJSqlParserUtil.parseStatements("FOO; KILL 5", + parser -> parser.withUnsupportedStatements(true)); + assertInstanceOf(UnsupportedStatement.class, unsupported.get(0)); + assertInstanceOf(KillStatement.class, unsupported.get(1)); + } + + @Test + void findsTablesOnlyInASubquery() throws JSQLParserException { + assertThat(new TablesNamesFinder<>().getTables(CCJSqlParserUtil.parse("KILL 5"))) + .isEmpty(); + assertThat(new TablesNamesFinder<>() + .getTables(CCJSqlParserUtil.parse("KILL (SELECT MAX(id) FROM t)"))) + .containsExactly("t"); + assertThat(new TablesNamesFinder<>().getTables(new KillStatement())).isEmpty(); + } + + @Test + void reportsSessionFeatures() throws JSQLParserException { + assertThat(CCJSqlParserUtil.parse("KILL QUERY 5").getFeatures().getCertain()) + .containsExactly(StmtFeature.MODIFIES_SESSION); + } + + @Test + void nodesSpanTheStatementAndTheIdentifier() throws JSQLParserException { + String sql = "KILL CONNECTION 1 + 2"; + KillStatement kill = parse(sql); + assertEquals(sql, text(sql, kill)); + assertEquals("1 + 2", text(sql, kill.getProcesslistId())); + } + + @Test + void buildsStatementsInCode() { + KillStatement kill = new KillStatement().withProcesslistId(new LongValue(5)); + assertEquals("KILL 5", kill.toString()); + kill.withKind(KillStatement.Kind.QUERY); + assertEquals("KILL QUERY 5", deparse(kill)); + assertEquals("KILL CONNECTION @x", new KillStatement(KillStatement.Kind.CONNECTION, + new UserVariable("x")).toString()); + } + + private static KillStatement parse(String sql) throws JSQLParserException { + return (KillStatement) assertSqlCanBeParsedAndDeparsed(sql, true, MYSQL); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/handler/MySqlHandlerStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/handler/MySqlHandlerStatementTest.java new file mode 100644 index 000000000..60708d425 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/handler/MySqlHandlerStatementTest.java @@ -0,0 +1,340 @@ +/*- + * #%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.handler; + +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Alias; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.expression.operators.relational.GreaterThan; +import net.sf.jsqlparser.expression.operators.relational.ParenthesedExpressionList; +import net.sf.jsqlparser.parser.ASTNodeAccess; +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.schema.Table; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.StmtFeature; +import net.sf.jsqlparser.statement.UnsupportedStatement; +import net.sf.jsqlparser.statement.create.accessmethod.CreateAccessMethod; +import net.sf.jsqlparser.statement.handler.HandlerStatement.Action; +import net.sf.jsqlparser.statement.handler.HandlerStatement.Direction; +import net.sf.jsqlparser.statement.handler.HandlerStatement.KeyComparison; +import net.sf.jsqlparser.statement.select.Limit; +import net.sf.jsqlparser.statement.select.Offset; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class MySqlHandlerStatementTest { + private static final Consumer MYSQL = parser -> parser.withDialect(Dialect.MYSQL); + private static final Consumer NO_DIALECT = parser -> { + }; + + @ParameterizedTest + @ValueSource(strings = { + "HANDLER t OPEN", + "HANDLER t OPEN AS h", + "HANDLER db1.t OPEN h2", + "HANDLER `db1`.`t` OPEN AS `h`", + "HANDLER h READ FIRST", + "HANDLER h READ NEXT LIMIT 10", + "HANDLER h READ FIRST WHERE a > 1 LIMIT 5", + "HANDLER h READ i1 = (1)", + "HANDLER h READ i1 >= (1, 2) WHERE a > 0 LIMIT 3", + "HANDLER h READ i1 <= (1)", + "HANDLER h READ i1 < (1)", + "HANDLER h READ i1 > ('a', 2)", + "HANDLER h READ `PRIMARY` = (1, DEFAULT)", + "HANDLER h READ i1 FIRST", + "HANDLER h READ i1 NEXT", + "HANDLER h READ i1 PREV", + "HANDLER h READ i1 LAST WHERE a IS NULL LIMIT 2, 5", + "HANDLER h READ i1 NEXT LIMIT 5 OFFSET 2", + "HANDLER h READ first NEXT", + "HANDLER `read` READ NEXT", + "HANDLER h CLOSE"}) + void parsesMySqlFormsWithAndWithoutDialect(String sql) throws JSQLParserException { + for (Consumer dialect : List.of(NO_DIALECT, MYSQL)) { + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, false, dialect); + assertInstanceOf(HandlerStatement.class, statement); + assertEquals(sql, deparse(statement)); + assertEquals(sql, CCJSqlParserUtil.parse(statement.toString(), dialect).toString()); + } + } + + @Test + void opensATableUnderItsHandlerName() throws JSQLParserException { + HandlerStatement open = parse("HANDLER db1.t OPEN AS h"); + assertEquals(Action.OPEN, open.getAction()); + assertEquals("db1.t", open.getTable().getFullyQualifiedName()); + assertEquals("h", open.getTable().getAlias().getName()); + assertTrue(open.getTable().getAlias().isUseAs()); + assertEquals("h", open.getHandlerName()); + + HandlerStatement withoutAs = parse("HANDLER t OPEN h2"); + assertFalse(withoutAs.getTable().getAlias().isUseAs()); + assertEquals("h2", withoutAs.getHandlerName()); + + HandlerStatement plain = parse("HANDLER t OPEN"); + assertNull(plain.getTable().getAlias()); + assertEquals("t", plain.getHandlerName()); + } + + @Test + void readsThroughAnIndexWithKeyValues() throws JSQLParserException { + HandlerStatement read = parse("HANDLER h READ i1 >= (1, 2) WHERE a > 0 LIMIT 3"); + assertEquals(Action.READ, read.getAction()); + assertEquals("h", read.getHandlerName()); + assertEquals("i1", read.getIndexName()); + assertEquals(KeyComparison.GREATER_THAN_OR_EQUALS, read.getKeyComparison()); + assertInstanceOf(ParenthesedExpressionList.class, read.getKeyValues()); + assertEquals(List.of("1", "2"), read.getKeyValues().stream().map(Object::toString) + .collect(Collectors.toList())); + assertNull(read.getDirection()); + assertInstanceOf(GreaterThan.class, read.getWhere()); + assertEquals("3", read.getLimit().getRowCount().toString()); + assertNull(read.getOffset()); + + for (KeyComparison comparison : KeyComparison.values()) { + HandlerStatement compared = + parse("HANDLER h READ i1 " + comparison.getOperator() + " (1)"); + assertEquals(comparison, compared.getKeyComparison()); + } + } + + @Test + void readsInIndexOrStorageOrder() throws JSQLParserException { + HandlerStatement index = parse("HANDLER h READ i1 PREV"); + assertEquals("i1", index.getIndexName()); + assertEquals(Direction.PREV, index.getDirection()); + assertNull(index.getKeyComparison()); + assertNull(index.getKeyValues()); + + HandlerStatement storage = parse("HANDLER h READ next WHERE a > 1"); + assertNull(storage.getIndexName()); + assertEquals(Direction.NEXT, storage.getDirection()); + + // a direction after the word makes it the name of an index, as in MySQL's grammar + HandlerStatement named = parse("HANDLER h READ first NEXT"); + assertEquals("first", named.getIndexName()); + assertEquals(Direction.NEXT, named.getDirection()); + + HandlerStatement offset = parse("HANDLER h READ i1 LAST LIMIT 5 OFFSET 2"); + assertEquals(Direction.LAST, offset.getDirection()); + assertEquals("5", offset.getLimit().getRowCount().toString()); + assertEquals("2", offset.getOffset().getOffset().toString()); + + HandlerStatement comma = parse("HANDLER h READ FIRST LIMIT 2, 5"); + assertEquals("2", comma.getLimit().getOffset().toString()); + assertNull(comma.getOffset()); + } + + @Test + void closesAHandler() throws JSQLParserException { + HandlerStatement close = parse("HANDLER h CLOSE"); + assertEquals(Action.CLOSE, close.getAction()); + assertEquals("h", close.getHandlerName()); + assertEquals("h", close.getTable().getName()); + } + + @ParameterizedTest + @ValueSource(strings = { + "HANDLER", + "HANDLER t", + "HANDLER t OPEN AS", + "HANDLER t OPEN h AS x", + "HANDLER t OPEN AS h(a)", + "HANDLER t OPEN AS 'h'", + "HANDLER db1.t CLOSE", + "HANDLER db1.t READ FIRST", + "HANDLER h READ", + "HANDLER h READ PREV", + "HANDLER h READ LAST", + "HANDLER h READ i1", + "HANDLER h READ i1 = 1", + "HANDLER h READ i1 = ()", + "HANDLER h READ i1 != (1)", + "HANDLER h READ i1 <> (1)", + "HANDLER h READ i1 FIRST NEXT", + "HANDLER h READ FIRST OFFSET 2", + "HANDLER h READ FIRST LIMIT 2, 5 OFFSET 1", + "HANDLER h READ FIRST WHERE", + "HANDLER h READ FIRST LIMIT 5 WHERE a > 1", + "HANDLER h CLOSE h", + "HANDLER h OPEN AS x CLOSE", + // MySQL parses HANDLER READ with subqueries disallowed + "HANDLER h READ FIRST WHERE a IN (SELECT b FROM s)", + "HANDLER h READ FIRST WHERE a = (SELECT 1)", + "HANDLER h READ FIRST WHERE EXISTS (SELECT 1)", + "HANDLER h READ FIRST WHERE a IN (TABLE s)", + "HANDLER h READ FIRST WHERE f((SELECT 1)) > 0", + "HANDLER h READ i1 = ((SELECT 1))", + "HANDLER h READ FIRST LIMIT (SELECT 1)"}) + void rejectsFormsMySqlRejects(String sql) { + for (Consumer dialect : List.of(NO_DIALECT, MYSQL)) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, dialect), + sql); + } + } + + @ParameterizedTest + @ValueSource(strings = {"HANDLER t OPEN", "HANDLER h READ FIRST", "HANDLER h CLOSE"}) + void otherDialectsKeepRejectingHandler(String sql) { + for (Dialect dialect : List.of(Dialect.POSTGRESQL, Dialect.MARIADB, Dialect.ORACLE, + Dialect.SQLSERVER)) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(dialect)), + sql + " " + dialect); + } + } + + @Test + void wordsRemainUsableAsNames() throws JSQLParserException { + for (Consumer dialect : List.of(NO_DIALECT, MYSQL)) { + assertSqlCanBeParsedAndDeparsed("SELECT handler, prev FROM handler", true, dialect); + assertSqlCanBeParsedAndDeparsed("SELECT handler.a FROM db.handler AS handler", true, + dialect); + assertSqlCanBeParsedAndDeparsed("SELECT * FROM t handler", true, dialect); + assertSqlCanBeParsedAndDeparsed("CREATE TABLE handler (handler INT)", true, dialect); + assertSqlCanBeParsedAndDeparsed("HANDLER handler OPEN AS handler", true, dialect); + } + CreateAccessMethod accessMethod = (CreateAccessMethod) assertSqlCanBeParsedAndDeparsed( + "CREATE ACCESS METHOD heap2 TYPE TABLE HANDLER heap_tableam_handler", true, + parser -> parser.withDialect(Dialect.POSTGRESQL)); + assertEquals("heap_tableam_handler", accessMethod.getHandler()); + } + + @Test + void endsTheStatementsAroundIt() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "HANDLER t OPEN AS h; HANDLER h READ FIRST; HANDLER h CLOSE; SELECT 1", MYSQL); + assertEquals(List.of("HandlerStatement", "HandlerStatement", "HandlerStatement", + "PlainSelect"), names(statements)); + + Statements unsupported = CCJSqlParserUtil.parseStatements( + "FOO; HANDLER t OPEN; BAR; HANDLER t CLOSE", + parser -> parser.withUnsupportedStatements(true)); + assertEquals(List.of("UnsupportedStatement", "HandlerStatement", "UnsupportedStatement", + "HandlerStatement"), names(unsupported)); + + Statements postgres = CCJSqlParserUtil.parseStatements("HANDLER t OPEN", + parser -> parser.withDialect(Dialect.POSTGRESQL).withUnsupportedStatements(true)); + assertInstanceOf(UnsupportedStatement.class, postgres.get(0)); + } + + @Test + void findsTheOpenedTableButNotTheHandlerName() throws JSQLParserException { + assertThat(new TablesNamesFinder<>() + .getTables(CCJSqlParserUtil.parse("HANDLER db1.t OPEN AS h"))) + .containsExactly("db1.t"); + // READ and CLOSE name the handler, which may be the alias that OPEN gave the table + assertThat(new TablesNamesFinder<>() + .getTables(CCJSqlParserUtil.parse("HANDLER h READ i1 = (1) WHERE a > 1"))) + .isEmpty(); + assertThat(new TablesNamesFinder<>().getTables(CCJSqlParserUtil.parse("HANDLER h CLOSE"))) + .isEmpty(); + } + + @Test + void reportsReadsAndSessionChanges() throws JSQLParserException { + assertThat(CCJSqlParserUtil.parse("HANDLER h READ FIRST").getFeatures().getCertain()) + .containsExactlyInAnyOrder(StmtFeature.READS_DATA, + StmtFeature.RETURNS_RESULT_SET); + for (String sql : new String[] {"HANDLER t OPEN", "HANDLER h CLOSE"}) { + assertThat(CCJSqlParserUtil.parse(sql).getFeatures().getCertain()) + .containsExactly(StmtFeature.MODIFIES_SESSION); + } + } + + @Test + void nodesSpanTheStatementAndItsParts() throws JSQLParserException { + String sql = "HANDLER db1.t OPEN AS h"; + HandlerStatement open = parse(sql); + assertEquals(sql, text(sql, open)); + assertEquals("db1.t", text(sql, open.getTable())); + + sql = "HANDLER h READ i1 >= (1, 2) WHERE a > 0 LIMIT 3"; + HandlerStatement read = parse(sql); + assertEquals(sql, text(sql, read)); + assertEquals("h", text(sql, read.getTable())); + assertEquals("(1, 2)", text(sql, read.getKeyValues())); + assertEquals("a > 0", text(sql, read.getWhere())); + assertEquals("LIMIT 3", text(sql, read.getLimit())); + } + + @Test + void buildsStatementsInCode() { + Table table = new Table("db", "t"); + table.setAlias(new Alias("h")); + HandlerStatement open = new HandlerStatement(Action.OPEN, table); + assertEquals("HANDLER db.t OPEN AS h", open.toString()); + assertEquals("h", open.getHandlerName()); + assertNull(new HandlerStatement().getHandlerName()); + assertEquals(Action.OPEN, new HandlerStatement().getAction()); + + HandlerStatement read = new HandlerStatement().withAction(Action.READ) + .withTable(new Table("h")).withIndexName("i1") + .withKeyComparison(KeyComparison.EQUALS) + .withKeyValues(new ParenthesedExpressionList<>(new LongValue(1))) + .withWhere(new GreaterThan(new Column("a"), new LongValue(0))) + .withLimit(new Limit().withRowCount(new LongValue(5))) + .withOffset(new Offset().withOffset(new LongValue(2))); + assertEquals("HANDLER h READ i1 = (1) WHERE a > 0 LIMIT 5 OFFSET 2", read.toString()); + assertEquals(read.toString(), deparse(read)); + + read.withKeyComparison(null).withKeyValues((ExpressionList) null) + .withDirection(Direction.FIRST).withIndexName(null).withWhere(null) + .withLimit(null).withOffset(null); + assertEquals("HANDLER h READ FIRST", read.toString()); + + HandlerStatement close = new HandlerStatement(Action.CLOSE, new Table("h")); + assertEquals("HANDLER h CLOSE", deparse(close)); + assertThrows(NullPointerException.class, () -> close.setAction(null)); + } + + private static HandlerStatement parse(String sql) throws JSQLParserException { + return (HandlerStatement) assertSqlCanBeParsedAndDeparsed(sql, true, MYSQL); + } + + private static List names(Statements statements) { + return statements.stream().map(statement -> statement.getClass().getSimpleName()) + .collect(Collectors.toList()); + } + + private static String deparse(Statement statement) { + StringBuilder builder = new StringBuilder(); + statement.accept(new StatementDeParser(builder), null); + return builder.toString(); + } + + private static String text(String sql, ASTNodeAccess access) { + Node node = access.getASTNode(); + return sql.substring(node.jjtGetFirstToken().absoluteBegin - 1, + node.jjtGetLastToken().absoluteEnd - 1); + } +} diff --git a/src/test/java/net/sf/jsqlparser/util/validation/validator/MySqlServerAdminValidatorTest.java b/src/test/java/net/sf/jsqlparser/util/validation/validator/MySqlServerAdminValidatorTest.java new file mode 100644 index 000000000..430e66782 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/util/validation/validator/MySqlServerAdminValidatorTest.java @@ -0,0 +1,63 @@ +/*- + * #%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.util.validation.validator; + +import java.util.stream.Stream; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.util.validation.ValidationTestAsserts; +import net.sf.jsqlparser.util.validation.feature.DatabaseType; +import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed; +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.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +public class MySqlServerAdminValidatorTest extends ValidationTestAsserts { + + static Stream statements() { + return Stream.of( + Arguments.of("FLUSH TABLES t WITH READ LOCK", Feature.flush), + Arguments.of("FLUSH LOCAL BINARY LOGS, RELAY LOGS FOR CHANNEL 'c1'", + Feature.flush), + Arguments.of("KILL QUERY 5", Feature.kill), + Arguments.of("HANDLER db1.t OPEN AS h", Feature.handler), + Arguments.of("HANDLER h READ i1 >= (1, 2) WHERE a > 0 LIMIT 3", Feature.handler), + Arguments.of("HANDLER h CLOSE", Feature.handler), + Arguments.of("RESET PERSIST IF EXISTS max_connections", Feature.resetPersist)); + } + + @ParameterizedTest + @MethodSource("statements") + public void testAllowedForMySql(String sql, Feature feature) throws JSQLParserException { + validateNoErrors(sql, 1, MySqlVersion.V8_0, DatabaseType.MYSQL); + } + + @ParameterizedTest + @MethodSource("statements") + public void testNotAllowedForSelect(String sql, Feature feature) { + validateNotAllowed(sql, 1, 1, FeaturesAllowed.SELECT, feature); + } + + @ParameterizedTest + @MethodSource("statements") + public void testNotAllowedForPostgresql(String sql, Feature feature) + throws JSQLParserException { + validate(sql, 1, 1, PostgresqlVersion.V14); + } + + @Test + public void testPostgresqlResetStaysAllowed() throws JSQLParserException { + validateNoErrors("RESET ALL", 1, PostgresqlVersion.V14, DatabaseType.POSTGRESQL); + validate("RESET ALL", 1, 1, MySqlVersion.V8_0); + } +}