From d170a1182f173aa40b9cf6cdab96d5a67e9e23ac Mon Sep 17 00:00:00 2001 From: minleejae Date: Fri, 6 Nov 2026 08:52:02 +0900 Subject: [PATCH] feat: parse MySQL FLUSH, KILL, RESET PERSIST and HANDLER FLUSH, KILL and HANDLER failed in every dialect, RESET PERSIST alone reset a parameter named PERSIST and RESET PERSIST name failed. FlushStatement keeps the binlog modifier, the flush options in source order (FlushOption, with the channel of RELAY LOGS) or the FLUSH TABLES form with its tables and WITH READ LOCK or FOR EXPORT. KillStatement keeps CONNECTION or QUERY and the identifier expression. ResetStatement gains isPersist() and isIfExists(), and the variable name may have a component or DEFAULT prefix. HandlerStatement keeps OPEN with the table and its alias, READ with the index, key comparison and values or direction, WHERE and LIMIT, and CLOSE; as in MySQL, a READ takes no subquery. The forms parse with the MySQL dialect and without one; other dialects keep rejecting them. HANDLER becomes a non-reserved keyword, and TablesNamesFinder no longer throws for RESET. The new statements and CHECK, CHECKSUM, OPTIMIZE and REPAIR TABLE now start a statement, so unsupported statement capture no longer takes them. Signed-off-by: minleejae --- README.md | 1 + .../parser/RoutineBodyBoundary.java | 2 +- .../sf/jsqlparser/parser/feature/Feature.java | 30 ++ .../jsqlparser/statement/ResetStatement.java | 59 ++- .../statement/StatementFeatureVisitor.java | 37 ++ .../statement/StatementVisitor.java | 27 ++ .../statement/StatementVisitorAdapter.java | 21 + .../statement/admin/FlushOption.java | 97 +++++ .../statement/admin/FlushStatement.java | 181 +++++++++ .../statement/admin/KillStatement.java | 89 +++++ .../statement/handler/HandlerStatement.java | 277 +++++++++++++ .../sf/jsqlparser/util/TablesNamesFinder.java | 34 +- .../util/deparser/ResetStatementDeParser.java | 3 +- .../util/deparser/StatementDeParser.java | 19 + .../util/validation/feature/MySqlVersion.java | 8 + .../validator/ResetStatementValidator.java | 2 +- .../validator/StatementValidator.java | 32 ++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 377 +++++++++++++++++- src/site/sphinx/index.rst | 2 + src/site/sphinx/usage.rst | 67 +++- .../parser/ASTNodeCoverageTest.java | 7 + .../statement/ResetStatementTest.java | 146 +++++++ .../TableMaintenanceStatementTest.java | 12 + .../admin/MySqlFlushStatementTest.java | 300 ++++++++++++++ .../admin/MySqlKillStatementTest.java | 182 +++++++++ .../handler/MySqlHandlerStatementTest.java | 340 ++++++++++++++++ .../MySqlServerAdminValidatorTest.java | 63 +++ 27 files changed, 2392 insertions(+), 23 deletions(-) create mode 100644 src/main/java/net/sf/jsqlparser/statement/admin/FlushOption.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/admin/FlushStatement.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/admin/KillStatement.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/handler/HandlerStatement.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/admin/MySqlFlushStatementTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/admin/MySqlKillStatementTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/handler/MySqlHandlerStatementTest.java create mode 100644 src/test/java/net/sf/jsqlparser/util/validation/validator/MySqlServerAdminValidatorTest.java diff --git a/README.md b/README.md index e7a8db4521..5946a44c2a 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 c5ef37b5d0..02b1c0b9c5 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 dc3d137e37..f69ff2e46b 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 6d40879f8f..a9cc1744e3 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 df67903b03..eba66018af 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 4063637f73..6a9aa337b8 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 9cb08129e4..906f812def 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 0000000000..b3945aef6a --- /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 0000000000..95957fe4d4 --- /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 0000000000..79d13dad88 --- /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 0000000000..f70be46b35 --- /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 e1cf00f3fb..11484a0684 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 22eaca518e..b65eabfccf 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 0e9adaba31..19777b82fd 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 5ca009b15e..c85cc390e1 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 3a4efb95b6..83d3b80cb7 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 ec1b85f29e..9b87405ce3 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 4cf4101880..1878180854 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 e0ff2d8a4d..e9aa7363e7 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 93bee33396..91da913781 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 25dd26d38d..a6be61c1c6 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 b3372ff736..6839c0a54b 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 0f7ad33af4..77e6084c97 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 0000000000..79566e6dd4 --- /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 0000000000..2fe79d9edb --- /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 0000000000..60708d4250 --- /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 0000000000..430e667826 --- /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); + } +}