From 1af2ff3e8656992fca98f24fcfb06aec5a001278 Mon Sep 17 00:00:00 2001 From: minleejae Date: Wed, 14 Oct 2026 13:17:49 +0900 Subject: [PATCH 1/3] fix: share MySQL account names across CREATE USER and DEFINER CREATE USER and DEFINER read user@host with their own copies of the same rule. Neither accepted app@'%' or 'app'@localhost, because the lexer reads app@ and @localhost as single tokens. Without a dialect a double-quoted part also lost its quotes, so CREATE USER "app"@"%" rendered as app@%. Add one MySqlAccountName() production that splits those tokens, keep the quote character of double-quoted and backquoted parts apart from their value, and use it for CREATE USER and DEFINER. Signed-off-by: minleejae --- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 70 ++++++++-- src/site/sphinx/usage.rst | 14 ++ .../create/user/MySqlAccountNameTest.java | 123 ++++++++++++++++++ 3 files changed, 197 insertions(+), 10 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/statement/create/user/MySqlAccountNameTest.java diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 2210dbe9d..9fcc539ce 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -15502,11 +15502,66 @@ 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; } +} + UserAuthentication MySqlUserAuthentication(): { UserAuthentication authentication = new UserAuthentication(); @@ -15557,14 +15612,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; } } @@ -15710,8 +15762,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 +15772,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()); } ) } diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 901608d62..26f7ff5d8 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -1718,6 +1718,20 @@ 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. 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 000000000..455f18b6a --- /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(); + } +} From ce2cdaa4993cc5a192fb0f7a726009b14e98f4bb Mon Sep 17 00:00:00 2001 From: minleejae Date: Wed, 14 Oct 2026 13:01:59 +0900 Subject: [PATCH 2/3] feat: parse MySQL GRANT and REVOKE privilege levels, privileges and accounts Parse MySQL privilege levels (*, *.*, db.*), static and dynamic privileges, user@host accounts, quoted role grants, PROXY, REVOKE ALL PRIVILEGES, GRANT OPTION, REVOKE IF EXISTS ... IGNORE UNKNOWN USER and GRANT ... AS user WITH ROLE. Grantees, granted roles and the grantor use a typed Grantee/UserAccount model shared with PostgreSQL, which also gains membership option lists such as WITH INHERIT TRUE, SET FALSE. Accounts are read with the shared MySqlAccountName() rule, and WITH ROLE uses a RoleSelection next to UserAccount. Role memberships no longer carry an empty table target. The string accessors remain as derived views. Signed-off-by: minleejae --- .../statement/create/user/RoleSelection.java | 100 +++++ .../sf/jsqlparser/statement/grant/Grant.java | 128 ++++--- .../jsqlparser/statement/grant/Grantee.java | 125 ++++++ .../jsqlparser/statement/grant/Privilege.java | 57 ++- .../statement/grant/PrivilegeClause.java | 92 ++++- .../statement/grant/PrivilegeTarget.java | 78 +++- .../sf/jsqlparser/statement/grant/Revoke.java | 30 +- .../statement/grant/StringListView.java | 52 +++ .../validation/feature/MariaDbVersion.java | 3 +- .../util/validation/feature/MySqlVersion.java | 3 +- .../validation/validator/GrantValidator.java | 11 +- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 240 ++++++++++-- src/site/sphinx/usage.rst | 59 ++- .../statement/grant/GranteeModelTest.java | 195 ++++++++++ .../statement/grant/MySqlGrantRevokeTest.java | 355 ++++++++++++++++++ 15 files changed, 1420 insertions(+), 108 deletions(-) create mode 100644 src/main/java/net/sf/jsqlparser/statement/create/user/RoleSelection.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/grant/Grantee.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/grant/StringListView.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/grant/GranteeModelTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/grant/MySqlGrantRevokeTest.java 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 000000000..eb78089c3 --- /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/grant/Grant.java b/src/main/java/net/sf/jsqlparser/statement/grant/Grant.java index a9c43dc26..3962e967d 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 000000000..a53b02064 --- /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 b1f3b461a..116ae93df 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 4f517d122..49f2ddb77 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 a92ac8539..7b815c072 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 f613e2d02..620827f58 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 000000000..9f220aa0e --- /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/util/validation/feature/MariaDbVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java index de84d3f82..ca4698e78 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 @@ -124,7 +124,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 6aabf1fe0..2aee4aef6 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 @@ -112,7 +112,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 c37f214ad..952163a67 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/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 9fcc539ce..6bfd7a0bc 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -2230,6 +2230,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; } @@ -15562,6 +15593,52 @@ UserAccount MySqlAccountName(): { 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(); @@ -20510,12 +20587,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(); } @@ -20591,34 +20702,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(): @@ -20626,31 +20804,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"); } + ] + [ 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") }) AccessKeyword("GRANTED") name=RelObjectName() - { requireAccessSyntax(!defaults, "GRANTED BY is not part of default privileges"); clause.setGrantedBy(name); } ] + [ 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; } diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 26f7ff5d8..944293b50 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -1737,7 +1737,7 @@ character in ``getQuoteStr()`` and its text in ``getValue()``:: ``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 ``()``. @@ -1766,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 ---------------------------- 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 000000000..dd26645ee --- /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 000000000..9e1009ef2 --- /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); + } + } +} From f5b522acd5baa549c278f457d85c514fbf50e61b Mon Sep 17 00:00:00 2001 From: minleejae Date: Wed, 14 Oct 2026 13:09:15 +0900 Subject: [PATCH 3/3] feat: parse MySQL ALTER USER, RENAME USER and role statements with shared user options Model the account options of CREATE USER and ALTER USER once in UserOptions (DEFAULT ROLE, REQUIRE, WITH resource limits, password and locking options, COMMENT/ATTRIBUTE), parsed by one shared production. Add ALTER USER, RENAME USER, MySQL CREATE ROLE names, SET ROLE, SET DEFAULT ROLE and SET PASSWORD, reading accounts and roles with the shared MySqlAccountName(), MySqlAccountNameList() and MySqlRoleSelection() productions. PostgreSQL ALTER USER and SET ROLE keep their existing AST. SET ROLE and SET PASSWORD without FOR become typed statements with the MySQL and MariaDB dialects. Signed-off-by: minleejae --- README.md | 1 + .../sf/jsqlparser/parser/feature/Feature.java | 11 + .../statement/SetDefaultRoleStatement.java | 94 ++++ .../statement/SetPasswordStatement.java | 130 +++++ .../statement/SetRoleStatement.java | 52 ++ .../statement/StatementFeatureVisitor.java | 27 + .../statement/StatementVisitor.java | 42 ++ .../statement/StatementVisitorAdapter.java | 27 + .../jsqlparser/statement/alter/AlterUser.java | 122 +++++ .../statement/alter/RenameUser.java | 95 ++++ .../statement/create/user/CreateUser.java | 29 +- .../create/user/PasswordLockOption.java | 123 +++++ .../statement/create/user/ResourceLimit.java | 63 +++ .../statement/create/user/TlsOption.java | 99 ++++ .../create/user/UserAuthentication.java | 57 +- .../statement/create/user/UserOptions.java | 187 +++++++ .../jsqlparser/statement/role/CreateRole.java | 46 +- .../sf/jsqlparser/util/TablesNamesFinder.java | 32 ++ .../util/deparser/StatementDeParser.java | 34 ++ .../validation/feature/MariaDbVersion.java | 4 + .../util/validation/feature/MySqlVersion.java | 4 + .../validator/StatementValidator.java | 56 ++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 504 +++++++++++++++++- src/site/sphinx/index.rst | 2 + src/site/sphinx/usage.rst | 59 ++ .../statement/SetPasswordStatementTest.java | 126 +++++ .../statement/SetRoleStatementTest.java | 175 ++++++ .../statement/alter/AlterUserTest.java | 266 +++++++++ .../statement/alter/RenameUserTest.java | 89 ++++ .../statement/create/user/CreateUserTest.java | 211 ++++++++ .../statement/role/MySqlCreateRoleTest.java | 115 ++++ .../AccountManagementValidatorTest.java | 53 ++ 32 files changed, 2928 insertions(+), 7 deletions(-) create mode 100644 src/main/java/net/sf/jsqlparser/statement/SetDefaultRoleStatement.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/SetPasswordStatement.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/SetRoleStatement.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/alter/AlterUser.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/alter/RenameUser.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/create/user/PasswordLockOption.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/create/user/ResourceLimit.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/create/user/TlsOption.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/create/user/UserOptions.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/SetPasswordStatementTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/SetRoleStatementTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/alter/AlterUserTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/alter/RenameUserTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/role/MySqlCreateRoleTest.java create mode 100644 src/test/java/net/sf/jsqlparser/util/validation/validator/AccountManagementValidatorTest.java diff --git a/README.md b/README.md index d39e81ea6..da6d1c279 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 0d3d080fb..dd41956fc 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 000000000..0e0268cde --- /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 000000000..17125d55f --- /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 000000000..4de3090f8 --- /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 91b272124..84215b233 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 bf0bf3e8b..b7230966c 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 8a80763ca..5a85f0643 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 000000000..b27dfa07b --- /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 000000000..152936b91 --- /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 ce5f16273..c08e342a8 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 000000000..2124454b7 --- /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 000000000..22ce1e36a --- /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/TlsOption.java b/src/main/java/net/sf/jsqlparser/statement/create/user/TlsOption.java new file mode 100644 index 000000000..75a967f26 --- /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 d05e76af9..b07864d82 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 000000000..84233c83a --- /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/role/CreateRole.java b/src/main/java/net/sf/jsqlparser/statement/role/CreateRole.java index 79cfa668b..13a557b86 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 aeecdb89d..640bbb5a1 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 2490baa3e..56de99530 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 ca4698e78..b19d040f9 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, 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 2aee4aef6..7c048893d 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, 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 1de8380a7..1801600ff 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 6bfd7a0bc..fef797f2b 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) { @@ -3934,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() | @@ -3970,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() @@ -15708,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(); @@ -19713,6 +20210,8 @@ Statement Alter(): | LOOKAHEAD() statement = AlterPolicy() | + LOOKAHEAD({ isMySqlAlterUserAhead() }) statement = AlterUser() + | LOOKAHEAD({ isKeywordAhead("ROLE") || isKeywordAhead("USER") || isKeywordAhead("GROUP") }) statement = AlterRole() | @@ -21163,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 5f3b0157b..7c0bf445b 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 944293b50..718442176 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -1963,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 000000000..6b19e84b8 --- /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 000000000..8b24d9bd1 --- /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 000000000..d784a16fe --- /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 000000000..656eb3fd5 --- /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 d0dcbb1af..a5bedb1c5 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/role/MySqlCreateRoleTest.java b/src/test/java/net/sf/jsqlparser/statement/role/MySqlCreateRoleTest.java new file mode 100644 index 000000000..29f97556c --- /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 000000000..218994770 --- /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()); + } + } +}