From 1af2ff3e8656992fca98f24fcfb06aec5a001278 Mon Sep 17 00:00:00 2001 From: minleejae Date: Wed, 14 Oct 2026 13:17:49 +0900 Subject: [PATCH] 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(); + } +}