From ebac6f96eb9ec253e6fa9900c5b63c6f583c0a9f Mon Sep 17 00:00:00 2001 From: minleejae Date: Tue, 3 Nov 2026 14:36:03 +0900 Subject: [PATCH] feat: parse MySQL LOCK TABLES, UNLOCK TABLES and the backup lock statements mysqldump wraps the rows of each table in LOCK TABLES ... WRITE and UNLOCK TABLES, so a dump script failed at its first table. LOCK INSTANCE FOR BACKUP and UNLOCK INSTANCE failed as well. LOCK TABLES extends LockStatement: each target keeps its lock type (TableLockType) and its table keeps the alias, and isTablesKeyword() records the TABLES spelling. PostgreSQL and Oracle LOCK keep their reading, since neither follows a table name with an alias or a lock type. MariaDB's WRITE CONCURRENT is a lock type, and its WAIT and NOWAIT reuse the wait clause of the statement. UNLOCK TABLES and the backup lock name no table, so they are the new UnlockStatement and BackupLockStatement. UNLOCK becomes a non-reserved keyword and remains usable as a name. Signed-off-by: minleejae --- README.md | 1 + .../sf/jsqlparser/parser/feature/Feature.java | 14 + .../statement/StatementFeatureVisitor.java | 12 + .../statement/StatementVisitor.java | 18 + .../statement/StatementVisitorAdapter.java | 12 + .../statement/lock/BackupLockStatement.java | 71 +++ .../statement/lock/LockStatement.java | 65 ++- .../statement/lock/TableLockType.java | 42 ++ .../statement/lock/UnlockStatement.java | 56 +++ .../sf/jsqlparser/util/TablesNamesFinder.java | 13 + .../util/deparser/StatementDeParser.java | 12 + .../validation/feature/MariaDbVersion.java | 3 + .../util/validation/feature/MySqlVersion.java | 6 + .../validator/StatementValidator.java | 20 + .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 156 +++++- src/site/sphinx/index.rst | 2 + src/site/sphinx/usage.rst | 37 ++ .../statement/lock/MySqlLockTablesTest.java | 454 ++++++++++++++++++ .../validator/LockStatementValidatorTest.java | 60 +++ 19 files changed, 1042 insertions(+), 12 deletions(-) create mode 100644 src/main/java/net/sf/jsqlparser/statement/lock/BackupLockStatement.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/lock/TableLockType.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/lock/UnlockStatement.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/lock/MySqlLockTablesTest.java create mode 100644 src/test/java/net/sf/jsqlparser/util/validation/validator/LockStatementValidatorTest.java diff --git a/README.md b/README.md index d8855f6771..e7a8db4521 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,7 @@ and missing syntax gets added on demand — [open an issue](https://github.com/J | **PostgreSQL ownership** | `DROP OWNED` · `REASSIGN OWNED` | | **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 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/feature/Feature.java b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java index 8ff4b2cd00..dc3d137e37 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -51,6 +51,8 @@ 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.lock.BackupLockStatement; +import net.sf.jsqlparser.statement.lock.UnlockStatement; import net.sf.jsqlparser.statement.merge.Merge; import net.sf.jsqlparser.statement.refresh.RefreshMaterializedViewStatement; import net.sf.jsqlparser.statement.reindex.Reindex; @@ -742,6 +744,18 @@ public enum Feature { * @see net.sf.jsqlparser.statement.lock.LockStatement */ lock, + /** + * MySQL/MariaDB "UNLOCK TABLES" statement is allowed + * + * @see UnlockStatement + */ + unlockTables, + /** + * MySQL "LOCK INSTANCE FOR BACKUP" and "UNLOCK INSTANCE" statements are allowed + * + * @see BackupLockStatement + */ + backupLock, /** * SQL "COMMENT ON" statement is allowed * diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index 7c3ce72c38..df67903b03 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -98,7 +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.lock.BackupLockStatement; import net.sf.jsqlparser.statement.lock.LockStatement; +import net.sf.jsqlparser.statement.lock.UnlockStatement; import net.sf.jsqlparser.statement.maintenance.CheckpointStatement; import net.sf.jsqlparser.statement.maintenance.ClusterStatement; import net.sf.jsqlparser.statement.maintenance.VacuumStatement; @@ -923,6 +925,16 @@ public Void visit(LockStatement lock, S context) { return transactionOnly(); } + @Override + public Void visit(UnlockStatement unlock, S context) { + return transactionOnly(); + } + + @Override + public Void visit(BackupLockStatement backupLock, S context) { + return transactionOnly(); + } + private Void transactionOnly() { analysis.claimTopLevel(); analysis.certain(StmtFeature.MODIFIES_TRANSACTION); diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index 5693198da7..4063637f73 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -90,7 +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.lock.BackupLockStatement; import net.sf.jsqlparser.statement.lock.LockStatement; +import net.sf.jsqlparser.statement.lock.UnlockStatement; import net.sf.jsqlparser.statement.maintenance.CheckpointStatement; import net.sf.jsqlparser.statement.maintenance.ClusterStatement; import net.sf.jsqlparser.statement.maintenance.VacuumStatement; @@ -702,6 +704,22 @@ default void visit(LockStatement lock) { this.visit(lock, null); } + default T visit(UnlockStatement unlock, S context) { + return null; + } + + default void visit(UnlockStatement unlock) { + visit(unlock, null); + } + + default T visit(BackupLockStatement backupLock, S context) { + return null; + } + + default void visit(BackupLockStatement backupLock) { + visit(backupLock, null); + } + T visit(CreatePolicy createPolicy, S context); default void visit(CreatePolicy createPolicy) { diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index adc28b2b56..9cb08129e4 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -93,7 +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.lock.BackupLockStatement; import net.sf.jsqlparser.statement.lock.LockStatement; +import net.sf.jsqlparser.statement.lock.UnlockStatement; import net.sf.jsqlparser.statement.maintenance.CheckpointStatement; import net.sf.jsqlparser.statement.maintenance.ClusterStatement; import net.sf.jsqlparser.statement.maintenance.VacuumStatement; @@ -547,6 +549,16 @@ public T visit(LockStatement lock, S context) { return null; } + @Override + public T visit(UnlockStatement unlock, S context) { + return null; + } + + @Override + public T visit(BackupLockStatement backupLock, S context) { + return null; + } + @Override public T visit(CreatePolicy createPolicy, S context) { fromItemVisitor.visitFromItem(createPolicy.getTable(), context); diff --git a/src/main/java/net/sf/jsqlparser/statement/lock/BackupLockStatement.java b/src/main/java/net/sf/jsqlparser/statement/lock/BackupLockStatement.java new file mode 100644 index 0000000000..235a220155 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/lock/BackupLockStatement.java @@ -0,0 +1,71 @@ +/*- + * #%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.lock; + +import java.util.Objects; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; + +/** + * MySQL {@code LOCK INSTANCE FOR BACKUP} and {@code UNLOCK INSTANCE}, which take and release the + * instance-level backup lock. It locks no table, unlike {@link LockStatement}. + * + * @see MySQL LOCK + * INSTANCE FOR BACKUP and UNLOCK INSTANCE + */ +public class BackupLockStatement extends ASTNodeAccessImpl implements Statement { + private Action action = Action.LOCK; + + public enum Action { + /** LOCK INSTANCE FOR BACKUP */ + LOCK("LOCK INSTANCE FOR BACKUP"), + /** UNLOCK INSTANCE */ + UNLOCK("UNLOCK INSTANCE"); + + private final String sql; + + Action(String sql) { + this.sql = sql; + } + + public String getSql() { + return sql; + } + } + + public BackupLockStatement() {} + + public BackupLockStatement(Action action) { + setAction(action); + } + + public Action getAction() { + return action; + } + + public void setAction(Action action) { + this.action = Objects.requireNonNull(action); + } + + public StringBuilder appendTo(StringBuilder builder) { + return builder.append(action.getSql()); + } + + @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/lock/LockStatement.java b/src/main/java/net/sf/jsqlparser/statement/lock/LockStatement.java index e72c259f06..b96d8b59e1 100644 --- a/src/main/java/net/sf/jsqlparser/statement/lock/LockStatement.java +++ b/src/main/java/net/sf/jsqlparser/statement/lock/LockStatement.java @@ -12,6 +12,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import net.sf.jsqlparser.expression.Alias; import net.sf.jsqlparser.parser.ASTNodeAccessImpl; import net.sf.jsqlparser.schema.Table; import net.sf.jsqlparser.statement.Statement; @@ -19,14 +20,17 @@ /** * Statement to lock one or more tables.
- * Example:
+ * Examples:
* LOCK TABLE t IN EXCLUSIVE MODE
+ * LOCK TABLES t READ, u AS a WRITE (MySQL and MariaDB, where each target has a + * {@link TableLockType})
*
*/ public class LockStatement extends ASTNodeAccessImpl implements Statement { private final List targets = new ArrayList<>(); private boolean useTableKeyword = true; + private boolean tablesKeyword; private LockMode lockMode; private boolean noWait; private Long waitSeconds; @@ -35,11 +39,44 @@ public enum Scope { DEFAULT, ONLY, INCLUDING_DESCENDANTS } + /** + * A locked table. A MySQL or MariaDB target also has a lock type and may have an alias, which + * is the {@link Table#getAlias() alias of its table}. + */ public static class Target extends net.sf.jsqlparser.schema.TableReference { + private TableLockType lockType; + public Target(Table table) { super(table); } + /** Returns the MySQL or MariaDB lock type, or null for a PostgreSQL or Oracle target. */ + public TableLockType getLockType() { + return lockType; + } + + public void setLockType(TableLockType lockType) { + this.lockType = lockType; + } + + public Target withLockType(TableLockType lockType) { + setLockType(lockType); + return this; + } + + @Override + public StringBuilder appendTo(StringBuilder builder) { + super.appendTo(builder); + Alias alias = getTable().getAlias(); + if (alias != null) { + builder.append(alias); + } + if (lockType != null) { + builder.append(' ').append(lockType.getValue()); + } + return builder; + } + public Scope getScope() { return isOnly() ? Scope.ONLY : isIncludeDescendants() ? Scope.INCLUDING_DESCENDANTS : Scope.DEFAULT; @@ -100,8 +137,25 @@ public boolean isUseTableKeyword() { return useTableKeyword; } + /** Setting false also clears {@link #isTablesKeyword()}. */ public void setUseTableKeyword(boolean useTableKeyword) { this.useTableKeyword = useTableKeyword; + if (!useTableKeyword) { + tablesKeyword = false; + } + } + + /** Whether the statement is spelled LOCK TABLES, as MySQL and MariaDB allow. */ + public boolean isTablesKeyword() { + return tablesKeyword; + } + + /** Setting true also sets {@link #isUseTableKeyword()}. */ + public void setTablesKeyword(boolean tablesKeyword) { + this.tablesKeyword = tablesKeyword; + if (tablesKeyword) { + useTableKeyword = true; + } } /** Returns null when IN ... MODE was omitted. */ @@ -150,12 +204,17 @@ public Long getWaitSeconds() { } public StringBuilder appendTo(StringBuilder builder) { - builder.append(useTableKeyword ? "LOCK TABLE " : "LOCK "); + builder.append("LOCK "); + if (tablesKeyword) { + builder.append("TABLES "); + } else if (useTableKeyword) { + builder.append("TABLE "); + } for (int i = 0; i < targets.size(); i++) { if (i > 0) { builder.append(", "); } - builder.append(targets.get(i)); + targets.get(i).appendTo(builder); } if (lockMode != null) { builder.append(" IN ").append(lockMode.getValue()).append(" MODE"); diff --git a/src/main/java/net/sf/jsqlparser/statement/lock/TableLockType.java b/src/main/java/net/sf/jsqlparser/statement/lock/TableLockType.java new file mode 100644 index 0000000000..d072efe591 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/lock/TableLockType.java @@ -0,0 +1,42 @@ +/*- + * #%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.lock; + +/** + * The lock type that MySQL and MariaDB give each table of {@code LOCK TABLES}. MySQL 8.4 has + * {@code READ [LOCAL] | WRITE}. + * + * @see MySQL LOCK TABLES + * @see MariaDB LOCK TABLES + */ +public enum TableLockType { + READ("READ"), READ_LOCAL("READ LOCAL"), WRITE("WRITE"), + + /** + * MySQL 8.0, where it is deprecated and has no effect, and MariaDB. MySQL 8.4 rejects it. + * + * @see MySQL 8.0 LOCK + * TABLES + */ + LOW_PRIORITY_WRITE("LOW_PRIORITY WRITE"), + + /** MariaDB only. */ + WRITE_CONCURRENT("WRITE CONCURRENT"); + + private final String value; + + TableLockType(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/lock/UnlockStatement.java b/src/main/java/net/sf/jsqlparser/statement/lock/UnlockStatement.java new file mode 100644 index 0000000000..07b28d7b5a --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/lock/UnlockStatement.java @@ -0,0 +1,56 @@ +/*- + * #%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.lock; + +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; + +/** + * MySQL and MariaDB {@code UNLOCK TABLES}, which releases the table locks that + * {@link LockStatement} took for the session.
+ * Example:
+ * UNLOCK TABLES
+ * + * @see MySQL LOCK TABLES and + * UNLOCK TABLES + */ +public class UnlockStatement extends ASTNodeAccessImpl implements Statement { + private boolean tablesKeyword = true; + + public UnlockStatement() {} + + public UnlockStatement(boolean tablesKeyword) { + this.tablesKeyword = tablesKeyword; + } + + /** Whether the statement is spelled UNLOCK TABLES rather than UNLOCK TABLE. */ + public boolean isTablesKeyword() { + return tablesKeyword; + } + + public void setTablesKeyword(boolean tablesKeyword) { + this.tablesKeyword = tablesKeyword; + } + + public StringBuilder appendTo(StringBuilder builder) { + return builder.append(tablesKeyword ? "UNLOCK TABLES" : "UNLOCK TABLE"); + } + + @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/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index a578249716..e1cf00f3fb 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -194,7 +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.lock.BackupLockStatement; import net.sf.jsqlparser.statement.lock.LockStatement; +import net.sf.jsqlparser.statement.lock.UnlockStatement; import net.sf.jsqlparser.statement.maintenance.CheckpointStatement; import net.sf.jsqlparser.statement.maintenance.ClusterStatement; import net.sf.jsqlparser.statement.maintenance.VacuumStatement; @@ -3079,6 +3081,17 @@ public void visit(LockStatement lock) { StatementVisitor.super.visit(lock); } + @Override + public Void visit(UnlockStatement unlock, S context) { + // UNLOCK TABLES releases every table lock of the session without naming a table + return null; + } + + @Override + public Void visit(BackupLockStatement backupLock, S context) { + return null; + } + @Override public Void visit(CreatePolicy createPolicy, S context) { if (createPolicy.getTable() != null) { 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 2fff9c94a2..0e9adaba31 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -121,7 +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.lock.BackupLockStatement; import net.sf.jsqlparser.statement.lock.LockStatement; +import net.sf.jsqlparser.statement.lock.UnlockStatement; import net.sf.jsqlparser.statement.maintenance.CheckpointStatement; import net.sf.jsqlparser.statement.maintenance.ClusterStatement; import net.sf.jsqlparser.statement.maintenance.VacuumStatement; @@ -823,6 +825,16 @@ public StringBuilder visit(LockStatement lock, S context) { return builder; } + @Override + public StringBuilder visit(UnlockStatement unlock, S context) { + return unlock.appendTo(builder); + } + + @Override + public StringBuilder visit(BackupLockStatement backupLock, S context) { + return backupLock.appendTo(builder); + } + @Override public StringBuilder visit(CreatePolicy createPolicy, S context) { new CreatePolicyDeParser(expressionDeParser, builder).deParse(createPolicy); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java index 205f8674f5..f4ff88e851 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java @@ -83,6 +83,9 @@ public enum MariaDbVersion implements Version { // https://mariadb.com/kb/en/table-statements/ Feature.analyze, Feature.tableMaintenance, + // https://mariadb.com/kb/en/lock-tables/ + Feature.lock, Feature.unlockTables, + // https://mariadb.com/kb/en/account-management-sql-statements/ Feature.createRole, Feature.createUser, Feature.alterUser, Feature.renameUser, Feature.setRole, 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 727299b79f..5ca009b15e 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,12 @@ 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/lock-tables.html + Feature.lock, Feature.unlockTables, + + // https://dev.mysql.com/doc/refman/8.0/en/lock-instance-for-backup.html + Feature.backupLock, + // https://dev.mysql.com/doc/refman/8.0/en/account-management-statements.html Feature.createRole, Feature.createUser, Feature.alterUser, Feature.renameUser, Feature.setRole, 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 7f60fd850c..ec1b85f29e 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,7 +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.lock.BackupLockStatement; import net.sf.jsqlparser.statement.lock.LockStatement; +import net.sf.jsqlparser.statement.lock.UnlockStatement; import net.sf.jsqlparser.statement.maintenance.CheckpointStatement; import net.sf.jsqlparser.statement.maintenance.ClusterStatement; import net.sf.jsqlparser.statement.maintenance.VacuumStatement; @@ -143,6 +145,7 @@ import net.sf.jsqlparser.statement.update.ParenthesedUpdate; import net.sf.jsqlparser.statement.update.Update; import net.sf.jsqlparser.statement.upsert.Upsert; +import net.sf.jsqlparser.util.validation.ValidationUtil; import net.sf.jsqlparser.util.validation.metadata.NamedObject; import net.sf.jsqlparser.statement.PragmaStatement; import net.sf.jsqlparser.statement.ExtensionStatement; @@ -820,6 +823,23 @@ public Void visit(Export export, S context) { @Override public Void visit(LockStatement lock, S context) { validateFeature(Feature.lock); + for (LockStatement.Target target : lock.getTargets()) { + Table table = target.getTable(); + validateNameWithAlias(NamedObject.table, table.getFullyQualifiedName(), + ValidationUtil.getAlias(table.getAlias())); + } + return null; + } + + @Override + public Void visit(UnlockStatement unlock, S context) { + validateFeature(Feature.unlockTables); + return null; + } + + @Override + public Void visit(BackupLockStatement backupLock, S context) { + validateFeature(Feature.backupLock); return null; } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 76fc4f7041..1dea483a28 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -174,6 +174,8 @@ public class CCJSqlParser extends AbstractJSqlParser { return isReindexAhead(); case K_VACUUM: case K_ANALYSE: case K_CLUSTER: case K_CHECKPOINT: return isPostgreSqlMaintenanceAhead(); + case K_UNLOCK: + return isUnlockTablesAhead() || isBackupLockAhead(); case K_IMPORT: case K_EXPORT: return Dialect.EXASOL.name().equals(getAsString(Feature.dialect)) || next.kind == K_EXPORT && getToken(2).kind == K_DATA; @@ -2518,6 +2520,56 @@ public class CCJSqlParser extends AbstractJSqlParser { return table == K_TABLE || table == K_TABLES; } + /** MySQL's table and backup locks parse without a dialect and in the dialects that have them. */ + private boolean isMySqlLockDialect(boolean mariaDb) { + String dialect = getAsString(Feature.dialect); + return dialect == null || Dialect.MYSQL.name().equals(dialect) + || mariaDb && Dialect.MARIADB.name().equals(dialect); + } + + /** + * Whether the words after LOCK are MySQL's TABLE or TABLES, a table name, an optional alias and + * a lock type. No PostgreSQL or Oracle LOCK continues a table name with an alias or with READ, + * WRITE or LOW_PRIORITY, so their readings, including a table named tables and ONLY t, stay + * unchanged. MySQL has no ONLY keyword here, so ONLY counts as a name. + */ + private boolean isMySqlLockTablesAhead() { + int kind = getToken(1).kind; + if (kind != K_TABLE && kind != K_TABLES || !isMySqlLockDialect(true)) { + return false; + } + int offset = 2; + while (isIdentifierishNameToken(getToken(offset)) && ".".equals(getToken(offset + 1).image)) { + offset += 2; + } + if (!isIdentifierishNameToken(getToken(offset))) { + return false; + } + Token next = getToken(offset + 1); + return next.kind == K_AS || isMySqlLockTypeAhead(offset + 1) + || isIdentifierishNameToken(next) && isMySqlLockTypeAhead(offset + 2); + } + + private boolean isMySqlLockTypeAhead(int offset) { + Token token = getToken(offset); + return token.kind == K_READ || token.kind == K_LOW_PRIORITY + || token.kind == S_IDENTIFIER && "WRITE".equalsIgnoreCase(token.image); + } + + private boolean isUnlockTablesAhead() { + int kind = getToken(2).kind; + return getToken(1).kind == K_UNLOCK && (kind == K_TABLE || kind == K_TABLES) + && isMySqlLockDialect(true); + } + + /** MySQL's LOCK INSTANCE FOR BACKUP or UNLOCK INSTANCE, which MariaDB does not have. */ + private boolean isBackupLockAhead() { + int kind = getToken(1).kind; + boolean instance = getToken(2).kind == S_IDENTIFIER && "INSTANCE".equalsIgnoreCase(getToken(2).image); + return instance && (kind == K_UNLOCK || kind == K_LOCK && getToken(3).kind == K_FOR) + && isMySqlLockDialect(false); + } + /** * 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. @@ -4509,6 +4561,7 @@ String NonReservedWordPart2() : | tk= | tk= | tk= + | tk= | tk= | tk= | tk= @@ -5248,6 +5301,10 @@ Statement SingleStatement() : | stm = SessionStatement() | + LOOKAHEAD({ isBackupLockAhead() }) stm = BackupLockStatement() + | + LOOKAHEAD({ isUnlockTablesAhead() }) stm = UnlockStatement() + | stm = LockStatement() | LOOKAHEAD({ Dialect.EXASOL.name().equals(getAsString(Feature.dialect)) }) ( stm = Import() | stm = Export() ) @@ -5581,16 +5638,97 @@ LockStatement LockStatement(): { LockStatement statement = new LockStatement(); LockStatement.Target target; LockMode mode; - Token waitSeconds; } { - { statement.setUseTableKeyword(false); } - [ LOOKAHEAD(1, ) { statement.setUseTableKeyword(true); } ] - target=LockTarget() { statement.getTargets().add(target); } - ( "," target=LockTarget() { statement.getTargets().add(target); } )* - [ mode=TableLockMode() { statement.setLockMode(mode); } ] - [ { statement.setNoWait(true); } - | waitSeconds= { statement.setWaitSeconds(Long.valueOf(waitSeconds.image)); } ] + ( + // https://dev.mysql.com/doc/refman/8.4/en/lock-tables.html + LOOKAHEAD({ isMySqlLockTablesAhead() }) + ( | { statement.setTablesKeyword(true); } ) + target=MySqlLockTarget() { statement.getTargets().add(target); } + ( "," target=MySqlLockTarget() { statement.getTargets().add(target); } )* + // https://mariadb.com/kb/en/lock-tables/ + [ + LOOKAHEAD({ Dialect.MARIADB.name().equals(getAsString(Feature.dialect)) + && (getToken(1).kind == K_NOWAIT || getToken(1).kind == K_WAIT) }) + LockWait(statement) + ] + | + { statement.setUseTableKeyword(false); } + [ LOOKAHEAD(1, ) { statement.setUseTableKeyword(true); } ] + target=LockTarget() { statement.getTargets().add(target); } + ( "," target=LockTarget() { statement.getTargets().add(target); } )* + [ mode=TableLockMode() { statement.setLockMode(mode); } ] + [ LockWait(statement) ] + ) + { return statement; } +} + +void LockWait(LockStatement statement): +{ Token waitSeconds; } +{ + { statement.setNoWait(true); } + | waitSeconds= { statement.setWaitSeconds(Long.valueOf(waitSeconds.image)); } +} + +/** A MySQL LOCK TABLES target: tbl_name [[AS] alias] lock_type. */ +LockStatement.Target MySqlLockTarget(): +{ + Table table; + Token only; + Alias alias; + TableLockType lockType; +} +{ + ( + // MySQL has no ONLY here, so it names a table, as in SELECT * FROM only + LOOKAHEAD({ getToken(1).kind == K_ONLY && !".".equals(getToken(2).image) }) + only= { table = linkToLastToken(createTable(Arrays.asList(only.image))); } + | + table=Table() + ) + [ + LOOKAHEAD({ getToken(1).kind == K_AS || !isMySqlLockTypeAhead(1) && isMySqlLockTypeAhead(2) }) + alias=Alias() { table.setAlias(alias); } + ] + lockType=MySqlTableLockType() + { return new LockStatement.Target(table).withLockType(lockType); } +} + +TableLockType MySqlTableLockType(): +{ TableLockType lockType; } +{ + ( + { lockType = TableLockType.READ; } [ { lockType = TableLockType.READ_LOCAL; } ] + | + ContextualKeyword("WRITE") { lockType = TableLockType.LOW_PRIORITY_WRITE; } + | + ContextualKeyword("WRITE") { lockType = TableLockType.WRITE; } + [ + LOOKAHEAD({ Dialect.MARIADB.name().equals(getAsString(Feature.dialect)) + && isKeywordAhead("CONCURRENT") }) + ContextualKeyword("CONCURRENT") { lockType = TableLockType.WRITE_CONCURRENT; } + ] + ) + { return lockType; } +} + +// https://dev.mysql.com/doc/refman/8.4/en/lock-tables.html +UnlockStatement UnlockStatement(): +{ UnlockStatement statement = new UnlockStatement(); } +{ + ( { statement.setTablesKeyword(false); } | ) + { return statement; } +} + +// https://dev.mysql.com/doc/refman/8.4/en/lock-instance-for-backup.html +BackupLockStatement BackupLockStatement(): +{ BackupLockStatement statement = new BackupLockStatement(); } +{ + ( + ContextualKeyword("INSTANCE") ContextualKeyword("BACKUP") + | + ContextualKeyword("INSTANCE") { statement.setAction(BackupLockStatement.Action.UNLOCK); } + ) { return statement; } } @@ -17497,7 +17635,7 @@ PasswordLockOption MySqlPasswordLockOption(): ( { option = new PasswordLockOption(PasswordLockOption.Kind.ACCOUNT_LOCK); } | - ContextualKeyword("UNLOCK") + { option = new PasswordLockOption(PasswordLockOption.Kind.ACCOUNT_UNLOCK); } ) | diff --git a/src/site/sphinx/index.rst b/src/site/sphinx/index.rst index ee0a400af7..e0ff2d8a4d 100644 --- a/src/site/sphinx/index.rst +++ b/src/site/sphinx/index.rst @@ -162,6 +162,8 @@ One grammar covers the SQL standard plus all major RDBMS. Missing syntax gets ad - ``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 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 f56cb9ea95..9c26d5893d 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -2557,6 +2557,43 @@ lists or the index. Validators report the ``analyze``, ``vacuum``, ``cluster`` a capabilities now enable ``analyze`` as well. Feature analysis reports ``MODIFIES_SCHEMA`` and ``READS_DATA`` for VACUUM and CLUSTER, as for ANALYZE, and ``MODIFIES_SESSION`` for CHECKPOINT, a server operation like ``ALTER SYSTEM CHECKPOINT``. +MySQL table and backup locks +---------------------------- + +MySQL and MariaDB `LOCK TABLES `_ +parses to the ``LockStatement`` of PostgreSQL and Oracle ``LOCK TABLE``. Each entry of +``getTargets()`` keeps its table, the alias as ``getTable().getAlias()``, and +``getLockType()``: ``READ``, ``READ_LOCAL``, ``WRITE``, ``LOW_PRIORITY_WRITE`` or MariaDB's +``WRITE_CONCURRENT``. MySQL 8.4 has only ``READ [LOCAL]`` and ``WRITE``; +``LOW_PRIORITY WRITE`` is accepted because `MySQL 8.0 +`_ (deprecated, without effect) +and MariaDB accept it. The lock type is null for a PostgreSQL or Oracle target, and +``isTablesKeyword()`` records the ``TABLES`` spelling. MariaDB's trailing ``WAIT n`` and +``NOWAIT`` use ``getWaitSeconds()`` and ``isNoWait()``, like PostgreSQL and Oracle. +``UNLOCK TABLE`` and ``UNLOCK TABLES`` parse to ``UnlockStatement``, and +`LOCK INSTANCE FOR BACKUP and UNLOCK INSTANCE +`_ to +``BackupLockStatement``, whose ``getAction()`` is ``LOCK`` or ``UNLOCK``. + +.. code-block:: java + + LockStatement lock = (LockStatement) CCJSqlParserUtil.parse( + "LOCK TABLES t AS a READ LOCAL, u WRITE"); + lock.getTargets().get(0).getTable().getAlias().getName(); // a + lock.getTargets().get(0).getLockType(); // READ_LOCAL + +The MySQL and MariaDB dialects read these forms, and so does the parser without a dialect: +an alias or a lock type after the first table name tells MySQL's form from PostgreSQL's +``LOCK [TABLE] name``, so ``LOCK tables NOWAIT`` still locks a table named ``tables``. +``WRITE CONCURRENT``, ``WAIT`` and ``NOWAIT`` after a MySQL target list require +``Dialect.MARIADB``, and ``Dialect.MARIADB`` rejects the backup lock statements, which +MariaDB does not have. PostgreSQL and the other dialects keep rejecting the MySQL forms. +``UNLOCK``, ``INSTANCE`` and ``BACKUP`` remain ordinary identifiers. + +Table visitors report the locked tables but not their aliases. Validators report the +``lock``, ``unlockTables`` and ``backupLock`` features; the MySQL capabilities enable all +three, MariaDB the first two, and PostgreSQL and Oracle ``lock``. Feature analysis reports +``MODIFIES_TRANSACTION``. MySQL account management statements ----------------------------------- diff --git a/src/test/java/net/sf/jsqlparser/statement/lock/MySqlLockTablesTest.java b/src/test/java/net/sf/jsqlparser/statement/lock/MySqlLockTablesTest.java new file mode 100644 index 0000000000..b9e517876f --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/lock/MySqlLockTablesTest.java @@ -0,0 +1,454 @@ +/*- + * #%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.lock; + +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.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.JSQLParserException; +import net.sf.jsqlparser.expression.Alias; +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.UnsupportedStatement; +import net.sf.jsqlparser.statement.alter.AlterUser; +import net.sf.jsqlparser.statement.create.table.CreateTable; +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 MySqlLockTablesTest { + private static final Consumer MYSQL = parser -> parser.withDialect(Dialect.MYSQL); + private static final Consumer MARIADB = + parser -> parser.withDialect(Dialect.MARIADB); + private static final Consumer POSTGRESQL = + parser -> parser.withDialect(Dialect.POSTGRESQL); + private static final Consumer ORACLE = + parser -> parser.withDialect(Dialect.ORACLE); + private static final Consumer NO_DIALECT = parser -> { + }; + + @ParameterizedTest + @ValueSource(strings = { + "LOCK TABLES `t` WRITE", + "LOCK TABLES t READ, s AS x WRITE", + "LOCK TABLES t READ LOCAL", + "LOCK TABLES t AS a READ LOCAL, u LOW_PRIORITY WRITE", + "LOCK TABLES db.t READ, `db`.`u` AS `a` LOW_PRIORITY WRITE", + "LOCK TABLES t a READ, t b WRITE", + "LOCK TABLES nowait WRITE, tables READ", + "LOCK TABLE t READ", + "LOCK TABLE t WRITE", + "UNLOCK TABLES", + "UNLOCK TABLE", + "LOCK INSTANCE FOR BACKUP", + "UNLOCK INSTANCE"}) + void parsesMySqlFormsWithAndWithoutDialect(String sql) throws JSQLParserException { + for (Consumer dialect : List.of(NO_DIALECT, MYSQL)) { + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, false, dialect); + assertEquals(sql, deparse(statement)); + assertEquals(sql, CCJSqlParserUtil.parse(statement.toString(), dialect).toString()); + } + } + + @Test + void keepsTheLockTypeAndAliasOfEachTable() throws JSQLParserException { + LockStatement lock = + parse("LOCK TABLES t AS a READ LOCAL, db.u LOW_PRIORITY WRITE, v b WRITE", + MYSQL); + + assertTrue(lock.isTablesKeyword()); + assertTrue(lock.isUseTableKeyword()); + assertNull(lock.getLockMode()); + assertFalse(lock.isNoWait()); + assertNull(lock.getWaitSeconds()); + assertEquals(3, lock.getTargets().size()); + + LockStatement.Target first = lock.getTargets().get(0); + assertEquals("t", first.getTable().getName()); + assertEquals("a", first.getTable().getAlias().getName()); + assertTrue(first.getTable().getAlias().isUseAs()); + assertEquals(TableLockType.READ_LOCAL, first.getLockType()); + assertEquals(LockStatement.Scope.DEFAULT, first.getScope()); + assertEquals("t", lock.getTable().getName()); + + LockStatement.Target second = lock.getTargets().get(1); + assertEquals("db.u", second.getTable().getFullyQualifiedName()); + assertNull(second.getTable().getAlias()); + assertEquals(TableLockType.LOW_PRIORITY_WRITE, second.getLockType()); + + LockStatement.Target third = lock.getTargets().get(2); + assertEquals("b", third.getTable().getAlias().getName()); + assertFalse(third.getTable().getAlias().isUseAs()); + assertEquals(TableLockType.WRITE, third.getLockType()); + } + + @Test + void keepsTheSpellingOfTableAndTables() throws JSQLParserException { + LockStatement lock = parse("LOCK TABLE t READ", NO_DIALECT); + assertTrue(lock.isUseTableKeyword()); + assertFalse(lock.isTablesKeyword()); + assertEquals(TableLockType.READ, lock.getTargets().get(0).getLockType()); + + UnlockStatement unlock = (UnlockStatement) CCJSqlParserUtil.parse("UNLOCK TABLE"); + assertFalse(unlock.isTablesKeyword()); + assertTrue(((UnlockStatement) CCJSqlParserUtil.parse("unlock tables")).isTablesKeyword()); + } + + @Test + void parsesBackupLocks() throws JSQLParserException { + BackupLockStatement lock = (BackupLockStatement) CCJSqlParserUtil.parse( + "lock instance for backup", MYSQL); + assertEquals(BackupLockStatement.Action.LOCK, lock.getAction()); + assertEquals("LOCK INSTANCE FOR BACKUP", lock.toString()); + + BackupLockStatement unlock = + (BackupLockStatement) CCJSqlParserUtil.parse("UNLOCK INSTANCE"); + assertEquals(BackupLockStatement.Action.UNLOCK, unlock.getAction()); + } + + @ParameterizedTest + @ValueSource(strings = { + "LOCK TABLES t WRITE CONCURRENT", + "LOCK TABLES t READ, u WRITE CONCURRENT WAIT 10", + "LOCK TABLES t READ NOWAIT", + "LOCK TABLE t AS a WRITE WAIT 5", + "LOCK TABLES t LOW_PRIORITY WRITE", + "UNLOCK TABLES"}) + void parsesMariaDbForms(String sql) throws JSQLParserException { + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, false, MARIADB); + assertEquals(sql, deparse(statement)); + } + + @Test + void mariaDbWaitReusesTheStatementWait() throws JSQLParserException { + LockStatement lock = parse("LOCK TABLES t READ, u WRITE CONCURRENT WAIT 10", MARIADB); + assertEquals(TableLockType.WRITE_CONCURRENT, lock.getTargets().get(1).getLockType()); + assertEquals(10L, lock.getWaitSeconds()); + + assertTrue(parse("LOCK TABLES t READ NOWAIT", MARIADB).isNoWait()); + } + + @ParameterizedTest + @ValueSource(strings = { + "LOCK TABLES t WRITE CONCURRENT", + "LOCK TABLES t WRITE NOWAIT", + "LOCK TABLES t READ WAIT 5"}) + void rejectsMariaDbOnlyFormsInMySql(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, MYSQL)); + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql)); + } + + @ParameterizedTest + @ValueSource(strings = {"LOCK INSTANCE FOR BACKUP", "UNLOCK INSTANCE"}) + void rejectsBackupLocksInMariaDb(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, MARIADB)); + } + + @ParameterizedTest + @ValueSource(strings = { + "LOCK TABLES t", + "LOCK TABLES t READ,", + "LOCK TABLES t READ, u", + "LOCK TABLES t READ IN SHARE MODE", + "LOCK TABLES t READ LOCAL LOCAL", + "LOCK TABLES t LOW_PRIORITY READ", + "LOCK TABLES t AS READ", + "LOCK TABLES t * WRITE", + "LOCK TABLES only * WRITE", + "UNLOCK", + "UNLOCK TABLES t", + "LOCK INSTANCE FOR", + "UNLOCK INSTANCE FOR BACKUP"}) + void rejectsIncompleteForms(String sql) { + for (Consumer dialect : List.of(NO_DIALECT, MYSQL, MARIADB)) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, dialect), + sql); + } + } + + @ParameterizedTest + @ValueSource(strings = { + "LOCK TABLES t WRITE", + "LOCK TABLE t READ", + "UNLOCK TABLES", + "LOCK INSTANCE FOR BACKUP", + "UNLOCK INSTANCE"}) + void otherDialectsKeepRejectingMySqlForms(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, POSTGRESQL)); + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, ORACLE)); + } + + @ParameterizedTest + @ValueSource(strings = { + "LOCK tables", + "LOCK tables NOWAIT", + "LOCK tables WAIT 5", + "LOCK tables IN SHARE MODE", + "LOCK tables.x IN SHARE MODE", + "LOCK TABLE tables, instance", + "LOCK instance", + "LOCK TABLE t, u IN ACCESS EXCLUSIVE MODE NOWAIT", + "LOCK TABLE ONLY t, u *", + "LOCK TABLE ONLY t IN SHARE MODE", + "LOCK ONLY (t) NOWAIT"}) + void postgreSqlAndOracleReadingsAreUnchanged(String sql) throws JSQLParserException { + for (Consumer dialect : List.of(NO_DIALECT, MYSQL, MARIADB, POSTGRESQL)) { + LockStatement lock = (LockStatement) assertSqlCanBeParsedAndDeparsed(sql, false, + dialect); + assertFalse(lock.isTablesKeyword()); + lock.getTargets().forEach(target -> { + assertNull(target.getLockType()); + assertNull(target.getTable().getAlias()); + }); + } + LockStatement tables = parse("LOCK tables NOWAIT", MYSQL); + assertFalse(tables.isUseTableKeyword()); + assertEquals("tables", tables.getTable().getName()); + assertTrue(tables.isNoWait()); + + for (Consumer dialect : List.of(NO_DIALECT, MYSQL, POSTGRESQL, ORACLE)) { + LockStatement only = (LockStatement) assertSqlCanBeParsedAndDeparsed( + "LOCK TABLE ONLY t IN SHARE MODE", false, dialect); + assertEquals(LockStatement.Scope.ONLY, only.getTargets().get(0).getScope()); + assertEquals("t", only.getTable().getName()); + assertEquals(LockMode.Share, only.getLockMode()); + } + } + + @ParameterizedTest + @ValueSource(strings = { + "LOCK TABLES only WRITE", + "LOCK TABLES only t WRITE", + "LOCK TABLES only AS t READ", + "LOCK TABLES t READ, only WRITE", + "LOCK TABLES only READ LOCAL, only AS o LOW_PRIORITY WRITE", + "LOCK TABLE ONLY t READ", + "LOCK TABLES only.x WRITE", + "LOCK TABLES `only` t WRITE", + "LOCK TABLES t AS only WRITE"}) + void readsOnlyAsATableName(String sql) throws JSQLParserException { + // MySQL has no ONLY keyword in LOCK TABLES, so only is the name of a table + for (Consumer dialect : List.of(NO_DIALECT, MYSQL, MARIADB)) { + LockStatement lock = parse(sql, dialect); + assertEquals(sql, deparse(lock)); + assertEquals(sql, CCJSqlParserUtil.parse(lock.toString(), dialect).toString()); + lock.getTargets().forEach(target -> { + assertEquals(LockStatement.Scope.DEFAULT, target.getScope()); + assertNotNull(target.getLockType()); + }); + } + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, POSTGRESQL)); + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, ORACLE)); + } + + @Test + void keepsTheTableNamedOnlyAndItsAlias() throws JSQLParserException { + LockStatement lock = parse("LOCK TABLES only t WRITE, s READ, only AS o READ", MYSQL); + Table first = lock.getTargets().get(0).getTable(); + assertEquals("only", first.getName()); + assertEquals("t", first.getAlias().getName()); + assertFalse(first.getAlias().isUseAs()); + assertEquals("only", lock.getTargets().get(2).getTable().getName()); + assertEquals("o", lock.getTargets().get(2).getTable().getAlias().getName()); + assertThat(new TablesNamesFinder().getTables(lock)) + .containsExactlyInAnyOrder("only", "s"); + + LockStatement keyword = parse("LOCK TABLE ONLY t READ", NO_DIALECT); + assertEquals("ONLY", keyword.getTable().getName()); + assertEquals("t", keyword.getTable().getAlias().getName()); + assertTrue(keyword.isUseTableKeyword()); + assertFalse(keyword.isTablesKeyword()); + + String sql = "LOCK TABLES only t WRITE"; + assertEquals("only", text(sql, parse(sql, MYSQL).getTable())); + } + + @Test + void wordsRemainUsableAsNames() throws JSQLParserException { + for (Consumer dialect : List.of(NO_DIALECT, MYSQL, POSTGRESQL)) { + assertSqlCanBeParsedAndDeparsed( + "SELECT unlock, instance, backup, concurrent FROM tables", true, dialect); + assertSqlCanBeParsedAndDeparsed("SELECT * FROM t unlock", true, dialect); + assertSqlCanBeParsedAndDeparsed("SELECT unlock.a FROM unlock.unlock AS unlock", true, + dialect); + assertInstanceOf(CreateTable.class, assertSqlCanBeParsedAndDeparsed( + "CREATE TABLE unlock (instance INT, unlock INT)", true, dialect)); + } + AlterUser alter = (AlterUser) assertSqlCanBeParsedAndDeparsed( + "ALTER USER u ACCOUNT UNLOCK", true, MYSQL); + assertEquals("ALTER USER u ACCOUNT UNLOCK", alter.toString()); + } + + @Test + void parsesAMysqldumpScript() throws JSQLParserException { + String script = "--\n-- Table structure for table `t`\n--\n\n" + + "DROP TABLE IF EXISTS `t`;\n" + + "CREATE TABLE `t` (\n" + + " `id` int NOT NULL AUTO_INCREMENT,\n" + + " `name` varchar(20) DEFAULT NULL,\n" + + " PRIMARY KEY (`id`)\n" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;\n\n" + + "--\n-- Dumping data for table `t`\n--\n\n" + + "LOCK TABLES `t` WRITE;\n" + + "/*!40000 ALTER TABLE `t` DISABLE KEYS */;\n" + + "INSERT INTO `t` VALUES (1,'a'),(2,'b');\n" + + "/*!40000 ALTER TABLE `t` ENABLE KEYS */;\n" + + "UNLOCK TABLES;\n"; + // only the MySQL dialect runs the /*!40000 ... */ statements of a server of version 8.4 + assertEquals(List.of("Drop", "CreateTable", "LockStatement", "Alter", "Insert", "Alter", + "UnlockStatement"), dumpStatements(script, MYSQL)); + for (Consumer dialect : List.of(NO_DIALECT, MARIADB)) { + assertEquals(List.of("Drop", "CreateTable", "LockStatement", "Insert", + "UnlockStatement"), dumpStatements(script, dialect)); + } + } + + private static List dumpStatements(String script, Consumer dialect) + throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements(script, + dialect.andThen(parser -> parser.withMySqlServerVersion(80400))); + LockStatement lock = assertInstanceOf(LockStatement.class, statements.get(2)); + assertEquals("LOCK TABLES `t` WRITE", lock.toString()); + assertEquals(TableLockType.WRITE, lock.getTargets().get(0).getLockType()); + assertInstanceOf(UnlockStatement.class, statements.get(statements.size() - 1)); + return statements.stream().map(statement -> statement.getClass().getSimpleName()) + .collect(Collectors.toList()); + } + + @Test + void endsTheStatementsAroundIt() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "SELECT 1; LOCK TABLE t READ; UNLOCK TABLES; LOCK INSTANCE FOR BACKUP; UNLOCK INSTANCE"); + assertEquals(List.of("PlainSelect", "LockStatement", "UnlockStatement", + "BackupLockStatement", "BackupLockStatement"), + statements.stream().map(statement -> statement.getClass().getSimpleName()) + .collect(Collectors.toList())); + } + + @Test + void isAStatementStartForUnsupportedStatements() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "FOO BAR; UNLOCK TABLES; BAZ; UNLOCK INSTANCE", + parser -> parser.withUnsupportedStatements(true)); + assertEquals(4, statements.size()); + assertInstanceOf(UnsupportedStatement.class, statements.get(0)); + assertInstanceOf(UnlockStatement.class, statements.get(1)); + assertInstanceOf(UnsupportedStatement.class, statements.get(2)); + assertInstanceOf(BackupLockStatement.class, statements.get(3)); + + Statements postgres = CCJSqlParserUtil.parseStatements("UNLOCK TABLES", + parser -> parser.withDialect(Dialect.POSTGRESQL).withUnsupportedStatements(true)); + assertInstanceOf(UnsupportedStatement.class, postgres.get(0)); + } + + @Test + void findsTheLockedTablesButNotTheAliases() throws JSQLParserException { + Statement lock = CCJSqlParserUtil.parse("LOCK TABLES t AS a READ, db.u b WRITE, t c READ"); + assertThat(new TablesNamesFinder().getTables(lock)) + .containsExactlyInAnyOrder("t", "db.u"); + assertThat(new TablesNamesFinder().getTables(CCJSqlParserUtil.parse("UNLOCK TABLES"))) + .isEmpty(); + assertThat(new TablesNamesFinder() + .getTables(CCJSqlParserUtil.parse("LOCK INSTANCE FOR BACKUP"))).isEmpty(); + } + + @Test + void reportsTransactionFeatures() throws JSQLParserException { + for (String sql : new String[] {"LOCK TABLES t WRITE", "UNLOCK TABLES", + "LOCK INSTANCE FOR BACKUP", "UNLOCK INSTANCE"}) { + StatementFeatures features = CCJSqlParserUtil.parse(sql).getFeatures(); + assertThat(features.getCertain()).containsExactly(StmtFeature.MODIFIES_TRANSACTION); + } + } + + @Test + void nodesSpanTheStatementAndItsTables() throws JSQLParserException { + String sql = "LOCK TABLES db.t AS a READ LOCAL, u WRITE"; + LockStatement lock = parse(sql, MYSQL); + assertEquals(sql, text(sql, lock)); + assertEquals("db.t", text(sql, lock.getTargets().get(0).getTable())); + assertEquals("u", text(sql, lock.getTargets().get(1).getTable())); + + for (String other : new String[] {"UNLOCK TABLES", "LOCK INSTANCE FOR BACKUP", + "UNLOCK INSTANCE"}) { + assertEquals(other, text(other, (ASTNodeAccess) CCJSqlParserUtil.parse(other))); + } + } + + @Test + void buildsStatementsInCode() { + LockStatement lock = new LockStatement(new Table("t"), null); + lock.setTablesKeyword(true); + lock.getTargets().get(0).setLockType(TableLockType.READ); + Table aliased = new Table("u"); + aliased.setAlias(new Alias("a")); + lock.getTargets().add( + new LockStatement.Target(aliased).withLockType(TableLockType.WRITE_CONCURRENT)); + assertEquals("LOCK TABLES t READ, u AS a WRITE CONCURRENT", lock.toString()); + assertEquals(lock.toString(), deparse(lock)); + + lock.setUseTableKeyword(false); + assertFalse(lock.isTablesKeyword()); + assertEquals("LOCK t READ, u AS a WRITE CONCURRENT", lock.toString()); + lock.setTablesKeyword(true); + assertTrue(lock.isUseTableKeyword()); + + UnlockStatement unlock = new UnlockStatement(); + assertEquals("UNLOCK TABLES", unlock.toString()); + unlock.setTablesKeyword(false); + assertEquals("UNLOCK TABLE", deparse(unlock)); + assertEquals("UNLOCK TABLE", new UnlockStatement(false).toString()); + + BackupLockStatement backup = new BackupLockStatement(); + assertEquals("LOCK INSTANCE FOR BACKUP", deparse(backup)); + backup.setAction(BackupLockStatement.Action.UNLOCK); + assertEquals("UNLOCK INSTANCE", backup.toString()); + assertEquals("UNLOCK INSTANCE", + new BackupLockStatement(BackupLockStatement.Action.UNLOCK).toString()); + assertThrows(NullPointerException.class, () -> backup.setAction(null)); + } + + private static LockStatement parse(String sql, Consumer dialect) + throws JSQLParserException { + return (LockStatement) assertSqlCanBeParsedAndDeparsed(sql, false, dialect); + } + + 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/LockStatementValidatorTest.java b/src/test/java/net/sf/jsqlparser/util/validation/validator/LockStatementValidatorTest.java new file mode 100644 index 0000000000..a68a6ebde4 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/util/validation/validator/LockStatementValidatorTest.java @@ -0,0 +1,60 @@ +/*- + * #%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 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.MariaDbVersion; +import net.sf.jsqlparser.util.validation.feature.MySqlVersion; +import net.sf.jsqlparser.util.validation.feature.OracleVersion; +import net.sf.jsqlparser.util.validation.feature.PostgresqlVersion; +import org.junit.jupiter.api.Test; + +public class LockStatementValidatorTest extends ValidationTestAsserts { + + @Test + public void testTableLocksAllowedForMySqlAndMariaDb() throws JSQLParserException { + for (String sql : new String[] {"LOCK TABLES t READ, u AS a WRITE", "UNLOCK TABLES"}) { + validateNoErrors(sql, 1, MySqlVersion.V8_0, MariaDbVersion.V10_5_4, + DatabaseType.MYSQL, DatabaseType.MARIADB); + } + } + + @Test + public void testBackupLocksAllowedForMySqlOnly() throws JSQLParserException { + for (String sql : new String[] {"LOCK INSTANCE FOR BACKUP", "UNLOCK INSTANCE"}) { + validateNoErrors(sql, 1, MySqlVersion.V8_0, DatabaseType.MYSQL); + validate(sql, 1, 1, MariaDbVersion.V10_5_4); + } + } + + @Test + public void testPostgresqlAndOracleLocksAllowed() throws JSQLParserException { + validateNoErrors("LOCK TABLE t IN SHARE MODE NOWAIT", 1, PostgresqlVersion.V14, + OracleVersion.V19C, DatabaseType.POSTGRESQL, DatabaseType.ORACLE); + } + + @Test + public void testUnlockNotAllowedForPostgresql() throws JSQLParserException { + validate("UNLOCK TABLES", 1, 1, PostgresqlVersion.V14); + } + + @Test + public void testFeatureNotAllowed() throws JSQLParserException { + validateNotAllowed("LOCK TABLES t WRITE", 1, 1, FeaturesAllowed.SELECT, + Feature.lock); + validateNotAllowed("UNLOCK TABLES", 1, 1, FeaturesAllowed.SELECT, Feature.unlockTables); + validateNotAllowed("LOCK INSTANCE FOR BACKUP", 1, 1, FeaturesAllowed.SELECT, + Feature.backupLock); + } +}