diff --git a/README.md b/README.md index d39e81ea68..da6d1c2790 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,7 @@ and missing syntax gets added on demand — [open an issue](https://github.com/J | **DML** | `INSERT` · `UPDATE` · `UPSERT` · `MERGE` · `DELETE` · `TRUNCATE TABLE` | | **DDL** | `CREATE …` · `ALTER …` · `DROP …` | | **MySQL table maintenance** | `ANALYZE` · `CHECK` · `OPTIMIZE` · `REPAIR TABLE` | +| **MySQL account management** | `CREATE`/`ALTER`/`RENAME USER` · `CREATE ROLE` · `SET ROLE` · `SET DEFAULT ROLE` · `SET PASSWORD` | | **PostgreSQL RLS** | `CREATE POLICY` · `ALTER TABLE … ENABLE`/`DISABLE`/`FORCE`/`NO FORCE ROW LEVEL SECURITY` | | **Informix constraints** | `ALTER TABLE … ADD CONSTRAINT` with trailing constraint names for primary, unique, foreign and check constraints; enable with `parser.withDialect(Dialect.INFORMIX)` | | **Salesforce SOQL** | `INCLUDES` · `EXCLUDES` | 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 0d3d080fbb..dd41956fcc 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -693,6 +693,17 @@ public enum Feature { * @see Grant */ grant, revoke, createRole, alterRole, alterDefaultPrivileges, + /** + * MySQL/MariaDB account management: "ALTER USER", "RENAME USER", "SET ROLE", "SET DEFAULT ROLE" + * and "SET PASSWORD" + * + * @see net.sf.jsqlparser.statement.alter.AlterUser + * @see net.sf.jsqlparser.statement.alter.RenameUser + * @see net.sf.jsqlparser.statement.SetRoleStatement + * @see net.sf.jsqlparser.statement.SetDefaultRoleStatement + * @see net.sf.jsqlparser.statement.SetPasswordStatement + */ + alterUser, renameUser, setRole, setDefaultRole, setPassword, /** * @see Function */ diff --git a/src/main/java/net/sf/jsqlparser/statement/SetDefaultRoleStatement.java b/src/main/java/net/sf/jsqlparser/statement/SetDefaultRoleStatement.java new file mode 100644 index 0000000000..0e0268cde3 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/SetDefaultRoleStatement.java @@ -0,0 +1,94 @@ +/*- + * #%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; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import net.sf.jsqlparser.statement.create.user.RoleSelection; +import net.sf.jsqlparser.statement.create.user.UserAccount; + +/** + * MySQL {@code SET DEFAULT ROLE {NONE | ALL | role, ...} TO user, ...}. MariaDB's + * {@code SET DEFAULT ROLE {role | NONE} [FOR user]} is kept with {@link #isForKeyword()}; without + * accounts the statement applies to the current user. + */ +public class SetDefaultRoleStatement implements Statement { + + private RoleSelection roles; + private final List accounts = new ArrayList<>(); + private boolean forKeyword; + + public RoleSelection getRoles() { + return roles; + } + + public void setRoles(RoleSelection roles) { + this.roles = roles; + } + + /** The accounts after {@code TO} or {@code FOR}; empty when omitted. */ + public List getAccounts() { + return Collections.unmodifiableList(accounts); + } + + public void setAccounts(Collection accounts) { + this.accounts.clear(); + if (accounts != null) { + this.accounts.addAll(accounts); + } + } + + /** Whether the accounts follow MariaDB's {@code FOR} instead of {@code TO}. */ + public boolean isForKeyword() { + return forKeyword; + } + + public void setForKeyword(boolean forKeyword) { + this.forKeyword = forKeyword; + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder("SET DEFAULT ROLE ").append(roles); + if (!accounts.isEmpty()) { + builder.append(forKeyword ? " FOR " : " TO ").append( + accounts.stream().map(Object::toString).collect(Collectors.joining(", "))); + } + return builder.toString(); + } + + public SetDefaultRoleStatement withRoles(RoleSelection roles) { + setRoles(roles); + return this; + } + + public SetDefaultRoleStatement withAccounts(Collection accounts) { + setAccounts(accounts); + return this; + } + + public SetDefaultRoleStatement addAccounts(UserAccount... accounts) { + Collections.addAll(this.accounts, accounts); + return this; + } + + public SetDefaultRoleStatement withForKeyword(boolean forKeyword) { + setForKeyword(forKeyword); + return this; + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/SetPasswordStatement.java b/src/main/java/net/sf/jsqlparser/statement/SetPasswordStatement.java new file mode 100644 index 0000000000..17125d55f0 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/SetPasswordStatement.java @@ -0,0 +1,130 @@ +/*- + * #%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; + +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.statement.create.user.UserAccount; + +/** + * MySQL/MariaDB {@code SET PASSWORD [FOR user] {= password | TO RANDOM} + * [REPLACE 'current'] [RETAIN CURRENT PASSWORD]}. The password is an expression, usually a string + * literal; MariaDB also accepts {@code PASSWORD('...')}. + */ +public class SetPasswordStatement implements Statement { + + private UserAccount account; + private Expression password; + private boolean random; + private StringValue currentCredential; + private boolean retainCurrentPassword; + + /** The account after {@code FOR}, or null for the current user. */ + public UserAccount getAccount() { + return account; + } + + public void setAccount(UserAccount account) { + this.account = account; + } + + /** The password after {@code =}; null for {@code TO RANDOM}. */ + public Expression getPassword() { + return password; + } + + public void setPassword(Expression password) { + this.password = password; + } + + /** Whether the statement generates a password with {@code TO RANDOM}. */ + public boolean isRandom() { + return random; + } + + public void setRandom(boolean random) { + this.random = random; + } + + /** The current password of {@code REPLACE 'current'}, or null when omitted. */ + public StringValue getCurrentCredential() { + return currentCredential; + } + + public void setCurrentCredential(StringValue currentCredential) { + this.currentCredential = currentCredential; + } + + public boolean isRetainCurrentPassword() { + return retainCurrentPassword; + } + + public void setRetainCurrentPassword(boolean retainCurrentPassword) { + this.retainCurrentPassword = retainCurrentPassword; + } + + /** Shares statement rendering while letting deparsers render the password expression. */ + public StringBuilder appendTo(StringBuilder builder, Consumer expressionRenderer) { + builder.append("SET PASSWORD"); + if (account != null) { + builder.append(" FOR ").append(account); + } + if (random) { + builder.append(" TO RANDOM"); + } else { + builder.append(" = "); + expressionRenderer.accept(password); + } + if (currentCredential != null) { + builder.append(" REPLACE ").append(currentCredential); + } + if (retainCurrentPassword) { + builder.append(" RETAIN CURRENT PASSWORD"); + } + return builder; + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } + + public SetPasswordStatement withAccount(UserAccount account) { + setAccount(account); + return this; + } + + public SetPasswordStatement withPassword(Expression password) { + setPassword(password); + return this; + } + + public SetPasswordStatement withRandom(boolean random) { + setRandom(random); + return this; + } + + public SetPasswordStatement withCurrentCredential(StringValue currentCredential) { + setCurrentCredential(currentCredential); + return this; + } + + public SetPasswordStatement withRetainCurrentPassword(boolean retainCurrentPassword) { + setRetainCurrentPassword(retainCurrentPassword); + return this; + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/SetRoleStatement.java b/src/main/java/net/sf/jsqlparser/statement/SetRoleStatement.java new file mode 100644 index 0000000000..4de3090f82 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/SetRoleStatement.java @@ -0,0 +1,52 @@ +/*- + * #%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; + +import net.sf.jsqlparser.statement.create.user.RoleSelection; + +/** + * MySQL/MariaDB {@code SET ROLE {DEFAULT | NONE | ALL [EXCEPT role, ...] | role, ...}}. PostgreSQL + * {@code SET [SESSION | LOCAL] ROLE name} remains a {@link SetStatement}. + */ +public class SetRoleStatement implements Statement { + + private RoleSelection roles; + + public SetRoleStatement() { + // empty constructor + } + + public SetRoleStatement(RoleSelection roles) { + this.roles = roles; + } + + public RoleSelection getRoles() { + return roles; + } + + public void setRoles(RoleSelection roles) { + this.roles = roles; + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } + + @Override + public String toString() { + return "SET ROLE " + roles; + } + + public SetRoleStatement withRoles(RoleSelection roles) { + setRoles(roles); + return this; + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index 91b2721249..84215b2331 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -42,6 +42,8 @@ import net.sf.jsqlparser.statement.alter.AlterType; import net.sf.jsqlparser.statement.alter.AlterUserMapping; import net.sf.jsqlparser.statement.alter.RenameTableStatement; +import net.sf.jsqlparser.statement.alter.AlterUser; +import net.sf.jsqlparser.statement.alter.RenameUser; import net.sf.jsqlparser.statement.alter.database.AlterDatabase; import net.sf.jsqlparser.statement.alter.schema.AlterSchema; import net.sf.jsqlparser.statement.alter.sequence.AlterSequence; @@ -867,6 +869,11 @@ public Void visit(SetIdentityInsertStatement statement, S context) { return sessionOnly(); } + @Override + public Void visit(SetRoleStatement statement, S context) { + return sessionOnly(); + } + @Override public Void visit(UseStatement use, S context) { return sessionOnly(); @@ -1216,6 +1223,26 @@ public Void visit(CreateUser statement, S context) { return schemaOnly(); } + @Override + public Void visit(AlterUser statement, S context) { + return schemaOnly(); + } + + @Override + public Void visit(RenameUser statement, S context) { + return schemaOnly(); + } + + @Override + public Void visit(SetDefaultRoleStatement statement, S context) { + return schemaOnly(); + } + + @Override + public Void visit(SetPasswordStatement statement, S context) { + return schemaOnly(); + } + // These statements store definitions; visiting their children must not make a stored // rule action or policy predicate appear to execute during the DDL statement itself. diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index bf0bf3e8b5..b7230966c0 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -54,6 +54,8 @@ import net.sf.jsqlparser.statement.alter.AlterSession; import net.sf.jsqlparser.statement.alter.AlterSystemStatement; import net.sf.jsqlparser.statement.alter.RenameTableStatement; +import net.sf.jsqlparser.statement.alter.AlterUser; +import net.sf.jsqlparser.statement.alter.RenameUser; import net.sf.jsqlparser.statement.alter.sequence.AlterSequence; import net.sf.jsqlparser.statement.analyze.Analyze; import net.sf.jsqlparser.statement.comment.Comment; @@ -205,6 +207,46 @@ default void visit(CreateUser createUser) { this.visit(createUser, null); } + default T visit(AlterUser alterUser, S context) { + return null; + } + + default void visit(AlterUser alterUser) { + this.visit(alterUser, null); + } + + default T visit(RenameUser renameUser, S context) { + return null; + } + + default void visit(RenameUser renameUser) { + this.visit(renameUser, null); + } + + default T visit(SetRoleStatement setRole, S context) { + return null; + } + + default void visit(SetRoleStatement setRole) { + this.visit(setRole, null); + } + + default T visit(SetDefaultRoleStatement setDefaultRole, S context) { + return null; + } + + default void visit(SetDefaultRoleStatement setDefaultRole) { + this.visit(setDefaultRole, null); + } + + default T visit(SetPasswordStatement setPassword, S context) { + return null; + } + + default void visit(SetPasswordStatement setPassword) { + this.visit(setPassword, null); + } + T visit(CreateEvent createEvent, S context); default void visit(CreateEvent createEvent) { diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index 8a80763ca3..5a85f0643c 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -56,6 +56,8 @@ import net.sf.jsqlparser.statement.alter.AlterSession; import net.sf.jsqlparser.statement.alter.AlterSystemStatement; import net.sf.jsqlparser.statement.alter.RenameTableStatement; +import net.sf.jsqlparser.statement.alter.AlterUser; +import net.sf.jsqlparser.statement.alter.RenameUser; import net.sf.jsqlparser.statement.alter.sequence.AlterSequence; import net.sf.jsqlparser.statement.analyze.Analyze; import net.sf.jsqlparser.statement.comment.Comment; @@ -396,6 +398,31 @@ public T visit(CreateUser createUser, S context) { return null; } + @Override + public T visit(AlterUser alterUser, S context) { + return null; + } + + @Override + public T visit(RenameUser renameUser, S context) { + return null; + } + + @Override + public T visit(SetRoleStatement setRole, S context) { + return null; + } + + @Override + public T visit(SetDefaultRoleStatement setDefaultRole, S context) { + return null; + } + + @Override + public T visit(SetPasswordStatement setPassword, S context) { + return null; + } + @Override public T visit(CreateEvent createEvent, S context) { return null; diff --git a/src/main/java/net/sf/jsqlparser/statement/alter/AlterUser.java b/src/main/java/net/sf/jsqlparser/statement/alter/AlterUser.java new file mode 100644 index 0000000000..b27dfa07bd --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/alter/AlterUser.java @@ -0,0 +1,122 @@ +/*- + * #%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.alter; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; +import net.sf.jsqlparser.statement.create.user.UserAccount; +import net.sf.jsqlparser.statement.create.user.UserAuthentication; +import net.sf.jsqlparser.statement.create.user.UserOptions; + +/** + * MySQL {@code ALTER USER} statement. Each account may change its authentication, including + * {@code REPLACE}, {@code RETAIN CURRENT PASSWORD} and {@code DISCARD OLD PASSWORD}; the + * {@link UserOptions} are those of {@link net.sf.jsqlparser.statement.create.user.CreateUser}. + * {@code ALTER USER acct DEFAULT ROLE ...} keeps its roles in + * {@link UserOptions#getDefaultRoles()}. {@code ALTER USER USER() ...} names the current user with + * the unquoted account name {@code USER()}, see {@link #isUserFunction()}. + */ +public class AlterUser implements Statement { + + private static final String USER_FUNCTION = "USER()"; + + private boolean ifExists; + private final List accounts = new ArrayList<>(); + private UserOptions options = new UserOptions(); + + public boolean isIfExists() { + return ifExists; + } + + public void setIfExists(boolean ifExists) { + this.ifExists = ifExists; + } + + public List getAccounts() { + return Collections.unmodifiableList(accounts); + } + + public void setAccounts(Collection accounts) { + this.accounts.clear(); + if (accounts != null) { + this.accounts.addAll(accounts); + } + } + + /** The account options after the account list; empty, never null, when none are given. */ + public UserOptions getOptions() { + return options; + } + + public void setOptions(UserOptions options) { + this.options = options != null ? options : new UserOptions(); + } + + /** Whether the statement alters the current user named by {@code USER()}. */ + public boolean isUserFunction() { + if (accounts.size() != 1) { + return false; + } + UserAccount account = accounts.get(0); + StringValue user = account.getUser(); + return account.getHost() == null && user != null && "".equals(user.getQuoteStr()) + && USER_FUNCTION.equalsIgnoreCase(user.getValue()); + } + + /** Replaces the accounts with {@code USER()} and the given authentication change. */ + public AlterUser withUserFunction(UserAuthentication authentication) { + UserAccount account = new UserAccount() + .withUser(new StringValue(USER_FUNCTION).setQuoteStr("")) + .withAuthentication(authentication); + setAccounts(Collections.singletonList(account)); + return this; + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder("ALTER USER "); + if (ifExists) { + builder.append("IF EXISTS "); + } + builder.append(accounts.stream().map(Object::toString).collect(Collectors.joining(", "))); + return options.appendTo(builder).toString(); + } + + public AlterUser withIfExists(boolean ifExists) { + setIfExists(ifExists); + return this; + } + + public AlterUser withAccounts(Collection accounts) { + setAccounts(accounts); + return this; + } + + public AlterUser addAccounts(UserAccount... accounts) { + Collections.addAll(this.accounts, accounts); + return this; + } + + public AlterUser withOptions(UserOptions options) { + setOptions(options); + return this; + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/alter/RenameUser.java b/src/main/java/net/sf/jsqlparser/statement/alter/RenameUser.java new file mode 100644 index 0000000000..152936b91a --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/alter/RenameUser.java @@ -0,0 +1,95 @@ +/*- + * #%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.alter; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; +import net.sf.jsqlparser.statement.create.user.UserAccount; + +/** MySQL {@code RENAME USER old TO new [, old TO new] ...} statement. */ +public class RenameUser implements Statement { + + private final List renames = new ArrayList<>(); + + /** One {@code old TO new} pair. */ + public static class Rename implements Serializable { + private UserAccount from; + private UserAccount to; + + public Rename() { + // empty constructor + } + + public Rename(UserAccount from, UserAccount to) { + this.from = from; + this.to = to; + } + + public UserAccount getFrom() { + return from; + } + + public void setFrom(UserAccount from) { + this.from = from; + } + + public UserAccount getTo() { + return to; + } + + public void setTo(UserAccount to) { + this.to = to; + } + + @Override + public String toString() { + return from + " TO " + to; + } + } + + /** Returns the renames in source order. */ + public List getRenames() { + return Collections.unmodifiableList(renames); + } + + public void setRenames(Collection renames) { + this.renames.clear(); + if (renames != null) { + this.renames.addAll(renames); + } + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } + + @Override + public String toString() { + return "RENAME USER " + + renames.stream().map(Rename::toString).collect(Collectors.joining(", ")); + } + + public RenameUser withRenames(Collection renames) { + setRenames(renames); + return this; + } + + public RenameUser addRename(UserAccount from, UserAccount to) { + renames.add(new Rename(from, to)); + return this; + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/create/user/CreateUser.java b/src/main/java/net/sf/jsqlparser/statement/create/user/CreateUser.java index ce5f16273c..c08e342a82 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/user/CreateUser.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/user/CreateUser.java @@ -17,11 +17,16 @@ import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.StatementVisitor; -/** MySQL {@code CREATE USER} statement with structured account and authentication data. */ +/** + * MySQL {@code CREATE USER} statement with structured account and authentication data. The + * {@link UserOptions} after the account list are shared with + * {@link net.sf.jsqlparser.statement.alter.AlterUser}. + */ public class CreateUser implements Statement { private boolean ifNotExists; private final List accounts = new ArrayList<>(); + private UserOptions options = new UserOptions(); public boolean isIfNotExists() { return ifNotExists; @@ -42,6 +47,15 @@ public void setAccounts(Collection accounts) { } } + /** The account options after the account list; empty, never null, when none are given. */ + public UserOptions getOptions() { + return options; + } + + public void setOptions(UserOptions options) { + this.options = options != null ? options : new UserOptions(); + } + @Override public T accept(StatementVisitor statementVisitor, S context) { return statementVisitor.visit(this, context); @@ -49,8 +63,12 @@ public T accept(StatementVisitor statementVisitor, S context) { @Override public String toString() { - return "CREATE USER " + (ifNotExists ? "IF NOT EXISTS " : "") - + accounts.stream().map(Object::toString).collect(Collectors.joining(", ")); + StringBuilder builder = new StringBuilder("CREATE USER "); + if (ifNotExists) { + builder.append("IF NOT EXISTS "); + } + builder.append(accounts.stream().map(Object::toString).collect(Collectors.joining(", "))); + return options.appendTo(builder).toString(); } public CreateUser withIfNotExists(boolean ifNotExists) { @@ -67,4 +85,9 @@ public CreateUser addAccounts(UserAccount... accounts) { Collections.addAll(this.accounts, accounts); return this; } + + public CreateUser withOptions(UserOptions options) { + setOptions(options); + return this; + } } diff --git a/src/main/java/net/sf/jsqlparser/statement/create/user/PasswordLockOption.java b/src/main/java/net/sf/jsqlparser/statement/create/user/PasswordLockOption.java new file mode 100644 index 0000000000..2124454b70 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/user/PasswordLockOption.java @@ -0,0 +1,123 @@ +/*- + * #%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.create.user; + +import java.io.Serializable; + +/** + * A MySQL account password-management or locking option. The option either has no argument, a + * number ({@link #getValue()}) or a keyword ({@link #getSetting()}): + * + *
    + *
  • {@code ACCOUNT LOCK}, {@code ACCOUNT UNLOCK}
  • + *
  • {@code PASSWORD EXPIRE [DEFAULT | NEVER | INTERVAL n DAY]}
  • + *
  • {@code PASSWORD HISTORY {DEFAULT | n}}
  • + *
  • {@code PASSWORD REUSE INTERVAL {DEFAULT | n DAY}}
  • + *
  • {@code PASSWORD REQUIRE CURRENT [DEFAULT | OPTIONAL]}
  • + *
  • {@code FAILED_LOGIN_ATTEMPTS n}
  • + *
  • {@code PASSWORD_LOCK_TIME {n | UNBOUNDED}}
  • + *
+ */ +public class PasswordLockOption implements Serializable { + + public enum Kind { + ACCOUNT_LOCK, ACCOUNT_UNLOCK, PASSWORD_EXPIRE, PASSWORD_HISTORY, PASSWORD_REUSE_INTERVAL, PASSWORD_REQUIRE_CURRENT, FAILED_LOGIN_ATTEMPTS, PASSWORD_LOCK_TIME + } + + /** Keyword arguments of the options. */ + public enum Setting { + DEFAULT, NEVER, OPTIONAL, UNBOUNDED + } + + private Kind kind; + private Long value; + private Setting setting; + + public PasswordLockOption() { + // empty constructor + } + + public PasswordLockOption(Kind kind) { + this.kind = kind; + } + + public Kind getKind() { + return kind; + } + + public void setKind(Kind kind) { + this.kind = kind; + } + + /** The count or number of days, or null when the option has no numeric argument. */ + public Long getValue() { + return value; + } + + public void setValue(Long value) { + this.value = value; + } + + /** The keyword argument, or null when the option has none. */ + public Setting getSetting() { + return setting; + } + + public void setSetting(Setting setting) { + this.setting = setting; + } + + @Override + public String toString() { + switch (kind) { + case ACCOUNT_LOCK: + return "ACCOUNT LOCK"; + case ACCOUNT_UNLOCK: + return "ACCOUNT UNLOCK"; + case PASSWORD_EXPIRE: + return "PASSWORD EXPIRE" + + (value != null ? " INTERVAL " + value + " DAY" : argument("")); + case PASSWORD_HISTORY: + return "PASSWORD HISTORY" + argument(""); + case PASSWORD_REUSE_INTERVAL: + return "PASSWORD REUSE INTERVAL" + argument(" DAY"); + case PASSWORD_REQUIRE_CURRENT: + return "PASSWORD REQUIRE CURRENT" + argument(""); + case FAILED_LOGIN_ATTEMPTS: + return "FAILED_LOGIN_ATTEMPTS" + argument(""); + case PASSWORD_LOCK_TIME: + return "PASSWORD_LOCK_TIME" + argument(""); + default: + throw new IllegalStateException("Unsupported option: " + kind); + } + } + + private String argument(String unit) { + if (setting != null) { + return " " + setting; + } + return value != null ? " " + value + unit : ""; + } + + public PasswordLockOption withKind(Kind kind) { + setKind(kind); + return this; + } + + public PasswordLockOption withValue(Long value) { + setValue(value); + return this; + } + + public PasswordLockOption withSetting(Setting setting) { + setSetting(setting); + return this; + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/create/user/ResourceLimit.java b/src/main/java/net/sf/jsqlparser/statement/create/user/ResourceLimit.java new file mode 100644 index 0000000000..22ce1e36a4 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/user/ResourceLimit.java @@ -0,0 +1,63 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.create.user; + +import java.io.Serializable; + +/** One limit of the MySQL account {@code WITH} clause, such as {@code MAX_USER_CONNECTIONS 10}. */ +public class ResourceLimit implements Serializable { + + public enum Kind { + MAX_QUERIES_PER_HOUR, MAX_UPDATES_PER_HOUR, MAX_CONNECTIONS_PER_HOUR, MAX_USER_CONNECTIONS + } + + private Kind kind; + private long value; + + public ResourceLimit() { + // empty constructor + } + + public ResourceLimit(Kind kind, long value) { + this.kind = kind; + this.value = value; + } + + public Kind getKind() { + return kind; + } + + public void setKind(Kind kind) { + this.kind = kind; + } + + public long getValue() { + return value; + } + + public void setValue(long value) { + this.value = value; + } + + @Override + public String toString() { + return kind + " " + value; + } + + public ResourceLimit withKind(Kind kind) { + setKind(kind); + return this; + } + + public ResourceLimit withValue(long value) { + setValue(value); + return this; + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/create/user/RoleSelection.java b/src/main/java/net/sf/jsqlparser/statement/create/user/RoleSelection.java new file mode 100644 index 0000000000..eb78089c30 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/user/RoleSelection.java @@ -0,0 +1,100 @@ +/*- + * #%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.create.user; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * The roles named by MySQL {@code SET ROLE}, {@code SET DEFAULT ROLE}, the {@code DEFAULT ROLE} + * clause of {@code CREATE USER} and {@code ALTER USER}, and {@code GRANT ... AS user WITH ROLE}. + * Role names use the account syntax, so each role is a {@link UserAccount} without authentication. + */ +public class RoleSelection implements Serializable { + + public enum Kind { + /** + * {@code DEFAULT}: the account's default roles, only in {@code SET ROLE} and + * {@code WITH ROLE}. + */ + DEFAULT, + /** {@code NONE}. */ + NONE, + /** {@code ALL}. */ + ALL, + /** {@code ALL EXCEPT role [, role] ...}, only in {@code SET ROLE} and {@code WITH ROLE}. */ + ALL_EXCEPT, + /** {@code role [, role] ...}. */ + ROLES + } + + private Kind kind; + private final List roles = new ArrayList<>(); + + public RoleSelection() { + // empty constructor + } + + public RoleSelection(Kind kind) { + this.kind = kind; + } + + public Kind getKind() { + return kind; + } + + public void setKind(Kind kind) { + this.kind = kind; + } + + /** Returns the listed roles of {@link Kind#ROLES} and {@link Kind#ALL_EXCEPT}. */ + public List getRoles() { + return Collections.unmodifiableList(roles); + } + + public void setRoles(Collection roles) { + this.roles.clear(); + if (roles != null) { + this.roles.addAll(roles); + } + } + + @Override + public String toString() { + String names = roles.stream().map(Object::toString).collect(Collectors.joining(", ")); + switch (kind) { + case ROLES: + return names; + case ALL_EXCEPT: + return "ALL EXCEPT " + names; + default: + return kind.name(); + } + } + + public RoleSelection withKind(Kind kind) { + setKind(kind); + return this; + } + + public RoleSelection withRoles(Collection roles) { + setRoles(roles); + return this; + } + + public RoleSelection addRoles(UserAccount... roles) { + Collections.addAll(this.roles, roles); + return this; + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/create/user/TlsOption.java b/src/main/java/net/sf/jsqlparser/statement/create/user/TlsOption.java new file mode 100644 index 0000000000..75a967f260 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/user/TlsOption.java @@ -0,0 +1,99 @@ +/*- + * #%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.create.user; + +import java.io.Serializable; +import net.sf.jsqlparser.expression.StringValue; + +/** + * One requirement of the MySQL account {@code REQUIRE} clause. {@code REQUIRE NONE} is a single + * {@link Kind#NONE} option; {@code CIPHER}, {@code ISSUER} and {@code SUBJECT} carry a string. + */ +public class TlsOption implements Serializable { + + public enum Kind { + NONE, SSL, X509, CIPHER, ISSUER, SUBJECT; + + /** Whether the option is followed by a string value. */ + public boolean hasValue() { + return this == CIPHER || this == ISSUER || this == SUBJECT; + } + } + + private Kind kind; + private StringValue value; + private boolean andKeyword; + + public TlsOption() { + // empty constructor + } + + public TlsOption(Kind kind) { + this.kind = kind; + } + + public TlsOption(Kind kind, StringValue value) { + this.kind = kind; + this.value = value; + } + + public Kind getKind() { + return kind; + } + + public void setKind(Kind kind) { + this.kind = kind; + } + + public StringValue getValue() { + return value; + } + + public void setValue(StringValue value) { + this.value = value; + } + + /** Whether {@code AND} is written before this option. */ + public boolean isAndKeyword() { + return andKeyword; + } + + public void setAndKeyword(boolean andKeyword) { + this.andKeyword = andKeyword; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + if (andKeyword) { + builder.append("AND "); + } + builder.append(kind); + if (value != null) { + builder.append(' ').append(value); + } + return builder.toString(); + } + + public TlsOption withKind(Kind kind) { + setKind(kind); + return this; + } + + public TlsOption withValue(StringValue value) { + setValue(value); + return this; + } + + public TlsOption withAndKeyword(boolean andKeyword) { + setAndKeyword(andKeyword); + return this; + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/create/user/UserAuthentication.java b/src/main/java/net/sf/jsqlparser/statement/create/user/UserAuthentication.java index d05e76af91..b07864d82e 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/user/UserAuthentication.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/user/UserAuthentication.java @@ -12,16 +12,28 @@ import java.io.Serializable; import net.sf.jsqlparser.expression.StringValue; -/** Structured authentication clause of a MySQL user account. */ +/** + * Structured authentication clause of a MySQL user account. {@code ALTER USER} adds + * {@code REPLACE 'current'} and {@code RETAIN CURRENT PASSWORD} to a password assignment, and + * {@code DISCARD OLD PASSWORD} as a mode of its own. + */ public class UserAuthentication implements Serializable { public enum Mode { - BY_PASSWORD, BY_RANDOM_PASSWORD, WITH_PLUGIN, WITH_PLUGIN_BY_PASSWORD, WITH_PLUGIN_BY_RANDOM_PASSWORD, WITH_PLUGIN_AS_STRING + BY_PASSWORD, BY_RANDOM_PASSWORD, WITH_PLUGIN, WITH_PLUGIN_BY_PASSWORD, WITH_PLUGIN_BY_RANDOM_PASSWORD, WITH_PLUGIN_AS_STRING, DISCARD_OLD_PASSWORD; + + /** Whether this mode assigns a new password, which REPLACE and RETAIN may follow. */ + public boolean isPasswordAssignment() { + return this == BY_PASSWORD || this == BY_RANDOM_PASSWORD + || this == WITH_PLUGIN_BY_PASSWORD || this == WITH_PLUGIN_BY_RANDOM_PASSWORD; + } } private Mode mode; private String plugin; private StringValue credential; + private StringValue currentCredential; + private boolean retainCurrentPassword; public Mode getMode() { return mode; @@ -47,8 +59,37 @@ public void setCredential(StringValue credential) { this.credential = credential; } + /** The current password of {@code REPLACE 'current'}, or null when omitted. */ + public StringValue getCurrentCredential() { + return currentCredential; + } + + public void setCurrentCredential(StringValue currentCredential) { + this.currentCredential = currentCredential; + } + + /** Whether {@code RETAIN CURRENT PASSWORD} keeps the replaced password as a secondary one. */ + public boolean isRetainCurrentPassword() { + return retainCurrentPassword; + } + + public void setRetainCurrentPassword(boolean retainCurrentPassword) { + this.retainCurrentPassword = retainCurrentPassword; + } + @Override public String toString() { + StringBuilder builder = new StringBuilder(modeToString()); + if (currentCredential != null) { + builder.append(" REPLACE ").append(currentCredential); + } + if (retainCurrentPassword) { + builder.append(" RETAIN CURRENT PASSWORD"); + } + return builder.toString(); + } + + private String modeToString() { switch (mode) { case BY_PASSWORD: return "IDENTIFIED BY " + credential; @@ -62,6 +103,8 @@ public String toString() { return "IDENTIFIED WITH " + plugin + " BY RANDOM PASSWORD"; case WITH_PLUGIN_AS_STRING: return "IDENTIFIED WITH " + plugin + " AS " + credential; + case DISCARD_OLD_PASSWORD: + return "DISCARD OLD PASSWORD"; default: throw new IllegalStateException("Unsupported authentication mode: " + mode); } @@ -81,4 +124,14 @@ public UserAuthentication withCredential(StringValue credential) { setCredential(credential); return this; } + + public UserAuthentication withCurrentCredential(StringValue currentCredential) { + setCurrentCredential(currentCredential); + return this; + } + + public UserAuthentication withRetainCurrentPassword(boolean retainCurrentPassword) { + setRetainCurrentPassword(retainCurrentPassword); + return this; + } } diff --git a/src/main/java/net/sf/jsqlparser/statement/create/user/UserOptions.java b/src/main/java/net/sf/jsqlparser/statement/create/user/UserOptions.java new file mode 100644 index 0000000000..84233c83a5 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/user/UserOptions.java @@ -0,0 +1,187 @@ +/*- + * #%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.create.user; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import net.sf.jsqlparser.expression.StringValue; + +/** + * The account options shared by MySQL {@code CREATE USER} and {@code ALTER USER}, rendered in the + * clause order MySQL requires: + * + *
+ * [DEFAULT ROLE ...] [REQUIRE ...] [WITH resource_limit ...]
+ * [password_or_lock_option ...] [COMMENT '...' | ATTRIBUTE '...']
+ * 
+ * + * Absent clauses are null or empty; nothing is filled in with server defaults. + */ +public class UserOptions implements Serializable { + + private RoleSelection defaultRoles; + private final List tlsOptions = new ArrayList<>(); + private final List resourceLimits = new ArrayList<>(); + private final List passwordLockOptions = new ArrayList<>(); + private StringValue comment; + private StringValue attribute; + + /** + * The {@code DEFAULT ROLE} clause: a role list for {@code CREATE USER}, also {@code NONE} or + * {@code ALL} for {@code ALTER USER}. Null when omitted. + */ + public RoleSelection getDefaultRoles() { + return defaultRoles; + } + + public void setDefaultRoles(RoleSelection defaultRoles) { + this.defaultRoles = defaultRoles; + } + + /** The {@code REQUIRE} options in source order; empty when the clause is omitted. */ + public List getTlsOptions() { + return Collections.unmodifiableList(tlsOptions); + } + + public void setTlsOptions(Collection tlsOptions) { + replace(this.tlsOptions, tlsOptions); + } + + /** The {@code WITH} resource limits in source order; empty when the clause is omitted. */ + public List getResourceLimits() { + return Collections.unmodifiableList(resourceLimits); + } + + public void setResourceLimits(Collection resourceLimits) { + replace(this.resourceLimits, resourceLimits); + } + + /** The password-management and locking options in source order, repetitions included. */ + public List getPasswordLockOptions() { + return Collections.unmodifiableList(passwordLockOptions); + } + + public void setPasswordLockOptions( + Collection passwordLockOptions) { + replace(this.passwordLockOptions, passwordLockOptions); + } + + public StringValue getComment() { + return comment; + } + + public void setComment(StringValue comment) { + this.comment = comment; + } + + /** The {@code ATTRIBUTE} JSON object string, which MySQL accepts instead of a comment. */ + public StringValue getAttribute() { + return attribute; + } + + public void setAttribute(StringValue attribute) { + this.attribute = attribute; + } + + /** Whether no clause is present. */ + public boolean isEmpty() { + return defaultRoles == null && tlsOptions.isEmpty() && resourceLimits.isEmpty() + && passwordLockOptions.isEmpty() && comment == null && attribute == null; + } + + /** Appends each present clause preceded by a space. */ + public StringBuilder appendTo(StringBuilder builder) { + if (defaultRoles != null) { + builder.append(" DEFAULT ROLE ").append(defaultRoles); + } + if (!tlsOptions.isEmpty()) { + builder.append(" REQUIRE ").append(join(tlsOptions)); + } + if (!resourceLimits.isEmpty()) { + builder.append(" WITH ").append(join(resourceLimits)); + } + if (!passwordLockOptions.isEmpty()) { + builder.append(' ').append(join(passwordLockOptions)); + } + if (comment != null) { + builder.append(" COMMENT ").append(comment); + } + if (attribute != null) { + builder.append(" ATTRIBUTE ").append(attribute); + } + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString().trim(); + } + + public UserOptions withDefaultRoles(RoleSelection defaultRoles) { + setDefaultRoles(defaultRoles); + return this; + } + + public UserOptions withTlsOptions(Collection tlsOptions) { + setTlsOptions(tlsOptions); + return this; + } + + public UserOptions addTlsOptions(TlsOption... tlsOptions) { + Collections.addAll(this.tlsOptions, tlsOptions); + return this; + } + + public UserOptions withResourceLimits(Collection resourceLimits) { + setResourceLimits(resourceLimits); + return this; + } + + public UserOptions addResourceLimits(ResourceLimit... resourceLimits) { + Collections.addAll(this.resourceLimits, resourceLimits); + return this; + } + + public UserOptions withPasswordLockOptions( + Collection passwordLockOptions) { + setPasswordLockOptions(passwordLockOptions); + return this; + } + + public UserOptions addPasswordLockOptions(PasswordLockOption... passwordLockOptions) { + Collections.addAll(this.passwordLockOptions, passwordLockOptions); + return this; + } + + public UserOptions withComment(StringValue comment) { + setComment(comment); + return this; + } + + public UserOptions withAttribute(StringValue attribute) { + setAttribute(attribute); + return this; + } + + private static void replace(List target, Collection source) { + target.clear(); + if (source != null) { + target.addAll(source); + } + } + + private static String join(List items) { + return items.stream().map(Object::toString).collect(Collectors.joining(" ")); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/grant/Grant.java b/src/main/java/net/sf/jsqlparser/statement/grant/Grant.java index a9c43dc267..3962e967d6 100644 --- a/src/main/java/net/sf/jsqlparser/statement/grant/Grant.java +++ b/src/main/java/net/sf/jsqlparser/statement/grant/Grant.java @@ -9,22 +9,23 @@ */ package net.sf.jsqlparser.statement.grant; -import java.util.AbstractList; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; -import java.util.Optional; import java.util.function.Consumer; import java.util.stream.Collectors; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.StatementVisitor; +import net.sf.jsqlparser.statement.create.user.RoleSelection; public class Grant implements Statement { private PrivilegeClause clause = new PrivilegeClause(); - private GrantOption option; + private final List options = new ArrayList<>(); + private Grantee asUser; + private RoleSelection withRole; public PrivilegeClause getClause() { return clause; @@ -34,12 +35,46 @@ public void setClause(PrivilegeClause clause) { this.clause = clause; } + /** + * The {@code WITH} options as a mutable list: {@code GRANT OPTION}, {@code ADMIN OPTION} or + * PostgreSQL membership options such as {@code INHERIT TRUE, SET FALSE}. + */ + public List getOptions() { + return options; + } + + public void setOptions(Collection options) { + List copy = options == null ? new ArrayList<>() : new ArrayList<>(options); + this.options.clear(); + this.options.addAll(copy); + } + + /** The first {@code WITH} option, or null; see {@link #getOptions()}. */ public GrantOption getOption() { - return option; + return options.isEmpty() ? null : options.get(0); } + /** Replaces all {@code WITH} options with the given option, or removes them for null. */ public void setOption(GrantOption option) { - this.option = option; + setOptions(option == null ? null : Collections.singletonList(option)); + } + + /** The MySQL {@code AS user} account, or null when omitted. */ + public Grantee getAsUser() { + return asUser; + } + + public void setAsUser(Grantee asUser) { + this.asUser = asUser; + } + + /** The MySQL {@code WITH ROLE} selection of the {@code AS user} clause, or null. */ + public RoleSelection getWithRole() { + return withRole; + } + + public void setWithRole(RoleSelection withRole) { + this.withRole = withRole; } public List getPrivilegeItems() { @@ -50,6 +85,7 @@ public void setPrivilegeItems(List privileges) { clause.setPrivileges(privileges); } + /** The target, or null for a role membership. */ public PrivilegeTarget getTarget() { return clause.getTarget(); } @@ -67,14 +103,16 @@ public void setRoles(List roles) { } public String getRole() { - return clause.getRoles().isEmpty() ? null : clause.getRoles().get(0); + return clause.getRoleItems().isEmpty() ? null : clause.getRoles().get(0); } + /** Makes this a membership of the given role, which has neither privileges nor a target. */ public void setRole(String role) { clause.setRoles(new ArrayList<>()); if (role != null) { clause.getRoles().add(role); clause.setPrivileges(null); + clause.setTarget(null); } } @@ -83,32 +121,7 @@ public List getPrivileges() { if (clause.getPrivileges() == null) { return null; } - return new AbstractList() { - @Override - public int size() { - return clause.getPrivileges().size(); - } - - @Override - public String get(int index) { - return clause.getPrivileges().get(index).toString(); - } - - @Override - public String set(int index, String value) { - return clause.getPrivileges().set(index, new Privilege(value)).toString(); - } - - @Override - public void add(int index, String value) { - clause.getPrivileges().add(index, new Privilege(value)); - } - - @Override - public String remove(int index) { - return clause.getPrivileges().remove(index).toString(); - } - }; + return new StringListView<>(() -> clause.getPrivileges(), Privilege::new); } public void setPrivileges(List privileges) { @@ -122,12 +135,22 @@ public void setPrivileges(List privileges) { clause.setPrivileges(items); } + /** + * The first named target as dotted text. MySQL wildcard levels keep their wildcard, for example + * {@code db.*} or {@code *.*}. + */ public String getObjectName() { - if (clause.getTarget().getNames().isEmpty() - || clause.getTarget().getNames().get(0).isEmpty()) { + PrivilegeTarget target = clause.getTarget(); + if (target == null) { return null; } - List parts = clause.getTarget().getNames().get(0); + if (target.getWildcard() != null) { + return target.getTargetText(); + } + if (target.getNames().isEmpty() || target.getNames().get(0).isEmpty()) { + return null; + } + List parts = target.getNames().get(0); return parts.stream().map(part -> part == null ? "" : part) .collect(Collectors.joining(".")); } @@ -136,14 +159,19 @@ public void setObjectName(String objectName) { setObjectName(Collections.singletonList(objectName)); } + /** Names the first target, replacing a MySQL wildcard level. */ public void setObjectName(List objectName) { List parts = new ArrayList<>(objectName); getObjectNameParts().clear(); getObjectNameParts().addAll(parts); + clause.getTarget().setWildcard(null); } /** Mutable parts of the first named target, retained for compatibility. */ public List getObjectNameParts() { + if (clause.getTarget() == null) { + clause.setTarget(new PrivilegeTarget()); + } if (clause.getTarget().getNames().isEmpty()) { clause.getTarget().getNames().add(new ArrayList<>()); } @@ -161,11 +189,18 @@ public void setUsers(List users) { public void appendTo(StringBuilder sql, Consumer visitor) { sql.append("GRANT "); clause.appendTo(sql, false, visitor); - if (option != null) { - sql.append(" WITH ").append(option); + if (!options.isEmpty()) { + sql.append(" WITH ").append( + options.stream().map(Object::toString).collect(Collectors.joining(", "))); + } + if (asUser != null) { + sql.append(" AS ").append(asUser); + if (withRole != null) { + sql.append(" WITH ROLE ").append(withRole); + } } - if (clause.getGrantedBy() != null) { - sql.append(" GRANTED BY ").append(clause.getGrantedBy()); + if (clause.getGrantor() != null) { + sql.append(" GRANTED BY ").append(clause.getGrantor()); } } @@ -222,14 +257,17 @@ public Grant addPrivileges(Collection privileges) { } public Grant addUsers(String... users) { - List collection = Optional.ofNullable(getUsers()).orElseGet(ArrayList::new); - Collections.addAll(collection, users); - return this.withUsers(collection); + return addUsers(Arrays.asList(users)); } public Grant addUsers(Collection users) { - List collection = Optional.ofNullable(getUsers()).orElseGet(ArrayList::new); - collection.addAll(users); - return this.withUsers(collection); + List additions = new ArrayList<>(users); + if (clause.getGranteeItems() == null) { + clause.setGranteeItems(new ArrayList<>()); + } + for (String user : additions) { + clause.getGranteeItems().add(Grantee.valueOf(user)); + } + return this; } } diff --git a/src/main/java/net/sf/jsqlparser/statement/grant/Grantee.java b/src/main/java/net/sf/jsqlparser/statement/grant/Grantee.java new file mode 100644 index 0000000000..a53b020640 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/grant/Grantee.java @@ -0,0 +1,125 @@ +/*- + * #%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.grant; + +import java.io.Serializable; +import java.util.Locale; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.statement.create.user.UserAccount; + +/** + * A role specification in GRANT and REVOKE: a grantee, the PostgreSQL grantor, the MySQL {@code AS} + * user or the MySQL proxied account. + * + *

+ * Named users and roles are held as a {@link UserAccount}: a MySQL account {@code user[@host]}, or + * a PostgreSQL role name without host. + */ +public class Grantee implements Serializable { + + public enum Kind { + /** A named user or role, see {@link Grantee#getAccount()}. */ + ACCOUNT, + /** PostgreSQL {@code GROUP name}, see {@link Grantee#getAccount()}. */ + GROUP, PUBLIC, CURRENT_USER, CURRENT_ROLE, SESSION_USER + } + + private Kind kind; + private UserAccount account; + private boolean currentUserParentheses; + + public Grantee(Kind kind) { + this.kind = kind; + } + + public Grantee(UserAccount account) { + this.kind = Kind.ACCOUNT; + this.account = account; + } + + /** + * Converts the text of the string-based API. Special role names are recognized; any other text + * becomes an account whose user name is kept verbatim. + */ + public static Grantee valueOf(String text) { + String upper = text.trim().toUpperCase(Locale.ROOT); + if (upper.startsWith("GROUP ")) { + return new Grantee(Kind.GROUP) + .withAccount(accountOf(text.trim().substring("GROUP ".length()).trim())); + } + if ("CURRENT_USER()".equals(upper.replace(" ", ""))) { + return new Grantee(Kind.CURRENT_USER).withCurrentUserParentheses(true); + } + for (Kind special : new Kind[] {Kind.PUBLIC, Kind.CURRENT_USER, Kind.CURRENT_ROLE, + Kind.SESSION_USER}) { + if (special.name().equals(upper)) { + return new Grantee(special); + } + } + return new Grantee(accountOf(text)); + } + + /** An account or role name kept as written, without a host. */ + static UserAccount accountOf(String name) { + StringValue user = new StringValue(); + user.setValue(name); + return new UserAccount().withUser(user.setQuoteStr("")); + } + + public Kind getKind() { + return kind; + } + + public void setKind(Kind kind) { + this.kind = kind; + } + + /** The named user or role of an {@link Kind#ACCOUNT} or {@link Kind#GROUP} grantee. */ + public UserAccount getAccount() { + return account; + } + + public void setAccount(UserAccount account) { + this.account = account; + } + + /** Whether MySQL {@code CURRENT_USER()} was written with parentheses. */ + public boolean isCurrentUserParentheses() { + return currentUserParentheses; + } + + public void setCurrentUserParentheses(boolean currentUserParentheses) { + this.currentUserParentheses = currentUserParentheses; + } + + public Grantee withAccount(UserAccount account) { + setAccount(account); + return this; + } + + public Grantee withCurrentUserParentheses(boolean currentUserParentheses) { + setCurrentUserParentheses(currentUserParentheses); + return this; + } + + @Override + public String toString() { + switch (kind) { + case ACCOUNT: + return String.valueOf(account); + case GROUP: + return "GROUP " + account; + case CURRENT_USER: + return currentUserParentheses ? "CURRENT_USER()" : "CURRENT_USER"; + default: + return kind.name(); + } + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/grant/Privilege.java b/src/main/java/net/sf/jsqlparser/statement/grant/Privilege.java index b1f3b461aa..116ae93df9 100644 --- a/src/main/java/net/sf/jsqlparser/statement/grant/Privilege.java +++ b/src/main/java/net/sf/jsqlparser/statement/grant/Privilege.java @@ -15,13 +15,23 @@ import net.sf.jsqlparser.expression.operators.relational.ExpressionList; import net.sf.jsqlparser.schema.Column; +/** + * A privilege of GRANT and REVOKE. Static privileges are {@link Kind} constants, rendered with + * spaces for underscores; MySQL dynamic privileges such as {@code BACKUP_ADMIN} are + * {@link Kind#DYNAMIC} with {@link #getDynamicName()}. + */ public class Privilege implements Serializable { public enum Kind { - ALL, SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER, MAINTAIN, USAGE, CREATE, CONNECT, TEMPORARY, TEMP, EXECUTE, SET, ALTER_SYSTEM, ALTER, DROP + ALL, SELECT, INSERT, UPDATE, DELETE, TRUNCATE, REFERENCES, TRIGGER, MAINTAIN, USAGE, CREATE, CONNECT, TEMPORARY, TEMP, EXECUTE, SET, ALTER_SYSTEM, ALTER, DROP, + // MySQL static privileges + ALTER_ROUTINE, CREATE_ROLE, CREATE_ROUTINE, CREATE_TABLESPACE, CREATE_TEMPORARY_TABLES, CREATE_USER, CREATE_VIEW, DROP_ROLE, EVENT, FILE, GRANT_OPTION, INDEX, LOCK_TABLES, PROCESS, PROXY, RELOAD, REPLICATION_CLIENT, REPLICATION_SLAVE, SHOW_DATABASES, SHOW_VIEW, SHUTDOWN, SUPER, + /** A MySQL dynamic privilege, see {@link Privilege#getDynamicName()}. */ + DYNAMIC } private String legacyName; private Kind kind; + private String dynamicName; private boolean usePrivileges; private ExpressionList columns; @@ -49,21 +59,56 @@ public void setColumns(ExpressionList columns) { this.columns = columns; } + /** The identifier of a {@link Kind#DYNAMIC} privilege, as written. */ + public String getDynamicName() { + return dynamicName; + } + + public void setDynamicName(String dynamicName) { + this.dynamicName = dynamicName; + } + + /** The privilege name as rendered, for example {@code CREATE VIEW} or {@code BACKUP_ADMIN}. */ + public String getName() { + if (kind == null) { + return legacyName; + } + return kind == Kind.DYNAMIC ? dynamicName : kind.name().replace('_', ' '); + } + public Privilege(Kind kind) { this.kind = kind; } - /** Preserves values supplied through the pre-existing string-based Grant API. */ + /** + * Preserves values supplied through the pre-existing string-based Grant API. Names of static + * privileges, such as {@code CREATE VIEW}, become typed; other text is kept as written. + */ public Privilege(String name) { - try { - this.kind = Kind.valueOf(name); - } catch (IllegalArgumentException ex) { + this.kind = staticKind(name); + if (kind == null) { this.legacyName = name; } } + private static Kind staticKind(String name) { + String constant = name.replace(' ', '_'); + for (Kind candidate : Kind.values()) { + if (candidate != Kind.DYNAMIC && candidate.name().equals(constant)) { + return candidate; + } + } + return null; + } + + public static Privilege dynamic(String name) { + Privilege privilege = new Privilege(Kind.DYNAMIC); + privilege.setDynamicName(name); + return privilege; + } + public void appendTo(StringBuilder sql, Consumer visitor) { - sql.append(kind == null ? legacyName : kind.name().replace('_', ' ')); + sql.append(getName()); if (usePrivileges) { sql.append(" PRIVILEGES"); } diff --git a/src/main/java/net/sf/jsqlparser/statement/grant/PrivilegeClause.java b/src/main/java/net/sf/jsqlparser/statement/grant/PrivilegeClause.java index 4f517d1229..49f2ddb77f 100644 --- a/src/main/java/net/sf/jsqlparser/statement/grant/PrivilegeClause.java +++ b/src/main/java/net/sf/jsqlparser/statement/grant/PrivilegeClause.java @@ -11,17 +11,29 @@ import java.io.Serializable; import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.function.Consumer; +import java.util.function.Function; +import java.util.stream.Collectors; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.create.user.UserAccount; -/** Shared, editable GRANT/REVOKE payload. Null privileges denote role membership. */ +/** + * Shared, editable GRANT/REVOKE payload. Null privileges denote role membership; role memberships, + * and MySQL {@code REVOKE ALL PRIVILEGES, GRANT OPTION}, have no target. + * + *

+ * Roles, grantees and the grantor are typed. The string accessors {@link #getRoles()}, + * {@link #getGrantees()} and {@link #getGrantedBy()} are derived from them and convert assigned + * text with {@link Grantee#valueOf(String)}. + */ public class PrivilegeClause implements Serializable { private List privileges; - private List grantees; - private String grantedBy; - private List roles = new ArrayList<>(); + private List granteeItems; + private Grantee grantor; + private List roleItems = new ArrayList<>(); private PrivilegeTarget target = new PrivilegeTarget(); public List getPrivileges() { @@ -32,12 +44,25 @@ public void setPrivileges(List privileges) { this.privileges = privileges; } + /** The granted or revoked roles of a role membership, as a mutable list. */ + public List getRoleItems() { + return roleItems; + } + + public void setRoleItems(Collection roleItems) { + this.roleItems = roleItems == null ? new ArrayList<>() : new ArrayList<>(roleItems); + } + + /** Mutable legacy string view of {@link #getRoleItems()}. */ public List getRoles() { - return roles; + return new StringListView<>(() -> roleItems, Grantee::accountOf); } public void setRoles(List roles) { - this.roles = roles; + roleItems = convert(roles, Grantee::accountOf); + if (roleItems == null) { + roleItems = new ArrayList<>(); + } } public PrivilegeTarget getTarget() { @@ -48,25 +73,56 @@ public void setTarget(PrivilegeTarget target) { this.target = target; } + /** The grantees or revokees as a mutable list, or null when unset. */ + public List getGranteeItems() { + return granteeItems; + } + + public void setGranteeItems(Collection granteeItems) { + this.granteeItems = granteeItems == null ? null : new ArrayList<>(granteeItems); + } + + /** Mutable legacy string view of {@link #getGranteeItems()}, or null when unset. */ public List getGrantees() { - return grantees; + return granteeItems == null ? null + : new StringListView<>(() -> granteeItems, Grantee::valueOf); } public void setGrantees(List grantees) { - this.grantees = grantees; + granteeItems = convert(grantees, Grantee::valueOf); + } + + /** The PostgreSQL {@code GRANTED BY} role, or null when omitted. */ + public Grantee getGrantor() { + return grantor; + } + + public void setGrantor(Grantee grantor) { + this.grantor = grantor; } public String getGrantedBy() { - return grantedBy; + return grantor == null ? null : grantor.toString(); } public void setGrantedBy(String grantedBy) { - this.grantedBy = grantedBy; + grantor = grantedBy == null ? null : Grantee.valueOf(grantedBy); + } + + private static List convert(List values, Function parse) { + if (values == null) { + return null; + } + List items = new ArrayList<>(); + for (String value : new ArrayList<>(values)) { + items.add(parse.apply(value)); + } + return items; } public void appendTo(StringBuilder sql, boolean revoke, Consumer visitor) { if (privileges == null) { - sql.append(String.join(", ", roles)); + sql.append(join(roleItems)); } else { for (int i = 0; i < privileges.size(); i++) { if (i > 0) { @@ -74,14 +130,22 @@ public void appendTo(StringBuilder sql, boolean revoke, Consumer vis } privileges.get(i).appendTo(sql, visitor); } - sql.append(" ON ").append(target); + if (target != null) { + sql.append(" ON ").append(target); + } } - sql.append(revoke ? " FROM " : " TO ").append(String.join(", ", grantees)); + sql.append(revoke ? " FROM " : " TO ").append(join(granteeItems)); + } + + private static String join(List items) { + return items.stream().map(Object::toString).collect(Collectors.joining(", ")); } public void visit(Consumer tables, Consumer expressions) { if (privileges != null) { - target.visitTables(tables); + if (target != null) { + target.visitTables(tables); + } for (Privilege privilege : privileges) { if (privilege.getColumns() != null) { privilege.getColumns().forEach(expressions); diff --git a/src/main/java/net/sf/jsqlparser/statement/grant/PrivilegeTarget.java b/src/main/java/net/sf/jsqlparser/statement/grant/PrivilegeTarget.java index a92ac85391..7b815c072f 100644 --- a/src/main/java/net/sf/jsqlparser/statement/grant/PrivilegeTarget.java +++ b/src/main/java/net/sf/jsqlparser/statement/grant/PrivilegeTarget.java @@ -17,16 +17,40 @@ import net.sf.jsqlparser.schema.Table; import net.sf.jsqlparser.statement.RoutineReference; -/** Targets have distinct kinds; a routine signature is not a function invocation. */ +/** + * Targets have distinct kinds; a routine signature is not a function invocation. MySQL privilege + * levels written with {@code *} are a {@link Wildcard}, and the account of MySQL + * {@code PROXY ON user} is an {@link Kind#ACCOUNT} target. + */ public class PrivilegeTarget implements Serializable { public enum Kind { - TABLE, SEQUENCE, DATABASE, DOMAIN, FOREIGN_DATA_WRAPPER, FOREIGN_SERVER, FUNCTION, PROCEDURE, ROUTINE, LANGUAGE, LARGE_OBJECT, PARAMETER, SCHEMA, TABLESPACE, TYPE + TABLE, SEQUENCE, DATABASE, DOMAIN, FOREIGN_DATA_WRAPPER, FOREIGN_SERVER, FUNCTION, PROCEDURE, ROUTINE, LANGUAGE, LARGE_OBJECT, PARAMETER, SCHEMA, TABLESPACE, TYPE, + /** + * The proxied account of MySQL {@code PROXY ON user}, see + * {@link PrivilegeTarget#getAccount()}. + */ + ACCOUNT + } + + /** MySQL privilege levels written with {@code *}. */ + public enum Wildcard { + /** {@code *}: the default database. */ + DEFAULT_DATABASE, + /** {@code *.*}: all databases. */ + GLOBAL, + /** + * {@code db.*}: the database named by the single entry of + * {@link PrivilegeTarget#getNames()}. + */ + DATABASE } private Kind kind = Kind.TABLE; private boolean explicitKind; private boolean allInSchema; private boolean defaultPrivileges; + private Wildcard wildcard; + private Grantee account; private List> names = new ArrayList<>(); private List routines = new ArrayList<>(); @@ -62,6 +86,24 @@ public void setDefaultPrivileges(boolean defaultPrivileges) { this.defaultPrivileges = defaultPrivileges; } + /** The MySQL wildcard privilege level, or null for named targets. */ + public Wildcard getWildcard() { + return wildcard; + } + + public void setWildcard(Wildcard wildcard) { + this.wildcard = wildcard; + } + + /** The proxied account of an {@link Kind#ACCOUNT} target. */ + public Grantee getAccount() { + return account; + } + + public void setAccount(Grantee account) { + this.account = account; + } + public List> getNames() { return names; } @@ -79,7 +121,7 @@ public void setRoutines(List routines) { } public void visitTables(Consumer
visitor) { - if (kind == Kind.TABLE && !allInSchema && !defaultPrivileges) { + if (kind == Kind.TABLE && wildcard == null && !allInSchema && !defaultPrivileges) { for (List name : names) { if (!name.isEmpty()) { visitor.accept(new Table(name)); @@ -88,19 +130,37 @@ public void visitTables(Consumer
visitor) { } } + /** The target names as written after the optional kind, for example {@code db.*}. */ + String getTargetText() { + if (kind == Kind.ACCOUNT) { + return String.valueOf(account); + } + if (wildcard == Wildcard.DEFAULT_DATABASE) { + return "*"; + } + if (wildcard == Wildcard.GLOBAL) { + return "*.*"; + } + String targets = routines.isEmpty() + ? names.stream().map(PrivilegeTarget::joinName).collect(Collectors.joining(", ")) + : routines.stream().map(Object::toString).collect(Collectors.joining(", ")); + return wildcard == Wildcard.DATABASE ? targets + ".*" : targets; + } + + private static String joinName(List name) { + return name.stream().map(part -> part == null ? "" : part) + .collect(Collectors.joining(".")); + } + @Override public String toString() { String type = kind.name().replace('_', ' '); - String targets = routines.isEmpty() - ? names.stream().map(n -> n.stream().map(part -> part == null ? "" : part) - .collect(Collectors.joining("."))).collect(Collectors.joining(", ")) - : routines.stream().map(Object::toString).collect(Collectors.joining(", ")); if (defaultPrivileges) { return type + "S"; } if (allInSchema) { - return "ALL " + type + "S IN SCHEMA " + targets; + return "ALL " + type + "S IN SCHEMA " + getTargetText(); } - return (explicitKind ? type + " " : "") + targets; + return (explicitKind ? type + " " : "") + getTargetText(); } } diff --git a/src/main/java/net/sf/jsqlparser/statement/grant/Revoke.java b/src/main/java/net/sf/jsqlparser/statement/grant/Revoke.java index f613e2d028..620827f582 100644 --- a/src/main/java/net/sf/jsqlparser/statement/grant/Revoke.java +++ b/src/main/java/net/sf/jsqlparser/statement/grant/Revoke.java @@ -17,8 +17,10 @@ public class Revoke implements Statement { private PrivilegeClause clause = new PrivilegeClause(); + private boolean ifExists; private GrantOption.Kind optionFor; private CascadeBehavior behavior; + private boolean ignoreUnknownUser; public PrivilegeClause getClause() { return clause; @@ -28,6 +30,24 @@ public void setClause(PrivilegeClause clause) { this.clause = clause; } + /** MySQL {@code REVOKE IF EXISTS}. */ + public boolean isIfExists() { + return ifExists; + } + + public void setIfExists(boolean ifExists) { + this.ifExists = ifExists; + } + + /** MySQL {@code IGNORE UNKNOWN USER}. */ + public boolean isIgnoreUnknownUser() { + return ignoreUnknownUser; + } + + public void setIgnoreUnknownUser(boolean ignoreUnknownUser) { + this.ignoreUnknownUser = ignoreUnknownUser; + } + public GrantOption.Kind getOptionFor() { return optionFor; } @@ -46,16 +66,22 @@ public void setBehavior(CascadeBehavior behavior) { public void appendTo(StringBuilder sql, Consumer visitor) { sql.append("REVOKE "); + if (ifExists) { + sql.append("IF EXISTS "); + } if (optionFor != null) { sql.append(optionFor).append(" OPTION FOR "); } clause.appendTo(sql, true, visitor); - if (clause.getGrantedBy() != null) { - sql.append(" GRANTED BY ").append(clause.getGrantedBy()); + if (clause.getGrantor() != null) { + sql.append(" GRANTED BY ").append(clause.getGrantor()); } if (behavior != null) { sql.append(' ').append(behavior); } + if (ignoreUnknownUser) { + sql.append(" IGNORE UNKNOWN USER"); + } } @Override diff --git a/src/main/java/net/sf/jsqlparser/statement/grant/StringListView.java b/src/main/java/net/sf/jsqlparser/statement/grant/StringListView.java new file mode 100644 index 0000000000..9f220aa0eb --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/grant/StringListView.java @@ -0,0 +1,52 @@ +/*- + * #%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.grant; + +import java.util.AbstractList; +import java.util.List; +import java.util.RandomAccess; +import java.util.function.Function; +import java.util.function.Supplier; + +/** Mutable string view over a typed list, retained for the string-based GRANT/REVOKE API. */ +final class StringListView extends AbstractList implements RandomAccess { + private final Supplier> items; + private final Function parse; + + StringListView(Supplier> items, Function parse) { + this.items = items; + this.parse = parse; + } + + @Override + public int size() { + return items.get().size(); + } + + @Override + public String get(int index) { + return String.valueOf(items.get().get(index)); + } + + @Override + public String set(int index, String value) { + return String.valueOf(items.get().set(index, parse.apply(value))); + } + + @Override + public void add(int index, String value) { + items.get().add(index, parse.apply(value)); + } + + @Override + public String remove(int index) { + return String.valueOf(items.get().remove(index)); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/role/CreateRole.java b/src/main/java/net/sf/jsqlparser/statement/role/CreateRole.java index 79cfa668b7..13a557b865 100644 --- a/src/main/java/net/sf/jsqlparser/statement/role/CreateRole.java +++ b/src/main/java/net/sf/jsqlparser/statement/role/CreateRole.java @@ -10,19 +10,30 @@ package net.sf.jsqlparser.statement.role; import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.function.Consumer; +import java.util.stream.Collectors; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.StatementVisitor; +import net.sf.jsqlparser.statement.create.user.UserAccount; +/** + * {@code CREATE ROLE}, {@code CREATE USER} or {@code CREATE GROUP} with PostgreSQL role attributes. + * MySQL's {@code CREATE ROLE [IF NOT EXISTS] role [, role] ...} keeps its account-style role names + * in {@link #getRoles()} instead of {@link #getName()}. + */ public class CreateRole implements Statement { public enum Command { ROLE, USER, GROUP } private Command command = Command.ROLE; + private boolean ifNotExists; private String name; + private final List roles = new ArrayList<>(); private boolean useWith; private List options = new ArrayList<>(); @@ -34,6 +45,16 @@ public void setCommand(Command command) { this.command = command; } + /** Whether MySQL's {@code IF NOT EXISTS} is present. */ + public boolean isIfNotExists() { + return ifNotExists; + } + + public void setIfNotExists(boolean ifNotExists) { + this.ifNotExists = ifNotExists; + } + + /** The PostgreSQL role name; null when MySQL role names are given in {@link #getRoles()}. */ public String getName() { return name; } @@ -42,6 +63,21 @@ public void setName(String name) { this.name = name; } + /** + * MySQL role names such as {@code 'reader'} or {@code 'reader'@'localhost'}, which replace + * {@link #getName()} when present. + */ + public List getRoles() { + return Collections.unmodifiableList(roles); + } + + public void setRoles(Collection roles) { + this.roles.clear(); + if (roles != null) { + this.roles.addAll(roles); + } + } + public boolean isUseWith() { return useWith; } @@ -59,7 +95,15 @@ public void setOptions(List options) { } public void appendTo(StringBuilder sql, Consumer visitor) { - sql.append("CREATE ").append(command).append(' ').append(name); + sql.append("CREATE ").append(command).append(' '); + if (ifNotExists) { + sql.append("IF NOT EXISTS "); + } + if (roles.isEmpty()) { + sql.append(name); + } else { + sql.append(roles.stream().map(Object::toString).collect(Collectors.joining(", "))); + } if (useWith) { sql.append(" WITH"); } diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index aeecdb89d8..640bbb5a10 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -101,6 +101,9 @@ import net.sf.jsqlparser.statement.SessionStatement; import net.sf.jsqlparser.statement.SetIdentityInsertStatement; import net.sf.jsqlparser.statement.SetStatement; +import net.sf.jsqlparser.statement.SetDefaultRoleStatement; +import net.sf.jsqlparser.statement.SetPasswordStatement; +import net.sf.jsqlparser.statement.SetRoleStatement; import net.sf.jsqlparser.statement.ShowColumnsStatement; import net.sf.jsqlparser.statement.ShowStatement; import net.sf.jsqlparser.statement.StartTransaction; @@ -128,6 +131,8 @@ import net.sf.jsqlparser.statement.alter.AlterType; import net.sf.jsqlparser.statement.alter.AlterUserMapping; import net.sf.jsqlparser.statement.alter.RenameTableStatement; +import net.sf.jsqlparser.statement.alter.AlterUser; +import net.sf.jsqlparser.statement.alter.RenameUser; import net.sf.jsqlparser.statement.alter.database.AlterDatabase; import net.sf.jsqlparser.statement.alter.schema.AlterSchema; import net.sf.jsqlparser.statement.alter.sequence.AlterSequence; @@ -1738,6 +1743,33 @@ public void visit(CreateUser createUser) { StatementVisitor.super.visit(createUser); } + // Account and role names are not tables. + + @Override + public Void visit(AlterUser alterUser, S context) { + return null; + } + + @Override + public Void visit(RenameUser renameUser, S context) { + return null; + } + + @Override + public Void visit(SetRoleStatement setRole, S context) { + return null; + } + + @Override + public Void visit(SetDefaultRoleStatement setDefaultRole, S context) { + return null; + } + + @Override + public Void visit(SetPasswordStatement setPassword, S context) { + return null; + } + @Override public Void visit(CreateEvent createEvent, S context) { if (createEvent.getBody() != 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 2490baa3ec..56de995305 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -69,6 +69,9 @@ import net.sf.jsqlparser.statement.SavepointStatement; import net.sf.jsqlparser.statement.SessionStatement; import net.sf.jsqlparser.statement.SetStatement; +import net.sf.jsqlparser.statement.SetDefaultRoleStatement; +import net.sf.jsqlparser.statement.SetPasswordStatement; +import net.sf.jsqlparser.statement.SetRoleStatement; import net.sf.jsqlparser.statement.ShowColumnsStatement; import net.sf.jsqlparser.statement.ShowStatement; import net.sf.jsqlparser.statement.Statement; @@ -82,6 +85,8 @@ import net.sf.jsqlparser.statement.alter.AlterSession; import net.sf.jsqlparser.statement.alter.AlterSystemStatement; import net.sf.jsqlparser.statement.alter.RenameTableStatement; +import net.sf.jsqlparser.statement.alter.AlterUser; +import net.sf.jsqlparser.statement.alter.RenameUser; import net.sf.jsqlparser.statement.alter.sequence.AlterSequence; import net.sf.jsqlparser.statement.analyze.Analyze; import net.sf.jsqlparser.statement.comment.Comment; @@ -529,6 +534,35 @@ public StringBuilder visit(CreateUser createUser, S context) { return builder; } + @Override + public StringBuilder visit(AlterUser alterUser, S context) { + builder.append(alterUser); + return builder; + } + + @Override + public StringBuilder visit(RenameUser renameUser, S context) { + builder.append(renameUser); + return builder; + } + + @Override + public StringBuilder visit(SetRoleStatement setRole, S context) { + builder.append(setRole); + return builder; + } + + @Override + public StringBuilder visit(SetDefaultRoleStatement setDefaultRole, S context) { + builder.append(setDefaultRole); + return builder; + } + + @Override + public StringBuilder visit(SetPasswordStatement setPassword, S context) { + return setPassword.appendTo(builder, e -> e.accept(expressionDeParser, context)); + } + @Override public StringBuilder visit(CreateEvent createEvent, S context) { builder.append(createEvent); 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 de84d3f823..b19d040f94 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 @@ -82,6 +82,10 @@ public enum MariaDbVersion implements Version { // https://mariadb.com/kb/en/table-statements/ Feature.tableMaintenance, + // https://mariadb.com/kb/en/account-management-sql-statements/ + Feature.createRole, Feature.alterUser, Feature.renameUser, Feature.setRole, + Feature.setDefaultRole, Feature.setPassword, + // https://mariadb.com/kb/en/call/ Feature.execute, Feature.executeCall, @@ -124,7 +128,8 @@ public enum MariaDbVersion implements Version { Feature.showColumns, // https://mariadb.com/kb/en/show-index/ Feature.showIndex, // https://mariadb.com/kb/en/use/ Feature.use, // https://mariadb.com/kb/en/grant/ - Feature.grant, // https://mariadb.com/kb/en/commit/ + Feature.grant, // https://mariadb.com/kb/en/revoke/ + Feature.revoke, // https://mariadb.com/kb/en/commit/ Feature.commit, // https://mariadb.com/kb/en/optimizer-hints/ Feature.mySqlHintStraightJoin, Feature.mysqlSelectModifiers, Feature.mysqlCalcFoundRows, 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 6aabf1fe0d..7c048893d2 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 @@ -78,6 +78,10 @@ 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/account-management-statements.html + Feature.createRole, Feature.alterUser, Feature.renameUser, Feature.setRole, + Feature.setDefaultRole, Feature.setPassword, + // https://dev.mysql.com/doc/refman/8.0/en/call.html Feature.execute, Feature.executeCall, @@ -112,7 +116,8 @@ public enum MySqlVersion implements Version { Feature.showTables, // https://dev.mysql.com/doc/refman/8.0/en/show-columns.html Feature.showColumns, // https://dev.mysql.com/doc/refman/8.0/en/show-index.html Feature.showIndex, // https://dev.mysql.com/doc/refman/8.0/en/grant.html - Feature.grant, // https://dev.mysql.com/doc/refman/8.0/en/use.html + Feature.grant, // https://dev.mysql.com/doc/refman/8.0/en/revoke.html + Feature.revoke, // https://dev.mysql.com/doc/refman/8.0/en/use.html Feature.use, // https://dev.mysql.com/doc/refman/8.0/en/commit.html Feature.commit, Feature.startTransaction, Feature.releaseSavepoint, Feature.transactionChain, Feature.transactionConsistentSnapshot, // diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/GrantValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/GrantValidator.java index c37f214ad1..952163a67a 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/GrantValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/GrantValidator.java @@ -23,10 +23,15 @@ public class GrantValidator extends AbstractValidator { public void validate(Grant grant) { for (ValidationCapability c : getCapabilities()) { validateFeature(c, Feature.grant); - if (isNotEmpty(grant.getUsers())) { - grant.getUsers().forEach(u -> validateName(NamedObject.user, u)); + // PUBLIC and CURRENT_USER are not names + if (isNotEmpty(grant.getClause().getGranteeItems())) { + grant.getClause().getGranteeItems().stream() + .filter(grantee -> grantee.getAccount() != null) + .forEach(grantee -> validateName(NamedObject.user, + grantee.getAccount().toString())); } - grant.getRoles().forEach(role -> validateName(NamedObject.role, role)); + grant.getClause().getRoleItems() + .forEach(role -> validateName(NamedObject.role, role.toString())); grant.getClause().visit(this::validateOptionalFromItem, this::validateOptionalExpression); 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 1de8380a75..1801600ffb 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 @@ -68,6 +68,9 @@ import net.sf.jsqlparser.statement.SavepointStatement; import net.sf.jsqlparser.statement.SessionStatement; import net.sf.jsqlparser.statement.SetStatement; +import net.sf.jsqlparser.statement.SetDefaultRoleStatement; +import net.sf.jsqlparser.statement.SetPasswordStatement; +import net.sf.jsqlparser.statement.SetRoleStatement; import net.sf.jsqlparser.statement.ShowColumnsStatement; import net.sf.jsqlparser.statement.ShowStatement; import net.sf.jsqlparser.statement.Statement; @@ -81,6 +84,8 @@ import net.sf.jsqlparser.statement.alter.AlterSession; import net.sf.jsqlparser.statement.alter.AlterSystemStatement; import net.sf.jsqlparser.statement.alter.RenameTableStatement; +import net.sf.jsqlparser.statement.alter.AlterUser; +import net.sf.jsqlparser.statement.alter.RenameUser; import net.sf.jsqlparser.statement.alter.sequence.AlterSequence; import net.sf.jsqlparser.statement.analyze.Analyze; import net.sf.jsqlparser.statement.comment.Comment; @@ -445,6 +450,37 @@ public Void visit(CreateUser createUser, S context) { return null; } + @Override + public Void visit(AlterUser alterUser, S context) { + validateFeature(Feature.alterUser); + return null; + } + + @Override + public Void visit(RenameUser renameUser, S context) { + validateFeature(Feature.renameUser); + return null; + } + + @Override + public Void visit(SetRoleStatement setRole, S context) { + validateFeature(Feature.setRole); + return null; + } + + @Override + public Void visit(SetDefaultRoleStatement setDefaultRole, S context) { + validateFeature(Feature.setDefaultRole); + return null; + } + + @Override + public Void visit(SetPasswordStatement setPassword, S context) { + validateFeature(Feature.setPassword); + validateOptionalExpression(setPassword.getPassword()); + return null; + } + @Override public Void visit(CreateEvent createEvent, S context) { if (createEvent.getBody() != null) { @@ -806,6 +842,26 @@ public void visit(CreateUser createUser) { visit(createUser, null); } + public void visit(AlterUser alterUser) { + visit(alterUser, null); + } + + public void visit(RenameUser renameUser) { + visit(renameUser, null); + } + + public void visit(SetRoleStatement setRole) { + visit(setRole, null); + } + + public void visit(SetDefaultRoleStatement setDefaultRole) { + visit(setDefaultRole, null); + } + + public void visit(SetPasswordStatement setPassword) { + visit(setPassword, null); + } + public void visit(CreateEvent createEvent) { visit(createEvent, null); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 2210dbe9d8..fef797f2b0 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -2196,7 +2196,144 @@ public class CCJSqlParser extends AbstractJSqlParser { if (isAccessKeywordAhead("ROLE") || isAccessKeywordAhead("GROUP")) { return true; } return isAccessKeywordAhead("USER") && !"MAPPING".equalsIgnoreCase(getToken(2).image) && (Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)) - || "WITH".equalsIgnoreCase(getToken(3).image) || isRoleOptionAhead(3)); + || !isMySqlUserOptionAhead(3) + && ("WITH".equalsIgnoreCase(getToken(3).image) || isRoleOptionAhead(3))); + } + + /** + * Whether a MySQL CREATE/ALTER USER clause starts at the offset: authentication, DEFAULT ROLE, + * REQUIRE, WITH resource limits, password and locking options, COMMENT or ATTRIBUTE. PostgreSQL + * role attributes such as WITH LOGIN or PASSWORD 'x' do not match. + */ + private boolean isMySqlUserOptionAhead(int offset) { + Token token = getToken(offset); + switch (token.kind) { + case K_IDENTIFIED: + case K_DISCARD: + return true; + case K_COMMENT: + return getToken(offset + 1).kind == S_CHAR_LITERAL; + case K_DEFAULT: + return "ROLE".equalsIgnoreCase(getToken(offset + 1).image); + case K_WITH: + return isMySqlResourceLimitAhead(offset + 1); + default: + return isMySqlPasswordLockOptionAhead(offset) || token.kind == S_IDENTIFIER + && ("REQUIRE".equalsIgnoreCase(token.image) + || "ATTRIBUTE".equalsIgnoreCase(token.image)); + } + } + + private boolean isMySqlResourceLimitAhead(int offset) { + Token token = getToken(offset); + return token.kind == S_IDENTIFIER && getToken(offset + 1).kind == S_LONG + && Arrays.asList("MAX_QUERIES_PER_HOUR", "MAX_UPDATES_PER_HOUR", + "MAX_CONNECTIONS_PER_HOUR", "MAX_USER_CONNECTIONS") + .contains(token.image.toUpperCase(Locale.ROOT)); + } + + private boolean isMySqlPasswordLockOptionAhead(int offset) { + Token token = getToken(offset); + Token next = getToken(offset + 1); + if (token.kind == K_PASSWORD) { + return next.kind == K_HISTORY || next.kind == S_IDENTIFIER + && Arrays.asList("EXPIRE", "REUSE", "REQUIRE") + .contains(next.image.toUpperCase(Locale.ROOT)); + } + if (token.kind != S_IDENTIFIER) { + return false; + } + String word = token.image.toUpperCase(Locale.ROOT); + return "ACCOUNT".equals(word) + && (next.kind == K_LOCK || "UNLOCK".equalsIgnoreCase(next.image)) + || "FAILED_LOGIN_ATTEMPTS".equals(word) || "PASSWORD_LOCK_TIME".equals(word); + } + + private boolean isMySqlTlsOptionAhead() { + int offset = getToken(1).kind == K_AND ? 2 : 1; + Token token = getToken(offset); + return token.kind == S_IDENTIFIER && Arrays.asList("SSL", "X509", "CIPHER", "ISSUER", + "SUBJECT").contains(token.image.toUpperCase(Locale.ROOT)); + } + + /** + * Whether ALTER USER uses MySQL account syntax rather than the PostgreSQL alias of ALTER ROLE, + * which names one unquoted role followed by an action: IF EXISTS, USER(), a quoted name, a + * host, an account list, no action at all, or a MySQL account clause. + */ + private boolean isMySqlAlterUserAhead() { + if (getToken(1).kind != K_USER) { return false; } + Token name = getToken(2); + if (name.kind == K_IF || name.kind == S_CHAR_LITERAL + || name.kind == K_USER && "(".equals(getToken(3).image) + || isMySqlAccountHostAhead(2, false)) { + return true; + } + Token next = getToken(3); + return ",".equals(next.image) || next.kind == EOF || next.kind == ST_SEMICOLON + || isMySqlUserOptionAhead(3); + } + + /** + * Whether CREATE ROLE uses MySQL syntax: IF NOT EXISTS, a quoted name, a host or a list. An + * unquoted name@host, which PostgreSQL reads as one role name, counts only in the MySQL and + * MariaDB dialects. + */ + private boolean isMySqlCreateRoleAhead() { + if (!isAccessKeywordAhead("ROLE")) { return false; } + Token name = getToken(2); + return name.kind == K_IF || name.kind == S_CHAR_LITERAL + || isMySqlAccountHostAhead(2, isMySqlDialect()) || ",".equals(getToken(3).image); + } + + /** + * Whether the account name at the offset has a host that only MySQL accepts: @ before the host, + * a user@ identifier before a quoted host or an @host identifier after a quoted user. An unquoted + * user@host, which the lexer reads as one identifier, counts only when merged is set. + */ + private boolean isMySqlAccountHostAhead(int offset, boolean merged) { + Token name = getToken(offset); + if (name.kind == S_IDENTIFIER && name.image.indexOf('@') > 0) { + return merged || name.image.endsWith("@"); + } + Token next = getToken(offset + 1); + return next.kind == K_AT_SIGN + || next.kind == S_AT_IDENTIFIER && !next.image.startsWith("@@"); + } + + /** + * Whether a MySQL SET ROLE statement starts here. The MySQL and MariaDB dialects type every + * form; elsewhere only ALL EXCEPT and role names with a quoted user or host do, so that + * SET ROLE name and SET ROLE NONE remain the SetStatement that PostgreSQL uses. + */ + private boolean isMySqlSetRoleAhead() { + if (getToken(1).kind != K_SET || getToken(2).kind != S_IDENTIFIER + || !"ROLE".equalsIgnoreCase(getToken(2).image)) { + return false; + } + Token next = getToken(3); + if (next.kind == EOF || next.kind == ST_SEMICOLON || next.kind == K_TO + || "=".equals(next.image) || ":=".equals(next.image) || ".".equals(next.image)) { + return false; + } + if (isMySqlDialect()) { return true; } + if (next.kind == K_ALL) { return getToken(4).kind == K_EXCEPT; } + // role [@ host] [, role [@ host]] ... + for (int i = 3; ; i += 2) { + if (isMySqlAccountHostAhead(i, false)) { return true; } + if (!",".equals(getToken(i + 1).image)) { return false; } + } + } + + /** + * Whether a MySQL SET PASSWORD statement starts here: always with FOR, otherwise only in the + * MySQL and MariaDB dialects, where SET PASSWORD = 'x' is not a variable assignment. + */ + private boolean isMySqlSetPasswordAhead() { + if (getToken(1).kind != K_SET || getToken(2).kind != K_PASSWORD) { return false; } + Token next = getToken(3); + return next.kind == K_FOR + || isMySqlDialect() && ("=".equals(next.image) || next.kind == K_TO); } private boolean isAccessKeywordAhead(String word) { @@ -2230,6 +2367,37 @@ public class CCJSqlParser extends AbstractJSqlParser { } } + /** MySQL {@code REVOKE ALL [PRIVILEGES], GRANT OPTION FROM ...}, which has no ON clause. */ + private boolean isAccessAllGrantOptionAhead() { + if (getToken(1).kind != K_ALL) { return false; } + int i = "PRIVILEGES".equalsIgnoreCase(getToken(2).image) ? 3 : 2; + return ",".equals(getToken(i).image) && getToken(i + 1).kind == K_GRANT + && getToken(i + 2).kind == K_OPTION && getToken(i + 3).kind == K_FROM; + } + + /** PUBLIC, CURRENT_USER, CURRENT_ROLE or SESSION_USER, unless used as the user of user@host. */ + private boolean isAccessSpecialGranteeAhead() { + Token token = getToken(1); + int next = getToken(2).kind; + return (token.kind == K_PUBLIC || token.kind == S_IDENTIFIER) + && next != K_AT_SIGN && next != S_AT_IDENTIFIER + && Arrays.asList("PUBLIC", "CURRENT_USER", "CURRENT_ROLE", "SESSION_USER") + .contains(token.image.toUpperCase(Locale.ROOT)); + } + + /** Privileges take only GRANT OPTION; role memberships take distinct ADMIN, INHERIT and SET options. */ + private static boolean isValidAccessGrantOptions(PrivilegeClause clause, List options) { + if (clause.getPrivileges() != null) { + return options.size() == 1 && options.get(0).getKind() == GrantOption.Kind.GRANT + && options.get(0).getValue() == GrantOption.Value.OPTION; + } + EnumSet kinds = EnumSet.noneOf(GrantOption.Kind.class); + for (GrantOption option : options) { + if (option.getKind() == GrantOption.Kind.GRANT || !kinds.add(option.getKind())) { return false; } + } + return true; + } + private boolean isPostgreSqlTriggerAhead() { if (getToken(1).kind == K_CONSTRAINT) { return true; } if (getToken(1).kind != K_TRIGGER) { return false; } @@ -3903,6 +4071,10 @@ Statement SingleStatement() : | LOOKAHEAD(2) stm = Alter() | + LOOKAHEAD({ getToken(1).kind == K_RENAME && getToken(2).kind == K_USER + && getToken(3).kind != K_TO && !".".equals(getToken(3).image) }) + stm = RenameUser() + | // @todo: merge this into the ALTER TABLE statement stm = RenameTableStatement() | @@ -3939,6 +4111,14 @@ Statement SingleStatement() : || getToken(3).kind == K_TO && isInformixIsolationLevel(getToken(4)))) }) stm = OpaqueSetStatement() | + LOOKAHEAD({ isMySqlSetRoleAhead() }) stm = SetRoleStatement() + | + LOOKAHEAD({ getToken(1).kind == K_SET && getToken(2).kind == K_DEFAULT + && "ROLE".equalsIgnoreCase(getToken(3).image) }) + stm = SetDefaultRoleStatement() + | + LOOKAHEAD({ isMySqlSetPasswordAhead() }) stm = SetPasswordStatement() + | stm = Set() | stm = Reset() @@ -15502,11 +15682,112 @@ StringValue MySqlAccountNamePart(): ( token= { value = stringValue(token); } | - name=RelObjectName() { value = new StringValue(name).setQuoteStr(""); } + name=RelObjectName() + { + // a double-quoted or backquoted part keeps its quote character separately from its + // value, like a string literal; new StringValue(name) would drop the double quotes + value = name.length() > 1 && (name.charAt(0) == '"' || name.charAt(0) == '`') + && name.charAt(name.length() - 1) == name.charAt(0) + ? new StringValue().withValue(name.substring(1, name.length() - 1)) + .setQuoteStr(name.substring(0, 1)) + : new StringValue().withValue(name).setQuoteStr(""); + } ) { return value; } } +/** + * A MySQL account name, user[@host], shared by CREATE USER, DEFINER and the account statements. + * Each part is unquoted or quoted with ', " or `. The lexer reads an unquoted user@host, and the + * user@ in front of a quoted host, as one identifier, and @host after a quoted user as one + * at-identifier, so those spellings are split here. + */ +UserAccount MySqlAccountName(): +{ + UserAccount account = new UserAccount(); + StringValue part; + Token token; + int at; +} +{ + ( + // user@ followed by a quoted host + LOOKAHEAD({ getToken(1).kind == S_IDENTIFIER && getToken(1).image.indexOf('@') > 0 + && getToken(1).image.indexOf('@') == getToken(1).image.length() - 1 }) + token= + { account.setUser(new StringValue().withValue(token.image.substring(0, token.image.length() - 1)) + .setQuoteStr("")); } + part=MySqlAccountNamePart() { account.setHost(part); } + | + // unquoted user@host + LOOKAHEAD({ getToken(1).kind == S_IDENTIFIER && getToken(1).image.indexOf('@') > 0 + && getToken(1).image.indexOf('@') == getToken(1).image.lastIndexOf('@') }) + token= + { + at = token.image.indexOf('@'); + account.setUser(new StringValue().withValue(token.image.substring(0, at)).setQuoteStr("")); + account.setHost(new StringValue().withValue(token.image.substring(at + 1)).setQuoteStr("")); + } + | + part=MySqlAccountNamePart() { account.setUser(part); } + [ + part=MySqlAccountNamePart() { account.setHost(part); } + | + // a quoted user followed by an unquoted @host + LOOKAHEAD({ getToken(1).kind == S_AT_IDENTIFIER && !getToken(1).image.startsWith("@@") }) + token= + { account.setHost(new StringValue().withValue(token.image.substring(1)).setQuoteStr("")); } + ] + ) + { return account; } +} + +List MySqlAccountNameList(): +{ + List accounts = new ArrayList(); + UserAccount account; +} +{ + account=MySqlAccountName() { accounts.add(account); } + ( "," account=MySqlAccountName() { accounts.add(account); } )* + { return accounts; } +} + +/** + * Roles of SET ROLE, SET DEFAULT ROLE, ALTER USER ... DEFAULT ROLE and GRANT ... AS user WITH ROLE. + * Only SET ROLE and WITH ROLE (setRole) accept DEFAULT and ALL EXCEPT. + */ +RoleSelection MySqlRoleSelection(boolean setRole): +{ + RoleSelection selection; + List roles; +} +{ + ( + LOOKAHEAD({ getToken(1).kind == K_DEFAULT }) + { selection = new RoleSelection(RoleSelection.Kind.DEFAULT); } + | + LOOKAHEAD({ getToken(1).kind == K_NONE }) + { selection = new RoleSelection(RoleSelection.Kind.NONE); } + | + LOOKAHEAD({ getToken(1).kind == K_ALL }) + { selection = new RoleSelection(RoleSelection.Kind.ALL); } + [ + LOOKAHEAD({ getToken(1).kind == K_EXCEPT }) roles=MySqlAccountNameList() + { selection.setKind(RoleSelection.Kind.ALL_EXCEPT); selection.setRoles(roles); } + ] + | + roles=MySqlAccountNameList() + { selection = new RoleSelection(RoleSelection.Kind.ROLES).withRoles(roles); } + ) + { + requireAccessSyntax(setRole || selection.getKind() != RoleSelection.Kind.DEFAULT + && selection.getKind() != RoleSelection.Kind.ALL_EXCEPT, + "DEFAULT and ALL EXCEPT require SET ROLE"); + return selection; + } +} + UserAuthentication MySqlUserAuthentication(): { UserAuthentication authentication = new UserAuthentication(); @@ -15557,14 +15838,11 @@ UserAuthentication MySqlUserAuthentication(): UserAccount MySqlUserAccount(): { - UserAccount account = new UserAccount(); - StringValue user = null; - StringValue host = null; + UserAccount account; UserAuthentication authentication = null; } { - user=MySqlAccountNamePart() { account.setUser(user); } - [ host=MySqlAccountNamePart() { account.setHost(host); } ] + account=MySqlAccountName() [ authentication=MySqlUserAuthentication() { account.setAuthentication(authentication); } ] { return account; } } @@ -15579,9 +15857,357 @@ CreateUser CreateUser(): [ LOOKAHEAD(3) { createUser.setIfNotExists(true); } ] account=MySqlUserAccount() { createUser.addAccounts(account); } ( "," account=MySqlUserAccount() { createUser.addAccounts(account); } )* + MySqlUserOptions(createUser.getOptions(), true) { return createUser; } } +/** + * MySQL account options shared by CREATE USER and ALTER USER, in the fixed clause order. Only + * CREATE USER lists DEFAULT ROLE here; ALTER USER uses its own single-account form. + */ +void MySqlUserOptions(UserOptions options, boolean create): +{ + RoleSelection roles; + TlsOption tls; + ResourceLimit limit; + PasswordLockOption option; + Token value; +} +{ + [ + LOOKAHEAD({ getToken(1).kind == K_DEFAULT && "ROLE".equalsIgnoreCase(getToken(2).image) }) + ContextualKeyword("ROLE") roles=MySqlRoleSelection(false) + { + requireAccessSyntax(create, + "ALTER USER accepts DEFAULT ROLE only for a single account"); + requireAccessSyntax(roles.getKind() == RoleSelection.Kind.ROLES, + "CREATE USER DEFAULT ROLE requires role names"); + options.setDefaultRoles(roles); + } + ] + [ + LOOKAHEAD({ isKeywordAhead("REQUIRE") }) ContextualKeyword("REQUIRE") + ( + LOOKAHEAD({ getToken(1).kind == K_NONE }) + { options.addTlsOptions(new TlsOption(TlsOption.Kind.NONE)); } + | + tls=MySqlTlsOption() + { + requireAccessSyntax(!tls.isAndKeyword(), "REQUIRE cannot start with AND"); + options.addTlsOptions(tls); + } + ( + LOOKAHEAD({ isMySqlTlsOptionAhead() }) + tls=MySqlTlsOption() { options.addTlsOptions(tls); } + )* + ) + ] + [ + LOOKAHEAD({ getToken(1).kind == K_WITH && isMySqlResourceLimitAhead(2) }) + ( + LOOKAHEAD({ isMySqlResourceLimitAhead(1) }) + limit=MySqlResourceLimit() { options.addResourceLimits(limit); } + )+ + ] + ( + LOOKAHEAD({ isMySqlPasswordLockOptionAhead(1) }) + option=MySqlPasswordLockOption() { options.addPasswordLockOptions(option); } + )* + [ + LOOKAHEAD({ getToken(1).kind == K_COMMENT && getToken(2).kind == S_CHAR_LITERAL }) + value= { options.setComment(stringValue(value)); } + | + LOOKAHEAD({ isKeywordAhead("ATTRIBUTE") && getToken(2).kind == S_CHAR_LITERAL }) + ContextualKeyword("ATTRIBUTE") value= + { options.setAttribute(stringValue(value)); } + ] +} + +/** One REQUIRE option, preceded by the optional AND that separates the options. */ +TlsOption MySqlTlsOption(): +{ + TlsOption option = new TlsOption(); + Token word; + Token value; +} +{ + [ { option.setAndKeyword(true); } ] + word= { option.setKind(accessEnum(TlsOption.Kind.class, word.image)); } + [ + LOOKAHEAD({ getToken(1).kind == S_CHAR_LITERAL }) + value= { option.setValue(stringValue(value)); } + ] + { + requireAccessSyntax(option.getKind().hasValue() == (option.getValue() != null), + "Invalid REQUIRE option " + word.image); + return option; + } +} + +ResourceLimit MySqlResourceLimit(): +{ + Token word; + Token value; +} +{ + word= value= + { + return new ResourceLimit(accessEnum(ResourceLimit.Kind.class, word.image), + Long.parseLong(value.image)); + } +} + +PasswordLockOption MySqlPasswordLockOption(): +{ + PasswordLockOption option = null; + Token value; +} +{ + ( + LOOKAHEAD({ isKeywordAhead("ACCOUNT") }) ContextualKeyword("ACCOUNT") + ( + { option = new PasswordLockOption(PasswordLockOption.Kind.ACCOUNT_LOCK); } + | + ContextualKeyword("UNLOCK") + { option = new PasswordLockOption(PasswordLockOption.Kind.ACCOUNT_UNLOCK); } + ) + | + LOOKAHEAD({ isKeywordAhead("FAILED_LOGIN_ATTEMPTS") }) + ContextualKeyword("FAILED_LOGIN_ATTEMPTS") value= + { + option = new PasswordLockOption(PasswordLockOption.Kind.FAILED_LOGIN_ATTEMPTS) + .withValue(Long.valueOf(value.image)); + } + | + LOOKAHEAD({ isKeywordAhead("PASSWORD_LOCK_TIME") }) ContextualKeyword("PASSWORD_LOCK_TIME") + { option = new PasswordLockOption(PasswordLockOption.Kind.PASSWORD_LOCK_TIME); } + ( + value= { option.setValue(Long.valueOf(value.image)); } + | + { option.setSetting(PasswordLockOption.Setting.UNBOUNDED); } + ) + | + + ( + LOOKAHEAD({ isKeywordAhead("EXPIRE") }) ContextualKeyword("EXPIRE") + { option = new PasswordLockOption(PasswordLockOption.Kind.PASSWORD_EXPIRE); } + [ + { option.setSetting(PasswordLockOption.Setting.DEFAULT); } + | + { option.setSetting(PasswordLockOption.Setting.NEVER); } + | + value= MySqlDayUnit() + { option.setValue(Long.valueOf(value.image)); } + ] + | + + { option = new PasswordLockOption(PasswordLockOption.Kind.PASSWORD_HISTORY); } + ( + { option.setSetting(PasswordLockOption.Setting.DEFAULT); } + | + value= { option.setValue(Long.valueOf(value.image)); } + ) + | + LOOKAHEAD({ isKeywordAhead("REUSE") }) ContextualKeyword("REUSE") + { option = new PasswordLockOption(PasswordLockOption.Kind.PASSWORD_REUSE_INTERVAL); } + ( + { option.setSetting(PasswordLockOption.Setting.DEFAULT); } + | + value= MySqlDayUnit() { option.setValue(Long.valueOf(value.image)); } + ) + | + ContextualKeyword("REQUIRE") + { option = new PasswordLockOption(PasswordLockOption.Kind.PASSWORD_REQUIRE_CURRENT); } + [ + { option.setSetting(PasswordLockOption.Setting.DEFAULT); } + | + LOOKAHEAD({ isKeywordAhead("OPTIONAL") }) ContextualKeyword("OPTIONAL") + { option.setSetting(PasswordLockOption.Setting.OPTIONAL); } + ] + ) + ) + { return option; } +} + +void MySqlDayUnit(): +{ Token unit; } +{ + unit= + { requireAccessSyntax("DAY".equalsIgnoreCase(unit.image), "Expected DAY"); } +} + +/** + * ALTER USER authentication: an IDENTIFIED clause, whose password assignment may add REPLACE and + * RETAIN CURRENT PASSWORD, or DISCARD OLD PASSWORD. + */ +UserAuthentication MySqlAlterUserAuthentication(): +{ + UserAuthentication authentication; + Token value; +} +{ + ( + authentication=MySqlUserAuthentication() + [ + LOOKAHEAD({ getToken(1).kind == K_REPLACE && getToken(2).kind == S_CHAR_LITERAL }) + value= + { authentication.setCurrentCredential(stringValue(value)); } + ] + [ + LOOKAHEAD({ isKeywordAhead("RETAIN") }) ContextualKeyword("RETAIN") + { authentication.setRetainCurrentPassword(true); } + ] + { + requireAccessSyntax(authentication.getMode().isPasswordAssignment() + || authentication.getCurrentCredential() == null + && !authentication.isRetainCurrentPassword(), + "REPLACE and RETAIN CURRENT PASSWORD require a password assignment"); + } + | + ContextualKeyword("OLD") + { + authentication = new UserAuthentication() + .withMode(UserAuthentication.Mode.DISCARD_OLD_PASSWORD); + } + ) + { return authentication; } +} + +UserAccount MySqlAlterUserAccount(): +{ + UserAccount account; + UserAuthentication authentication; +} +{ + account=MySqlAccountName() + [ + LOOKAHEAD({ getToken(1).kind == K_IDENTIFIED || getToken(1).kind == K_DISCARD }) + authentication=MySqlAlterUserAuthentication() { account.setAuthentication(authentication); } + ] + { return account; } +} + +/** + * MySQL ALTER USER: accounts with authentication changes and the options shared with CREATE USER, + * USER() with a password change, or one account with DEFAULT ROLE. + */ +AlterUser AlterUser(): +{ + AlterUser alterUser = new AlterUser(); + UserAccount account; + UserAuthentication authentication; + RoleSelection roles; +} +{ + + [ LOOKAHEAD(2) { alterUser.setIfExists(true); } ] + ( + LOOKAHEAD({ getToken(1).kind == K_USER && "(".equals(getToken(2).image) }) + "(" ")" authentication=MySqlAlterUserAuthentication() + { + requireAccessSyntax(authentication.getMode() == UserAuthentication.Mode.BY_PASSWORD + || authentication.getMode() == UserAuthentication.Mode.BY_RANDOM_PASSWORD + || authentication.getMode() == UserAuthentication.Mode.DISCARD_OLD_PASSWORD, + "USER() accepts IDENTIFIED BY or DISCARD OLD PASSWORD"); + alterUser.withUserFunction(authentication); + } + | + account=MySqlAlterUserAccount() { alterUser.addAccounts(account); } + ( + LOOKAHEAD({ getToken(1).kind == K_DEFAULT && "ROLE".equalsIgnoreCase(getToken(2).image) }) + ContextualKeyword("ROLE") roles=MySqlRoleSelection(false) + { + requireAccessSyntax(account.getAuthentication() == null, + "DEFAULT ROLE cannot follow an authentication change"); + alterUser.getOptions().setDefaultRoles(roles); + } + | + ( "," account=MySqlAlterUserAccount() { alterUser.addAccounts(account); } )* + MySqlUserOptions(alterUser.getOptions(), false) + ) + ) + { return alterUser; } +} + +/** MySQL CREATE ROLE [IF NOT EXISTS] role [, role] ... with account-style role names. */ +CreateRole MySqlCreateRole(): +{ + CreateRole role = new CreateRole(); + List roles; +} +{ + ContextualKeyword("ROLE") + [ LOOKAHEAD(3) { role.setIfNotExists(true); } ] + roles=MySqlAccountNameList() { role.setRoles(roles); } + { return role; } +} + +RenameUser RenameUser(): +{ + RenameUser renameUser = new RenameUser(); + UserAccount from; + UserAccount to; +} +{ + + from=MySqlAccountName() to=MySqlAccountName() { renameUser.addRename(from, to); } + ( + "," from=MySqlAccountName() to=MySqlAccountName() + { renameUser.addRename(from, to); } + )* + { return renameUser; } +} + +SetRoleStatement SetRoleStatement(): +{ RoleSelection roles; } +{ + ContextualKeyword("ROLE") roles=MySqlRoleSelection(true) + { return new SetRoleStatement(roles); } +} + +/** MySQL SET DEFAULT ROLE ... TO accounts, or MariaDB's form with an optional FOR account. */ +SetDefaultRoleStatement SetDefaultRoleStatement(): +{ + SetDefaultRoleStatement statement = new SetDefaultRoleStatement(); + RoleSelection roles; + List accounts; +} +{ + ContextualKeyword("ROLE") roles=MySqlRoleSelection(false) + { statement.setRoles(roles); } + [ + ( | { statement.setForKeyword(true); } ) + accounts=MySqlAccountNameList() { statement.setAccounts(accounts); } + ] + { return statement; } +} + +SetPasswordStatement SetPasswordStatement(): +{ + SetPasswordStatement statement = new SetPasswordStatement(); + UserAccount account; + Expression password; + Token value; +} +{ + + [ account=MySqlAccountName() { statement.setAccount(account); } ] + ( + "=" password=Expression() { statement.setPassword(password); } + | + { statement.setRandom(true); } + ) + [ + LOOKAHEAD({ getToken(1).kind == K_REPLACE && getToken(2).kind == S_CHAR_LITERAL }) + value= { statement.setCurrentCredential(stringValue(value)); } + ] + [ + LOOKAHEAD({ isKeywordAhead("RETAIN") }) ContextualKeyword("RETAIN") + { statement.setRetainCurrentPassword(true); } + ] + { return statement; } +} + EventSchedule MySqlEventSchedule(): { EventSchedule schedule = new EventSchedule(); @@ -15710,8 +16336,7 @@ AlterEvent AlterEvent(): /** Account spelling is shared, including CURRENT_USER and CURRENT_USER(). */ void MySqlDefiner(MySqlDefiner definer): { - StringValue user; - StringValue host; + UserAccount account; String name; } { @@ -15721,8 +16346,7 @@ void MySqlDefiner(MySqlDefiner definer): { definer.setUser(new StringValue(name).setQuoteStr("")); } [ "(" ")" { definer.setCurrentUserParentheses(true); } ] | - user=MySqlAccountNamePart() { definer.setUser(user); } - [ host=MySqlAccountNamePart() { definer.setHost(host); } ] + account=MySqlAccountName() { definer.setUser(account.getUser()); definer.setHost(account.getHost()); } ) } @@ -19586,6 +20210,8 @@ Statement Alter(): | LOOKAHEAD() statement = AlterPolicy() | + LOOKAHEAD({ isMySqlAlterUserAhead() }) statement = AlterUser() + | LOOKAHEAD({ isKeywordAhead("ROLE") || isKeywordAhead("USER") || isKeywordAhead("GROUP") }) statement = AlterRole() | @@ -20460,12 +21086,46 @@ Expression AccessSettingValue(): { Expression value; Token token; } { ( LOOKAHEAD(1) token= { value = new Column(token.image); } | value=PrimaryExpression() ) { return value; } } +/** PostgreSQL and MySQL static privileges; another identifier is a MySQL dynamic privilege. */ Privilege AccessPrivilege(): -{ Privilege result; Token token; boolean all = false; String column; ExpressionList columns = null; } +{ Privilege result; Privilege.Kind kind = null; Token token = null; String column; ExpressionList columns = null; } { - ( token= | token= | token= | token= | token= | token= | token= | token= | token= | token= | token= | token= | token= | token= | token= | token= | token= ) - { result = new Privilege(accessEnum(Privilege.Kind.class, token.image)); } - [ { requireAccessSyntax(result.getKind() == Privilege.Kind.ALTER, "SYSTEM requires ALTER"); result.setKind(Privilege.Kind.ALTER_SYSTEM); } ] + ( + { kind = Privilege.Kind.ALTER; } + [ { kind = Privilege.Kind.ALTER_SYSTEM; } + | LOOKAHEAD({ isAccessKeywordAhead("ROUTINE") }) AccessKeyword("ROUTINE") { kind = Privilege.Kind.ALTER_ROUTINE; } ] + | + { kind = Privilege.Kind.CREATE; } + [ { kind = Privilege.Kind.CREATE_VIEW; } + | { kind = Privilege.Kind.CREATE_USER; } + | { kind = Privilege.Kind.CREATE_TABLESPACE; } + | { kind = Privilege.Kind.CREATE_TEMPORARY_TABLES; } + | LOOKAHEAD({ isAccessKeywordAhead("ROLE") }) AccessKeyword("ROLE") { kind = Privilege.Kind.CREATE_ROLE; } + | LOOKAHEAD({ isAccessKeywordAhead("ROUTINE") }) AccessKeyword("ROUTINE") { kind = Privilege.Kind.CREATE_ROUTINE; } ] + | + { kind = Privilege.Kind.DROP; } + [ LOOKAHEAD({ isAccessKeywordAhead("ROLE") }) AccessKeyword("ROLE") { kind = Privilege.Kind.DROP_ROLE; } ] + | + { kind = Privilege.Kind.GRANT_OPTION; } + | + { kind = Privilege.Kind.LOCK_TABLES; } + | + ( { kind = Privilege.Kind.SHOW_VIEW; } | AccessKeyword("DATABASES") { kind = Privilege.Kind.SHOW_DATABASES; } ) + | + LOOKAHEAD({ isAccessKeywordAhead("REPLICATION") && (getToken(2).kind == K_SLAVE || "CLIENT".equalsIgnoreCase(getToken(2).image)) }) + AccessKeyword("REPLICATION") + ( { kind = Privilege.Kind.REPLICATION_SLAVE; } | AccessKeyword("CLIENT") { kind = Privilege.Kind.REPLICATION_CLIENT; } ) + | + ( token= | token= | token= | token= | token= | token= | token= | token= | token= | token= | token= | token= | token= | token= | token= | token= | token= ) + { kind = accessEnum(Privilege.Kind.class, token.image); } + | + token= + { if (Arrays.asList("MAINTAIN", "USAGE", "PROCESS", "PROXY", "RELOAD", "SUPER").contains(token.image.toUpperCase(Locale.ROOT))) { + kind = accessEnum(Privilege.Kind.class, token.image); + requireAccessSyntax(kind != Privilege.Kind.PROXY, "PROXY requires an account target"); + } } + ) + { result = kind == null ? Privilege.dynamic(token.image) : new Privilege(kind); } [ LOOKAHEAD({ isAccessKeywordAhead("PRIVILEGES") }) AccessKeyword("PRIVILEGES") { requireAccessSyntax(result.getKind() == Privilege.Kind.ALL, "PRIVILEGES requires ALL"); result.setUsePrivileges(true); } ] [ "(" { columns = new ExpressionList(); } @@ -20541,34 +21201,101 @@ PrivilegeTarget AccessTarget(boolean defaults): ( "," number= { target.getNames().add(Collections.singletonList(number.image)); } )* | [ LOOKAHEAD({ isAccessTargetKindAhead() }) kind=AccessObjectKind(false) { target.setKind(kind); target.setExplicitKind(true); } ] - name=RelObjectNames() { target.getNames().add(name.getNames()); } - ( "," name=RelObjectNames() { target.getNames().add(name.getNames()); } )* + ( + // MySQL privilege levels *, *.* and db.* + "*" { target.setWildcard(PrivilegeTarget.Wildcard.DEFAULT_DATABASE); } + [ "." "*" { target.setWildcard(PrivilegeTarget.Wildcard.GLOBAL); } ] + | + name=RelObjectNames() { target.getNames().add(name.getNames()); } + ( + "." "*" + { requireAccessSyntax(name.getNames().size() == 1, "Expected database.*"); target.setWildcard(PrivilegeTarget.Wildcard.DATABASE); } + | + ( "," name=RelObjectNames() { target.getNames().add(name.getNames()); } )* + ) + ) + { requireAccessSyntax(target.getWildcard() == null || target.getKind() == PrivilegeTarget.Kind.TABLE, "Wildcards require a table target"); } ) { requireAccessSyntax(target.isDefaultPrivileges() == defaults, "Invalid default-privilege target"); return target; } } -String AccessGrantee(): -{ String name; boolean group = false; } -{ [ LOOKAHEAD(1) { group = true; } ] name=RelObjectName() { return (group ? "GROUP " : "") + name; } } +/** A role specification shared by MySQL and PostgreSQL grantees, grantors and accounts. */ +Grantee AccessGrantee(): +{ Grantee grantee; StringValue name; UserAccount account; Token token; } +{ + ( + LOOKAHEAD({ getToken(1).kind == K_GROUP }) name=MySqlAccountNamePart() + { grantee = new Grantee(Grantee.Kind.GROUP).withAccount(new UserAccount().withUser(name)); } + | + LOOKAHEAD({ isAccessSpecialGranteeAhead() }) ( token= | token= ) + { grantee = new Grantee(accessEnum(Grantee.Kind.class, token.image)); } + [ LOOKAHEAD(2) "(" ")" { requireAccessSyntax(grantee.getKind() == Grantee.Kind.CURRENT_USER, "Only CURRENT_USER takes parentheses"); + grantee.setCurrentUserParentheses(true); } ] + | + account=MySqlAccountName() { grantee = new Grantee(account); } + ) + { return grantee; } +} -List AccessGrantees(): -{ List names = new ArrayList(); String name; } -{ name=AccessGrantee() { names.add(name); } ( "," name=AccessGrantee() { names.add(name); } )* { return names; } } +List AccessGrantees(): +{ List grantees = new ArrayList(); Grantee grantee; } +{ grantee=AccessGrantee() { grantees.add(grantee); } ( "," grantee=AccessGrantee() { grantees.add(grantee); } )* { return grantees; } } PrivilegeClause AccessClause(boolean revoke, boolean defaults): -{ PrivilegeClause clause = new PrivilegeClause(); Privilege privilege; PrivilegeTarget target; List names; } +{ PrivilegeClause clause = new PrivilegeClause(); Privilege privilege; PrivilegeTarget target; List roles; List grantees; Grantee account; } { ( + LOOKAHEAD({ isAccessKeywordAhead("PROXY") && getToken(2).kind == K_ON }) + AccessKeyword("PROXY") account=AccessGrantee() + { + requireAccessSyntax(!defaults, "PROXY is not part of default privileges"); + requireAccessSyntax(account.getKind() == Grantee.Kind.ACCOUNT || account.getKind() == Grantee.Kind.CURRENT_USER, "PROXY requires an account"); + clause.setPrivileges(new ArrayList()); + clause.getPrivileges().add(new Privilege(Privilege.Kind.PROXY)); + target = new PrivilegeTarget(); target.setKind(PrivilegeTarget.Kind.ACCOUNT); target.setAccount(account); + clause.setTarget(target); + } + | + LOOKAHEAD({ isAccessAllGrantOptionAhead() }) + { privilege = new Privilege(Privilege.Kind.ALL); } + [ AccessKeyword("PRIVILEGES") { privilege.setUsePrivileges(true); } ] + "," + { + requireAccessSyntax(!defaults, "ALL, GRANT OPTION is not part of default privileges"); + clause.setPrivileges(new ArrayList()); + clause.getPrivileges().add(privilege); + clause.getPrivileges().add(new Privilege(Privilege.Kind.GRANT_OPTION)); + clause.setTarget(null); + } + | LOOKAHEAD({ isObjectPrivilegeAhead() }) { clause.setPrivileges(new ArrayList()); } privilege=AccessPrivilege() { clause.getPrivileges().add(privilege); } ( "," privilege=AccessPrivilege() { clause.getPrivileges().add(privilege); } )* target=AccessTarget(defaults) { clause.setTarget(target); } | - names=AccessNames() { clause.setRoles(names); } + roles=MySqlAccountNameList() { clause.setRoleItems(roles); clause.setTarget(null); } ) ( { requireAccessSyntax(!revoke, "REVOKE requires FROM"); } | { requireAccessSyntax(revoke, "GRANT requires TO"); } ) - names=AccessGrantees() { clause.setGrantees(names); return clause; } + grantees=AccessGrantees() { clause.setGranteeItems(grantees); return clause; } +} + +GrantOption AccessGrantOption(): +{ GrantOption option; Token token; } +{ + ( token= | token= | token= ) { option = new GrantOption(accessEnum(GrantOption.Kind.class, token.image)); } + ( token= | token= | token= ) { option.setValue(accessEnum(GrantOption.Value.class, token.image)); return option; } +} + +Grantee AccessGrantor(boolean defaults): +{ Grantee grantor; } +{ + AccessKeyword("GRANTED") grantor=AccessGrantee() + { + requireAccessSyntax(!defaults, "GRANTED BY is not part of default privileges"); + requireAccessSyntax(grantor.getKind() != Grantee.Kind.PUBLIC && grantor.getKind() != Grantee.Kind.GROUP, "Invalid grantor"); + return grantor; + } } Grant Grant(): @@ -20576,31 +21303,41 @@ Grant Grant(): { grant=AccessGrant(false) { return grant; } } Grant AccessGrant(boolean defaults): -{ Grant grant = new Grant(); PrivilegeClause clause; Token token; GrantOption option; String name; } +{ Grant grant = new Grant(); PrivilegeClause clause; GrantOption option; Grantee grantee; RoleSelection roles; } { clause=AccessClause(false, defaults) { grant.setClause(clause); } [ LOOKAHEAD(1) - ( token= | token= | token= ) { option = new GrantOption(accessEnum(GrantOption.Kind.class, token.image)); } - ( token= | token= | token= ) - { option.setValue(accessEnum(GrantOption.Value.class, token.image)); grant.setOption(option); - requireAccessSyntax(clause.getPrivileges() == null ? option.getKind() != GrantOption.Kind.GRANT - : option.getKind() == GrantOption.Kind.GRANT && option.getValue() == GrantOption.Value.OPTION, "Invalid GRANT option"); } + option=AccessGrantOption() { grant.getOptions().add(option); } + ( "," option=AccessGrantOption() { grant.getOptions().add(option); } )* + { requireAccessSyntax(isValidAccessGrantOptions(clause, grant.getOptions()), "Invalid GRANT option"); } ] - [ LOOKAHEAD({ isAccessKeywordAhead("GRANTED") }) AccessKeyword("GRANTED") name=RelObjectName() - { requireAccessSyntax(!defaults, "GRANTED BY is not part of default privileges"); clause.setGrantedBy(name); } ] + [ grantee=AccessGrantee() + { + requireAccessSyntax(!defaults && clause.getPrivileges() != null && clause.getTarget() != null + && clause.getTarget().getKind() != PrivilegeTarget.Kind.ACCOUNT, "AS requires object privileges"); + requireAccessSyntax(grantee.getKind() == Grantee.Kind.ACCOUNT || grantee.getKind() == Grantee.Kind.CURRENT_USER, "AS requires an account"); + grant.setAsUser(grantee); + } + [ LOOKAHEAD({ getToken(1).kind == K_WITH && "ROLE".equalsIgnoreCase(getToken(2).image) }) + AccessKeyword("ROLE") roles=MySqlRoleSelection(true) { grant.setWithRole(roles); } ] + ] + [ LOOKAHEAD({ isAccessKeywordAhead("GRANTED") }) grantee=AccessGrantor(defaults) { clause.setGrantor(grantee); } ] { return grant; } } Revoke Revoke(boolean defaults): -{ Revoke revoke = new Revoke(); PrivilegeClause clause; Token token; String name; } +{ Revoke revoke = new Revoke(); PrivilegeClause clause; Token token; Grantee grantor; } { + [ LOOKAHEAD(2) + { requireAccessSyntax(!defaults, "IF EXISTS is not part of default privileges"); revoke.setIfExists(true); } ] [ LOOKAHEAD(3) ( token= | token= | token= ) { revoke.setOptionFor(accessEnum(GrantOption.Kind.class, token.image)); } ] clause=AccessClause(true, defaults) { revoke.setClause(clause); } - [ LOOKAHEAD({ isAccessKeywordAhead("GRANTED") }) AccessKeyword("GRANTED") name=RelObjectName() - { requireAccessSyntax(!defaults, "GRANTED BY is not part of default privileges"); clause.setGrantedBy(name); } ] + [ LOOKAHEAD({ isAccessKeywordAhead("GRANTED") }) grantor=AccessGrantor(defaults) { clause.setGrantor(grantor); } ] [ ( { revoke.setBehavior(CascadeBehavior.CASCADE); } | { revoke.setBehavior(CascadeBehavior.RESTRICT); } ) ] + [ + { requireAccessSyntax(!defaults, "IGNORE UNKNOWN USER is not part of default privileges"); revoke.setIgnoreUnknownUser(true); } ] { requireAccessSyntax(revoke.getOptionFor() == null || (clause.getPrivileges() == null ? revoke.getOptionFor() != GrantOption.Kind.GRANT : revoke.getOptionFor() == GrantOption.Kind.GRANT), "Invalid REVOKE option"); return revoke; } @@ -20925,6 +21662,9 @@ Statement Create(): | LOOKAHEAD({ isKeywordAhead("STATISTICS") }) statement = CreateStatistics() | + LOOKAHEAD({ isMySqlCreateRoleAhead() }) statement = MySqlCreateRole() + { requireAccessSyntax(!isUsingOrReplace, "CREATE ROLE does not accept OR REPLACE"); } + | LOOKAHEAD({ isPostgreSqlRoleAhead() }) statement = CreateRole() { requireAccessSyntax(!isUsingOrReplace, "CREATE ROLE does not accept OR REPLACE"); } | diff --git a/src/site/sphinx/index.rst b/src/site/sphinx/index.rst index 5f3b0157b4..7c0bf445bf 100644 --- a/src/site/sphinx/index.rst +++ b/src/site/sphinx/index.rst @@ -158,6 +158,8 @@ One grammar covers the SQL standard plus all major RDBMS. Missing syntax gets ad - ``CREATE …`` · ``ALTER …`` · ``DROP …`` * - **MySQL table maintenance** - ``ANALYZE`` · ``CHECK`` · ``OPTIMIZE`` · ``REPAIR TABLE`` + * - **MySQL account management** + - ``CREATE``/``ALTER``/``RENAME USER`` · ``CREATE ROLE`` · ``SET ROLE`` · ``SET DEFAULT ROLE`` · ``SET PASSWORD`` * - **PostgreSQL RLS** - ``CREATE POLICY`` · ``ALTER TABLE … ENABLE``/``DISABLE``/``FORCE``/``NO FORCE ROW LEVEL SECURITY`` * - **Salesforce SOQL** diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 901608d62c..7184421764 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -1718,12 +1718,26 @@ the PostgreSQL ``CreateRole`` AST for this ambiguous form:: "CREATE USER app", parser -> parser.withDialect(AbstractJSqlParser.Dialect.POSTGRESQL)); +MySQL account names (``user[@host]``) of ``CREATE USER`` and of view, trigger and +routine ``DEFINER`` clauses share one grammar rule and are read into separate user +and host ``StringValue`` parts (``UserAccount`` and ``MySqlDefiner``). Every MySQL +spelling is accepted, including ``app@localhost``, ``app@'%'`` and +``'app'@localhost``. A part quoted with ``'``, ``"`` or a backquote keeps its quote +character in ``getQuoteStr()`` and its text in ``getValue()``:: + + CreateUser createUser = (CreateUser) CCJSqlParserUtil.parse( + "CREATE USER `app`@'%' IDENTIFIED BY 'secret'"); + UserAccount account = createUser.getAccounts().get(0); + account.getUser().getValue(); // app + account.getUser().getQuoteStr(); // ` + account.getHost().getValue(); // % + ``Grant`` retains its string-based getters, setters and fluent methods. Its ``PrivilegeClause`` exposes typed ``Privilege`` items, column lists, ``PrivilegeTarget`` kinds, multiple role memberships and grantor information. ``getPrivileges()`` is a mutable string view of the typed privilege list. ``getRole()`` and ``getObjectName()`` expose the first role or named target for -legacy callers; use ``getRoles()`` and ``getTarget()`` for the complete lists. +legacy callers; use ``getRoleItems()`` and ``getTarget()`` for the complete lists. Names in ``PrivilegeTarget`` are multipart identifiers, not SQL clause text. Routine targets use ``RoutineReference`` data-type signatures, with null arguments for an omitted signature and an empty list for explicit ``()``. @@ -1752,6 +1766,63 @@ References: `CREATE ROLE `_, `CREATE TRIGGER `_. +MySQL privileges and accounts in GRANT and REVOKE +------------------------------------------------- + +``Grant`` and ``Revoke`` also parse the MySQL forms, in every dialect: + +- **Privilege levels**: ``PrivilegeTarget.getWildcard()`` is ``DEFAULT_DATABASE`` for ``*``, + ``GLOBAL`` for ``*.*`` and ``DATABASE`` for ``db.*``, whose database is the single entry + of ``getNames()``. Named levels such as ``db.tbl`` keep a null wildcard. Only named + table targets are reported by table-name discovery. +- **Privileges**: MySQL static privileges are ``Privilege.Kind`` constants, for example + ``CREATE_TEMPORARY_TABLES`` or ``REPLICATION_SLAVE``; ``getName()`` returns the SQL + spelling. Another identifier, such as ``BACKUP_ADMIN``, is a dynamic privilege of kind + ``DYNAMIC`` with ``getDynamicName()``. +- **Grantees**: ``PrivilegeClause.getGranteeItems()`` returns ``Grantee`` values, which + MySQL and PostgreSQL share. ``ACCOUNT`` holds a ``UserAccount``: a MySQL ``user[@host]``, + read like the ``CREATE USER`` account names above, or a PostgreSQL role name without + host. The other kinds are + PostgreSQL ``GROUP name``, ``PUBLIC``, ``CURRENT_USER`` (MySQL also ``CURRENT_USER()``), + ``CURRENT_ROLE`` and ``SESSION_USER``. ``getGrantor()`` types ``GRANTED BY`` the same way. +- **Role memberships**: ``getRoleItems()`` holds the granted roles as ``UserAccount`` + values, so ``'reader'`` and ``'writer'@'%'`` keep their quoting and host. A membership + has no privileges and no target. +- **MySQL clauses**: ``PROXY ON user`` is a ``PROXY`` privilege on an ``ACCOUNT`` target + (``PrivilegeTarget.getAccount()``). ``REVOKE ALL [PRIVILEGES], GRANT OPTION FROM ...`` + has the privileges ``ALL`` and ``GRANT_OPTION`` and no target. ``Revoke`` records + ``IF EXISTS`` and ``IGNORE UNKNOWN USER``; ``Grant.getAsUser()`` and ``getWithRole()`` + hold ``AS user [WITH ROLE ...]``; the role selection is a ``RoleSelection`` from + ``net.sf.jsqlparser.statement.create.user``. + +.. code-block:: java + + Grant grant = (Grant) CCJSqlParserUtil.parse( + "GRANT SELECT, BACKUP_ADMIN ON db1.* TO 'app'@'%', CURRENT_USER"); + grant.getTarget().getWildcard(); // DATABASE + grant.getPrivilegeItems().get(1).getDynamicName(); // BACKUP_ADMIN + UserAccount app = grant.getClause().getGranteeItems().get(0).getAccount(); + app.getHost().getValue(); // % + +``Grant.getOptions()`` lists every ``WITH`` option, including PostgreSQL membership +options such as ``WITH INHERIT TRUE, SET FALSE``. Privileges take only ``GRANT OPTION``; +memberships take distinct ``ADMIN``, ``INHERIT`` and ``SET`` options. + +The string accessors are kept and derived from the typed state: ``getUsers()``, +``getGrantees()`` and ``getRoles()`` are mutable string views that write through, +``getOption()`` is the first option and ``getGrantedBy()`` renders the grantor. +``getObjectName()`` returns a wildcard level as written, for example ``db1.*``, and +``setObjectName()`` replaces it with a named target. Assigned text is converted with +``Grantee.valueOf()``: special role names are recognized and other text is kept as the +account name. Parsed memberships have no target; ``new Grant()`` still starts with an +empty table target, and ``setRole()`` clears it. + +The MySQL and MariaDB validation capabilities now include ``revoke``. + +References: `GRANT `_, +`REVOKE `_, +`Specifying Account Names `_. + Migrating shared ALTER enums ---------------------------- @@ -1892,6 +1963,65 @@ Table visitors and metadata validation use the table list. Validators report the analysis reports ``READS_DATA`` and ``RETURNS_RESULT_SET``; OPTIMIZE and REPAIR also report ``MODIFIES_SCHEMA``, and REPAIR may report ``MODIFIES_DATA``. +MySQL account management statements +----------------------------------- + +``CREATE USER`` and ``ALTER USER`` share ``UserOptions`` (``getOptions()``) in MySQL's +clause order: the ``DEFAULT ROLE`` roles, the ``REQUIRE`` options (``TlsOption``), the +``WITH`` resource limits (``ResourceLimit``), the password and locking options +(``PasswordLockOption``) in source order, and ``COMMENT`` or ``ATTRIBUTE``. Omitted +clauses stay omitted. ``UserAuthentication`` adds ``ALTER USER``'s ``REPLACE 'current'`` +(``getCurrentCredential()``), ``RETAIN CURRENT PASSWORD`` and the +``DISCARD_OLD_PASSWORD`` mode. + +.. code-block:: java + + AlterUser alter = (AlterUser) CCJSqlParserUtil.parse( + "ALTER USER 'app'@'%' IDENTIFIED BY 'new' RETAIN CURRENT PASSWORD " + + "PASSWORD EXPIRE INTERVAL 90 DAY ACCOUNT LOCK"); + alter.getAccounts().get(0).getAuthentication().isRetainCurrentPassword(); // true + alter.getOptions().getPasswordLockOptions(); // [PASSWORD EXPIRE INTERVAL 90 DAY, ACCOUNT LOCK] + +``ALTER USER USER() ...`` keeps ``USER()`` as the unquoted account name, as +``DEFINER = CURRENT_USER`` does; ``isUserFunction()`` identifies it. +``ALTER USER acct DEFAULT ROLE {NONE | ALL | role, ...}`` stores a ``RoleSelection`` in +``getOptions().getDefaultRoles()``. PostgreSQL's ``ALTER USER``, an alias of ``ALTER ROLE`` +(``WITH LOGIN``, ``PASSWORD 'x'``, ``SET``, ``RENAME TO`` ...), still parses to +``AlterRole``. MySQL syntax is recognized by ``IF EXISTS``, ``USER()``, quoted names, +hosts, account lists, a missing action or a MySQL account clause. + +``RENAME USER`` parses to ``RenameUser`` with ordered ``from``/``to`` pairs. MySQL +``CREATE ROLE [IF NOT EXISTS] role, ...`` with quoted names, hosts or several roles keeps +them in ``CreateRole.getRoles()``, and ``getName()`` is null. A single unquoted name, which +both databases accept, keeps the PostgreSQL ``getName()``; so does an unquoted ``r@h``, which +the lexer reads as one identifier, unless the MySQL or MariaDB dialect is selected. Role names +use the shared account spelling and are ``UserAccount`` values without authentication. + +``SET DEFAULT ROLE`` (MySQL's ``TO``, MariaDB's ``FOR``) and ``SET PASSWORD FOR`` parse to +``SetDefaultRoleStatement`` and ``SetPasswordStatement`` in every dialect. ``SET ROLE`` and +``SET PASSWORD`` without ``FOR`` share the variable assignment shape that PostgreSQL uses +for ``SET ROLE name`` and ``SET ROLE NONE``, so they remain a ``SetStatement`` unless +``Dialect.MYSQL`` or ``Dialect.MARIADB`` is selected. With these dialects every form parses +to ``SetRoleStatement`` (``DEFAULT``, ``NONE``, ``ALL [EXCEPT ...]`` or a role list) or +``SetPasswordStatement`` (``= password`` or ``TO RANDOM``, then ``REPLACE`` and +``RETAIN CURRENT PASSWORD``). The MySQL-only ``SET ROLE ALL EXCEPT ...`` and role names +with a quoted user or host, such as ``'r'@'%'``, parse to ``SetRoleStatement`` in every +dialect. + +.. code-block:: java + + SetRoleStatement setRole = (SetRoleStatement) CCJSqlParserUtil.parse( + "SET ROLE ALL EXCEPT 'admin'@'%'", + parser -> parser.withDialect(Dialect.MYSQL)); + setRole.getRoles().getKind(); // ALL_EXCEPT + setRole.getRoles().getRoles(); // ['admin'@'%'] + +Validators report ``alterUser``, ``renameUser``, ``setRole``, ``setDefaultRole`` and +``setPassword``; the MySQL and MariaDB capabilities enable them and ``createRole``. +Feature analysis reports ``MODIFIES_SCHEMA``, or ``MODIFIES_SESSION`` for ``SET ROLE``. +Multi-factor authentication clauses are not modeled. Serialized account SQL can contain +passwords; avoid logging real credentials. + Legacy MySQL GROUP BY ordering ============================== diff --git a/src/test/java/net/sf/jsqlparser/statement/SetPasswordStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/SetPasswordStatementTest.java new file mode 100644 index 0000000000..6b19e84b83 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/SetPasswordStatementTest.java @@ -0,0 +1,126 @@ +/*- + * #%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; + +import static net.sf.jsqlparser.test.TestUtils.assertDeparse; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Function; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.create.user.UserAccount; +import net.sf.jsqlparser.test.TestUtils; +import net.sf.jsqlparser.util.TablesNamesFinder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +class SetPasswordStatementTest { + + private static SetPasswordStatement parse(String sql, Dialect dialect) + throws JSQLParserException { + SetPasswordStatement statement = assertInstanceOf(SetPasswordStatement.class, TestUtils + .assertSqlCanBeParsedAndDeparsed(sql, false, + parser -> parser.withDialect(dialect))); + assertEquals(sql, assertInstanceOf(SetPasswordStatement.class, CCJSqlParserUtil + .parse(statement.toString(), parser -> parser.withDialect(dialect))).toString()); + return statement; + } + + @ParameterizedTest + @EnumSource(value = Dialect.class, names = {"MYSQL", "MARIADB"}) + void setsPasswordForAccount(Dialect dialect) throws JSQLParserException { + SetPasswordStatement statement = parse("SET PASSWORD FOR 'app'@'%' = 'x'", dialect); + + assertEquals("app", statement.getAccount().getUser().getValue()); + assertEquals("%", statement.getAccount().getHost().getValue()); + assertEquals("x", ((StringValue) statement.getPassword()).getValue()); + assertFalse(statement.isRandom()); + + SetPasswordStatement current = parse("SET PASSWORD = 'x'", dialect); + assertNull(current.getAccount()); + } + + @Test + void generatesRandomPasswordAndRetainsCurrent() throws JSQLParserException { + SetPasswordStatement statement = parse( + "SET PASSWORD FOR app TO RANDOM REPLACE 'old' RETAIN CURRENT PASSWORD", + Dialect.MYSQL); + + assertTrue(statement.isRandom()); + assertNull(statement.getPassword()); + assertEquals("old", statement.getCurrentCredential().getValue()); + assertTrue(statement.isRetainCurrentPassword()); + + assertTrue(parse("SET PASSWORD TO RANDOM", Dialect.MYSQL).isRandom()); + assertTrue(parse("SET PASSWORD = 'new' REPLACE 'old'", Dialect.MYSQL) + .getCurrentCredential() != null); + } + + @Test + void keepsMariaDbPasswordFunction() throws JSQLParserException { + SetPasswordStatement statement = + parse("SET PASSWORD FOR 'app'@'%' = PASSWORD('x')", Dialect.MARIADB); + + assertEquals("PASSWORD", ((Function) statement.getPassword()).getName()); + } + + @Test + void keepsGenericAssignmentWithoutMySqlDialect() throws JSQLParserException { + for (String sql : List.of("SET PASSWORD = 'x'", "SET PASSWORD TO RANDOM")) { + assertInstanceOf(SetStatement.class, CCJSqlParserUtil.parse(sql), sql); + assertInstanceOf(SetStatement.class, + CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(Dialect.POSTGRESQL)), + sql); + } + // FOR names an account and is typed in every configuration + assertInstanceOf(SetPasswordStatement.class, TestUtils + .assertSqlCanBeParsedAndDeparsed("SET PASSWORD FOR 'app'@'%' = 'x'", false)); + } + + @Test + void buildsAndDeparses() { + SetPasswordStatement statement = new SetPasswordStatement() + .withAccount(new UserAccount().withUser(new StringValue("app"))) + .withPassword(new StringValue("new")).withRetainCurrentPassword(true); + + assertDeparse(statement, "SET PASSWORD FOR 'app' = 'new' RETAIN CURRENT PASSWORD"); + assertDeparse(statement.withRandom(true).withPassword(null), + "SET PASSWORD FOR 'app' TO RANDOM RETAIN CURRENT PASSWORD"); + } + + @Test + void reportsSchemaChangeWithoutTables() throws JSQLParserException { + Statement statement = CCJSqlParserUtil.parse("SET PASSWORD FOR app = 'x'"); + + assertThat(statement.getFeatures().getCertain()) + .containsExactly(StmtFeature.MODIFIES_SCHEMA); + assertThat(TablesNamesFinder.findTables(statement.toString())).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"SET PASSWORD FOR app", "SET PASSWORD = 'x' REPLACE", + "SET PASSWORD TO 'x'", "SET PASSWORD FOR app TO RANDOM RETAIN PASSWORD", + "SET PASSWORD FOR app IDENTIFIED BY 'x' = 'y'"}) + void rejectsSyntaxOutsideTheStatementDefinition(String sql) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(Dialect.MYSQL))); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/SetRoleStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/SetRoleStatementTest.java new file mode 100644 index 0000000000..8b24d9bd16 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/SetRoleStatementTest.java @@ -0,0 +1,175 @@ +/*- + * #%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; + +import static net.sf.jsqlparser.test.TestUtils.assertDeparse; +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.create.user.RoleSelection; +import net.sf.jsqlparser.statement.create.user.RoleSelection.Kind; +import net.sf.jsqlparser.statement.create.user.UserAccount; +import net.sf.jsqlparser.test.TestUtils; +import net.sf.jsqlparser.util.TablesNamesFinder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** MySQL SET ROLE and SET DEFAULT ROLE; PostgreSQL SET ROLE stays a {@link SetStatement}. */ +class SetRoleStatementTest { + + private static T parse(Class type, String sql, Dialect dialect) + throws JSQLParserException { + T statement = assertInstanceOf(type, TestUtils.assertSqlCanBeParsedAndDeparsed(sql, false, + parser -> parser.withDialect(dialect))); + assertEquals(sql, assertInstanceOf(type, + CCJSqlParserUtil.parse(statement.toString(), parser -> parser.withDialect(dialect))) + .toString()); + return statement; + } + + private static List names(RoleSelection roles) { + return roles.getRoles().stream().map(UserAccount::toString).collect(Collectors.toList()); + } + + @ParameterizedTest + @EnumSource(value = Dialect.class, names = {"MYSQL", "MARIADB"}) + void typesEverySetRoleFormInMySqlDialects(Dialect dialect) throws JSQLParserException { + assertEquals(Kind.DEFAULT, + parse(SetRoleStatement.class, "SET ROLE DEFAULT", dialect).getRoles().getKind()); + assertEquals(Kind.NONE, + parse(SetRoleStatement.class, "SET ROLE NONE", dialect).getRoles().getKind()); + assertEquals(Kind.ALL, + parse(SetRoleStatement.class, "SET ROLE ALL", dialect).getRoles().getKind()); + + RoleSelection except = + parse(SetRoleStatement.class, "SET ROLE ALL EXCEPT 'writer', 'admin'@'%'", dialect) + .getRoles(); + assertEquals(Kind.ALL_EXCEPT, except.getKind()); + assertEquals(List.of("'writer'", "'admin'@'%'"), names(except)); + + RoleSelection roles = + parse(SetRoleStatement.class, "SET ROLE 'reader', app_role", dialect).getRoles(); + assertEquals(Kind.ROLES, roles.getKind()); + assertEquals(List.of("'reader'", "app_role"), names(roles)); + } + + @Test + void keepsPostgreSqlSetRole() throws JSQLParserException { + for (Dialect dialect : new Dialect[] {null, Dialect.POSTGRESQL}) { + for (String sql : List.of("SET ROLE app_admin", "SET ROLE 'reader'", "SET ROLE NONE", + "SET ROLE DEFAULT", "SET ROLE ALL", "SET LOCAL ROLE 'app'", + "SET SESSION ROLE app")) { + assertInstanceOf(SetStatement.class, CCJSqlParserUtil.parse(sql, parser -> { + if (dialect != null) { + parser.withDialect(dialect); + } + }), sql); + } + } + // variable assignments and scoped forms are not MySQL SET ROLE + for (String sql : List.of("SET role = 'x'", "SET LOCAL ROLE 'app'")) { + assertInstanceOf(SetStatement.class, + CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(Dialect.MYSQL)), sql); + } + } + + @Test + void typesMySqlOnlyFormsWithoutDialect() throws JSQLParserException { + for (Dialect dialect : new Dialect[] {Dialect.ANSI_SQL, Dialect.POSTGRESQL}) { + assertEquals(Kind.ALL_EXCEPT, + parse(SetRoleStatement.class, "SET ROLE ALL EXCEPT 'writer'", dialect) + .getRoles().getKind()); + } + for (String sql : List.of("SET ROLE r1, 'r2'@'%'", "SET ROLE 'r'@localhost", + "SET ROLE r@'%'", "SET ROLE `r`@`%`")) { + assertInstanceOf(SetRoleStatement.class, + TestUtils.assertSqlCanBeParsedAndDeparsed(sql, false), sql); + } + // an unquoted r@h is one identifier, as before + assertInstanceOf(SetStatement.class, CCJSqlParserUtil.parse("SET ROLE r@h")); + } + + @Test + void setsDefaultRoles() throws JSQLParserException { + SetDefaultRoleStatement single = parse(SetDefaultRoleStatement.class, + "SET DEFAULT ROLE 'reader' TO 'app'@'%'", Dialect.MYSQL); + assertEquals(List.of("'reader'"), names(single.getRoles())); + assertEquals("app", single.getAccounts().get(0).getUser().getValue()); + assertFalse(single.isForKeyword()); + + SetDefaultRoleStatement all = parse(SetDefaultRoleStatement.class, + "SET DEFAULT ROLE ALL TO 'app'@'%', 'u2'@'%'", Dialect.MYSQL); + assertEquals(Kind.ALL, all.getRoles().getKind()); + assertEquals(2, all.getAccounts().size()); + + assertEquals(Kind.NONE, parse(SetDefaultRoleStatement.class, + "SET DEFAULT ROLE NONE TO app", Dialect.MYSQL).getRoles().getKind()); + // available without a dialect, as no generic SET form starts with SET DEFAULT ROLE + assertInstanceOf(SetDefaultRoleStatement.class, TestUtils + .assertSqlCanBeParsedAndDeparsed("SET DEFAULT ROLE r1, r2 TO app", false)); + } + + @Test + void setsMariaDbDefaultRole() throws JSQLParserException { + SetDefaultRoleStatement forUser = parse(SetDefaultRoleStatement.class, + "SET DEFAULT ROLE reader FOR 'app'@'localhost'", Dialect.MARIADB); + assertTrue(forUser.isForKeyword()); + assertEquals(1, forUser.getAccounts().size()); + + SetDefaultRoleStatement current = + parse(SetDefaultRoleStatement.class, "SET DEFAULT ROLE NONE", Dialect.MARIADB); + assertTrue(current.getAccounts().isEmpty()); + } + + @Test + void buildsAndDeparses() { + UserAccount reader = new UserAccount().withUser(new StringValue("reader")); + assertDeparse(new SetRoleStatement(new RoleSelection(Kind.ALL_EXCEPT).addRoles(reader)), + "SET ROLE ALL EXCEPT 'reader'"); + assertDeparse(new SetDefaultRoleStatement().withRoles(new RoleSelection(Kind.ROLES) + .addRoles(reader)).addAccounts(new UserAccount().withUser(new StringValue("app")) + .withHost(new StringValue("%"))), + "SET DEFAULT ROLE 'reader' TO 'app'@'%'"); + } + + @Test + void reportsStatementFeatures() throws JSQLParserException { + Statement setRole = + CCJSqlParserUtil.parse("SET ROLE ALL", parser -> parser.withDialect(Dialect.MYSQL)); + assertThat(setRole.getFeatures().getCertain()) + .containsExactly(StmtFeature.MODIFIES_SESSION); + + Statement setDefaultRole = CCJSqlParserUtil.parse("SET DEFAULT ROLE ALL TO app"); + assertThat(setDefaultRole.getFeatures().getCertain()) + .containsExactly(StmtFeature.MODIFIES_SCHEMA); + assertThat(TablesNamesFinder.findTables(setDefaultRole.toString())).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"SET ROLE ALL EXCEPT", "SET ROLE 'r' IDENTIFIED BY 'x'", + "SET DEFAULT ROLE DEFAULT TO app", "SET DEFAULT ROLE ALL EXCEPT r TO app", + "SET DEFAULT ROLE TO app", "SET DEFAULT ROLE ALL TO"}) + void rejectsSyntaxOutsideTheStatementDefinition(String sql) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(Dialect.MYSQL))); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/alter/AlterUserTest.java b/src/test/java/net/sf/jsqlparser/statement/alter/AlterUserTest.java new file mode 100644 index 0000000000..d784a16fee --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/alter/AlterUserTest.java @@ -0,0 +1,266 @@ +/*- + * #%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.alter; + +import static net.sf.jsqlparser.test.TestUtils.assertDeparse; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StmtFeature; +import net.sf.jsqlparser.statement.create.user.PasswordLockOption; +import net.sf.jsqlparser.statement.create.user.RoleSelection; +import net.sf.jsqlparser.statement.create.user.TlsOption; +import net.sf.jsqlparser.statement.create.user.UserAccount; +import net.sf.jsqlparser.statement.create.user.UserAuthentication; +import net.sf.jsqlparser.statement.create.user.UserAuthentication.Mode; +import net.sf.jsqlparser.statement.create.user.UserOptions; +import net.sf.jsqlparser.statement.role.AlterRole; +import net.sf.jsqlparser.test.TestUtils; +import net.sf.jsqlparser.util.TablesNamesFinder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class AlterUserTest { + + private static AlterUser parse(String sql, Dialect dialect) throws JSQLParserException { + AlterUser alterUser = assertInstanceOf(AlterUser.class, + TestUtils.assertSqlCanBeParsedAndDeparsed(sql, false, + parser -> parser.withDialect(dialect))); + Statement reparsed = CCJSqlParserUtil.parse(alterUser.toString(), + parser -> parser.withDialect(dialect)); + assertEquals(sql, assertInstanceOf(AlterUser.class, reparsed).toString()); + return alterUser; + } + + private static AlterUser parse(String sql) throws JSQLParserException { + return parse(sql, Dialect.MYSQL); + } + + private static List names(List accounts) { + return accounts.stream().map(UserAccount::toString).collect(Collectors.toList()); + } + + @Test + void retainsCurrentPassword() throws JSQLParserException { + AlterUser alterUser = + parse("ALTER USER 'app'@'%' IDENTIFIED BY 'newsecret' RETAIN CURRENT PASSWORD"); + + UserAccount account = alterUser.getAccounts().get(0); + assertEquals("app", account.getUser().getValue()); + assertEquals("%", account.getHost().getValue()); + UserAuthentication authentication = account.getAuthentication(); + assertEquals(Mode.BY_PASSWORD, authentication.getMode()); + assertEquals("newsecret", authentication.getCredential().getValue()); + assertTrue(authentication.isRetainCurrentPassword()); + assertNull(authentication.getCurrentCredential()); + assertTrue(alterUser.getOptions().isEmpty()); + assertFalse(alterUser.isIfExists()); + assertFalse(alterUser.isUserFunction()); + } + + @Test + void replacesCurrentPassword() throws JSQLParserException { + UserAuthentication authentication = parse("ALTER USER 'app'@'%' IDENTIFIED WITH " + + "caching_sha2_password BY 'x' REPLACE 'old' RETAIN CURRENT PASSWORD") + .getAccounts().get(0).getAuthentication(); + + assertEquals(Mode.WITH_PLUGIN_BY_PASSWORD, authentication.getMode()); + assertEquals("caching_sha2_password", authentication.getPlugin()); + assertEquals("old", authentication.getCurrentCredential().getValue()); + assertTrue(authentication.isRetainCurrentPassword()); + } + + @Test + void altersTheCurrentUserWithUserFunction() throws JSQLParserException { + AlterUser alterUser = parse("ALTER USER USER() IDENTIFIED BY 'x'"); + + assertTrue(alterUser.isUserFunction()); + assertEquals("USER()", alterUser.getAccounts().get(0).getUser().getValue()); + assertEquals(Mode.BY_PASSWORD, + alterUser.getAccounts().get(0).getAuthentication().getMode()); + + AlterUser random = parse("ALTER USER IF EXISTS USER() IDENTIFIED BY RANDOM PASSWORD " + + "REPLACE 'old' RETAIN CURRENT PASSWORD"); + assertTrue(random.isIfExists()); + assertTrue(random.isUserFunction()); + + assertEquals(Mode.DISCARD_OLD_PASSWORD, + parse("ALTER USER USER() DISCARD OLD PASSWORD").getAccounts().get(0) + .getAuthentication().getMode()); + } + + @Test + void sharesOptionsWithCreateUser() throws JSQLParserException { + AlterUser alterUser = + parse("ALTER USER IF EXISTS 'app'@'%' REQUIRE NONE WITH MAX_USER_CONNECTIONS 10"); + + assertTrue(alterUser.isIfExists()); + UserOptions options = alterUser.getOptions(); + assertEquals(TlsOption.Kind.NONE, options.getTlsOptions().get(0).getKind()); + assertEquals(10, options.getResourceLimits().get(0).getValue()); + + assertEquals(PasswordLockOption.Kind.PASSWORD_EXPIRE, + parse("ALTER USER 'app'@'%' PASSWORD EXPIRE").getOptions() + .getPasswordLockOptions().get(0).getKind()); + assertEquals(PasswordLockOption.Kind.ACCOUNT_UNLOCK, + parse("ALTER USER 'app'@'%' ACCOUNT UNLOCK").getOptions() + .getPasswordLockOptions().get(0).getKind()); + } + + @Test + void altersSeveralAccounts() throws JSQLParserException { + AlterUser alterUser = parse("ALTER USER a IDENTIFIED BY 'x', 'b'@'%' DISCARD OLD PASSWORD, " + + "c ACCOUNT LOCK COMMENT 'locked'"); + + assertEquals(List.of("a IDENTIFIED BY 'x'", "'b'@'%' DISCARD OLD PASSWORD", "c"), + names(alterUser.getAccounts())); + assertEquals(Mode.DISCARD_OLD_PASSWORD, + alterUser.getAccounts().get(1).getAuthentication().getMode()); + assertEquals("locked", alterUser.getOptions().getComment().getValue()); + } + + @Test + void setsDefaultRoles() throws JSQLParserException { + RoleSelection all = parse("ALTER USER 'app'@'%' DEFAULT ROLE ALL").getOptions() + .getDefaultRoles(); + assertEquals(RoleSelection.Kind.ALL, all.getKind()); + assertTrue(all.getRoles().isEmpty()); + + assertEquals(RoleSelection.Kind.NONE, + parse("ALTER USER app DEFAULT ROLE NONE").getOptions().getDefaultRoles() + .getKind()); + + RoleSelection roles = parse("ALTER USER app DEFAULT ROLE r1, 'r2'@'%'").getOptions() + .getDefaultRoles(); + assertEquals(RoleSelection.Kind.ROLES, roles.getKind()); + assertEquals(List.of("r1", "'r2'@'%'"), names(roles.getRoles())); + } + + @Test + void parsesUnquotedAccountNames() throws JSQLParserException { + assertEquals("app", parse("ALTER USER app IDENTIFIED BY 'x'").getAccounts().get(0) + .getUser().getValue()); + UserAccount account = + parse("ALTER USER app@localhost IDENTIFIED BY 'x'").getAccounts().get(0); + assertEquals("app", account.getUser().getValue()); + assertEquals("localhost", account.getHost().getValue()); + assertTrue(parse("ALTER USER app").getOptions().isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = { + "ALTER USER \"app\"@\"%\" IDENTIFIED BY 'x' ACCOUNT LOCK", + "ALTER USER `app`@'%', app@'%', 'app'@localhost ACCOUNT LOCK", + "RENAME USER \"a\"@\"%\" TO `b`@localhost", + "SET PASSWORD FOR \"app\"@\"%\" = 'x'", + "SET DEFAULT ROLE `r`@'%' TO \"app\"@\"%\"", + "CREATE ROLE \"reader\", `writer`@'%'" + }) + void keepsQuotedAccountNamesInEveryMode(String sql) throws JSQLParserException { + // without a dialect "app" is a quoted identifier, with MySQL a double-quoted string + for (Dialect dialect : new Dialect[] {null, Dialect.MYSQL}) { + Consumer configuration = parser -> { + if (dialect != null) { + parser.withDialect(dialect); + } + }; + Statement statement = + TestUtils.assertSqlCanBeParsedAndDeparsed(sql, false, configuration); + assertEquals(sql, + CCJSqlParserUtil.parse(statement.toString(), configuration).toString()); + } + } + + @Test + void parsesInDefaultModeAndMariaDb() throws JSQLParserException { + String sql = "ALTER USER IF EXISTS 'app'@'%' IDENTIFIED BY 'x' REQUIRE SSL " + + "WITH MAX_QUERIES_PER_HOUR 10 PASSWORD EXPIRE NEVER ACCOUNT UNLOCK"; + parse(sql, Dialect.MARIADB); + assertInstanceOf(AlterUser.class, TestUtils.assertSqlCanBeParsedAndDeparsed(sql, false)); + } + + @Test + void keepsPostgreSqlAlterUser() throws JSQLParserException { + for (String sql : List.of("ALTER USER app WITH LOGIN", "ALTER USER app PASSWORD 'x'", + "ALTER USER app WITH PASSWORD NULL", "ALTER USER app VALID UNTIL '2030-01-01'", + "ALTER USER app CONNECTION LIMIT 5", "ALTER USER app RENAME TO app2", + "ALTER USER app SET search_path TO app, public", "ALTER USER ALL RESET ALL", + "ALTER USER CURRENT_USER WITH PASSWORD NULL", + "ALTER USER SESSION_USER SET enable_seqscan TO on")) { + assertInstanceOf(AlterRole.class, CCJSqlParserUtil.parse(sql), sql); + assertInstanceOf(AlterRole.class, CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(Dialect.POSTGRESQL)), sql); + } + } + + @Test + void buildsAndDeparses() { + AlterUser alterUser = new AlterUser().withIfExists(true) + .addAccounts(new UserAccount().withUser(new StringValue("app")) + .withAuthentication(new UserAuthentication().withMode(Mode.BY_PASSWORD) + .withCredential(new StringValue("new")) + .withCurrentCredential(new StringValue("old")))) + .withOptions(new UserOptions().addPasswordLockOptions( + new PasswordLockOption(PasswordLockOption.Kind.ACCOUNT_LOCK))); + + assertDeparse(alterUser, + "ALTER USER IF EXISTS 'app' IDENTIFIED BY 'new' REPLACE 'old' ACCOUNT LOCK"); + + alterUser.withUserFunction( + new UserAuthentication().withMode(Mode.DISCARD_OLD_PASSWORD)); + assertTrue(alterUser.isUserFunction()); + assertEquals("ALTER USER IF EXISTS USER() DISCARD OLD PASSWORD ACCOUNT LOCK", + alterUser.toString()); + } + + @Test + void reportsSchemaChangeWithoutTables() throws JSQLParserException { + Statement statement = CCJSqlParserUtil.parse("ALTER USER 'app'@'%' ACCOUNT LOCK"); + + assertThat(statement.getFeatures().getCertain()) + .containsExactly(StmtFeature.MODIFIES_SCHEMA); + assertThat(TablesNamesFinder.findTables(statement.toString())).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = { + "ALTER USER a, b DEFAULT ROLE r", + "ALTER USER app IDENTIFIED BY 'x' DEFAULT ROLE r", + "ALTER USER app DEFAULT ROLE DEFAULT", + "ALTER USER app DEFAULT ROLE ALL EXCEPT r", + "ALTER USER app IDENTIFIED WITH auth_socket REPLACE 'x'", + "ALTER USER app IDENTIFIED WITH auth_socket AS 'h' RETAIN CURRENT PASSWORD", + "ALTER USER app IDENTIFIED BY 'x' REPLACE", + "ALTER USER USER() IDENTIFIED WITH auth_socket BY 'x'", + "ALTER USER USER() ACCOUNT LOCK", + "ALTER USER USER()", + "ALTER USER app DISCARD OLD PASSWORD RETAIN CURRENT PASSWORD", + "ALTER USER app ACCOUNT LOCK REQUIRE SSL" + }) + void rejectsSyntaxOutsideTheStatementDefinition(String sql) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(Dialect.MYSQL))); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/alter/RenameUserTest.java b/src/test/java/net/sf/jsqlparser/statement/alter/RenameUserTest.java new file mode 100644 index 0000000000..656eb3fd5c --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/alter/RenameUserTest.java @@ -0,0 +1,89 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.alter; + +import static net.sf.jsqlparser.test.TestUtils.assertDeparse; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.List; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StmtFeature; +import net.sf.jsqlparser.statement.create.user.UserAccount; +import net.sf.jsqlparser.test.TestUtils; +import net.sf.jsqlparser.util.TablesNamesFinder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +class RenameUserTest { + + @ParameterizedTest + @EnumSource(value = Dialect.class, names = {"MYSQL", "MARIADB"}) + void renamesAccounts(Dialect dialect) throws JSQLParserException { + String sql = "RENAME USER 'a'@'%' TO 'b'@'%', old_name TO `new name`@localhost"; + RenameUser renameUser = assertInstanceOf(RenameUser.class, + TestUtils.assertSqlCanBeParsedAndDeparsed(sql, false, + parser -> parser.withDialect(dialect))); + assertEquals(sql, CCJSqlParserUtil + .parse(renameUser.toString(), parser -> parser.withDialect(dialect)).toString()); + + List renames = renameUser.getRenames(); + assertEquals(2, renames.size()); + assertEquals("a", renames.get(0).getFrom().getUser().getValue()); + assertEquals("%", renames.get(0).getTo().getHost().getValue()); + assertEquals("old_name", renames.get(1).getFrom().toString()); + assertEquals("new name", renames.get(1).getTo().getUser().getValue()); + assertEquals("`", renames.get(1).getTo().getUser().getQuoteStr()); + assertEquals("localhost", renames.get(1).getTo().getHost().getValue()); + } + + @Test + void keepsTableRenames() throws JSQLParserException { + assertInstanceOf(RenameTableStatement.class, + TestUtils.assertSqlCanBeParsedAndDeparsed("RENAME TABLE a TO b")); + // a table named user, renamed without the optional TABLE keyword + assertInstanceOf(RenameTableStatement.class, CCJSqlParserUtil.parse("RENAME user TO b")); + } + + @Test + void buildsAndDeparses() { + RenameUser renameUser = new RenameUser().addRename( + new UserAccount().withUser(new StringValue("a")).withHost(new StringValue("%")), + new UserAccount().withUser(new StringValue("b")).withHost(new StringValue("%"))); + + assertDeparse(renameUser, "RENAME USER 'a'@'%' TO 'b'@'%'"); + renameUser.setRenames(null); + assertEquals(0, renameUser.getRenames().size()); + } + + @Test + void reportsSchemaChangeWithoutTables() throws JSQLParserException { + Statement statement = CCJSqlParserUtil.parse("RENAME USER a TO b"); + + assertThat(statement.getFeatures().getCertain()) + .containsExactly(StmtFeature.MODIFIES_SCHEMA); + assertThat(TablesNamesFinder.findTables(statement.toString())).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"RENAME USER a", "RENAME USER a TO", + "RENAME USER a IDENTIFIED BY 'x' TO b", "RENAME USER a TO b,"}) + void rejectsIncompleteRenames(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql)); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/create/user/CreateUserTest.java b/src/test/java/net/sf/jsqlparser/statement/create/user/CreateUserTest.java index d0dcbb1af9..a5bedb1c5b 100644 --- a/src/test/java/net/sf/jsqlparser/statement/create/user/CreateUserTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/create/user/CreateUserTest.java @@ -9,19 +9,50 @@ */ package net.sf.jsqlparser.statement.create.user; +import static net.sf.jsqlparser.test.TestUtils.assertDeparse; import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.List; +import java.util.stream.Collectors; import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StmtFeature; +import net.sf.jsqlparser.statement.create.user.PasswordLockOption.Kind; +import net.sf.jsqlparser.statement.create.user.PasswordLockOption.Setting; +import net.sf.jsqlparser.statement.role.CreateRole; +import net.sf.jsqlparser.util.TablesNamesFinder; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; class CreateUserTest { + /** Parses with the dialect, checks both renderers, then reparses the rendered SQL. */ + static T roundTrip(Class type, String sql, Dialect dialect) + throws JSQLParserException { + T statement = assertInstanceOf(type, assertSqlCanBeParsedAndDeparsed(sql, false, + parser -> parser.withDialect(dialect))); + Statement reparsed = + CCJSqlParserUtil.parse(statement.toString(), parser -> parser.withDialect(dialect)); + assertInstanceOf(type, reparsed); + assertEquals(sql, reparsed.toString()); + return statement; + } + + private static List names(List accounts) { + return accounts.stream().map(UserAccount::toString).collect(Collectors.toList()); + } + @Test void parsesAccountAndPasswordAsStructuredValues() throws JSQLParserException { String sql = "CREATE USER IF NOT EXISTS 'replicator' IDENTIFIED BY 'replpass'"; @@ -68,4 +99,184 @@ void parsesMultipleAccountsAndAuthenticationPlugin() throws JSQLParserException assertEquals(UserAuthentication.Mode.BY_RANDOM_PASSWORD, createUser.getAccounts().get(1).getAuthentication().getMode()); } + + @Test + void parsesSharedOptionsInClauseOrder() throws JSQLParserException { + String sql = "CREATE USER IF NOT EXISTS 'app'@'10.0.0.%' IDENTIFIED WITH " + + "caching_sha2_password BY 'secret' DEFAULT ROLE 'reader' REQUIRE SSL " + + "WITH MAX_QUERIES_PER_HOUR 100 PASSWORD EXPIRE INTERVAL 90 DAY " + + "FAILED_LOGIN_ATTEMPTS 3 PASSWORD_LOCK_TIME 2 ACCOUNT LOCK COMMENT 'app user'"; + CreateUser createUser = roundTrip(CreateUser.class, sql, Dialect.MYSQL); + + UserOptions options = createUser.getOptions(); + assertEquals(RoleSelection.Kind.ROLES, options.getDefaultRoles().getKind()); + assertEquals(List.of("'reader'"), names(options.getDefaultRoles().getRoles())); + assertEquals(TlsOption.Kind.SSL, options.getTlsOptions().get(0).getKind()); + ResourceLimit limit = options.getResourceLimits().get(0); + assertEquals(ResourceLimit.Kind.MAX_QUERIES_PER_HOUR, limit.getKind()); + assertEquals(100, limit.getValue()); + assertThat(options.getPasswordLockOptions()).extracting(PasswordLockOption::getKind) + .containsExactly(Kind.PASSWORD_EXPIRE, Kind.FAILED_LOGIN_ATTEMPTS, + Kind.PASSWORD_LOCK_TIME, Kind.ACCOUNT_LOCK); + assertThat(options.getPasswordLockOptions()).extracting(PasswordLockOption::getValue) + .containsExactly(90L, 3L, 2L, null); + assertEquals("app user", options.getComment().getValue()); + assertNull(options.getAttribute()); + } + + @Test + void parsesPasswordSettingsAndRepeatedOptions() throws JSQLParserException { + String sql = "CREATE USER app PASSWORD EXPIRE NEVER PASSWORD EXPIRE DEFAULT " + + "PASSWORD EXPIRE PASSWORD HISTORY DEFAULT PASSWORD HISTORY 5 " + + "PASSWORD REUSE INTERVAL DEFAULT PASSWORD REUSE INTERVAL 365 DAY " + + "PASSWORD REQUIRE CURRENT PASSWORD REQUIRE CURRENT DEFAULT " + + "PASSWORD REQUIRE CURRENT OPTIONAL PASSWORD_LOCK_TIME UNBOUNDED " + + "ACCOUNT UNLOCK ACCOUNT LOCK"; + List options = roundTrip(CreateUser.class, sql, Dialect.MYSQL) + .getOptions().getPasswordLockOptions(); + + assertThat(options).extracting(PasswordLockOption::getKind).containsExactly( + Kind.PASSWORD_EXPIRE, Kind.PASSWORD_EXPIRE, Kind.PASSWORD_EXPIRE, + Kind.PASSWORD_HISTORY, Kind.PASSWORD_HISTORY, Kind.PASSWORD_REUSE_INTERVAL, + Kind.PASSWORD_REUSE_INTERVAL, Kind.PASSWORD_REQUIRE_CURRENT, + Kind.PASSWORD_REQUIRE_CURRENT, Kind.PASSWORD_REQUIRE_CURRENT, + Kind.PASSWORD_LOCK_TIME, Kind.ACCOUNT_UNLOCK, Kind.ACCOUNT_LOCK); + assertThat(options).extracting(PasswordLockOption::getSetting).containsExactly( + Setting.NEVER, Setting.DEFAULT, null, Setting.DEFAULT, null, Setting.DEFAULT, + null, null, Setting.DEFAULT, Setting.OPTIONAL, Setting.UNBOUNDED, null, null); + assertEquals(5L, options.get(4).getValue()); + assertEquals(365L, options.get(6).getValue()); + } + + @Test + void parsesTlsOptionsWithOptionalAnd() throws JSQLParserException { + String sql = "CREATE USER 'app'@'%' REQUIRE CIPHER 'EDH-RSA-DES-CBC3-SHA' " + + "AND ISSUER '/C=SE/O=MySQL' SUBJECT '/CN=app'"; + List options = + roundTrip(CreateUser.class, sql, Dialect.MYSQL).getOptions().getTlsOptions(); + + assertThat(options).extracting(TlsOption::getKind).containsExactly(TlsOption.Kind.CIPHER, + TlsOption.Kind.ISSUER, TlsOption.Kind.SUBJECT); + assertThat(options).extracting(TlsOption::isAndKeyword).containsExactly(false, true, + false); + assertEquals("/CN=app", options.get(2).getValue().getValue()); + + assertEquals(TlsOption.Kind.X509, + roundTrip(CreateUser.class, "CREATE USER app REQUIRE X509", Dialect.MYSQL) + .getOptions().getTlsOptions().get(0).getKind()); + assertEquals(TlsOption.Kind.NONE, + roundTrip(CreateUser.class, "CREATE USER app REQUIRE NONE", Dialect.MYSQL) + .getOptions().getTlsOptions().get(0).getKind()); + } + + @Test + void parsesResourceLimitsAndAttribute() throws JSQLParserException { + String sql = "CREATE USER app DEFAULT ROLE r1, 'r2'@'%' WITH MAX_QUERIES_PER_HOUR 10 " + + "MAX_UPDATES_PER_HOUR 5 MAX_CONNECTIONS_PER_HOUR 2 MAX_USER_CONNECTIONS 1 " + + "ATTRIBUTE '{\"team\": \"ops\"}'"; + UserOptions options = roundTrip(CreateUser.class, sql, Dialect.MYSQL).getOptions(); + + assertEquals(List.of("r1", "'r2'@'%'"), names(options.getDefaultRoles().getRoles())); + assertThat(options.getResourceLimits()).extracting(ResourceLimit::getKind) + .containsExactly(ResourceLimit.Kind.MAX_QUERIES_PER_HOUR, + ResourceLimit.Kind.MAX_UPDATES_PER_HOUR, + ResourceLimit.Kind.MAX_CONNECTIONS_PER_HOUR, + ResourceLimit.Kind.MAX_USER_CONNECTIONS); + assertEquals("{\"team\": \"ops\"}", options.getAttribute().getValue()); + assertNull(options.getComment()); + } + + @Test + void parsesMariaDbAccountOptions() throws JSQLParserException { + CreateUser createUser = roundTrip(CreateUser.class, + "CREATE USER 'app'@'%' IDENTIFIED BY 'x' REQUIRE SSL WITH MAX_USER_CONNECTIONS 5 " + + "ACCOUNT LOCK PASSWORD EXPIRE INTERVAL 30 DAY", + Dialect.MARIADB); + + assertEquals(2, createUser.getOptions().getPasswordLockOptions().size()); + } + + @Test + void mySqlOptionsAfterAnUnquotedNameSelectCreateUser() throws JSQLParserException { + for (String sql : List.of("CREATE USER app WITH MAX_USER_CONNECTIONS 1", + "CREATE USER app PASSWORD EXPIRE", "CREATE USER app PASSWORD HISTORY 2", + "CREATE USER app ACCOUNT LOCK", "CREATE USER app COMMENT 'c'")) { + assertInstanceOf(CreateUser.class, CCJSqlParserUtil.parse(sql), sql); + } + // PostgreSQL role attributes keep the PostgreSQL CREATE USER alias + assertInstanceOf(CreateRole.class, CCJSqlParserUtil.parse("CREATE USER app WITH LOGIN")); + assertInstanceOf(CreateRole.class, CCJSqlParserUtil.parse("CREATE USER app PASSWORD 'x'")); + assertInstanceOf(CreateRole.class, CCJSqlParserUtil.parse("CREATE USER app", + parser -> parser.withDialect(Dialect.POSTGRESQL))); + } + + @Test + void omittedOptionsRenderNothing() throws JSQLParserException { + CreateUser createUser = roundTrip(CreateUser.class, "CREATE USER app", Dialect.MYSQL); + + assertTrue(createUser.getOptions().isEmpty()); + createUser.setOptions(null); + assertTrue(createUser.getOptions().isEmpty()); + assertEquals("CREATE USER app", createUser.toString()); + } + + @Test + void buildsOptions() { + CreateUser createUser = new CreateUser() + .addAccounts(new UserAccount().withUser(new StringValue("app")) + .withHost(new StringValue("%"))) + .withOptions(new UserOptions() + .addTlsOptions(new TlsOption(TlsOption.Kind.SUBJECT, + new StringValue("/CN=app"))) + .addResourceLimits( + new ResourceLimit(ResourceLimit.Kind.MAX_USER_CONNECTIONS, 3)) + .addPasswordLockOptions( + new PasswordLockOption(Kind.PASSWORD_EXPIRE).withValue(30L), + new PasswordLockOption(Kind.ACCOUNT_LOCK)) + .withComment(new StringValue("svc"))); + + assertDeparse(createUser, "CREATE USER 'app'@'%' REQUIRE SUBJECT '/CN=app' " + + "WITH MAX_USER_CONNECTIONS 3 PASSWORD EXPIRE INTERVAL 30 DAY ACCOUNT LOCK " + + "COMMENT 'svc'"); + assertEquals("REQUIRE SUBJECT '/CN=app' WITH MAX_USER_CONNECTIONS 3 " + + "PASSWORD EXPIRE INTERVAL 30 DAY ACCOUNT LOCK COMMENT 'svc'", + createUser.getOptions().toString()); + } + + @Test + void reportsSchemaChangeWithoutTables() throws JSQLParserException { + Statement statement = CCJSqlParserUtil.parse("CREATE USER app REQUIRE SSL ACCOUNT LOCK"); + + assertThat(statement.getFeatures().getCertain()) + .containsExactly(StmtFeature.MODIFIES_SCHEMA); + assertThat(TablesNamesFinder.findTables(statement.toString())).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = { + "CREATE USER app DEFAULT ROLE ALL", + "CREATE USER app DEFAULT ROLE NONE", + "CREATE USER app REQUIRE AND SSL", + "CREATE USER app REQUIRE CIPHER", + "CREATE USER app REQUIRE SSL 'x'", + "CREATE USER app REQUIRE NONE AND SSL", + "CREATE USER app PASSWORD EXPIRE INTERVAL 5 MONTH", + "CREATE USER app PASSWORD HISTORY", + "CREATE USER app COMMENT 'x' ATTRIBUTE '{}'", + "CREATE USER app ACCOUNT LOCK REQUIRE SSL", + "CREATE USER app IDENTIFIED BY 'x' REPLACE 'y'", + "CREATE USER app IDENTIFIED BY 'x' RETAIN CURRENT PASSWORD", + "CREATE USER app DISCARD OLD PASSWORD" + }) + void rejectsOptionsOutsideTheGrammar(String sql) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(Dialect.MYSQL))); + } + + @Test + void optionWordsRemainIdentifiers() throws JSQLParserException { + assertSqlCanBeParsedAndDeparsed("SELECT account, require, expire, reuse, optional, retain, " + + "old, unlock, ssl, x509, cipher, issuer, subject, attribute, " + + "failed_login_attempts, password_lock_time, max_user_connections FROM t"); + } } diff --git a/src/test/java/net/sf/jsqlparser/statement/create/user/MySqlAccountNameTest.java b/src/test/java/net/sf/jsqlparser/statement/create/user/MySqlAccountNameTest.java new file mode 100644 index 0000000000..455f18b6a5 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/user/MySqlAccountNameTest.java @@ -0,0 +1,123 @@ +/*- + * #%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.create.user; + +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.function.Consumer; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.MySqlDefiner; +import net.sf.jsqlparser.statement.create.trigger.CreateTrigger; +import net.sf.jsqlparser.statement.create.view.CreateView; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.stream.Stream; + +class MySqlAccountNameTest { + + private static final Consumer MYSQL = p -> p.withDialect(Dialect.MYSQL); + private static final Consumer NO_DIALECT = p -> { + }; + + /** SQL spelling, user value, user quote, host value, host quote. */ + static Stream accounts() { + return Stream.of( + Arguments.of("app", "app", "", null, null), + Arguments.of("app@localhost", "app", "", "localhost", ""), + Arguments.of("app@'%'", "app", "", "%", "'"), + Arguments.of("app@'10.0.0.%'", "app", "", "10.0.0.%", "'"), + Arguments.of("'app'@localhost", "app", "'", "localhost", ""), + Arguments.of("'app'@'%'", "app", "'", "%", "'"), + Arguments.of("`app`@`%`", "app", "`", "%", "`"), + Arguments.of("\"app\"@\"%\"", "app", "\"", "%", "\""), + Arguments.of("'app'", "app", "'", null, null), + Arguments.of("`my app`@'%'", "my app", "`", "%", "'")); + } + + @ParameterizedTest + @MethodSource("accounts") + void createUserSplitsEveryAccountSpelling(String account, String user, String userQuote, + String host, String hostQuote) throws JSQLParserException { + for (Consumer mode : java.util.Arrays.asList(MYSQL, NO_DIALECT)) { + String sql = "CREATE USER " + account + " IDENTIFIED BY 'x'"; + CreateUser createUser = assertInstanceOf(CreateUser.class, + assertSqlCanBeParsedAndDeparsed(sql, true, mode)); + assertEquals(sql, createUser.toString()); + assertEquals(sql, deparse(createUser)); + assertPart(createUser.getAccounts().get(0).getUser(), user, userQuote); + assertPart(createUser.getAccounts().get(0).getHost(), host, hostQuote); + } + } + + @ParameterizedTest + @MethodSource("accounts") + void definerSplitsEveryAccountSpelling(String account, String user, String userQuote, + String host, String hostQuote) throws JSQLParserException { + String view = "CREATE DEFINER = " + account + " VIEW v AS SELECT 1"; + CreateView createView = assertInstanceOf(CreateView.class, + assertSqlCanBeParsedAndDeparsed(view, true, MYSQL)); + MySqlDefiner definer = createView.getMySqlOptions().getDefiner(); + assertPart(definer.getUser(), user, userQuote); + assertPart(definer.getHost(), host, hostQuote); + + String trigger = "CREATE DEFINER = " + account + + " TRIGGER trg BEFORE INSERT ON t FOR EACH ROW SET NEW.a = 1"; + CreateTrigger createTrigger = assertInstanceOf(CreateTrigger.class, + assertSqlCanBeParsedAndDeparsed(trigger, true, MYSQL)); + assertEquals(trigger, createTrigger.toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"CREATE USER app@", "CREATE USER @host", "CREATE USER 'app'@"}) + void rejectsMalformedAccounts(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, MYSQL)); + } + + @org.junit.jupiter.api.Test + void keepsDoubleQuotesWithoutDialect() throws JSQLParserException { + // before, the double quotes were dropped and the account rendered as app@% + String sql = "CREATE USER \"app\"@\"%\" IDENTIFIED BY 'x'"; + assertEquals(sql, CCJSqlParserUtil.parse(sql).toString()); + assertEquals(sql, CCJSqlParserUtil.parse(sql, MYSQL).toString()); + } + + @org.junit.jupiter.api.Test + void definerCurrentUserIsUnchanged() throws JSQLParserException { + CreateView view = assertInstanceOf(CreateView.class, assertSqlCanBeParsedAndDeparsed( + "CREATE DEFINER = CURRENT_USER() VIEW v AS SELECT 1", true, MYSQL)); + assertNull(view.getMySqlOptions().getDefiner().getHost()); + } + + private static void assertPart(StringValue part, String value, String quote) { + if (value == null) { + assertNull(part); + return; + } + assertEquals(value, part.getValue()); + assertEquals(quote, part.getQuoteStr()); + } + + private static String deparse(CreateUser createUser) { + StringBuilder builder = new StringBuilder(); + createUser.accept(new StatementDeParser(builder), null); + return builder.toString(); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/grant/GranteeModelTest.java b/src/test/java/net/sf/jsqlparser/statement/grant/GranteeModelTest.java new file mode 100644 index 0000000000..dd26645eed --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/grant/GranteeModelTest.java @@ -0,0 +1,195 @@ +/*- + * #%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.grant; + +import static net.sf.jsqlparser.test.TestUtils.assertEqualsObjectTree; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Arrays; +import java.util.Collections; +import java.util.function.Consumer; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.util.TablesNamesFinder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** The grantee model shared by MySQL and PostgreSQL, and the string-based legacy API. */ +class GranteeModelTest { + + private static final Consumer POSTGRESQL = + parser -> parser.withDialect(Dialect.POSTGRESQL); + + private static Statement parse(String sql) throws JSQLParserException { + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, false, POSTGRESQL); + assertEquals(sql, CCJSqlParserUtil.parse(statement.toString(), POSTGRESQL).toString()); + return statement; + } + + @Test + void typesPostgreSqlRoleSpecifications() throws JSQLParserException { + Grant grant = (Grant) parse("GRANT SELECT ON users TO app, GROUP staff, PUBLIC, " + + "CURRENT_USER, CURRENT_ROLE, SESSION_USER, \"Mixed Case\" GRANTED BY CURRENT_ROLE"); + + assertThat(grant.getClause().getGranteeItems()).extracting(Grantee::getKind) + .containsExactly(Grantee.Kind.ACCOUNT, Grantee.Kind.GROUP, Grantee.Kind.PUBLIC, + Grantee.Kind.CURRENT_USER, Grantee.Kind.CURRENT_ROLE, + Grantee.Kind.SESSION_USER, Grantee.Kind.ACCOUNT); + Grantee group = grant.getClause().getGranteeItems().get(1); + assertEquals("staff", group.getAccount().getUser().getValue()); + assertNull(group.getAccount().getHost()); + Grantee quoted = grant.getClause().getGranteeItems().get(6); + assertEquals("Mixed Case", quoted.getAccount().getUser().getValue()); + assertEquals("\"", quoted.getAccount().getUser().getQuoteStr()); + assertEquals(Grantee.Kind.CURRENT_ROLE, grant.getClause().getGrantor().getKind()); + + assertEquals(Arrays.asList("app", "GROUP staff", "PUBLIC", "CURRENT_USER", "CURRENT_ROLE", + "SESSION_USER", "\"Mixed Case\""), grant.getUsers()); + assertEquals("CURRENT_ROLE", grant.getClause().getGrantedBy()); + assertThat(TablesNamesFinder.findTables(grant.toString())).containsExactly("users"); + } + + @Test + void typesMembershipOptionList() throws JSQLParserException { + Grant grant = (Grant) parse( + "GRANT app_read TO alice WITH INHERIT TRUE, SET FALSE, ADMIN OPTION GRANTED BY postgres"); + + assertNull(grant.getTarget()); + assertThat(grant.getOptions()).extracting(GrantOption::getKind).containsExactly( + GrantOption.Kind.INHERIT, GrantOption.Kind.SET, GrantOption.Kind.ADMIN); + assertThat(grant.getOptions()).extracting(GrantOption::getValue).containsExactly( + GrantOption.Value.TRUE, GrantOption.Value.FALSE, GrantOption.Value.OPTION); + assertSame(grant.getOptions().get(0), grant.getOption()); + assertEquals("postgres", grant.getClause().getGrantor().getAccount().getUser().getValue()); + + grant.setOption(new GrantOption(GrantOption.Kind.ADMIN)); + assertEquals("GRANT app_read TO alice WITH ADMIN OPTION GRANTED BY postgres", + grant.toString()); + grant.setOption(null); + assertEquals("GRANT app_read TO alice GRANTED BY postgres", grant.toString()); + } + + @ParameterizedTest + @ValueSource(strings = { + "GRANT app_read TO alice WITH INHERIT TRUE, INHERIT FALSE", + "GRANT app_read TO alice WITH ADMIN OPTION, GRANT OPTION", + "GRANT SELECT ON users TO alice WITH GRANT OPTION, INHERIT TRUE", + "GRANT SELECT ON users TO alice GRANTED BY PUBLIC", + "GRANT SELECT ON users TO alice GRANTED BY GROUP staff", + "GRANT SELECT ON users TO GROUP" + }) + void rejectsInvalidRoleSpecifications(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, POSTGRESQL)); + } + + @Test + void legacyGranteeStringsWriteThroughTypedState() throws JSQLParserException { + Grant grant = (Grant) CCJSqlParserUtil.parse("GRANT SELECT ON t TO 'app'@'%', app2", + parser -> parser.withDialect(Dialect.MYSQL)); + Grantee typed = grant.getClause().getGranteeItems().get(0); + + grant.getUsers().add("PUBLIC"); + grant.getUsers().set(1, "GROUP staff"); + grant.addUsers("CURRENT_USER()"); + assertSame(typed, grant.getClause().getGranteeItems().get(0)); + assertThat(grant.getClause().getGranteeItems()).extracting(Grantee::getKind) + .containsExactly(Grantee.Kind.ACCOUNT, Grantee.Kind.GROUP, Grantee.Kind.PUBLIC, + Grantee.Kind.CURRENT_USER); + assertEquals("GRANT SELECT ON t TO 'app'@'%', GROUP staff, PUBLIC, CURRENT_USER()", + grant.toString()); + + grant.getUsers().remove(0); + grant.getClause().setGrantedBy("SESSION_USER"); + assertEquals(Grantee.Kind.SESSION_USER, grant.getClause().getGrantor().getKind()); + assertEquals("GRANT SELECT ON t TO GROUP staff, PUBLIC, CURRENT_USER() " + + "GRANTED BY SESSION_USER", grant.toString()); + + grant.setUsers(Collections.singletonList("reader")); + assertEquals(Grantee.Kind.ACCOUNT, grant.getClause().getGranteeItems().get(0).getKind()); + grant.setUsers(null); + assertNull(grant.getUsers()); + assertNull(new Grant().getUsers()); + } + + @Test + void legacyBuildersMatchParsedStatements() throws JSQLParserException { + Grant parsed = + (Grant) CCJSqlParserUtil.parse("GRANT SELECT, CREATE VIEW ON t1 TO u, PUBLIC"); + Grant built = new Grant().addPrivileges("SELECT", "CREATE VIEW").withObjectName("t1") + .addUsers("u", "PUBLIC"); + assertEqualsObjectTree(parsed, built); + assertEquals(Privilege.Kind.CREATE_VIEW, built.getPrivilegeItems().get(1).getKind()); + + Grant role = (Grant) CCJSqlParserUtil.parse("GRANT r1 TO u GRANTED BY CURRENT_USER"); + Grant builtRole = new Grant().withRole("r1").addUsers("u"); + builtRole.getClause().setGrantedBy("CURRENT_USER"); + assertEqualsObjectTree(role, builtRole); + assertNull(builtRole.getTarget()); + + builtRole.getRoles().add("'r2'@'%'"); + assertEquals("GRANT r1, 'r2'@'%' TO u GRANTED BY CURRENT_USER", builtRole.toString()); + assertEquals("r1", builtRole.getRole()); + } + + @Test + void legacyObjectNameReplacesWildcard() throws JSQLParserException { + Grant grant = (Grant) CCJSqlParserUtil.parse("GRANT SELECT ON *.* TO app"); + assertEquals("*.*", grant.getObjectName()); + + grant.setObjectName(Arrays.asList("db1", "t1")); + assertNull(grant.getTarget().getWildcard()); + assertEquals("GRANT SELECT ON db1.t1 TO app", grant.toString()); + + Grant role = (Grant) CCJSqlParserUtil.parse("GRANT reader TO app"); + assertNull(role.getObjectName()); + role.setPrivileges(Collections.singletonList("SELECT")); + role.setObjectName("t1"); + assertEquals("GRANT SELECT ON t1 TO app", role.toString()); + } + + @Test + void legacyPrivilegeNames() { + assertEquals(Privilege.Kind.CREATE_TEMPORARY_TABLES, + new Privilege("CREATE TEMPORARY TABLES").getKind()); + assertEquals(Privilege.Kind.ALTER_SYSTEM, new Privilege("ALTER SYSTEM").getKind()); + Privilege unknown = new Privilege("DYNAMIC"); + assertNull(unknown.getKind()); + assertEquals("DYNAMIC", unknown.toString()); + Privilege text = new Privilege("select (a)"); + assertNull(text.getKind()); + assertEquals("select (a)", text.getName()); + assertFalse(new Privilege(Privilege.Kind.SELECT).isUsePrivileges()); + } + + @Test + void grantedByAndRolesAcceptLegacyText() { + PrivilegeClause clause = new PrivilegeClause(); + clause.setGrantedBy("GROUP admins"); + assertEquals(Grantee.Kind.GROUP, clause.getGrantor().getKind()); + clause.setGrantedBy(null); + assertNull(clause.getGrantor()); + + clause.setRoles(Arrays.asList("a", "b")); + clause.getRoles().set(0, "c"); + assertEquals(Arrays.asList("c", "b"), clause.getRoles()); + clause.setRoles(null); + assertEquals(Collections.emptyList(), clause.getRoleItems()); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/grant/MySqlGrantRevokeTest.java b/src/test/java/net/sf/jsqlparser/statement/grant/MySqlGrantRevokeTest.java new file mode 100644 index 0000000000..9e1009ef2c --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/grant/MySqlGrantRevokeTest.java @@ -0,0 +1,355 @@ +/*- + * #%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.grant; + +import static net.sf.jsqlparser.test.TestUtils.assertDeparse; +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static net.sf.jsqlparser.util.validation.ValidationTestAsserts.validateNoErrors; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StmtFeature; +import net.sf.jsqlparser.statement.create.user.RoleSelection; +import net.sf.jsqlparser.statement.create.user.UserAccount; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.validation.feature.DatabaseType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class MySqlGrantRevokeTest { + + private static final Consumer MYSQL = parser -> parser.withDialect(Dialect.MYSQL); + + /** Parses with the MySQL dialect, checks toString and the deparser, and reparses. */ + private static Statement parse(String sql) throws JSQLParserException { + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, false, MYSQL); + assertEquals(sql, CCJSqlParserUtil.parse(statement.toString(), MYSQL).toString()); + return statement; + } + + private static Grant grant(String sql) throws JSQLParserException { + return (Grant) parse(sql); + } + + private static Revoke revoke(String sql) throws JSQLParserException { + return (Revoke) parse(sql); + } + + /** The value and quote of the user and host, for example {@code app(')@%(')}. */ + private static String account(UserAccount account) { + StringValue host = account.getHost(); + return account.getUser().getValue() + "(" + account.getUser().getQuoteStr() + ")" + + (host == null ? "" : "@" + host.getValue() + "(" + host.getQuoteStr() + ")"); + } + + @ParameterizedTest + @ValueSource(strings = { + "GRANT SELECT ON db1.* TO 'app'@'%'", + "GRANT SELECT, INSERT, UPDATE, DELETE ON `db1`.`t1` TO 'app'@'%' WITH GRANT OPTION", + "GRANT ALL PRIVILEGES ON *.* TO 'admin'@'localhost' WITH GRANT OPTION", + "GRANT SELECT (id, name), UPDATE (name) ON db1.t1 TO 'app'@'%'", + "GRANT EXECUTE ON PROCEDURE db1.p1 TO 'app'@'%'", + "GRANT EXECUTE ON FUNCTION db1.f1 TO app@'%'", + "GRANT CREATE, ALTER, DROP, INDEX, REFERENCES, CREATE VIEW, SHOW VIEW, TRIGGER, EVENT, " + + "CREATE ROUTINE, ALTER ROUTINE, CREATE TEMPORARY TABLES, LOCK TABLES " + + "ON db1.* TO 'dev'@'%'", + "GRANT SELECT, RELOAD, SHOW DATABASES, REPLICATION SLAVE, REPLICATION CLIENT " + + "ON *.* TO 'cdc'@'%'", + "GRANT PROCESS, SUPER, FILE, SHUTDOWN, CREATE USER, CREATE TABLESPACE, CREATE ROLE, " + + "DROP ROLE, USAGE, GRANT OPTION ON *.* TO 'admin'@'%'", + "GRANT BACKUP_ADMIN, replication_slave_admin ON *.* TO 'admin'@'%'", + "GRANT 'reader', 'writer'@'%' TO 'app'@'%' WITH ADMIN OPTION", + "GRANT reader TO app", + "GRANT PROXY ON 'a'@'%' TO 'b'@'%', CURRENT_USER WITH GRANT OPTION", + "GRANT SELECT ON TABLE db1.* TO `app`@`%`", + "GRANT ALL ON * TO app@localhost", + "GRANT SELECT ON `my-db`.* TO 'app'@localhost, CURRENT_USER()", + "GRANT SELECT ON db1.* TO 'app'@'%' AS 'admin'@'%' WITH ROLE 'r1', r2@'%'", + "GRANT SELECT ON db1.t1 TO app WITH GRANT OPTION AS CURRENT_USER WITH ROLE DEFAULT", + "REVOKE INSERT ON db1.* FROM 'app'@'%', app2", + "REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'app'@'%'", + "REVOKE ALL, GRANT OPTION FROM app", + "REVOKE GRANT OPTION ON *.* FROM app", + "REVOKE 'reader', writer FROM 'app'@'%'", + "REVOKE IF EXISTS SELECT ON db1.t1 FROM 'app'@'%' IGNORE UNKNOWN USER", + "REVOKE IF EXISTS PROXY ON 'a'@'%' FROM 'b'@'%'", + "REVOKE REPLICATION SLAVE, REPLICATION CLIENT ON *.* FROM 'repl'@'%'" + }) + void roundTripsMySqlForms(String sql) throws JSQLParserException { + Statement statement = parse(sql); + + Statement withoutDialect = assertSqlCanBeParsedAndDeparsed(sql, false); + assertEquals(statement.toString(), withoutDialect.toString()); + assertThat(statement.getFeatures().getCertain()) + .containsExactly(StmtFeature.MODIFIES_SCHEMA); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', value = { + "* | DEFAULT_DATABASE | [] | *", + "*.* | GLOBAL | [] | *.*", + "db1.* | DATABASE | [db1] | db1.*", + "`my-db`.* | DATABASE | [`my-db`] | `my-db`.*" + }) + void typesWildcardPrivilegeLevels(String level, PrivilegeTarget.Wildcard wildcard, + String names, String objectName) throws JSQLParserException { + Grant grant = grant("GRANT SELECT ON " + level + " TO app"); + PrivilegeTarget target = grant.getTarget(); + + assertEquals(wildcard, target.getWildcard()); + assertEquals(PrivilegeTarget.Kind.TABLE, target.getKind()); + assertFalse(target.isExplicitKind()); + assertEquals(names, target.getNames().stream().map(name -> name.get(0)) + .collect(Collectors.toList()).toString()); + assertEquals(objectName, grant.getObjectName()); + assertThat(TablesNamesFinder.findTables(grant.toString())).isEmpty(); + } + + @Test + void namedPrivilegeLevelsRemainTables() throws JSQLParserException { + Grant qualified = grant("GRANT SELECT ON TABLE db1.t1 TO app"); + assertNull(qualified.getTarget().getWildcard()); + assertTrue(qualified.getTarget().isExplicitKind()); + assertEquals(Arrays.asList("db1", "t1"), qualified.getTarget().getNames().get(0)); + assertThat(TablesNamesFinder.findTables(qualified.toString())).containsExactly("db1.t1"); + + Grant routine = grant("GRANT EXECUTE ON PROCEDURE db1.p1 TO app"); + assertEquals(PrivilegeTarget.Kind.PROCEDURE, routine.getTarget().getKind()); + assertEquals("db1.p1", routine.getTarget().getRoutines().get(0).getName()); + } + + @Test + void typesStaticAndDynamicPrivileges() throws JSQLParserException { + Grant grant = grant("GRANT CREATE TEMPORARY TABLES, LOCK TABLES, SHOW DATABASES, " + + "REPLICATION SLAVE, REPLICATION CLIENT, ALTER ROUTINE, CREATE VIEW, INDEX, " + + "SHUTDOWN, GRANT OPTION, backup_admin ON *.* TO app"); + + assertThat(grant.getPrivilegeItems()).extracting(Privilege::getKind).containsExactly( + Privilege.Kind.CREATE_TEMPORARY_TABLES, Privilege.Kind.LOCK_TABLES, + Privilege.Kind.SHOW_DATABASES, Privilege.Kind.REPLICATION_SLAVE, + Privilege.Kind.REPLICATION_CLIENT, Privilege.Kind.ALTER_ROUTINE, + Privilege.Kind.CREATE_VIEW, Privilege.Kind.INDEX, Privilege.Kind.SHUTDOWN, + Privilege.Kind.GRANT_OPTION, Privilege.Kind.DYNAMIC); + Privilege dynamic = grant.getPrivilegeItems().get(10); + assertEquals("backup_admin", dynamic.getDynamicName()); + assertEquals("backup_admin", dynamic.getName()); + assertEquals("CREATE TEMPORARY TABLES", grant.getPrivilegeItems().get(0).getName()); + assertEquals("CREATE TEMPORARY TABLES", grant.getPrivileges().get(0)); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', quoteCharacter = '"', value = { + "'app'@'%' | app(')@%(')", + "app@'10.0.0.%' | app()@10.0.0.%(')", + "app@localhost | app()@localhost()", + "'app'@localhost | app(')@localhost()", + "`app`@`%` | app(`)@%(`)", + "'app' | app(')", + "app | app()" + }) + void typesAccountGrantees(String account, String expected) throws JSQLParserException { + Grant grant = grant("GRANT SELECT ON db1.t1 TO " + account); + Grantee grantee = grant.getClause().getGranteeItems().get(0); + + assertEquals(Grantee.Kind.ACCOUNT, grantee.getKind()); + assertEquals(expected, account(grantee.getAccount())); + assertEquals(Collections.singletonList(account), grant.getUsers()); + } + + @Test + void typesCurrentUser() throws JSQLParserException { + Grant grant = grant("GRANT SELECT ON db1.* TO CURRENT_USER, CURRENT_USER()"); + List grantees = grant.getClause().getGranteeItems(); + + assertThat(grantees).extracting(Grantee::getKind) + .containsExactly(Grantee.Kind.CURRENT_USER, Grantee.Kind.CURRENT_USER); + assertFalse(grantees.get(0).isCurrentUserParentheses()); + assertTrue(grantees.get(1).isCurrentUserParentheses()); + assertNull(grantees.get(0).getAccount()); + assertEquals(Arrays.asList("CURRENT_USER", "CURRENT_USER()"), grant.getUsers()); + } + + @Test + void roleGrantsHaveTypedRolesAndNoTarget() throws JSQLParserException { + Grant grant = grant("GRANT 'reader', 'writer'@'%' TO 'app'@'%' WITH ADMIN OPTION"); + + assertNull(grant.getTarget()); + assertNull(grant.getPrivilegeItems()); + assertNull(grant.getObjectName()); + assertThat(grant.getClause().getRoleItems()).extracting(MySqlGrantRevokeTest::account) + .containsExactly("reader(')", "writer(')@%(')"); + assertEquals(Arrays.asList("'reader'", "'writer'@'%'"), grant.getRoles()); + assertEquals("'reader'", grant.getRole()); + assertEquals(GrantOption.Kind.ADMIN, grant.getOption().getKind()); + assertThat(TablesNamesFinder.findTables(grant.toString())).isEmpty(); + + Revoke revoke = revoke("REVOKE 'reader' FROM 'app'@'%'"); + assertNull(revoke.getClause().getTarget()); + assertEquals("reader", revoke.getClause().getRoleItems().get(0).getUser().getValue()); + } + + @Test + void proxyTargetsAnAccount() throws JSQLParserException { + Grant grant = grant("GRANT PROXY ON 'a'@'%' TO 'b'@'%' WITH GRANT OPTION"); + + assertThat(grant.getPrivilegeItems()).extracting(Privilege::getKind) + .containsExactly(Privilege.Kind.PROXY); + PrivilegeTarget target = grant.getTarget(); + assertEquals(PrivilegeTarget.Kind.ACCOUNT, target.getKind()); + assertEquals("a(')@%(')", account(target.getAccount().getAccount())); + assertTrue(target.getNames().isEmpty()); + assertNull(grant.getObjectName()); + assertThat(TablesNamesFinder.findTables(grant.toString())).isEmpty(); + + Revoke revoke = revoke("REVOKE PROXY ON CURRENT_USER FROM 'b'@'%'"); + assertEquals(Grantee.Kind.CURRENT_USER, + revoke.getClause().getTarget().getAccount().getKind()); + } + + @Test + void revokesAllPrivilegesAndGrantOptionWithoutTarget() throws JSQLParserException { + Revoke revoke = revoke("REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'app'@'%', app2"); + PrivilegeClause clause = revoke.getClause(); + + assertThat(clause.getPrivileges()).extracting(Privilege::getKind) + .containsExactly(Privilege.Kind.ALL, Privilege.Kind.GRANT_OPTION); + assertTrue(clause.getPrivileges().get(0).isUsePrivileges()); + assertNull(clause.getTarget()); + assertEquals(Arrays.asList("'app'@'%'", "app2"), clause.getGrantees()); + assertThat(TablesNamesFinder.findTables(revoke.toString())).isEmpty(); + } + + @Test + void revokeIfExistsIgnoreUnknownUser() throws JSQLParserException { + Revoke revoke = + revoke("REVOKE IF EXISTS SELECT ON db1.t1 FROM 'app'@'%' IGNORE UNKNOWN USER"); + assertTrue(revoke.isIfExists()); + assertTrue(revoke.isIgnoreUnknownUser()); + assertThat(TablesNamesFinder.findTables(revoke.toString())).containsExactly("db1.t1"); + + revoke.setIfExists(false); + revoke.setIgnoreUnknownUser(false); + assertEquals("REVOKE SELECT ON db1.t1 FROM 'app'@'%'", revoke.toString()); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', quoteCharacter = '"', value = { + "DEFAULT | DEFAULT | \"\"", + "NONE | NONE | \"\"", + "ALL | ALL | \"\"", + "ALL EXCEPT 'r1', r2@'%' | ALL_EXCEPT | 'r1', r2@'%'", + "'r1', r2 | ROLES | 'r1', r2" + }) + void typesAsUserWithRole(String selection, RoleSelection.Kind kind, String roles) + throws JSQLParserException { + Grant grant = grant("GRANT SELECT ON db1.* TO app AS 'admin'@'%' WITH ROLE " + selection); + + assertEquals(Grantee.Kind.ACCOUNT, grant.getAsUser().getKind()); + assertEquals("admin(')@%(')", account(grant.getAsUser().getAccount())); + assertEquals(kind, grant.getWithRole().getKind()); + assertEquals(roles, grant.getWithRole().getRoles().stream().map(Object::toString) + .collect(Collectors.joining(", "))); + } + + @ParameterizedTest + @ValueSource(strings = { + "GRANT SELECT ON db1.t1.* TO app", + "GRANT SELECT ON db1.*, db2.* TO app", + "GRANT USAGE ON SCHEMA * TO app", + "GRANT EXECUTE ON PROCEDURE db1.* TO app", + "GRANT PROXY ON db1.t1 TO app", + "GRANT PROXY, SELECT ON t1 TO app", + "GRANT PROXY ON PUBLIC TO app", + "GRANT PROXY ON 'a'@'%' TO app AS admin", + "GRANT reader TO app AS admin", + "GRANT SELECT ON t1 TO app AS PUBLIC", + "GRANT SELECT ON t1 TO app WITH ROLE r1", + "GRANT SELECT ON t1 TO app WITH GRANT OPTION, GRANT OPTION", + "GRANT SELECT ON t1 TO 'app'@", + "GRANT SELECT ON t1 TO app@", + "GRANT SELECT ON t1 TO PUBLIC()", + "GRANT BACKUP_ADMIN PRIVILEGES ON *.* TO app", + "GRANT ALL PRIVILEGES, GRANT OPTION TO app", + "REVOKE SELECT ON t1 FROM app IGNORE UNKNOWN", + "ALTER DEFAULT PRIVILEGES GRANT SELECT ON *.* TO app", + "ALTER DEFAULT PRIVILEGES REVOKE IF EXISTS SELECT ON TABLES FROM app", + "ALTER DEFAULT PRIVILEGES REVOKE SELECT ON TABLES FROM app IGNORE UNKNOWN USER", + "ALTER DEFAULT PRIVILEGES GRANT PROXY ON a TO app", + "ALTER DEFAULT PRIVILEGES REVOKE ALL, GRANT OPTION FROM app" + }) + void rejectsInvalidForms(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, MYSQL)); + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql)); + } + + @Test + void buildsTypedGrant() { + PrivilegeTarget target = new PrivilegeTarget(); + target.setWildcard(PrivilegeTarget.Wildcard.DATABASE); + target.getNames().add(Collections.singletonList("db1")); + UserAccount app = new UserAccount().withUser(new StringValue("app")) + .withHost(new StringValue("%")); + Grant grant = new Grant(); + grant.setPrivilegeItems(Arrays.asList(new Privilege(Privilege.Kind.SHOW_VIEW), + Privilege.dynamic("BACKUP_ADMIN"))); + grant.setTarget(target); + grant.getClause().setGranteeItems(Arrays.asList(new Grantee(app), + new Grantee(Grantee.Kind.CURRENT_USER).withCurrentUserParentheses(true))); + grant.setOption(new GrantOption(GrantOption.Kind.GRANT)); + grant.setAsUser(new Grantee(new UserAccount().withUser(new StringValue("admin")))); + grant.setWithRole(new RoleSelection(RoleSelection.Kind.ALL_EXCEPT) + .withRoles(Collections.singletonList( + new UserAccount().withUser(new StringValue("r1"))))); + + assertDeparse(grant, "GRANT SHOW VIEW, BACKUP_ADMIN ON db1.* TO 'app'@'%', " + + "CURRENT_USER() WITH GRANT OPTION AS 'admin' WITH ROLE ALL EXCEPT 'r1'"); + + Revoke revoke = new Revoke(); + revoke.setIfExists(true); + revoke.getClause().setPrivileges(Collections.singletonList( + new Privilege(Privilege.Kind.PROXY))); + PrivilegeTarget proxied = new PrivilegeTarget(); + proxied.setKind(PrivilegeTarget.Kind.ACCOUNT); + proxied.setAccount(new Grantee(app)); + revoke.getClause().setTarget(proxied); + revoke.getClause().setGranteeItems( + Collections.singletonList(new Grantee(Grantee.Kind.CURRENT_USER))); + revoke.setIgnoreUnknownUser(true); + assertDeparse(revoke, + "REVOKE IF EXISTS PROXY ON 'app'@'%' FROM CURRENT_USER IGNORE UNKNOWN USER"); + } + + @Test + void validatesWithMySqlAndMariaDbCapabilities() { + for (String sql : Arrays.asList("GRANT SELECT ON db1.* TO 'app'@'%'", + "GRANT 'reader' TO 'app'@'%'", + "REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'app'@'%'", + "REVOKE SELECT ON *.* FROM app")) { + validateNoErrors(sql, 1, DatabaseType.MYSQL, DatabaseType.MARIADB); + } + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/role/MySqlCreateRoleTest.java b/src/test/java/net/sf/jsqlparser/statement/role/MySqlCreateRoleTest.java new file mode 100644 index 0000000000..29f97556cc --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/role/MySqlCreateRoleTest.java @@ -0,0 +1,115 @@ +/*- + * #%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.role; + +import static net.sf.jsqlparser.test.TestUtils.assertDeparse; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.stream.Collectors; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.create.user.UserAccount; +import net.sf.jsqlparser.test.TestUtils; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +class MySqlCreateRoleTest { + + private static CreateRole parse(String sql, Dialect dialect) throws JSQLParserException { + CreateRole role = assertInstanceOf(CreateRole.class, TestUtils + .assertSqlCanBeParsedAndDeparsed(sql, false, + parser -> parser.withDialect(dialect))); + assertEquals(sql, CCJSqlParserUtil + .parse(role.toString(), parser -> parser.withDialect(dialect)).toString()); + return role; + } + + private static List names(CreateRole role) { + return role.getRoles().stream().map(UserAccount::toString).collect(Collectors.toList()); + } + + @ParameterizedTest + @EnumSource(value = Dialect.class, names = {"MYSQL", "MARIADB"}) + void parsesAccountStyleRoleNames(Dialect dialect) throws JSQLParserException { + CreateRole role = parse("CREATE ROLE 'reader', 'writer'@'localhost'", dialect); + + assertEquals(List.of("'reader'", "'writer'@'localhost'"), names(role)); + assertEquals("localhost", role.getRoles().get(1).getHost().getValue()); + assertNull(role.getName()); + assertFalse(role.isIfNotExists()); + assertTrue(role.getOptions().isEmpty()); + } + + @Test + void parsesIfNotExists() throws JSQLParserException { + CreateRole role = parse("CREATE ROLE IF NOT EXISTS app_read, app_write", Dialect.MYSQL); + + assertTrue(role.isIfNotExists()); + assertEquals(List.of("app_read", "app_write"), names(role)); + assertInstanceOf(CreateRole.class, TestUtils + .assertSqlCanBeParsedAndDeparsed("CREATE ROLE IF NOT EXISTS 'reader'", false)); + } + + @Test + void keepsPostgreSqlRoleName() throws JSQLParserException { + // a single unquoted name means the same in both databases and keeps getName() + for (Dialect dialect : List.of(Dialect.MYSQL, Dialect.POSTGRESQL)) { + CreateRole role = parse("CREATE ROLE reader", dialect); + assertEquals("reader", role.getName()); + assertTrue(role.getRoles().isEmpty()); + } + CreateRole role = parse("CREATE ROLE reader WITH LOGIN", Dialect.POSTGRESQL); + assertEquals(RoleOption.Kind.LOGIN, role.getOptions().get(0).getKind()); + } + + @Test + void splitsAnUnquotedNameAtHostOnlyWithMySqlDialects() throws JSQLParserException { + CreateRole mySql = parse("CREATE ROLE r@localhost", Dialect.MYSQL); + assertEquals("r", mySql.getRoles().get(0).getUser().getValue()); + assertEquals("localhost", mySql.getRoles().get(0).getHost().getValue()); + + // without a dialect the lexer's single identifier stays the PostgreSQL role name + CreateRole role = assertInstanceOf(CreateRole.class, + TestUtils.assertSqlCanBeParsedAndDeparsed("CREATE ROLE r@localhost", false)); + assertEquals("r@localhost", role.getName()); + assertEquals(List.of("r", "'%'"), List.of( + parse("CREATE ROLE r@'%'", Dialect.ANSI_SQL).getRoles().get(0).getUser().getValue(), + parse("CREATE ROLE r@'%'", Dialect.ANSI_SQL).getRoles().get(0).getHost() + .toString())); + } + + @Test + void buildsAndDeparses() { + CreateRole role = new CreateRole(); + role.setIfNotExists(true); + role.setRoles(List.of(new UserAccount().withUser(new StringValue("r1")), + new UserAccount().withUser(new StringValue("r2")).withHost(new StringValue("%")))); + + assertDeparse(role, "CREATE ROLE IF NOT EXISTS 'r1', 'r2'@'%'"); + } + + @ParameterizedTest + @ValueSource(strings = {"CREATE OR REPLACE ROLE 'r'", "CREATE ROLE 'r' WITH LOGIN", + "CREATE ROLE IF NOT EXISTS", "CREATE ROLE 'r'@'h' IDENTIFIED BY 'x'", + "CREATE ROLE r1, r2 LOGIN"}) + void rejectsMixedSyntax(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql)); + } +} diff --git a/src/test/java/net/sf/jsqlparser/util/validation/validator/AccountManagementValidatorTest.java b/src/test/java/net/sf/jsqlparser/util/validation/validator/AccountManagementValidatorTest.java new file mode 100644 index 0000000000..218994770a --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/util/validation/validator/AccountManagementValidatorTest.java @@ -0,0 +1,53 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.util.validation.validator; + +import java.util.Map; +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.FeaturesAllowed; +import net.sf.jsqlparser.util.validation.feature.MariaDbVersion; +import net.sf.jsqlparser.util.validation.feature.MySqlVersion; +import net.sf.jsqlparser.util.validation.feature.PostgresqlVersion; +import org.junit.jupiter.api.Test; + +public class AccountManagementValidatorTest extends ValidationTestAsserts { + + private static final Map STATEMENTS = Map.of( + "ALTER USER 'app'@'%' IDENTIFIED BY 'x' ACCOUNT LOCK", Feature.alterUser, + "RENAME USER 'a'@'%' TO 'b'@'%'", Feature.renameUser, + "SET ROLE ALL EXCEPT 'writer'", Feature.setRole, + "SET DEFAULT ROLE ALL TO 'app'@'%'", Feature.setDefaultRole, + "SET PASSWORD FOR 'app'@'%' = 'x'", Feature.setPassword, + "CREATE ROLE IF NOT EXISTS 'reader'", Feature.createRole); + + @Test + public void testAllowedForMySqlAndMariaDb() throws JSQLParserException { + for (String sql : STATEMENTS.keySet()) { + validateNoErrors(sql, 1, MySqlVersion.V8_0, MariaDbVersion.V10_5_4); + } + validateNoErrors("CREATE USER 'app'@'%' REQUIRE SSL PASSWORD EXPIRE NEVER", 1, + MySqlVersion.V8_0, MariaDbVersion.V10_5_4); + } + + @Test + public void testMySqlAccountStatementsNotAllowedForPostgresql() throws JSQLParserException { + validate("ALTER USER 'app'@'%' ACCOUNT LOCK", 1, 1, PostgresqlVersion.V14); + validate("SET DEFAULT ROLE ALL TO app", 1, 1, PostgresqlVersion.V14); + } + + @Test + public void testFeatureNotAllowed() throws JSQLParserException { + for (Map.Entry entry : STATEMENTS.entrySet()) { + validateNotAllowed(entry.getKey(), 1, 1, FeaturesAllowed.DDL, entry.getValue()); + } + } +}