diff --git a/README.md b/README.md index d4b0e2848a..437201fc3c 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ and missing syntax gets added on demand — [open an issue](https://github.com/J | **Oracle vector types** | `VECTOR(*, *)` · `VECTOR(*, FLOAT32)` · `VECTOR(3, *)`, including dense and sparse storage formats | | **PostgreSQL ownership** | `DROP OWNED` · `REASSIGN OWNED` | | **MySQL table maintenance** | `ANALYZE` · `CHECK` · `CHECKSUM` · `OPTIMIZE` · `REPAIR TABLE` | +| **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 3ce49efd6a..566d456835 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -48,6 +48,9 @@ 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.LockStatement; +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; @@ -676,6 +679,25 @@ public enum Feature { transactionDeferrable, /** MySQL WITH CONSISTENT SNAPSHOT transaction characteristic. */ transactionConsistentSnapshot, + /** + * "LOCK [TABLE | TABLES]" statement is allowed: PostgreSQL and Oracle lock modes, and MySQL and + * MariaDB lock types + * + * @see LockStatement + */ + lockTables, + /** + * 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 8f4e7aa8f7..eec6f46205 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -96,7 +96,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.merge.Merge; import net.sf.jsqlparser.statement.oracle.OracleAssignment; import net.sf.jsqlparser.statement.oracle.OracleBlock; @@ -866,6 +868,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 c802107c0b..488a58e1c7 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.merge.Merge; import net.sf.jsqlparser.statement.refresh.RefreshMaterializedViewStatement; import net.sf.jsqlparser.statement.reindex.Reindex; @@ -675,6 +677,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 5367cc9a0c..8bae3b3168 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.merge.Merge; import net.sf.jsqlparser.statement.merge.MergeOperationVisitor; import net.sf.jsqlparser.statement.merge.MergeOperationVisitorAdapter; @@ -544,6 +546,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 7befd9beb2..b4d2eb5603 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.merge.Merge; import net.sf.jsqlparser.statement.merge.MergeDelete; import net.sf.jsqlparser.statement.merge.MergeInsert; @@ -3041,6 +3043,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 6a3e88c7cd..52926a38aa 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.merge.Merge; import net.sf.jsqlparser.statement.refresh.RefreshMaterializedViewStatement; import net.sf.jsqlparser.statement.reindex.Reindex; @@ -805,6 +807,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 f4ca890270..8aac9c1472 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.tableMaintenance, + // https://mariadb.com/kb/en/lock-tables/ + Feature.lockTables, Feature.unlockTables, + // https://mariadb.com/kb/en/account-management-sql-statements/ Feature.createRole, Feature.alterUser, Feature.renameUser, Feature.setRole, Feature.setDefaultRole, Feature.setPassword, 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 17a1eae4d7..ebe8cf48ce 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.tableMaintenance, + // https://dev.mysql.com/doc/refman/8.0/en/lock-tables.html + Feature.lockTables, 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.alterUser, Feature.renameUser, Feature.setRole, Feature.setDefaultRole, Feature.setPassword, diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/OracleVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/OracleVersion.java index 8d94ab72c1..31d748fe23 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/OracleVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/OracleVersion.java @@ -125,6 +125,9 @@ public enum OracleVersion implements Version { Feature.commit, + // https://docs.oracle.com/en/database/oracle/oracle-database/19/sqlrf/LOCK-TABLE.html + Feature.lockTables, + // https://docs.oracle.com/en/database/oracle/oracle-database/19/sqlrf/COMMENT.html Feature.comment, Feature.commentOnTable, Feature.commentOnColumn, Feature.commentOnView, diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java index b0d8115d91..9c5ce4dac9 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java @@ -187,7 +187,8 @@ public enum PostgresqlVersion implements Version { Feature.alterDefaultPrivileges, Feature.createTrigger, // https://www.postgresql.org/docs/current/sql-createeventtrigger.html Feature.createEventTrigger, Feature.alterEventTrigger, // https://www.postgresql.org/docs/current/sql-reindex.html Feature.reindex, // https://www.postgresql.org/docs/current/sql-security-label.html - Feature.securityLabel, // https://www.postgresql.org/docs/current/sql-set.html + Feature.securityLabel, // https://www.postgresql.org/docs/current/sql-lock.html + Feature.lockTables, // https://www.postgresql.org/docs/current/sql-set.html Feature.set, Feature.setAssignmentTo, // https://www.postgresql.org/docs/current/sql-reset.html Feature.reset, // https://www.postgresql.org/docs/current/sql-commit.html Feature.commit, Feature.startTransaction, Feature.releaseSavepoint, 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 e15bded789..6fc68c61c2 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 @@ -123,7 +123,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.merge.Merge; import net.sf.jsqlparser.statement.refresh.RefreshMaterializedViewStatement; import net.sf.jsqlparser.statement.reindex.Reindex; @@ -138,6 +140,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; @@ -789,7 +792,24 @@ public Void visit(Export export, S context) { @Override public Void visit(LockStatement lock, S context) { - // TODO: not yet implemented + validateFeature(Feature.lockTables); + 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 087c530b43..bd2990d536 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -171,6 +171,8 @@ public class CCJSqlParser extends AbstractJSqlParser { return "LABEL".equalsIgnoreCase(getToken(2).image); case K_REINDEX: return isReindexAhead(); + 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; @@ -2516,6 +2518,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. @@ -4388,6 +4440,7 @@ String NonReservedWordPart2() : | tk= | tk= | tk= + | tk= | tk= | tk= | tk= @@ -5120,6 +5173,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() ) @@ -5453,16 +5510,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; } } @@ -17374,7 +17512,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 a5a6969f6a..08fb0da1f1 100644 --- a/src/site/sphinx/index.rst +++ b/src/site/sphinx/index.rst @@ -160,6 +160,8 @@ One grammar covers the SQL standard plus all major RDBMS. Missing syntax gets ad - ``DROP OWNED`` · ``REASSIGN OWNED`` * - **MySQL table maintenance** - ``ANALYZE`` · ``CHECK`` · ``CHECKSUM`` · ``OPTIMIZE`` · ``REPAIR TABLE`` + * - **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 250dda5b6f..e2bb60094e 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -2493,6 +2493,44 @@ report ``MODIFIES_SCHEMA``, and REPAIR may report ``MODIFIES_DATA``. ``StringValue``). ``USING DATA`` excludes the other two options, and ``DROP HISTOGRAM`` takes none of them. +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 +``lockTables``, ``unlockTables`` and ``backupLock`` features; the MySQL capabilities enable +all three, MariaDB the first two, and PostgreSQL and Oracle ``lockTables``. 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..29eebf170f --- /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.lockTables); + validateNotAllowed("UNLOCK TABLES", 1, 1, FeaturesAllowed.SELECT, Feature.unlockTables); + validateNotAllowed("LOCK INSTANCE FOR BACKUP", 1, 1, FeaturesAllowed.SELECT, + Feature.backupLock); + } +}