From 1af2ff3e8656992fca98f24fcfb06aec5a001278 Mon Sep 17 00:00:00 2001 From: minleejae Date: Wed, 14 Oct 2026 13:17:49 +0900 Subject: [PATCH 1/2] 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 2210dbe9d8..9fcc539cef 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 901608d62c..26f7ff5d86 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 0000000000..455f18b6a5 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/user/MySqlAccountNameTest.java @@ -0,0 +1,123 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.create.user; + +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.function.Consumer; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.MySqlDefiner; +import net.sf.jsqlparser.statement.create.trigger.CreateTrigger; +import net.sf.jsqlparser.statement.create.view.CreateView; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.stream.Stream; + +class MySqlAccountNameTest { + + private static final Consumer MYSQL = p -> p.withDialect(Dialect.MYSQL); + private static final Consumer NO_DIALECT = p -> { + }; + + /** SQL spelling, user value, user quote, host value, host quote. */ + static Stream accounts() { + return Stream.of( + Arguments.of("app", "app", "", null, null), + Arguments.of("app@localhost", "app", "", "localhost", ""), + Arguments.of("app@'%'", "app", "", "%", "'"), + Arguments.of("app@'10.0.0.%'", "app", "", "10.0.0.%", "'"), + Arguments.of("'app'@localhost", "app", "'", "localhost", ""), + Arguments.of("'app'@'%'", "app", "'", "%", "'"), + Arguments.of("`app`@`%`", "app", "`", "%", "`"), + Arguments.of("\"app\"@\"%\"", "app", "\"", "%", "\""), + Arguments.of("'app'", "app", "'", null, null), + Arguments.of("`my app`@'%'", "my app", "`", "%", "'")); + } + + @ParameterizedTest + @MethodSource("accounts") + void createUserSplitsEveryAccountSpelling(String account, String user, String userQuote, + String host, String hostQuote) throws JSQLParserException { + for (Consumer mode : java.util.Arrays.asList(MYSQL, NO_DIALECT)) { + String sql = "CREATE USER " + account + " IDENTIFIED BY 'x'"; + CreateUser createUser = assertInstanceOf(CreateUser.class, + assertSqlCanBeParsedAndDeparsed(sql, true, mode)); + assertEquals(sql, createUser.toString()); + assertEquals(sql, deparse(createUser)); + assertPart(createUser.getAccounts().get(0).getUser(), user, userQuote); + assertPart(createUser.getAccounts().get(0).getHost(), host, hostQuote); + } + } + + @ParameterizedTest + @MethodSource("accounts") + void definerSplitsEveryAccountSpelling(String account, String user, String userQuote, + String host, String hostQuote) throws JSQLParserException { + String view = "CREATE DEFINER = " + account + " VIEW v AS SELECT 1"; + CreateView createView = assertInstanceOf(CreateView.class, + assertSqlCanBeParsedAndDeparsed(view, true, MYSQL)); + MySqlDefiner definer = createView.getMySqlOptions().getDefiner(); + assertPart(definer.getUser(), user, userQuote); + assertPart(definer.getHost(), host, hostQuote); + + String trigger = "CREATE DEFINER = " + account + + " TRIGGER trg BEFORE INSERT ON t FOR EACH ROW SET NEW.a = 1"; + CreateTrigger createTrigger = assertInstanceOf(CreateTrigger.class, + assertSqlCanBeParsedAndDeparsed(trigger, true, MYSQL)); + assertEquals(trigger, createTrigger.toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"CREATE USER app@", "CREATE USER @host", "CREATE USER 'app'@"}) + void rejectsMalformedAccounts(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, MYSQL)); + } + + @org.junit.jupiter.api.Test + void keepsDoubleQuotesWithoutDialect() throws JSQLParserException { + // before, the double quotes were dropped and the account rendered as app@% + String sql = "CREATE USER \"app\"@\"%\" IDENTIFIED BY 'x'"; + assertEquals(sql, CCJSqlParserUtil.parse(sql).toString()); + assertEquals(sql, CCJSqlParserUtil.parse(sql, MYSQL).toString()); + } + + @org.junit.jupiter.api.Test + void definerCurrentUserIsUnchanged() throws JSQLParserException { + CreateView view = assertInstanceOf(CreateView.class, assertSqlCanBeParsedAndDeparsed( + "CREATE DEFINER = CURRENT_USER() VIEW v AS SELECT 1", true, MYSQL)); + assertNull(view.getMySqlOptions().getDefiner().getHost()); + } + + private static void assertPart(StringValue part, String value, String quote) { + if (value == null) { + assertNull(part); + return; + } + assertEquals(value, part.getValue()); + assertEquals(quote, part.getQuoteStr()); + } + + private static String deparse(CreateUser createUser) { + StringBuilder builder = new StringBuilder(); + createUser.accept(new StatementDeParser(builder), null); + return builder.toString(); + } +} From ce2cdaa4993cc5a192fb0f7a726009b14e98f4bb Mon Sep 17 00:00:00 2001 From: minleejae Date: Wed, 14 Oct 2026 13:01:59 +0900 Subject: [PATCH 2/2] 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 0000000000..eb78089c30 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/user/RoleSelection.java @@ -0,0 +1,100 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.create.user; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +/** + * The roles named by MySQL {@code SET ROLE}, {@code SET DEFAULT ROLE}, the {@code DEFAULT ROLE} + * clause of {@code CREATE USER} and {@code ALTER USER}, and {@code GRANT ... AS user WITH ROLE}. + * Role names use the account syntax, so each role is a {@link UserAccount} without authentication. + */ +public class RoleSelection implements Serializable { + + public enum Kind { + /** + * {@code DEFAULT}: the account's default roles, only in {@code SET ROLE} and + * {@code WITH ROLE}. + */ + DEFAULT, + /** {@code NONE}. */ + NONE, + /** {@code ALL}. */ + ALL, + /** {@code ALL EXCEPT role [, role] ...}, only in {@code SET ROLE} and {@code WITH ROLE}. */ + ALL_EXCEPT, + /** {@code role [, role] ...}. */ + ROLES + } + + private Kind kind; + private final List roles = new ArrayList<>(); + + public RoleSelection() { + // empty constructor + } + + public RoleSelection(Kind kind) { + this.kind = kind; + } + + public Kind getKind() { + return kind; + } + + public void setKind(Kind kind) { + this.kind = kind; + } + + /** Returns the listed roles of {@link Kind#ROLES} and {@link Kind#ALL_EXCEPT}. */ + public List getRoles() { + return Collections.unmodifiableList(roles); + } + + public void setRoles(Collection roles) { + this.roles.clear(); + if (roles != null) { + this.roles.addAll(roles); + } + } + + @Override + public String toString() { + String names = roles.stream().map(Object::toString).collect(Collectors.joining(", ")); + switch (kind) { + case ROLES: + return names; + case ALL_EXCEPT: + return "ALL EXCEPT " + names; + default: + return kind.name(); + } + } + + public RoleSelection withKind(Kind kind) { + setKind(kind); + return this; + } + + public RoleSelection withRoles(Collection roles) { + setRoles(roles); + return this; + } + + public RoleSelection addRoles(UserAccount... roles) { + Collections.addAll(this.roles, roles); + return this; + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/grant/Grant.java b/src/main/java/net/sf/jsqlparser/statement/grant/Grant.java index a9c43dc267..3962e967d6 100644 --- a/src/main/java/net/sf/jsqlparser/statement/grant/Grant.java +++ b/src/main/java/net/sf/jsqlparser/statement/grant/Grant.java @@ -9,22 +9,23 @@ */ package net.sf.jsqlparser.statement.grant; -import java.util.AbstractList; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; -import java.util.Optional; import java.util.function.Consumer; import java.util.stream.Collectors; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.StatementVisitor; +import net.sf.jsqlparser.statement.create.user.RoleSelection; public class Grant implements Statement { private PrivilegeClause clause = new PrivilegeClause(); - private GrantOption option; + private final List options = new ArrayList<>(); + private Grantee asUser; + private RoleSelection withRole; public PrivilegeClause getClause() { return clause; @@ -34,12 +35,46 @@ public void setClause(PrivilegeClause clause) { this.clause = clause; } + /** + * The {@code WITH} options as a mutable list: {@code GRANT OPTION}, {@code ADMIN OPTION} or + * PostgreSQL membership options such as {@code INHERIT TRUE, SET FALSE}. + */ + public List getOptions() { + return options; + } + + public void setOptions(Collection options) { + List copy = options == null ? new ArrayList<>() : new ArrayList<>(options); + this.options.clear(); + this.options.addAll(copy); + } + + /** The first {@code WITH} option, or null; see {@link #getOptions()}. */ public GrantOption getOption() { - return option; + return options.isEmpty() ? null : options.get(0); } + /** Replaces all {@code WITH} options with the given option, or removes them for null. */ public void setOption(GrantOption option) { - this.option = option; + setOptions(option == null ? null : Collections.singletonList(option)); + } + + /** The MySQL {@code AS user} account, or null when omitted. */ + public Grantee getAsUser() { + return asUser; + } + + public void setAsUser(Grantee asUser) { + this.asUser = asUser; + } + + /** The MySQL {@code WITH ROLE} selection of the {@code AS user} clause, or null. */ + public RoleSelection getWithRole() { + return withRole; + } + + public void setWithRole(RoleSelection withRole) { + this.withRole = withRole; } public List getPrivilegeItems() { @@ -50,6 +85,7 @@ public void setPrivilegeItems(List privileges) { clause.setPrivileges(privileges); } + /** The target, or null for a role membership. */ public PrivilegeTarget getTarget() { return clause.getTarget(); } @@ -67,14 +103,16 @@ public void setRoles(List roles) { } public String getRole() { - return clause.getRoles().isEmpty() ? null : clause.getRoles().get(0); + return clause.getRoleItems().isEmpty() ? null : clause.getRoles().get(0); } + /** Makes this a membership of the given role, which has neither privileges nor a target. */ public void setRole(String role) { clause.setRoles(new ArrayList<>()); if (role != null) { clause.getRoles().add(role); clause.setPrivileges(null); + clause.setTarget(null); } } @@ -83,32 +121,7 @@ public List getPrivileges() { if (clause.getPrivileges() == null) { return null; } - return new AbstractList() { - @Override - public int size() { - return clause.getPrivileges().size(); - } - - @Override - public String get(int index) { - return clause.getPrivileges().get(index).toString(); - } - - @Override - public String set(int index, String value) { - return clause.getPrivileges().set(index, new Privilege(value)).toString(); - } - - @Override - public void add(int index, String value) { - clause.getPrivileges().add(index, new Privilege(value)); - } - - @Override - public String remove(int index) { - return clause.getPrivileges().remove(index).toString(); - } - }; + return new StringListView<>(() -> clause.getPrivileges(), Privilege::new); } public void setPrivileges(List privileges) { @@ -122,12 +135,22 @@ public void setPrivileges(List privileges) { clause.setPrivileges(items); } + /** + * The first named target as dotted text. MySQL wildcard levels keep their wildcard, for example + * {@code db.*} or {@code *.*}. + */ public String getObjectName() { - if (clause.getTarget().getNames().isEmpty() - || clause.getTarget().getNames().get(0).isEmpty()) { + PrivilegeTarget target = clause.getTarget(); + if (target == null) { return null; } - List parts = clause.getTarget().getNames().get(0); + if (target.getWildcard() != null) { + return target.getTargetText(); + } + if (target.getNames().isEmpty() || target.getNames().get(0).isEmpty()) { + return null; + } + List parts = target.getNames().get(0); return parts.stream().map(part -> part == null ? "" : part) .collect(Collectors.joining(".")); } @@ -136,14 +159,19 @@ public void setObjectName(String objectName) { setObjectName(Collections.singletonList(objectName)); } + /** Names the first target, replacing a MySQL wildcard level. */ public void setObjectName(List objectName) { List parts = new ArrayList<>(objectName); getObjectNameParts().clear(); getObjectNameParts().addAll(parts); + clause.getTarget().setWildcard(null); } /** Mutable parts of the first named target, retained for compatibility. */ public List getObjectNameParts() { + if (clause.getTarget() == null) { + clause.setTarget(new PrivilegeTarget()); + } if (clause.getTarget().getNames().isEmpty()) { clause.getTarget().getNames().add(new ArrayList<>()); } @@ -161,11 +189,18 @@ public void setUsers(List users) { public void appendTo(StringBuilder sql, Consumer visitor) { sql.append("GRANT "); clause.appendTo(sql, false, visitor); - if (option != null) { - sql.append(" WITH ").append(option); + if (!options.isEmpty()) { + sql.append(" WITH ").append( + options.stream().map(Object::toString).collect(Collectors.joining(", "))); + } + if (asUser != null) { + sql.append(" AS ").append(asUser); + if (withRole != null) { + sql.append(" WITH ROLE ").append(withRole); + } } - if (clause.getGrantedBy() != null) { - sql.append(" GRANTED BY ").append(clause.getGrantedBy()); + if (clause.getGrantor() != null) { + sql.append(" GRANTED BY ").append(clause.getGrantor()); } } @@ -222,14 +257,17 @@ public Grant addPrivileges(Collection privileges) { } public Grant addUsers(String... users) { - List collection = Optional.ofNullable(getUsers()).orElseGet(ArrayList::new); - Collections.addAll(collection, users); - return this.withUsers(collection); + return addUsers(Arrays.asList(users)); } public Grant addUsers(Collection users) { - List collection = Optional.ofNullable(getUsers()).orElseGet(ArrayList::new); - collection.addAll(users); - return this.withUsers(collection); + List additions = new ArrayList<>(users); + if (clause.getGranteeItems() == null) { + clause.setGranteeItems(new ArrayList<>()); + } + for (String user : additions) { + clause.getGranteeItems().add(Grantee.valueOf(user)); + } + return this; } } diff --git a/src/main/java/net/sf/jsqlparser/statement/grant/Grantee.java b/src/main/java/net/sf/jsqlparser/statement/grant/Grantee.java new file mode 100644 index 0000000000..a53b020640 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/grant/Grantee.java @@ -0,0 +1,125 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.grant; + +import java.io.Serializable; +import java.util.Locale; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.statement.create.user.UserAccount; + +/** + * A role specification in GRANT and REVOKE: a grantee, the PostgreSQL grantor, the MySQL {@code AS} + * user or the MySQL proxied account. + * + *

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

+ * Roles, grantees and the grantor are typed. The string accessors {@link #getRoles()}, + * {@link #getGrantees()} and {@link #getGrantedBy()} are derived from them and convert assigned + * text with {@link Grantee#valueOf(String)}. + */ public class PrivilegeClause implements Serializable { private List privileges; - private List grantees; - private String grantedBy; - private List roles = new ArrayList<>(); + private List granteeItems; + private Grantee grantor; + private List roleItems = new ArrayList<>(); private PrivilegeTarget target = new PrivilegeTarget(); public List getPrivileges() { @@ -32,12 +44,25 @@ public void setPrivileges(List privileges) { this.privileges = privileges; } + /** The granted or revoked roles of a role membership, as a mutable list. */ + public List getRoleItems() { + return roleItems; + } + + public void setRoleItems(Collection roleItems) { + this.roleItems = roleItems == null ? new ArrayList<>() : new ArrayList<>(roleItems); + } + + /** Mutable legacy string view of {@link #getRoleItems()}. */ public List getRoles() { - return roles; + return new StringListView<>(() -> roleItems, Grantee::accountOf); } public void setRoles(List roles) { - this.roles = roles; + roleItems = convert(roles, Grantee::accountOf); + if (roleItems == null) { + roleItems = new ArrayList<>(); + } } public PrivilegeTarget getTarget() { @@ -48,25 +73,56 @@ public void setTarget(PrivilegeTarget target) { this.target = target; } + /** The grantees or revokees as a mutable list, or null when unset. */ + public List getGranteeItems() { + return granteeItems; + } + + public void setGranteeItems(Collection granteeItems) { + this.granteeItems = granteeItems == null ? null : new ArrayList<>(granteeItems); + } + + /** Mutable legacy string view of {@link #getGranteeItems()}, or null when unset. */ public List getGrantees() { - return grantees; + return granteeItems == null ? null + : new StringListView<>(() -> granteeItems, Grantee::valueOf); } public void setGrantees(List grantees) { - this.grantees = grantees; + granteeItems = convert(grantees, Grantee::valueOf); + } + + /** The PostgreSQL {@code GRANTED BY} role, or null when omitted. */ + public Grantee getGrantor() { + return grantor; + } + + public void setGrantor(Grantee grantor) { + this.grantor = grantor; } public String getGrantedBy() { - return grantedBy; + return grantor == null ? null : grantor.toString(); } public void setGrantedBy(String grantedBy) { - this.grantedBy = grantedBy; + grantor = grantedBy == null ? null : Grantee.valueOf(grantedBy); + } + + private static List convert(List values, Function parse) { + if (values == null) { + return null; + } + List items = new ArrayList<>(); + for (String value : new ArrayList<>(values)) { + items.add(parse.apply(value)); + } + return items; } public void appendTo(StringBuilder sql, boolean revoke, Consumer visitor) { if (privileges == null) { - sql.append(String.join(", ", roles)); + sql.append(join(roleItems)); } else { for (int i = 0; i < privileges.size(); i++) { if (i > 0) { @@ -74,14 +130,22 @@ public void appendTo(StringBuilder sql, boolean revoke, Consumer vis } privileges.get(i).appendTo(sql, visitor); } - sql.append(" ON ").append(target); + if (target != null) { + sql.append(" ON ").append(target); + } } - sql.append(revoke ? " FROM " : " TO ").append(String.join(", ", grantees)); + sql.append(revoke ? " FROM " : " TO ").append(join(granteeItems)); + } + + private static String join(List items) { + return items.stream().map(Object::toString).collect(Collectors.joining(", ")); } public void visit(Consumer tables, Consumer expressions) { if (privileges != null) { - target.visitTables(tables); + if (target != null) { + target.visitTables(tables); + } for (Privilege privilege : privileges) { if (privilege.getColumns() != null) { privilege.getColumns().forEach(expressions); diff --git a/src/main/java/net/sf/jsqlparser/statement/grant/PrivilegeTarget.java b/src/main/java/net/sf/jsqlparser/statement/grant/PrivilegeTarget.java index a92ac85391..7b815c072f 100644 --- a/src/main/java/net/sf/jsqlparser/statement/grant/PrivilegeTarget.java +++ b/src/main/java/net/sf/jsqlparser/statement/grant/PrivilegeTarget.java @@ -17,16 +17,40 @@ import net.sf.jsqlparser.schema.Table; import net.sf.jsqlparser.statement.RoutineReference; -/** Targets have distinct kinds; a routine signature is not a function invocation. */ +/** + * Targets have distinct kinds; a routine signature is not a function invocation. MySQL privilege + * levels written with {@code *} are a {@link Wildcard}, and the account of MySQL + * {@code PROXY ON user} is an {@link Kind#ACCOUNT} target. + */ public class PrivilegeTarget implements Serializable { public enum Kind { - TABLE, SEQUENCE, DATABASE, DOMAIN, FOREIGN_DATA_WRAPPER, FOREIGN_SERVER, FUNCTION, PROCEDURE, ROUTINE, LANGUAGE, LARGE_OBJECT, PARAMETER, SCHEMA, TABLESPACE, TYPE + TABLE, SEQUENCE, DATABASE, DOMAIN, FOREIGN_DATA_WRAPPER, FOREIGN_SERVER, FUNCTION, PROCEDURE, ROUTINE, LANGUAGE, LARGE_OBJECT, PARAMETER, SCHEMA, TABLESPACE, TYPE, + /** + * The proxied account of MySQL {@code PROXY ON user}, see + * {@link PrivilegeTarget#getAccount()}. + */ + ACCOUNT + } + + /** MySQL privilege levels written with {@code *}. */ + public enum Wildcard { + /** {@code *}: the default database. */ + DEFAULT_DATABASE, + /** {@code *.*}: all databases. */ + GLOBAL, + /** + * {@code db.*}: the database named by the single entry of + * {@link PrivilegeTarget#getNames()}. + */ + DATABASE } private Kind kind = Kind.TABLE; private boolean explicitKind; private boolean allInSchema; private boolean defaultPrivileges; + private Wildcard wildcard; + private Grantee account; private List> names = new ArrayList<>(); private List routines = new ArrayList<>(); @@ -62,6 +86,24 @@ public void setDefaultPrivileges(boolean defaultPrivileges) { this.defaultPrivileges = defaultPrivileges; } + /** The MySQL wildcard privilege level, or null for named targets. */ + public Wildcard getWildcard() { + return wildcard; + } + + public void setWildcard(Wildcard wildcard) { + this.wildcard = wildcard; + } + + /** The proxied account of an {@link Kind#ACCOUNT} target. */ + public Grantee getAccount() { + return account; + } + + public void setAccount(Grantee account) { + this.account = account; + } + public List> getNames() { return names; } @@ -79,7 +121,7 @@ public void setRoutines(List routines) { } public void visitTables(Consumer
visitor) { - if (kind == Kind.TABLE && !allInSchema && !defaultPrivileges) { + if (kind == Kind.TABLE && wildcard == null && !allInSchema && !defaultPrivileges) { for (List name : names) { if (!name.isEmpty()) { visitor.accept(new Table(name)); @@ -88,19 +130,37 @@ public void visitTables(Consumer
visitor) { } } + /** The target names as written after the optional kind, for example {@code db.*}. */ + String getTargetText() { + if (kind == Kind.ACCOUNT) { + return String.valueOf(account); + } + if (wildcard == Wildcard.DEFAULT_DATABASE) { + return "*"; + } + if (wildcard == Wildcard.GLOBAL) { + return "*.*"; + } + String targets = routines.isEmpty() + ? names.stream().map(PrivilegeTarget::joinName).collect(Collectors.joining(", ")) + : routines.stream().map(Object::toString).collect(Collectors.joining(", ")); + return wildcard == Wildcard.DATABASE ? targets + ".*" : targets; + } + + private static String joinName(List name) { + return name.stream().map(part -> part == null ? "" : part) + .collect(Collectors.joining(".")); + } + @Override public String toString() { String type = kind.name().replace('_', ' '); - String targets = routines.isEmpty() - ? names.stream().map(n -> n.stream().map(part -> part == null ? "" : part) - .collect(Collectors.joining("."))).collect(Collectors.joining(", ")) - : routines.stream().map(Object::toString).collect(Collectors.joining(", ")); if (defaultPrivileges) { return type + "S"; } if (allInSchema) { - return "ALL " + type + "S IN SCHEMA " + targets; + return "ALL " + type + "S IN SCHEMA " + getTargetText(); } - return (explicitKind ? type + " " : "") + targets; + return (explicitKind ? type + " " : "") + getTargetText(); } } diff --git a/src/main/java/net/sf/jsqlparser/statement/grant/Revoke.java b/src/main/java/net/sf/jsqlparser/statement/grant/Revoke.java index f613e2d028..620827f582 100644 --- a/src/main/java/net/sf/jsqlparser/statement/grant/Revoke.java +++ b/src/main/java/net/sf/jsqlparser/statement/grant/Revoke.java @@ -17,8 +17,10 @@ public class Revoke implements Statement { private PrivilegeClause clause = new PrivilegeClause(); + private boolean ifExists; private GrantOption.Kind optionFor; private CascadeBehavior behavior; + private boolean ignoreUnknownUser; public PrivilegeClause getClause() { return clause; @@ -28,6 +30,24 @@ public void setClause(PrivilegeClause clause) { this.clause = clause; } + /** MySQL {@code REVOKE IF EXISTS}. */ + public boolean isIfExists() { + return ifExists; + } + + public void setIfExists(boolean ifExists) { + this.ifExists = ifExists; + } + + /** MySQL {@code IGNORE UNKNOWN USER}. */ + public boolean isIgnoreUnknownUser() { + return ignoreUnknownUser; + } + + public void setIgnoreUnknownUser(boolean ignoreUnknownUser) { + this.ignoreUnknownUser = ignoreUnknownUser; + } + public GrantOption.Kind getOptionFor() { return optionFor; } @@ -46,16 +66,22 @@ public void setBehavior(CascadeBehavior behavior) { public void appendTo(StringBuilder sql, Consumer visitor) { sql.append("REVOKE "); + if (ifExists) { + sql.append("IF EXISTS "); + } if (optionFor != null) { sql.append(optionFor).append(" OPTION FOR "); } clause.appendTo(sql, true, visitor); - if (clause.getGrantedBy() != null) { - sql.append(" GRANTED BY ").append(clause.getGrantedBy()); + if (clause.getGrantor() != null) { + sql.append(" GRANTED BY ").append(clause.getGrantor()); } if (behavior != null) { sql.append(' ').append(behavior); } + if (ignoreUnknownUser) { + sql.append(" IGNORE UNKNOWN USER"); + } } @Override diff --git a/src/main/java/net/sf/jsqlparser/statement/grant/StringListView.java b/src/main/java/net/sf/jsqlparser/statement/grant/StringListView.java new file mode 100644 index 0000000000..9f220aa0eb --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/grant/StringListView.java @@ -0,0 +1,52 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.grant; + +import java.util.AbstractList; +import java.util.List; +import java.util.RandomAccess; +import java.util.function.Function; +import java.util.function.Supplier; + +/** Mutable string view over a typed list, retained for the string-based GRANT/REVOKE API. */ +final class StringListView extends AbstractList implements RandomAccess { + private final Supplier> items; + private final Function parse; + + StringListView(Supplier> items, Function parse) { + this.items = items; + this.parse = parse; + } + + @Override + public int size() { + return items.get().size(); + } + + @Override + public String get(int index) { + return String.valueOf(items.get().get(index)); + } + + @Override + public String set(int index, String value) { + return String.valueOf(items.get().set(index, parse.apply(value))); + } + + @Override + public void add(int index, String value) { + items.get().add(index, parse.apply(value)); + } + + @Override + public String remove(int index) { + return String.valueOf(items.get().remove(index)); + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java index de84d3f823..ca4698e784 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 6aabf1fe0d..2aee4aef6b 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 c37f214ad1..952163a67a 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/GrantValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/GrantValidator.java @@ -23,10 +23,15 @@ public class GrantValidator extends AbstractValidator { public void validate(Grant grant) { for (ValidationCapability c : getCapabilities()) { validateFeature(c, Feature.grant); - if (isNotEmpty(grant.getUsers())) { - grant.getUsers().forEach(u -> validateName(NamedObject.user, u)); + // PUBLIC and CURRENT_USER are not names + if (isNotEmpty(grant.getClause().getGranteeItems())) { + grant.getClause().getGranteeItems().stream() + .filter(grantee -> grantee.getAccount() != null) + .forEach(grantee -> validateName(NamedObject.user, + grantee.getAccount().toString())); } - grant.getRoles().forEach(role -> validateName(NamedObject.role, role)); + grant.getClause().getRoleItems() + .forEach(role -> validateName(NamedObject.role, role.toString())); grant.getClause().visit(this::validateOptionalFromItem, this::validateOptionalExpression); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 9fcc539cef..6bfd7a0bce 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 26f7ff5d86..944293b502 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 0000000000..dd26645eed --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/grant/GranteeModelTest.java @@ -0,0 +1,195 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.grant; + +import static net.sf.jsqlparser.test.TestUtils.assertEqualsObjectTree; +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Arrays; +import java.util.Collections; +import java.util.function.Consumer; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.util.TablesNamesFinder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** The grantee model shared by MySQL and PostgreSQL, and the string-based legacy API. */ +class GranteeModelTest { + + private static final Consumer POSTGRESQL = + parser -> parser.withDialect(Dialect.POSTGRESQL); + + private static Statement parse(String sql) throws JSQLParserException { + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, false, POSTGRESQL); + assertEquals(sql, CCJSqlParserUtil.parse(statement.toString(), POSTGRESQL).toString()); + return statement; + } + + @Test + void typesPostgreSqlRoleSpecifications() throws JSQLParserException { + Grant grant = (Grant) parse("GRANT SELECT ON users TO app, GROUP staff, PUBLIC, " + + "CURRENT_USER, CURRENT_ROLE, SESSION_USER, \"Mixed Case\" GRANTED BY CURRENT_ROLE"); + + assertThat(grant.getClause().getGranteeItems()).extracting(Grantee::getKind) + .containsExactly(Grantee.Kind.ACCOUNT, Grantee.Kind.GROUP, Grantee.Kind.PUBLIC, + Grantee.Kind.CURRENT_USER, Grantee.Kind.CURRENT_ROLE, + Grantee.Kind.SESSION_USER, Grantee.Kind.ACCOUNT); + Grantee group = grant.getClause().getGranteeItems().get(1); + assertEquals("staff", group.getAccount().getUser().getValue()); + assertNull(group.getAccount().getHost()); + Grantee quoted = grant.getClause().getGranteeItems().get(6); + assertEquals("Mixed Case", quoted.getAccount().getUser().getValue()); + assertEquals("\"", quoted.getAccount().getUser().getQuoteStr()); + assertEquals(Grantee.Kind.CURRENT_ROLE, grant.getClause().getGrantor().getKind()); + + assertEquals(Arrays.asList("app", "GROUP staff", "PUBLIC", "CURRENT_USER", "CURRENT_ROLE", + "SESSION_USER", "\"Mixed Case\""), grant.getUsers()); + assertEquals("CURRENT_ROLE", grant.getClause().getGrantedBy()); + assertThat(TablesNamesFinder.findTables(grant.toString())).containsExactly("users"); + } + + @Test + void typesMembershipOptionList() throws JSQLParserException { + Grant grant = (Grant) parse( + "GRANT app_read TO alice WITH INHERIT TRUE, SET FALSE, ADMIN OPTION GRANTED BY postgres"); + + assertNull(grant.getTarget()); + assertThat(grant.getOptions()).extracting(GrantOption::getKind).containsExactly( + GrantOption.Kind.INHERIT, GrantOption.Kind.SET, GrantOption.Kind.ADMIN); + assertThat(grant.getOptions()).extracting(GrantOption::getValue).containsExactly( + GrantOption.Value.TRUE, GrantOption.Value.FALSE, GrantOption.Value.OPTION); + assertSame(grant.getOptions().get(0), grant.getOption()); + assertEquals("postgres", grant.getClause().getGrantor().getAccount().getUser().getValue()); + + grant.setOption(new GrantOption(GrantOption.Kind.ADMIN)); + assertEquals("GRANT app_read TO alice WITH ADMIN OPTION GRANTED BY postgres", + grant.toString()); + grant.setOption(null); + assertEquals("GRANT app_read TO alice GRANTED BY postgres", grant.toString()); + } + + @ParameterizedTest + @ValueSource(strings = { + "GRANT app_read TO alice WITH INHERIT TRUE, INHERIT FALSE", + "GRANT app_read TO alice WITH ADMIN OPTION, GRANT OPTION", + "GRANT SELECT ON users TO alice WITH GRANT OPTION, INHERIT TRUE", + "GRANT SELECT ON users TO alice GRANTED BY PUBLIC", + "GRANT SELECT ON users TO alice GRANTED BY GROUP staff", + "GRANT SELECT ON users TO GROUP" + }) + void rejectsInvalidRoleSpecifications(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, POSTGRESQL)); + } + + @Test + void legacyGranteeStringsWriteThroughTypedState() throws JSQLParserException { + Grant grant = (Grant) CCJSqlParserUtil.parse("GRANT SELECT ON t TO 'app'@'%', app2", + parser -> parser.withDialect(Dialect.MYSQL)); + Grantee typed = grant.getClause().getGranteeItems().get(0); + + grant.getUsers().add("PUBLIC"); + grant.getUsers().set(1, "GROUP staff"); + grant.addUsers("CURRENT_USER()"); + assertSame(typed, grant.getClause().getGranteeItems().get(0)); + assertThat(grant.getClause().getGranteeItems()).extracting(Grantee::getKind) + .containsExactly(Grantee.Kind.ACCOUNT, Grantee.Kind.GROUP, Grantee.Kind.PUBLIC, + Grantee.Kind.CURRENT_USER); + assertEquals("GRANT SELECT ON t TO 'app'@'%', GROUP staff, PUBLIC, CURRENT_USER()", + grant.toString()); + + grant.getUsers().remove(0); + grant.getClause().setGrantedBy("SESSION_USER"); + assertEquals(Grantee.Kind.SESSION_USER, grant.getClause().getGrantor().getKind()); + assertEquals("GRANT SELECT ON t TO GROUP staff, PUBLIC, CURRENT_USER() " + + "GRANTED BY SESSION_USER", grant.toString()); + + grant.setUsers(Collections.singletonList("reader")); + assertEquals(Grantee.Kind.ACCOUNT, grant.getClause().getGranteeItems().get(0).getKind()); + grant.setUsers(null); + assertNull(grant.getUsers()); + assertNull(new Grant().getUsers()); + } + + @Test + void legacyBuildersMatchParsedStatements() throws JSQLParserException { + Grant parsed = + (Grant) CCJSqlParserUtil.parse("GRANT SELECT, CREATE VIEW ON t1 TO u, PUBLIC"); + Grant built = new Grant().addPrivileges("SELECT", "CREATE VIEW").withObjectName("t1") + .addUsers("u", "PUBLIC"); + assertEqualsObjectTree(parsed, built); + assertEquals(Privilege.Kind.CREATE_VIEW, built.getPrivilegeItems().get(1).getKind()); + + Grant role = (Grant) CCJSqlParserUtil.parse("GRANT r1 TO u GRANTED BY CURRENT_USER"); + Grant builtRole = new Grant().withRole("r1").addUsers("u"); + builtRole.getClause().setGrantedBy("CURRENT_USER"); + assertEqualsObjectTree(role, builtRole); + assertNull(builtRole.getTarget()); + + builtRole.getRoles().add("'r2'@'%'"); + assertEquals("GRANT r1, 'r2'@'%' TO u GRANTED BY CURRENT_USER", builtRole.toString()); + assertEquals("r1", builtRole.getRole()); + } + + @Test + void legacyObjectNameReplacesWildcard() throws JSQLParserException { + Grant grant = (Grant) CCJSqlParserUtil.parse("GRANT SELECT ON *.* TO app"); + assertEquals("*.*", grant.getObjectName()); + + grant.setObjectName(Arrays.asList("db1", "t1")); + assertNull(grant.getTarget().getWildcard()); + assertEquals("GRANT SELECT ON db1.t1 TO app", grant.toString()); + + Grant role = (Grant) CCJSqlParserUtil.parse("GRANT reader TO app"); + assertNull(role.getObjectName()); + role.setPrivileges(Collections.singletonList("SELECT")); + role.setObjectName("t1"); + assertEquals("GRANT SELECT ON t1 TO app", role.toString()); + } + + @Test + void legacyPrivilegeNames() { + assertEquals(Privilege.Kind.CREATE_TEMPORARY_TABLES, + new Privilege("CREATE TEMPORARY TABLES").getKind()); + assertEquals(Privilege.Kind.ALTER_SYSTEM, new Privilege("ALTER SYSTEM").getKind()); + Privilege unknown = new Privilege("DYNAMIC"); + assertNull(unknown.getKind()); + assertEquals("DYNAMIC", unknown.toString()); + Privilege text = new Privilege("select (a)"); + assertNull(text.getKind()); + assertEquals("select (a)", text.getName()); + assertFalse(new Privilege(Privilege.Kind.SELECT).isUsePrivileges()); + } + + @Test + void grantedByAndRolesAcceptLegacyText() { + PrivilegeClause clause = new PrivilegeClause(); + clause.setGrantedBy("GROUP admins"); + assertEquals(Grantee.Kind.GROUP, clause.getGrantor().getKind()); + clause.setGrantedBy(null); + assertNull(clause.getGrantor()); + + clause.setRoles(Arrays.asList("a", "b")); + clause.getRoles().set(0, "c"); + assertEquals(Arrays.asList("c", "b"), clause.getRoles()); + clause.setRoles(null); + assertEquals(Collections.emptyList(), clause.getRoleItems()); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/grant/MySqlGrantRevokeTest.java b/src/test/java/net/sf/jsqlparser/statement/grant/MySqlGrantRevokeTest.java new file mode 100644 index 0000000000..9e1009ef2c --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/grant/MySqlGrantRevokeTest.java @@ -0,0 +1,355 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.grant; + +import static net.sf.jsqlparser.test.TestUtils.assertDeparse; +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static net.sf.jsqlparser.util.validation.ValidationTestAsserts.validateNoErrors; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StmtFeature; +import net.sf.jsqlparser.statement.create.user.RoleSelection; +import net.sf.jsqlparser.statement.create.user.UserAccount; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.validation.feature.DatabaseType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class MySqlGrantRevokeTest { + + private static final Consumer MYSQL = parser -> parser.withDialect(Dialect.MYSQL); + + /** Parses with the MySQL dialect, checks toString and the deparser, and reparses. */ + private static Statement parse(String sql) throws JSQLParserException { + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, false, MYSQL); + assertEquals(sql, CCJSqlParserUtil.parse(statement.toString(), MYSQL).toString()); + return statement; + } + + private static Grant grant(String sql) throws JSQLParserException { + return (Grant) parse(sql); + } + + private static Revoke revoke(String sql) throws JSQLParserException { + return (Revoke) parse(sql); + } + + /** The value and quote of the user and host, for example {@code app(')@%(')}. */ + private static String account(UserAccount account) { + StringValue host = account.getHost(); + return account.getUser().getValue() + "(" + account.getUser().getQuoteStr() + ")" + + (host == null ? "" : "@" + host.getValue() + "(" + host.getQuoteStr() + ")"); + } + + @ParameterizedTest + @ValueSource(strings = { + "GRANT SELECT ON db1.* TO 'app'@'%'", + "GRANT SELECT, INSERT, UPDATE, DELETE ON `db1`.`t1` TO 'app'@'%' WITH GRANT OPTION", + "GRANT ALL PRIVILEGES ON *.* TO 'admin'@'localhost' WITH GRANT OPTION", + "GRANT SELECT (id, name), UPDATE (name) ON db1.t1 TO 'app'@'%'", + "GRANT EXECUTE ON PROCEDURE db1.p1 TO 'app'@'%'", + "GRANT EXECUTE ON FUNCTION db1.f1 TO app@'%'", + "GRANT CREATE, ALTER, DROP, INDEX, REFERENCES, CREATE VIEW, SHOW VIEW, TRIGGER, EVENT, " + + "CREATE ROUTINE, ALTER ROUTINE, CREATE TEMPORARY TABLES, LOCK TABLES " + + "ON db1.* TO 'dev'@'%'", + "GRANT SELECT, RELOAD, SHOW DATABASES, REPLICATION SLAVE, REPLICATION CLIENT " + + "ON *.* TO 'cdc'@'%'", + "GRANT PROCESS, SUPER, FILE, SHUTDOWN, CREATE USER, CREATE TABLESPACE, CREATE ROLE, " + + "DROP ROLE, USAGE, GRANT OPTION ON *.* TO 'admin'@'%'", + "GRANT BACKUP_ADMIN, replication_slave_admin ON *.* TO 'admin'@'%'", + "GRANT 'reader', 'writer'@'%' TO 'app'@'%' WITH ADMIN OPTION", + "GRANT reader TO app", + "GRANT PROXY ON 'a'@'%' TO 'b'@'%', CURRENT_USER WITH GRANT OPTION", + "GRANT SELECT ON TABLE db1.* TO `app`@`%`", + "GRANT ALL ON * TO app@localhost", + "GRANT SELECT ON `my-db`.* TO 'app'@localhost, CURRENT_USER()", + "GRANT SELECT ON db1.* TO 'app'@'%' AS 'admin'@'%' WITH ROLE 'r1', r2@'%'", + "GRANT SELECT ON db1.t1 TO app WITH GRANT OPTION AS CURRENT_USER WITH ROLE DEFAULT", + "REVOKE INSERT ON db1.* FROM 'app'@'%', app2", + "REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'app'@'%'", + "REVOKE ALL, GRANT OPTION FROM app", + "REVOKE GRANT OPTION ON *.* FROM app", + "REVOKE 'reader', writer FROM 'app'@'%'", + "REVOKE IF EXISTS SELECT ON db1.t1 FROM 'app'@'%' IGNORE UNKNOWN USER", + "REVOKE IF EXISTS PROXY ON 'a'@'%' FROM 'b'@'%'", + "REVOKE REPLICATION SLAVE, REPLICATION CLIENT ON *.* FROM 'repl'@'%'" + }) + void roundTripsMySqlForms(String sql) throws JSQLParserException { + Statement statement = parse(sql); + + Statement withoutDialect = assertSqlCanBeParsedAndDeparsed(sql, false); + assertEquals(statement.toString(), withoutDialect.toString()); + assertThat(statement.getFeatures().getCertain()) + .containsExactly(StmtFeature.MODIFIES_SCHEMA); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', value = { + "* | DEFAULT_DATABASE | [] | *", + "*.* | GLOBAL | [] | *.*", + "db1.* | DATABASE | [db1] | db1.*", + "`my-db`.* | DATABASE | [`my-db`] | `my-db`.*" + }) + void typesWildcardPrivilegeLevels(String level, PrivilegeTarget.Wildcard wildcard, + String names, String objectName) throws JSQLParserException { + Grant grant = grant("GRANT SELECT ON " + level + " TO app"); + PrivilegeTarget target = grant.getTarget(); + + assertEquals(wildcard, target.getWildcard()); + assertEquals(PrivilegeTarget.Kind.TABLE, target.getKind()); + assertFalse(target.isExplicitKind()); + assertEquals(names, target.getNames().stream().map(name -> name.get(0)) + .collect(Collectors.toList()).toString()); + assertEquals(objectName, grant.getObjectName()); + assertThat(TablesNamesFinder.findTables(grant.toString())).isEmpty(); + } + + @Test + void namedPrivilegeLevelsRemainTables() throws JSQLParserException { + Grant qualified = grant("GRANT SELECT ON TABLE db1.t1 TO app"); + assertNull(qualified.getTarget().getWildcard()); + assertTrue(qualified.getTarget().isExplicitKind()); + assertEquals(Arrays.asList("db1", "t1"), qualified.getTarget().getNames().get(0)); + assertThat(TablesNamesFinder.findTables(qualified.toString())).containsExactly("db1.t1"); + + Grant routine = grant("GRANT EXECUTE ON PROCEDURE db1.p1 TO app"); + assertEquals(PrivilegeTarget.Kind.PROCEDURE, routine.getTarget().getKind()); + assertEquals("db1.p1", routine.getTarget().getRoutines().get(0).getName()); + } + + @Test + void typesStaticAndDynamicPrivileges() throws JSQLParserException { + Grant grant = grant("GRANT CREATE TEMPORARY TABLES, LOCK TABLES, SHOW DATABASES, " + + "REPLICATION SLAVE, REPLICATION CLIENT, ALTER ROUTINE, CREATE VIEW, INDEX, " + + "SHUTDOWN, GRANT OPTION, backup_admin ON *.* TO app"); + + assertThat(grant.getPrivilegeItems()).extracting(Privilege::getKind).containsExactly( + Privilege.Kind.CREATE_TEMPORARY_TABLES, Privilege.Kind.LOCK_TABLES, + Privilege.Kind.SHOW_DATABASES, Privilege.Kind.REPLICATION_SLAVE, + Privilege.Kind.REPLICATION_CLIENT, Privilege.Kind.ALTER_ROUTINE, + Privilege.Kind.CREATE_VIEW, Privilege.Kind.INDEX, Privilege.Kind.SHUTDOWN, + Privilege.Kind.GRANT_OPTION, Privilege.Kind.DYNAMIC); + Privilege dynamic = grant.getPrivilegeItems().get(10); + assertEquals("backup_admin", dynamic.getDynamicName()); + assertEquals("backup_admin", dynamic.getName()); + assertEquals("CREATE TEMPORARY TABLES", grant.getPrivilegeItems().get(0).getName()); + assertEquals("CREATE TEMPORARY TABLES", grant.getPrivileges().get(0)); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', quoteCharacter = '"', value = { + "'app'@'%' | app(')@%(')", + "app@'10.0.0.%' | app()@10.0.0.%(')", + "app@localhost | app()@localhost()", + "'app'@localhost | app(')@localhost()", + "`app`@`%` | app(`)@%(`)", + "'app' | app(')", + "app | app()" + }) + void typesAccountGrantees(String account, String expected) throws JSQLParserException { + Grant grant = grant("GRANT SELECT ON db1.t1 TO " + account); + Grantee grantee = grant.getClause().getGranteeItems().get(0); + + assertEquals(Grantee.Kind.ACCOUNT, grantee.getKind()); + assertEquals(expected, account(grantee.getAccount())); + assertEquals(Collections.singletonList(account), grant.getUsers()); + } + + @Test + void typesCurrentUser() throws JSQLParserException { + Grant grant = grant("GRANT SELECT ON db1.* TO CURRENT_USER, CURRENT_USER()"); + List grantees = grant.getClause().getGranteeItems(); + + assertThat(grantees).extracting(Grantee::getKind) + .containsExactly(Grantee.Kind.CURRENT_USER, Grantee.Kind.CURRENT_USER); + assertFalse(grantees.get(0).isCurrentUserParentheses()); + assertTrue(grantees.get(1).isCurrentUserParentheses()); + assertNull(grantees.get(0).getAccount()); + assertEquals(Arrays.asList("CURRENT_USER", "CURRENT_USER()"), grant.getUsers()); + } + + @Test + void roleGrantsHaveTypedRolesAndNoTarget() throws JSQLParserException { + Grant grant = grant("GRANT 'reader', 'writer'@'%' TO 'app'@'%' WITH ADMIN OPTION"); + + assertNull(grant.getTarget()); + assertNull(grant.getPrivilegeItems()); + assertNull(grant.getObjectName()); + assertThat(grant.getClause().getRoleItems()).extracting(MySqlGrantRevokeTest::account) + .containsExactly("reader(')", "writer(')@%(')"); + assertEquals(Arrays.asList("'reader'", "'writer'@'%'"), grant.getRoles()); + assertEquals("'reader'", grant.getRole()); + assertEquals(GrantOption.Kind.ADMIN, grant.getOption().getKind()); + assertThat(TablesNamesFinder.findTables(grant.toString())).isEmpty(); + + Revoke revoke = revoke("REVOKE 'reader' FROM 'app'@'%'"); + assertNull(revoke.getClause().getTarget()); + assertEquals("reader", revoke.getClause().getRoleItems().get(0).getUser().getValue()); + } + + @Test + void proxyTargetsAnAccount() throws JSQLParserException { + Grant grant = grant("GRANT PROXY ON 'a'@'%' TO 'b'@'%' WITH GRANT OPTION"); + + assertThat(grant.getPrivilegeItems()).extracting(Privilege::getKind) + .containsExactly(Privilege.Kind.PROXY); + PrivilegeTarget target = grant.getTarget(); + assertEquals(PrivilegeTarget.Kind.ACCOUNT, target.getKind()); + assertEquals("a(')@%(')", account(target.getAccount().getAccount())); + assertTrue(target.getNames().isEmpty()); + assertNull(grant.getObjectName()); + assertThat(TablesNamesFinder.findTables(grant.toString())).isEmpty(); + + Revoke revoke = revoke("REVOKE PROXY ON CURRENT_USER FROM 'b'@'%'"); + assertEquals(Grantee.Kind.CURRENT_USER, + revoke.getClause().getTarget().getAccount().getKind()); + } + + @Test + void revokesAllPrivilegesAndGrantOptionWithoutTarget() throws JSQLParserException { + Revoke revoke = revoke("REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'app'@'%', app2"); + PrivilegeClause clause = revoke.getClause(); + + assertThat(clause.getPrivileges()).extracting(Privilege::getKind) + .containsExactly(Privilege.Kind.ALL, Privilege.Kind.GRANT_OPTION); + assertTrue(clause.getPrivileges().get(0).isUsePrivileges()); + assertNull(clause.getTarget()); + assertEquals(Arrays.asList("'app'@'%'", "app2"), clause.getGrantees()); + assertThat(TablesNamesFinder.findTables(revoke.toString())).isEmpty(); + } + + @Test + void revokeIfExistsIgnoreUnknownUser() throws JSQLParserException { + Revoke revoke = + revoke("REVOKE IF EXISTS SELECT ON db1.t1 FROM 'app'@'%' IGNORE UNKNOWN USER"); + assertTrue(revoke.isIfExists()); + assertTrue(revoke.isIgnoreUnknownUser()); + assertThat(TablesNamesFinder.findTables(revoke.toString())).containsExactly("db1.t1"); + + revoke.setIfExists(false); + revoke.setIgnoreUnknownUser(false); + assertEquals("REVOKE SELECT ON db1.t1 FROM 'app'@'%'", revoke.toString()); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', quoteCharacter = '"', value = { + "DEFAULT | DEFAULT | \"\"", + "NONE | NONE | \"\"", + "ALL | ALL | \"\"", + "ALL EXCEPT 'r1', r2@'%' | ALL_EXCEPT | 'r1', r2@'%'", + "'r1', r2 | ROLES | 'r1', r2" + }) + void typesAsUserWithRole(String selection, RoleSelection.Kind kind, String roles) + throws JSQLParserException { + Grant grant = grant("GRANT SELECT ON db1.* TO app AS 'admin'@'%' WITH ROLE " + selection); + + assertEquals(Grantee.Kind.ACCOUNT, grant.getAsUser().getKind()); + assertEquals("admin(')@%(')", account(grant.getAsUser().getAccount())); + assertEquals(kind, grant.getWithRole().getKind()); + assertEquals(roles, grant.getWithRole().getRoles().stream().map(Object::toString) + .collect(Collectors.joining(", "))); + } + + @ParameterizedTest + @ValueSource(strings = { + "GRANT SELECT ON db1.t1.* TO app", + "GRANT SELECT ON db1.*, db2.* TO app", + "GRANT USAGE ON SCHEMA * TO app", + "GRANT EXECUTE ON PROCEDURE db1.* TO app", + "GRANT PROXY ON db1.t1 TO app", + "GRANT PROXY, SELECT ON t1 TO app", + "GRANT PROXY ON PUBLIC TO app", + "GRANT PROXY ON 'a'@'%' TO app AS admin", + "GRANT reader TO app AS admin", + "GRANT SELECT ON t1 TO app AS PUBLIC", + "GRANT SELECT ON t1 TO app WITH ROLE r1", + "GRANT SELECT ON t1 TO app WITH GRANT OPTION, GRANT OPTION", + "GRANT SELECT ON t1 TO 'app'@", + "GRANT SELECT ON t1 TO app@", + "GRANT SELECT ON t1 TO PUBLIC()", + "GRANT BACKUP_ADMIN PRIVILEGES ON *.* TO app", + "GRANT ALL PRIVILEGES, GRANT OPTION TO app", + "REVOKE SELECT ON t1 FROM app IGNORE UNKNOWN", + "ALTER DEFAULT PRIVILEGES GRANT SELECT ON *.* TO app", + "ALTER DEFAULT PRIVILEGES REVOKE IF EXISTS SELECT ON TABLES FROM app", + "ALTER DEFAULT PRIVILEGES REVOKE SELECT ON TABLES FROM app IGNORE UNKNOWN USER", + "ALTER DEFAULT PRIVILEGES GRANT PROXY ON a TO app", + "ALTER DEFAULT PRIVILEGES REVOKE ALL, GRANT OPTION FROM app" + }) + void rejectsInvalidForms(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, MYSQL)); + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql)); + } + + @Test + void buildsTypedGrant() { + PrivilegeTarget target = new PrivilegeTarget(); + target.setWildcard(PrivilegeTarget.Wildcard.DATABASE); + target.getNames().add(Collections.singletonList("db1")); + UserAccount app = new UserAccount().withUser(new StringValue("app")) + .withHost(new StringValue("%")); + Grant grant = new Grant(); + grant.setPrivilegeItems(Arrays.asList(new Privilege(Privilege.Kind.SHOW_VIEW), + Privilege.dynamic("BACKUP_ADMIN"))); + grant.setTarget(target); + grant.getClause().setGranteeItems(Arrays.asList(new Grantee(app), + new Grantee(Grantee.Kind.CURRENT_USER).withCurrentUserParentheses(true))); + grant.setOption(new GrantOption(GrantOption.Kind.GRANT)); + grant.setAsUser(new Grantee(new UserAccount().withUser(new StringValue("admin")))); + grant.setWithRole(new RoleSelection(RoleSelection.Kind.ALL_EXCEPT) + .withRoles(Collections.singletonList( + new UserAccount().withUser(new StringValue("r1"))))); + + assertDeparse(grant, "GRANT SHOW VIEW, BACKUP_ADMIN ON db1.* TO 'app'@'%', " + + "CURRENT_USER() WITH GRANT OPTION AS 'admin' WITH ROLE ALL EXCEPT 'r1'"); + + Revoke revoke = new Revoke(); + revoke.setIfExists(true); + revoke.getClause().setPrivileges(Collections.singletonList( + new Privilege(Privilege.Kind.PROXY))); + PrivilegeTarget proxied = new PrivilegeTarget(); + proxied.setKind(PrivilegeTarget.Kind.ACCOUNT); + proxied.setAccount(new Grantee(app)); + revoke.getClause().setTarget(proxied); + revoke.getClause().setGranteeItems( + Collections.singletonList(new Grantee(Grantee.Kind.CURRENT_USER))); + revoke.setIgnoreUnknownUser(true); + assertDeparse(revoke, + "REVOKE IF EXISTS PROXY ON 'app'@'%' FROM CURRENT_USER IGNORE UNKNOWN USER"); + } + + @Test + void validatesWithMySqlAndMariaDbCapabilities() { + for (String sql : Arrays.asList("GRANT SELECT ON db1.* TO 'app'@'%'", + "GRANT 'reader' TO 'app'@'%'", + "REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'app'@'%'", + "REVOKE SELECT ON *.* FROM app")) { + validateNoErrors(sql, 1, DatabaseType.MYSQL, DatabaseType.MARIADB); + } + } +}