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 2210dbe9d..9fcc539ce 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -15502,11 +15502,66 @@ StringValue MySqlAccountNamePart(): ( token= { value = stringValue(token); } | - name=RelObjectName() { value = new StringValue(name).setQuoteStr(""); } + name=RelObjectName() + { + // a double-quoted or backquoted part keeps its quote character separately from its + // value, like a string literal; new StringValue(name) would drop the double quotes + value = name.length() > 1 && (name.charAt(0) == '"' || name.charAt(0) == '`') + && name.charAt(name.length() - 1) == name.charAt(0) + ? new StringValue().withValue(name.substring(1, name.length() - 1)) + .setQuoteStr(name.substring(0, 1)) + : new StringValue().withValue(name).setQuoteStr(""); + } ) { return value; } } +/** + * A MySQL account name, user[@host], shared by CREATE USER, DEFINER and the account statements. + * Each part is unquoted or quoted with ', " or `. The lexer reads an unquoted user@host, and the + * user@ in front of a quoted host, as one identifier, and @host after a quoted user as one + * at-identifier, so those spellings are split here. + */ +UserAccount MySqlAccountName(): +{ + UserAccount account = new UserAccount(); + StringValue part; + Token token; + int at; +} +{ + ( + // user@ followed by a quoted host + LOOKAHEAD({ getToken(1).kind == S_IDENTIFIER && getToken(1).image.indexOf('@') > 0 + && getToken(1).image.indexOf('@') == getToken(1).image.length() - 1 }) + token= + { account.setUser(new StringValue().withValue(token.image.substring(0, token.image.length() - 1)) + .setQuoteStr("")); } + part=MySqlAccountNamePart() { account.setHost(part); } + | + // unquoted user@host + LOOKAHEAD({ getToken(1).kind == S_IDENTIFIER && getToken(1).image.indexOf('@') > 0 + && getToken(1).image.indexOf('@') == getToken(1).image.lastIndexOf('@') }) + token= + { + at = token.image.indexOf('@'); + account.setUser(new StringValue().withValue(token.image.substring(0, at)).setQuoteStr("")); + account.setHost(new StringValue().withValue(token.image.substring(at + 1)).setQuoteStr("")); + } + | + part=MySqlAccountNamePart() { account.setUser(part); } + [ + part=MySqlAccountNamePart() { account.setHost(part); } + | + // a quoted user followed by an unquoted @host + LOOKAHEAD({ getToken(1).kind == S_AT_IDENTIFIER && !getToken(1).image.startsWith("@@") }) + token= + { account.setHost(new StringValue().withValue(token.image.substring(1)).setQuoteStr("")); } + ] + ) + { return account; } +} + UserAuthentication MySqlUserAuthentication(): { UserAuthentication authentication = new UserAuthentication(); @@ -15557,14 +15612,11 @@ UserAuthentication MySqlUserAuthentication(): UserAccount MySqlUserAccount(): { - UserAccount account = new UserAccount(); - StringValue user = null; - StringValue host = null; + UserAccount account; UserAuthentication authentication = null; } { - user=MySqlAccountNamePart() { account.setUser(user); } - [ host=MySqlAccountNamePart() { account.setHost(host); } ] + account=MySqlAccountName() [ authentication=MySqlUserAuthentication() { account.setAuthentication(authentication); } ] { return account; } } @@ -15710,8 +15762,7 @@ AlterEvent AlterEvent(): /** Account spelling is shared, including CURRENT_USER and CURRENT_USER(). */ void MySqlDefiner(MySqlDefiner definer): { - StringValue user; - StringValue host; + UserAccount account; String name; } { @@ -15721,8 +15772,7 @@ void MySqlDefiner(MySqlDefiner definer): { definer.setUser(new StringValue(name).setQuoteStr("")); } [ "(" ")" { definer.setCurrentUserParentheses(true); } ] | - user=MySqlAccountNamePart() { definer.setUser(user); } - [ host=MySqlAccountNamePart() { definer.setHost(host); } ] + account=MySqlAccountName() { definer.setUser(account.getUser()); definer.setHost(account.getHost()); } ) } diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 901608d62..26f7ff5d8 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -1718,6 +1718,20 @@ the PostgreSQL ``CreateRole`` AST for this ambiguous form:: "CREATE USER app", parser -> parser.withDialect(AbstractJSqlParser.Dialect.POSTGRESQL)); +MySQL account names (``user[@host]``) of ``CREATE USER`` and of view, trigger and +routine ``DEFINER`` clauses share one grammar rule and are read into separate user +and host ``StringValue`` parts (``UserAccount`` and ``MySqlDefiner``). Every MySQL +spelling is accepted, including ``app@localhost``, ``app@'%'`` and +``'app'@localhost``. A part quoted with ``'``, ``"`` or a backquote keeps its quote +character in ``getQuoteStr()`` and its text in ``getValue()``:: + + CreateUser createUser = (CreateUser) CCJSqlParserUtil.parse( + "CREATE USER `app`@'%' IDENTIFIED BY 'secret'"); + UserAccount account = createUser.getAccounts().get(0); + account.getUser().getValue(); // app + account.getUser().getQuoteStr(); // ` + account.getHost().getValue(); // % + ``Grant`` retains its string-based getters, setters and fluent methods. Its ``PrivilegeClause`` exposes typed ``Privilege`` items, column lists, ``PrivilegeTarget`` kinds, multiple role memberships and grantor information. diff --git a/src/test/java/net/sf/jsqlparser/statement/create/user/MySqlAccountNameTest.java b/src/test/java/net/sf/jsqlparser/statement/create/user/MySqlAccountNameTest.java new file mode 100644 index 000000000..455f18b6a --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/user/MySqlAccountNameTest.java @@ -0,0 +1,123 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.create.user; + +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.function.Consumer; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.MySqlDefiner; +import net.sf.jsqlparser.statement.create.trigger.CreateTrigger; +import net.sf.jsqlparser.statement.create.view.CreateView; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.stream.Stream; + +class MySqlAccountNameTest { + + private static final Consumer MYSQL = p -> p.withDialect(Dialect.MYSQL); + private static final Consumer NO_DIALECT = p -> { + }; + + /** SQL spelling, user value, user quote, host value, host quote. */ + static Stream accounts() { + return Stream.of( + Arguments.of("app", "app", "", null, null), + Arguments.of("app@localhost", "app", "", "localhost", ""), + Arguments.of("app@'%'", "app", "", "%", "'"), + Arguments.of("app@'10.0.0.%'", "app", "", "10.0.0.%", "'"), + Arguments.of("'app'@localhost", "app", "'", "localhost", ""), + Arguments.of("'app'@'%'", "app", "'", "%", "'"), + Arguments.of("`app`@`%`", "app", "`", "%", "`"), + Arguments.of("\"app\"@\"%\"", "app", "\"", "%", "\""), + Arguments.of("'app'", "app", "'", null, null), + Arguments.of("`my app`@'%'", "my app", "`", "%", "'")); + } + + @ParameterizedTest + @MethodSource("accounts") + void createUserSplitsEveryAccountSpelling(String account, String user, String userQuote, + String host, String hostQuote) throws JSQLParserException { + for (Consumer mode : java.util.Arrays.asList(MYSQL, NO_DIALECT)) { + String sql = "CREATE USER " + account + " IDENTIFIED BY 'x'"; + CreateUser createUser = assertInstanceOf(CreateUser.class, + assertSqlCanBeParsedAndDeparsed(sql, true, mode)); + assertEquals(sql, createUser.toString()); + assertEquals(sql, deparse(createUser)); + assertPart(createUser.getAccounts().get(0).getUser(), user, userQuote); + assertPart(createUser.getAccounts().get(0).getHost(), host, hostQuote); + } + } + + @ParameterizedTest + @MethodSource("accounts") + void definerSplitsEveryAccountSpelling(String account, String user, String userQuote, + String host, String hostQuote) throws JSQLParserException { + String view = "CREATE DEFINER = " + account + " VIEW v AS SELECT 1"; + CreateView createView = assertInstanceOf(CreateView.class, + assertSqlCanBeParsedAndDeparsed(view, true, MYSQL)); + MySqlDefiner definer = createView.getMySqlOptions().getDefiner(); + assertPart(definer.getUser(), user, userQuote); + assertPart(definer.getHost(), host, hostQuote); + + String trigger = "CREATE DEFINER = " + account + + " TRIGGER trg BEFORE INSERT ON t FOR EACH ROW SET NEW.a = 1"; + CreateTrigger createTrigger = assertInstanceOf(CreateTrigger.class, + assertSqlCanBeParsedAndDeparsed(trigger, true, MYSQL)); + assertEquals(trigger, createTrigger.toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"CREATE USER app@", "CREATE USER @host", "CREATE USER 'app'@"}) + void rejectsMalformedAccounts(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, MYSQL)); + } + + @org.junit.jupiter.api.Test + void keepsDoubleQuotesWithoutDialect() throws JSQLParserException { + // before, the double quotes were dropped and the account rendered as app@% + String sql = "CREATE USER \"app\"@\"%\" IDENTIFIED BY 'x'"; + assertEquals(sql, CCJSqlParserUtil.parse(sql).toString()); + assertEquals(sql, CCJSqlParserUtil.parse(sql, MYSQL).toString()); + } + + @org.junit.jupiter.api.Test + void definerCurrentUserIsUnchanged() throws JSQLParserException { + CreateView view = assertInstanceOf(CreateView.class, assertSqlCanBeParsedAndDeparsed( + "CREATE DEFINER = CURRENT_USER() VIEW v AS SELECT 1", true, MYSQL)); + assertNull(view.getMySqlOptions().getDefiner().getHost()); + } + + private static void assertPart(StringValue part, String value, String quote) { + if (value == null) { + assertNull(part); + return; + } + assertEquals(value, part.getValue()); + assertEquals(quote, part.getQuoteStr()); + } + + private static String deparse(CreateUser createUser) { + StringBuilder builder = new StringBuilder(); + createUser.accept(new StatementDeParser(builder), null); + return builder.toString(); + } +} From 4980c94652ae6c6752a8e702d78be86505555e2d Mon Sep 17 00:00:00 2001 From: minleejae Date: Wed, 14 Oct 2026 12:57:04 +0900 Subject: [PATCH 2/2] fix: keep routine signatures and owning tables in DROP statements DROP PROCEDURE p(int) lost its signature, DROP FUNCTION accepted only one signature, and owner tables, CASCADE/RESTRICT and the roles of DROP OWNED BY were kept as raw parameter tokens. Model routine targets as RoutineReference, role and account targets as UserAccount read with MySqlAccountName(), the ON table of DROP TRIGGER/RULE in the existing owner field and CASCADE/RESTRICT as CascadeBehavior. Keep getNames(), getTypeToParameters(), getParamsByType() and getParameters() as derived values. Parse DROP TYPE, DOMAIN, EVENT, EVENT TRIGGER, [UNDO] TABLESPACE ... ENGINE, USER, GROUP, MySQL account names in DROP USER/ROLE, DROP DATABASE ... WITH (FORCE) and PostgreSQL REASSIGN OWNED. Signed-off-by: minleejae --- README.md | 1 + .../sf/jsqlparser/parser/feature/Feature.java | 7 + .../statement/StatementFeatureVisitor.java | 6 + .../statement/StatementVisitor.java | 9 + .../statement/StatementVisitorAdapter.java | 6 + .../sf/jsqlparser/statement/drop/Drop.java | 314 +++++++++++++++++- .../statement/role/ReassignOwned.java | 74 +++++ .../sf/jsqlparser/util/TablesNamesFinder.java | 7 + .../util/deparser/StatementDeParser.java | 7 + .../validation/feature/PostgresqlVersion.java | 3 +- .../validator/StatementValidator.java | 11 + .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 157 +++++---- src/site/sphinx/index.rst | 2 + src/site/sphinx/usage.rst | 64 ++++ .../statement/drop/DropObjectTypeTest.java | 202 +++++++++++ .../statement/drop/DropRoleTest.java | 178 ++++++++++ .../drop/DropRoutineSignatureTest.java | 236 +++++++++++++ .../jsqlparser/statement/drop/DropTest.java | 5 +- .../validator/DropValidatorTest.java | 19 ++ 19 files changed, 1233 insertions(+), 75 deletions(-) create mode 100644 src/main/java/net/sf/jsqlparser/statement/role/ReassignOwned.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/drop/DropObjectTypeTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/drop/DropRoleTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/drop/DropRoutineSignatureTest.java diff --git a/README.md b/README.md index d39e81ea6..58fd6618f 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,7 @@ and missing syntax gets added on demand — [open an issue](https://github.com/J | **ClickHouse column selection** | `COLUMNS('regexp')` select items with chained `APPLY`, `EXCEPT`, and `REPLACE` transformers | | **DML** | `INSERT` · `UPDATE` · `UPSERT` · `MERGE` · `DELETE` · `TRUNCATE TABLE` | | **DDL** | `CREATE …` · `ALTER …` · `DROP …` | +| **PostgreSQL ownership** | `DROP OWNED` · `REASSIGN OWNED` | | **MySQL table maintenance** | `ANALYZE` · `CHECK` · `OPTIMIZE` · `REPAIR TABLE` | | **PostgreSQL RLS** | `CREATE POLICY` · `ALTER TABLE … ENABLE`/`DISABLE`/`FORCE`/`NO FORCE ROW LEVEL SECURITY` | | **Informix constraints** | `ALTER TABLE … ADD CONSTRAINT` with trailing constraint names for primary, unique, foreign and check constraints; enable with `parser.withDialect(Dialect.INFORMIX)` | diff --git a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java index 0d3d080fb..c18c84233 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -48,6 +48,7 @@ import net.sf.jsqlparser.statement.grant.Grant; import net.sf.jsqlparser.statement.merge.Merge; import net.sf.jsqlparser.statement.refresh.RefreshMaterializedViewStatement; +import net.sf.jsqlparser.statement.role.ReassignOwned; import net.sf.jsqlparser.statement.select.Fetch; import net.sf.jsqlparser.statement.select.First; import net.sf.jsqlparser.statement.select.KSQLWindow; @@ -502,6 +503,12 @@ public enum Feature { * @see Drop */ drop, dropTable, dropIndex, dropView, dropSchema, dropSequence, dropTableIfExists, dropIndexIfExists, dropViewIfExists, dropSchemaIfExists, dropSequenceIfExists, + /** + * PostgreSQL "REASSIGN OWNED BY ... TO ..." statement is allowed + * + * @see ReassignOwned + */ + reassignOwned, /** * SQL "CREATE DATABASE" statement is allowed diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index 91b272124..e0a53b9e6 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -95,6 +95,7 @@ import net.sf.jsqlparser.statement.refresh.RefreshMaterializedViewStatement; import net.sf.jsqlparser.statement.role.AlterRole; import net.sf.jsqlparser.statement.role.CreateRole; +import net.sf.jsqlparser.statement.role.ReassignOwned; import net.sf.jsqlparser.statement.select.FromItemVisitor; import net.sf.jsqlparser.statement.select.FromItemVisitorAdapter; import net.sf.jsqlparser.statement.select.MySqlSelectIntoClause; @@ -546,6 +547,11 @@ public Void visit(Drop drop, S context) { return null; } + @Override + public Void visit(ReassignOwned statement, S context) { + return schemaOnly(); + } + @Override public Void visit(PragmaStatement pragmaStatement, S context) { analysis.claimTopLevel(); diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index bf0bf3e8b..6da8f9bea 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -37,6 +37,7 @@ import net.sf.jsqlparser.statement.role.CreateRole; import net.sf.jsqlparser.statement.role.AlterRole; +import net.sf.jsqlparser.statement.role.ReassignOwned; import net.sf.jsqlparser.statement.grant.Revoke; import net.sf.jsqlparser.statement.grant.AlterDefaultPrivileges; import net.sf.jsqlparser.statement.create.type.CreateType; @@ -175,6 +176,14 @@ default void visit(Drop drop) { this.visit(drop, null); } + default T visit(ReassignOwned statement, S context) { + return null; + } + + default void visit(ReassignOwned statement) { + this.visit(statement, null); + } + T visit(Truncate truncate, S context); default void visit(Truncate truncate) { diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index 8a80763ca..a5284bd67 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -31,6 +31,7 @@ import net.sf.jsqlparser.statement.role.CreateRole; import net.sf.jsqlparser.statement.role.AlterRole; +import net.sf.jsqlparser.statement.role.ReassignOwned; import net.sf.jsqlparser.statement.role.RoleOption; import net.sf.jsqlparser.statement.grant.Revoke; import net.sf.jsqlparser.statement.grant.AlterDefaultPrivileges; @@ -368,6 +369,11 @@ public T visit(Drop drop, S context) { return null; } + @Override + public T visit(ReassignOwned statement, S context) { + return null; + } + @Override public T visit(Truncate truncate, S context) { return truncate.getTable().accept(fromItemVisitor, context); diff --git a/src/main/java/net/sf/jsqlparser/statement/drop/Drop.java b/src/main/java/net/sf/jsqlparser/statement/drop/Drop.java index c0adf8769..d6dec72f0 100644 --- a/src/main/java/net/sf/jsqlparser/statement/drop/Drop.java +++ b/src/main/java/net/sf/jsqlparser/statement/drop/Drop.java @@ -17,28 +17,54 @@ import java.util.Locale; import java.util.Map; import java.util.function.Consumer; +import java.util.stream.Collectors; import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.CascadeBehavior; +import net.sf.jsqlparser.statement.RoutineReference; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.StatementVisitor; +import net.sf.jsqlparser.statement.create.user.UserAccount; import net.sf.jsqlparser.statement.select.PlainSelect; public class Drop implements Statement { public enum ObjectType { - DATABASE, EVENT, FUNCTION, INDEX, PROCEDURE, SCHEMA, SEQUENCE, SERVER, TABLE, TABLESPACE, TRIGGER, VIEW, FOREIGN_TABLE, OTHER + DATABASE, EVENT, FUNCTION, INDEX, PROCEDURE, SCHEMA, SEQUENCE, SERVER, TABLE, TABLESPACE, TRIGGER, VIEW, FOREIGN_TABLE, AGGREGATE, DOMAIN, EVENT_TRIGGER, GROUP, OWNED, ROLE, ROUTINE, RULE, TYPE, USER, OTHER; + + /** Targets are {@link RoutineReference}s, each with an optional argument signature. */ + public boolean hasRoutineTargets() { + return this == FUNCTION || this == PROCEDURE || this == ROUTINE || this == AGGREGATE; + } + + /** Targets are role or MySQL account names, including the roles of DROP OWNED BY. */ + public boolean hasAccountTargets() { + return this == USER || this == ROLE || this == GROUP || this == OWNED; + } + + /** The target can name the table that owns it with ON. */ + public boolean hasOwnerTable() { + return this == INDEX || this == TRIGGER || this == RULE; + } } private String type; private ObjectType objectType = ObjectType.OTHER; private final List names = new ArrayList<>(); + private final List routines = new ArrayList<>(); + private final List accounts = new ArrayList<>(); private List parameters; private Table table; private int tablePosition; + private CascadeBehavior behavior; private Map> typeToParameters = new HashMap<>(); private boolean ifExists = false; private boolean materialized = false; private boolean concurrently; + private boolean undo; + private boolean force; + private String engine; + private boolean engineUseEquals; /** PostgreSQL DROP INDEX CONCURRENTLY, preceding IF EXISTS and the index name. */ public boolean isConcurrently() { @@ -70,39 +96,175 @@ public T accept(StatementVisitor statementVisitor, S context) { } public Table getName() { - return names.isEmpty() ? null : names.get(0); + List
current = getNames(); + return current.isEmpty() ? null : current.get(0); } public void setName(Table name) { + useNameTargets(); names.clear(); if (name != null) { names.add(name); } } + /** + * Returns the dropped object names. Routine and account targets return a read-only snapshot of + * their names; edit {@link #getRoutines()} or {@link #getAccounts()} to change them. + */ public List
getNames() { + if (!routines.isEmpty()) { + return Collections.unmodifiableList(routines.stream() + .map(routine -> routine.getName() == null ? new Table() + : qualifiedTable(routine.getName())) + .collect(Collectors.toList())); + } + if (!accounts.isEmpty()) { + return Collections.unmodifiableList(accounts.stream() + .map(account -> new Table(account.toString(), false)) + .collect(Collectors.toList())); + } return Collections.unmodifiableList(names); } public void setNames(Collection names) { + useNameTargets(); this.names.clear(); if (names != null) { this.names.addAll(names); } } - /** Returns legacy tokens, including ON and its owner. Structured owners produce a snapshot. */ + /** + * Routine targets of DROP FUNCTION, PROCEDURE, ROUTINE and AGGREGATE. A routine without an + * argument list has null arguments; an explicit {@code ()} has an empty list. + */ + public List getRoutines() { + return Collections.unmodifiableList(routines); + } + + /** Replaces the targets with routines, discarding names, accounts and legacy parameters. */ + public void setRoutines(Collection routines) { + clearTargets(); + if (routines != null) { + this.routines.addAll(routines); + } + } + + public Drop withRoutines(Collection routines) { + setRoutines(routines); + return this; + } + + /** Appends routine targets. Name or account targets are discarded first. */ + public Drop addRoutines(RoutineReference... routines) { + if (routines.length > 0 && this.routines.isEmpty()) { + clearTargets(); + } + Collections.addAll(this.routines, routines); + return this; + } + + /** Role or MySQL account targets of DROP USER, ROLE, GROUP and the roles of DROP OWNED BY. */ + public List getAccounts() { + return Collections.unmodifiableList(accounts); + } + + /** Replaces the targets with accounts, discarding names, routines and legacy parameters. */ + public void setAccounts(Collection accounts) { + clearTargets(); + if (accounts != null) { + this.accounts.addAll(accounts); + } + } + + public Drop withAccounts(Collection accounts) { + setAccounts(accounts); + return this; + } + + /** Appends account targets. Name or routine targets are discarded first. */ + public Drop addAccounts(UserAccount... accounts) { + if (accounts.length > 0 && this.accounts.isEmpty()) { + clearTargets(); + } + Collections.addAll(this.accounts, accounts); + return this; + } + + private void clearTargets() { + names.clear(); + routines.clear(); + accounts.clear(); + typeToParameters = new HashMap<>(); + } + + /** + * Converts routine or account targets to the legacy representation before a legacy edit: the + * names, and the last routine's argument declarations as the single legacy signature. + */ + private void useNameTargets() { + if (routines.isEmpty() && accounts.isEmpty()) { + return; + } + List
legacyNames = getNames(); + Map> legacyParameters = new HashMap<>(); + getTypeToParameters().forEach((key, value) -> legacyParameters.put(key, + value == null ? null : new ArrayList<>(value))); + routines.clear(); + accounts.clear(); + names.addAll(legacyNames); + typeToParameters = legacyParameters; + } + + /** Splits a routine name at the dots outside quoted identifiers. */ + private static Table qualifiedTable(String name) { + List parts = new ArrayList<>(); + int start = 0; + char closingQuote = 0; + for (int i = 0; i < name.length(); i++) { + char ch = name.charAt(i); + if (closingQuote != 0) { + if (ch == closingQuote) { + closingQuote = 0; + } + } else if (ch == '"' || ch == '`') { + closingQuote = ch; + } else if (ch == '[') { + closingQuote = ']'; + } else if (ch == '.') { + parts.add(name.substring(start, i)); + start = i + 1; + } + } + parts.add(name.substring(start)); + return parts.size() == 1 ? new Table(parts.get(0), false) : new Table(parts); + } + + /** + * Returns the legacy tokens after the targets. With a structured ON table or CASCADE/RESTRICT + * behavior, this is a snapshot that includes them; edit {@link #setTable} or + * {@link #setBehavior} instead. + */ public List getParameters() { - if (table == null) { + if (table == null && behavior == null) { return parameters; } List tokens = parameters == null ? new ArrayList<>() : new ArrayList<>(parameters); - tokens.add(tablePosition, "ON"); - tokens.add(tablePosition + 1, table.toString()); + if (table != null) { + tokens.add(tablePosition, "ON"); + tokens.add(tablePosition + 1, table.toString()); + } + if (behavior != null) { + tokens.add(behavior.name()); + } return tokens; } - /** The table explicitly named by DROP INDEX ... ON, or null when SQL does not name one. */ + /** + * The table named by ON in DROP INDEX, DROP TRIGGER or DROP RULE, or null when SQL does not + * name one. + */ public Table getTable() { return table; } @@ -126,21 +288,95 @@ public Drop withTable(Table table) { return this; } - /** Visits table/view targets and explicit index owners without resolving catalog objects. */ + /** Visits table/view targets and explicit owner tables without resolving catalog objects. */ public void visitTables(Consumer
visitor) { if (objectType == ObjectType.TABLE || objectType == ObjectType.VIEW || objectType == ObjectType.FOREIGN_TABLE) { names.forEach(visitor); - } else if (objectType == ObjectType.INDEX && table != null) { + } else if (objectType.hasOwnerTable() && table != null) { visitor.accept(table); } } - /** Replaces all legacy parameters and clears the structured ON table. */ + /** + * Replaces all legacy parameters and clears the structured ON table and CASCADE/RESTRICT + * behavior. + */ public void setParameters(List list) { parameters = list; table = null; tablePosition = 0; + behavior = null; + } + + /** The explicit CASCADE or RESTRICT clause, or null when it is omitted. */ + public CascadeBehavior getBehavior() { + return behavior; + } + + public void setBehavior(CascadeBehavior behavior) { + this.behavior = behavior; + } + + public Drop withBehavior(CascadeBehavior behavior) { + setBehavior(behavior); + return this; + } + + /** MySQL DROP UNDO TABLESPACE. */ + public boolean isUndo() { + return undo; + } + + public void setUndo(boolean undo) { + this.undo = undo; + } + + public Drop withUndo(boolean undo) { + setUndo(undo); + return this; + } + + /** PostgreSQL DROP DATABASE ... WITH (FORCE). */ + public boolean isForce() { + return force; + } + + public void setForce(boolean force) { + this.force = force; + } + + public Drop withForce(boolean force) { + setForce(force); + return this; + } + + /** The storage engine of MySQL DROP TABLESPACE ... ENGINE, or null when it is omitted. */ + public String getEngine() { + return engine; + } + + public void setEngine(String engine) { + this.engine = engine; + } + + public Drop withEngine(String engine) { + setEngine(engine); + return this; + } + + /** Whether the ENGINE clause is written with an equals sign. */ + public boolean isEngineUseEquals() { + return engineUseEquals; + } + + public void setEngineUseEquals(boolean engineUseEquals) { + this.engineUseEquals = engineUseEquals; + } + + public Drop withEngineUseEquals(boolean engineUseEquals) { + setEngineUseEquals(engineUseEquals); + return this; } public String getType() { @@ -196,11 +432,29 @@ public void setMaterialized(boolean materialized) { this.materialized = materialized; } + /** + * Returns the legacy routine parameters by object type. For routine targets, this is a + * read-only snapshot of the last routine's argument declarations, where the legacy + * single-signature form kept them; edit {@link #getRoutines()} instead. + */ public Map> getTypeToParameters() { - return typeToParameters; + if (routines.isEmpty()) { + return typeToParameters; + } + RoutineReference last = routines.get(routines.size() - 1); + List arguments = null; + if (last.isAllArguments()) { + arguments = Collections.singletonList("*"); + } else if (last.getArguments() != null) { + arguments = Collections.unmodifiableList(last.getArguments().stream() + .map(Object::toString).collect(Collectors.toList())); + } + return Collections.singletonMap( + objectType.hasRoutineTargets() ? objectType.name() : type, arguments); } public void setTypeToParameters(Map> typeToParameters) { + useNameTargets(); this.typeToParameters = typeToParameters; } @@ -218,19 +472,46 @@ public StringBuilder appendTo(StringBuilder builder, Consumer
tablePrinte if (materialized) { builder.append("MATERIALIZED "); } + if (undo) { + builder.append("UNDO "); + } builder.append(type); if (concurrently) { builder.append(" CONCURRENTLY"); } + if (objectType == ObjectType.OWNED) { + builder.append(" BY"); + } builder.append(ifExists ? " IF EXISTS " : " "); - appendNames(builder, tablePrinter); - if ("FUNCTION".equals(type)) { - builder.append(formatFuncParams(getParamsByType("FUNCTION"))); + appendTargets(builder, tablePrinter); + if (force) { + builder.append(" WITH (FORCE)"); + } + if (engine != null) { + builder.append(engineUseEquals ? " ENGINE = " : " ENGINE ").append(engine); } appendParameters(builder, tablePrinter); + if (behavior != null) { + builder.append(' ').append(behavior); + } return builder; } + private void appendTargets(StringBuilder builder, Consumer
tablePrinter) { + if (!routines.isEmpty()) { + builder.append(routines.stream().map(Object::toString) + .collect(Collectors.joining(", "))); + } else if (!accounts.isEmpty()) { + builder.append(accounts.stream().map(Object::toString) + .collect(Collectors.joining(", "))); + } else { + appendNames(builder, tablePrinter); + if ("FUNCTION".equals(type)) { + builder.append(formatFuncParams(getParamsByType("FUNCTION"))); + } + } + } + private void appendNames(StringBuilder builder, Consumer
tablePrinter) { for (int i = 0; i < names.size(); i++) { if (i > 0) { @@ -259,7 +540,7 @@ private void appendParameters(StringBuilder builder, Consumer
tablePrinte } public List getParamsByType(String type) { - return typeToParameters.get(type); + return getTypeToParameters().get(type); } public Drop withIfExists(boolean ifExists) { @@ -288,6 +569,7 @@ public Drop withNames(Collection names) { } public Drop addNames(Table... names) { + useNameTargets(); Collections.addAll(this.names, names); return this; } @@ -306,7 +588,7 @@ public Drop addParameters(String... parameters) { return addParameters(java.util.Arrays.asList(parameters)); } - /** Appends trailing tokens without discarding a structured ON table. */ + /** Appends trailing tokens without discarding a structured ON table or behavior. */ public Drop addParameters(Collection parameters) { if (this.parameters == null) { this.parameters = new ArrayList<>(); diff --git a/src/main/java/net/sf/jsqlparser/statement/role/ReassignOwned.java b/src/main/java/net/sf/jsqlparser/statement/role/ReassignOwned.java new file mode 100644 index 000000000..2ca93b73b --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/role/ReassignOwned.java @@ -0,0 +1,74 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.role; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; +import net.sf.jsqlparser.statement.create.user.UserAccount; + +/** + * PostgreSQL {@code REASSIGN OWNED BY old_role [, ...] TO new_role}. Roles use the same name model + * as the roles of {@code DROP OWNED BY}. + */ +public class ReassignOwned implements Statement { + private final List roles = new ArrayList<>(); + private UserAccount newRole; + + public List getRoles() { + return Collections.unmodifiableList(roles); + } + + public void setRoles(Collection roles) { + this.roles.clear(); + if (roles != null) { + this.roles.addAll(roles); + } + } + + public ReassignOwned withRoles(Collection roles) { + setRoles(roles); + return this; + } + + public ReassignOwned addRoles(UserAccount... roles) { + Collections.addAll(this.roles, roles); + return this; + } + + public UserAccount getNewRole() { + return newRole; + } + + public void setNewRole(UserAccount newRole) { + this.newRole = newRole; + } + + public ReassignOwned withNewRole(UserAccount newRole) { + setNewRole(newRole); + return this; + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } + + @Override + public String toString() { + return "REASSIGN OWNED BY " + + roles.stream().map(Object::toString).collect(Collectors.joining(", ")) + + " TO " + newRole; + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index aeecdb89d..d12343786 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -215,6 +215,7 @@ import net.sf.jsqlparser.statement.refresh.RefreshMaterializedViewStatement; import net.sf.jsqlparser.statement.role.AlterRole; import net.sf.jsqlparser.statement.role.CreateRole; +import net.sf.jsqlparser.statement.role.ReassignOwned; import net.sf.jsqlparser.statement.select.AllColumns; import net.sf.jsqlparser.statement.select.AllTableColumns; import net.sf.jsqlparser.statement.select.FromItem; @@ -1669,6 +1670,12 @@ public void visit(Drop drop) { StatementVisitor.super.visit(drop); } + @Override + public Void visit(ReassignOwned statement, S context) { + // Role names do not refer to tables. + return null; + } + @Override public Void visit(Truncate truncate, S context) { truncate.getTables().forEach(table -> visit(table, context)); diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java index 2490baa3e..11c41b042 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -37,6 +37,7 @@ import net.sf.jsqlparser.statement.role.CreateRole; import net.sf.jsqlparser.statement.role.AlterRole; +import net.sf.jsqlparser.statement.role.ReassignOwned; import net.sf.jsqlparser.statement.grant.Revoke; import net.sf.jsqlparser.statement.grant.AlterDefaultPrivileges; import net.sf.jsqlparser.statement.create.type.CreateType; @@ -255,6 +256,12 @@ public StringBuilder visit(Drop drop, S context) { return builder; } + @Override + public StringBuilder visit(ReassignOwned statement, S context) { + builder.append(statement); + return builder; + } + @Override public StringBuilder visit(Insert insert, S context) { InsertDeParser insertDeParser = diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java index 023a8c0e1..fd79c4315 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java @@ -148,7 +148,8 @@ public enum PostgresqlVersion implements Version { Feature.dropSchema, // https://www.postgresql.org/docs/current/sql-dropsequence.html Feature.dropSequence, Feature.dropTableIfExists, Feature.dropIndexIfExists, Feature.dropViewIfExists, - Feature.dropSchemaIfExists, Feature.dropSequenceIfExists, + Feature.dropSchemaIfExists, Feature.dropSequenceIfExists, // https://www.postgresql.org/docs/current/sql-reassign-owned.html + Feature.reassignOwned, // https://www.postgresql.org/docs/current/sql-altertable.html Feature.alterTable, // https://www.postgresql.org/docs/current/using-explain.html diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java index 1de8380a7..4070b2bbc 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java @@ -35,6 +35,7 @@ import net.sf.jsqlparser.statement.role.CreateRole; import net.sf.jsqlparser.statement.role.AlterRole; +import net.sf.jsqlparser.statement.role.ReassignOwned; import net.sf.jsqlparser.statement.role.RoleOption; import net.sf.jsqlparser.statement.grant.Revoke; import net.sf.jsqlparser.statement.grant.AlterDefaultPrivileges; @@ -207,6 +208,12 @@ public Void visit(Drop drop, S context) { return null; } + @Override + public Void visit(ReassignOwned statement, S context) { + validateFeature(Feature.reassignOwned); + return null; + } + @Override public Void visit(Insert insert, S context) { getValidator(InsertValidator.class).validate(insert); @@ -702,6 +709,10 @@ public void visit(Drop drop) { visit(drop, null); } + public void visit(ReassignOwned statement) { + visit(statement, null); + } + public void visit(Insert insert) { visit(insert, null); } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 9fcc539ce..39e3b3d5b 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -166,6 +166,7 @@ public class CCJSqlParser extends AbstractJSqlParser { || next.kind == K_EXPORT && getToken(2).kind == K_DATA; default: return "NOTIFY".equalsIgnoreCase(next.image) + || "REASSIGN".equalsIgnoreCase(next.image) || "REVOKE".equalsIgnoreCase(next.image) || "RELEASE".equalsIgnoreCase(next.image); } @@ -2508,12 +2509,23 @@ public class CCJSqlParser extends AbstractJSqlParser { } } - private void requireNoDropIndexOwner(Table owner) throws ParseException { + private void requireNoDropOwnerTable(Table owner) throws ParseException { if (owner != null) { - throw new ParseException("Duplicate DROP INDEX ON clause"); + throw new ParseException("Duplicate ON clause in DROP"); } } + /** + * Whether ON names the table of a dropped index, trigger or rule. SQL Server DDL triggers + * name a scope instead: DROP TRIGGER t ON DATABASE or ON ALL SERVER. + */ + private static boolean isDropOwnerTable(Drop drop, Table name) { + String text = name.getFullyQualifiedName(); + boolean triggerScope = drop.getObjectType() == Drop.ObjectType.TRIGGER + && ("DATABASE".equalsIgnoreCase(text) || "ALL".equalsIgnoreCase(text)); + return drop.getObjectType().hasOwnerTable() && !triggerScope; + } + /** Builds names from lexer-separated components without reinterpreting PostgreSQL dots. */ protected Table createTable(List names) { return Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect)) && names.size() == 1 @@ -3202,6 +3214,7 @@ String NonReservedWord() : | tk= | tk= | tk= + | tk= | tk= | tk= | tk= @@ -3912,6 +3925,9 @@ Statement SingleStatement() : | stm = Drop() | + LOOKAHEAD({ getToken(1).kind == K_REASSIGN && "OWNED".equalsIgnoreCase(getToken(2).image) }) + stm = ReassignOwned() + | stm = Analyze() | LOOKAHEAD({ isTableMaintenanceAhead() }) stm = TableMaintenanceStatement() @@ -17894,48 +17910,6 @@ List ColumnsNamesList(): } } -String FuncArgsListItem(): -{ - Token tk = null; - String argName = null; - String argType = null; -} -{ - ( - LOOKAHEAD(2) ( - argName = RelObjectName() - argType = RelObjectName() - [ "(" tk = ")" { argType = argType + "(" + tk.image + ")"; } ] - ) - | - ( - argType = RelObjectName() - [ "(" tk = ")" { argType = argType + "(" + tk.image + ")"; } ] - ) - ) - { - return argName != null ? String.format("%s %s", argName, argType) : argType; - } -} - -List FuncArgsList(): -{ - List retval = null; - String img = null; -} -{ - "(" - { retval = new ArrayList(); } - [ - img=FuncArgsListItem() { retval.add(img); } - ( "," img=FuncArgsListItem() { retval.add(img); } )* - ] - ")" - { - return retval; - } -} - /** DROP names are lexer-separated identifiers, not strings to split again on dots. */ Table DropObjectName() #TableName: { @@ -17955,6 +17929,29 @@ Table DropObjectName() #TableName: { linkAST(table, jjtThis); return table; } } +/** The targets of a DROP statement, typed by the object kind that was dropped. */ +void DropTargets(Drop drop): +{ + boolean aggregate = drop.getObjectType() == Drop.ObjectType.AGGREGATE; + RoutineReference routine; + UserAccount account; + Table name; +} +{ + ( + LOOKAHEAD({ drop.getObjectType().hasRoutineTargets() }) + routine=RoutineReference(aggregate) { drop.addRoutines(routine); } + ( "," routine=RoutineReference(aggregate) { drop.addRoutines(routine); } )* + | + LOOKAHEAD({ drop.getObjectType().hasAccountTargets() }) + account=MySqlAccountName() { drop.addAccounts(account); } + ( "," account=MySqlAccountName() { drop.addAccounts(account); } )* + | + name=DropObjectName() { drop.addNames(name); } + ( "," name=DropObjectName() { drop.addNames(name); } )* + ) +} + Drop Drop(): { Drop drop = new Drop(); @@ -17963,10 +17960,12 @@ Drop Drop(): Table indexTable = null; int indexTablePosition = 0; List dropArgs = new ArrayList(); - List funcArgs = null; List indexOption; boolean useTemporary = false; boolean foreign = false; + boolean eventTrigger = false; + CascadeBehavior behavior = null; + String engine; } { @@ -17974,6 +17973,13 @@ Drop Drop(): ( tk= { foreign = true; } | + // PostgreSQL DROP OWNED BY role [, ...] + LOOKAHEAD({ isKeywordAhead("OWNED") && getToken(2).kind == K_BY }) tk= + | + // MySQL DROP UNDO TABLESPACE + LOOKAHEAD({ isKeywordAhead("UNDO") && getToken(2).kind == K_TABLESPACE }) + tk= { drop.setUndo(true); } + | tk= | ( @@ -17995,24 +18001,50 @@ Drop Drop(): tk= | tk= + | + tk= + | + // PostgreSQL DROP EVENT TRIGGER and MySQL DROP EVENT (TRIGGER cannot name a MySQL event) + tk= [ LOOKAHEAD(1) { eventTrigger = true; } ] + | + tk= + | + tk= + | + tk= + | + tk= + | + tk= ) - { drop.setType(foreign ? "FOREIGN TABLE" : tk.image); } + { drop.setType(foreign ? "FOREIGN TABLE" : eventTrigger ? "EVENT TRIGGER" : tk.image); } [ LOOKAHEAD({ drop.getObjectType() == Drop.ObjectType.INDEX && getToken(1).kind == K_CONCURRENTLY }) { drop.setConcurrently(true); } ] - [ LOOKAHEAD(2) {drop.setIfExists(true);} ] + [ LOOKAHEAD(2, { drop.getObjectType() != Drop.ObjectType.OWNED }) + {drop.setIfExists(true);} ] - name = DropObjectName() { drop.setName(name); } - ( "," name = DropObjectName() { drop.addNames(name); } )* - [ LOOKAHEAD(2) funcArgs = FuncArgsList() ] + DropTargets(drop) + [ + // PostgreSQL DROP DATABASE name [ [ WITH ] ( FORCE ) ] + LOOKAHEAD({ drop.getObjectType() == Drop.ObjectType.DATABASE + && (getToken(1).kind == K_WITH && "(".equals(getToken(2).image) && getToken(3).kind == K_FORCE + || "(".equals(getToken(1).image) && getToken(2).kind == K_FORCE) }) + [ ] "(" ")" { drop.setForce(true); } + ] + [ + // MySQL DROP [UNDO] TABLESPACE name [ ENGINE [=] engine_name ] + LOOKAHEAD({ drop.getObjectType() == Drop.ObjectType.TABLESPACE && getToken(1).kind == K_ENGINE }) + [ "=" { drop.setEngineUseEquals(true); } ] engine=RelObjectName() { drop.setEngine(engine); } + ] ( LOOKAHEAD({ getToken(1).kind == K_ALGORITHM || (getToken(1).kind == K_LOCK && getToken(2).kind != K_TABLE) }) indexOption=IndexAlgorithmOrLockOption() { dropArgs.addAll(indexOption); } | ( - tk= | tk= | tk= + tk= // MySQL DROP INDEX accepts a trailing algorithm_option / lock_option, // e.g. "DROP INDEX i ON t ALGORITHM = INPLACE LOCK = NONE". | tk= | tk= | tk="=" @@ -18020,8 +18052,8 @@ Drop Drop(): | ( name = DropObjectName() { - if (drop.getObjectType() == Drop.ObjectType.INDEX) { - requireNoDropIndexOwner(indexTable); + if (isDropOwnerTable(drop, name)) { + requireNoDropOwnerTable(indexTable); indexTable = name; indexTablePosition = dropArgs.size(); } else { @@ -18035,6 +18067,7 @@ Drop Drop(): LOOKAHEAD({ getToken(1).kind == K_LOCK && getToken(2).kind != K_TABLE }) tk= { dropArgs.add(tk.image); } )* + [ { behavior = CascadeBehavior.CASCADE; } | { behavior = CascadeBehavior.RESTRICT; } ] { if (dropArgs.size() > 0) { drop.setParameters(dropArgs); @@ -18042,9 +18075,7 @@ Drop Drop(): if (indexTable != null) { drop.setTable(indexTable, indexTablePosition); } - if (drop.getType().equals("FUNCTION")) { - drop.getTypeToParameters().put("FUNCTION", funcArgs); - } + drop.setBehavior(behavior); drop.setUsingTemporary(useTemporary); @@ -18052,6 +18083,20 @@ Drop Drop(): } } +/** PostgreSQL REASSIGN OWNED BY role [, ...] TO role */ +ReassignOwned ReassignOwned(): +{ + ReassignOwned statement = new ReassignOwned(); + UserAccount role; +} +{ + ContextualKeyword("OWNED") + role=MySqlAccountName() { statement.addRoles(role); } + ( "," role=MySqlAccountName() { statement.addRoles(role); } )* + role=MySqlAccountName() { statement.setNewRole(role); } + { return statement; } +} + Truncate Truncate(): { Truncate truncate = new Truncate(); TableReference target; } { diff --git a/src/site/sphinx/index.rst b/src/site/sphinx/index.rst index 5f3b0157b..50c52ed5e 100644 --- a/src/site/sphinx/index.rst +++ b/src/site/sphinx/index.rst @@ -156,6 +156,8 @@ One grammar covers the SQL standard plus all major RDBMS. Missing syntax gets ad - ``INSERT`` · ``UPDATE`` · ``UPSERT`` · ``MERGE`` · ``DELETE`` · ``TRUNCATE TABLE`` * - **DDL** - ``CREATE …`` · ``ALTER …`` · ``DROP …`` + * - **PostgreSQL ownership** + - ``DROP OWNED`` · ``REASSIGN OWNED`` * - **MySQL table maintenance** - ``ANALYZE`` · ``CHECK`` · ``OPTIMIZE`` · ``REPAIR TABLE`` * - **PostgreSQL RLS** diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 26f7ff5d8..66ab6ad87 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -245,6 +245,70 @@ are independent: ``isNoInherit()`` is true and ``getConstraintAttributes().isNotValid()`` is true. Clearing one flag preserves the other when the statement is rendered. +DROP targets +------------ + +``Drop`` keeps the parts of a DROP statement that are not plain object names in +typed properties: + +* FUNCTION, PROCEDURE, ROUTINE and AGGREGATE targets are ``RoutineReference`` + items in ``getRoutines()``, the model that GRANT and COMMENT use. Every target + keeps its own signature, so ``DROP PROCEDURE p(int)`` and + ``DROP FUNCTION f(int), g(text)`` render as written. Null arguments mean an + omitted signature; an empty list is an explicit ``()``. +* USER, ROLE and GROUP targets and the roles of PostgreSQL ``DROP OWNED BY`` are + ``UserAccount`` items in ``getAccounts()``, read with the MySQL account-name rule + that CREATE USER uses. MySQL accounts keep their host, as in ``'app'@'%'``, + and a quoted PostgreSQL role name such as ``"Admin"`` keeps ``"`` in + ``getQuoteStr()``. +* ``DROP TRIGGER t ON tbl`` and ``DROP RULE r ON tbl`` expose ``tbl`` through + ``getTable()``, like ``DROP INDEX i ON tbl``, and table discovery reports it. + SQL Server's ``ON DATABASE`` and ``ON ALL SERVER`` trigger scopes stay + parameters. +* ``getBehavior()`` holds an explicit CASCADE or RESTRICT as a + ``CascadeBehavior``. It is rendered last. +* ``isUndo()``, ``getEngine()`` and ``isEngineUseEquals()`` cover MySQL + ``DROP [UNDO] TABLESPACE name [ENGINE [=] engine]``. ``isForce()`` covers + PostgreSQL ``DROP DATABASE name [WITH] (FORCE)``; the optional WITH is + always rendered. + +TYPE, DOMAIN, EVENT, EVENT TRIGGER, TABLESPACE, USER and GROUP are recognized +object types. PostgreSQL ``REASSIGN OWNED BY old_role [, ...] TO new_role`` is +parsed into ``ReassignOwned``, whose roles use the same ``UserAccount`` model. + +.. code-block:: java + + Drop drop = (Drop) CCJSqlParserUtil.parse("DROP FUNCTION f(int), g(text) CASCADE", + parser -> parser.withDialect(Dialect.POSTGRESQL)); + RoutineReference g = drop.getRoutines().get(1); + g.getArguments().get(0).setDataType(new ColDataType("varchar")); + // DROP FUNCTION f(int), g(varchar) CASCADE + String sql = drop.toString(); + +The legacy accessors remain and are derived from these properties: + +* ``getName()`` and ``getNames()`` return read-only snapshots of routine and + account names. Edit ``getRoutines()`` or ``getAccounts()`` to change them. +* ``getTypeToParameters()`` and ``getParamsByType()`` return a read-only map with + the argument declarations of the last routine, where the legacy + single-signature form kept them. The map now also covers PROCEDURE, ROUTINE + and AGGREGATE. +* ``getParameters()`` is a snapshot that includes ON with the owner table and + the CASCADE or RESTRICT keyword. ``setParameters()`` replaces the clause with + raw tokens and clears both; ``addParameters()`` keeps them. Raw tokens are not + parsed: ``addParameters("CASCADE")`` leaves ``getBehavior()`` null. +* ``setName()``, ``setNames()``, ``addNames()`` and ``setTypeToParameters()`` + first convert routine and account targets to the legacy names and single + signature, so ``setName()`` on ``DROP FUNCTION f(int)`` still renders + ``DROP FUNCTION g(int)``. Use ``getRoutines()`` to keep several signatures. + +Migration: mutating the map returned by ``getTypeToParameters()`` for a parsed +routine now throws ``UnsupportedOperationException``; edit its +``RoutineReference`` instead. ``getObjectType()`` reports the new ``AGGREGATE``, +``DOMAIN``, ``EVENT_TRIGGER``, ``GROUP``, ``OWNED``, ``ROLE``, ``ROUTINE``, +``RULE``, ``TYPE`` and ``USER`` kinds where it reported ``OTHER``. +``DROP OWNED BY r`` no longer returns the name ``BY``. + DROP INDEX owners ----------------- diff --git a/src/test/java/net/sf/jsqlparser/statement/drop/DropObjectTypeTest.java b/src/test/java/net/sf/jsqlparser/statement/drop/DropObjectTypeTest.java new file mode 100644 index 000000000..5906128b2 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/drop/DropObjectTypeTest.java @@ -0,0 +1,202 @@ +/*- + * #%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.drop; + +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.Set; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.CascadeBehavior; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.ExpressionDeParser; +import net.sf.jsqlparser.util.deparser.SelectDeParser; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class DropObjectTypeTest { + + @ParameterizedTest + @ValueSource(strings = {"DROP TRIGGER IF EXISTS trg ON users CASCADE", + "DROP TRIGGER trg ON app.users", "DROP RULE r1 ON v", + "DROP RULE IF EXISTS r1 ON v RESTRICT"}) + void triggerAndRuleOwnersAreTables(String sql) throws JSQLParserException { + for (Drop drop : roundTrip(sql, Dialect.POSTGRESQL)) { + assertEquals(sql, drop.toString()); + String owner = sql.replaceAll(".* ON (\\S+).*", "$1"); + assertEquals(owner, drop.getTable().getFullyQualifiedName()); + assertEquals(Set.of(owner), new TablesNamesFinder().getTables(drop)); + } + } + + @Test + void ownerTableReplacesLegacyOnTokens() throws JSQLParserException { + Drop drop = parse("DROP TRIGGER IF EXISTS trg ON users CASCADE", Dialect.POSTGRESQL); + assertEquals("trg", drop.getName().getName()); + assertEquals(CascadeBehavior.CASCADE, drop.getBehavior()); + assertEquals(List.of("ON", "users", "CASCADE"), drop.getParameters()); + + drop.getTable().setName("accounts"); + drop.setBehavior(CascadeBehavior.RESTRICT); + assertEquals("DROP TRIGGER IF EXISTS trg ON accounts RESTRICT", drop.toString()); + assertEquals(List.of("ON", "accounts", "RESTRICT"), drop.getParameters()); + + StringBuilder builder = new StringBuilder(); + SelectDeParser tables = new SelectDeParser() { + @Override + public StringBuilder visit(Table table, S context) { + return getBuilder().append("renamed"); + } + }; + drop.accept(new StatementDeParser(new ExpressionDeParser(), tables, builder), null); + assertEquals("DROP TRIGGER IF EXISTS trg ON renamed RESTRICT", builder.toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"DROP TRIGGER safety ON DATABASE", "DROP TRIGGER safety ON ALL SERVER"}) + void sqlServerTriggerScopesAreNotTables(String sql) throws JSQLParserException { + Drop drop = parse(sql, Dialect.SQLSERVER); + assertNull(drop.getTable()); + assertTrue(new TablesNamesFinder().getTables(drop).isEmpty()); + assertEquals(sql, drop.toString()); + } + + @Test + void cascadeAndRestrictAreTyped() throws JSQLParserException { + Drop table = parse("DROP TABLE IF EXISTS t1, t2 RESTRICT", Dialect.MYSQL); + assertEquals(CascadeBehavior.RESTRICT, table.getBehavior()); + assertEquals(List.of("RESTRICT"), table.getParameters()); + + Drop index = parse("DROP INDEX a_idx, b_idx CASCADE", Dialect.POSTGRESQL); + assertEquals(CascadeBehavior.CASCADE, index.getBehavior()); + index.setBehavior(null); + assertEquals("DROP INDEX a_idx, b_idx", index.toString()); + assertNull(index.getParameters()); + + Drop plain = parse("DROP SCHEMA s", Dialect.POSTGRESQL); + assertNull(plain.getBehavior()); + assertNull(plain.getParameters()); + } + + @Test + void rawParametersAndBehaviorFollowExplicitRules() throws JSQLParserException { + Drop drop = parse("DROP INDEX i ON t CASCADE", Dialect.MYSQL); + drop.addParameters("LOCK", "NONE"); + assertEquals(List.of("ON", "t", "LOCK", "NONE", "CASCADE"), drop.getParameters()); + assertEquals("DROP INDEX i ON t LOCK NONE CASCADE", drop.toString()); + + drop.setParameters(List.of("RESTRICT")); + assertNull(drop.getBehavior()); + assertNull(drop.getTable()); + assertEquals("DROP INDEX i RESTRICT", drop.toString()); + + // Raw tokens are not parsed: the builder spelling renders the same SQL. + Drop legacy = new Drop().withType("VIEW").withName(new Table("v")).addParameters("CASCADE"); + assertNull(legacy.getBehavior()); + assertEquals("DROP VIEW v CASCADE", legacy.toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"DROP TYPE IF EXISTS mood CASCADE", "DROP TYPE mood, app.color", + "DROP DOMAIN d1", "DROP DOMAIN IF EXISTS d1 RESTRICT", "DROP EVENT TRIGGER etg", + "DROP EVENT TRIGGER IF EXISTS etg CASCADE", "DROP TABLESPACE IF EXISTS fast", + "DROP DATABASE IF EXISTS mydb WITH (FORCE)", "DROP DATABASE mydb WITH (FORCE)"}) + void postgreSqlObjectTypes(String sql) throws JSQLParserException { + for (Drop drop : roundTrip(sql, Dialect.POSTGRESQL)) { + assertEquals(sql, drop.toString()); + assertNotEquals(Drop.ObjectType.OTHER, drop.getObjectType()); + assertTrue(new TablesNamesFinder().getTables(drop).isEmpty()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"DROP EVENT IF EXISTS ev1", "DROP EVENT db1.ev1", "DROP TABLESPACE ts1", + "DROP TABLESPACE ts1 ENGINE = InnoDB", "DROP UNDO TABLESPACE ts1", + "DROP UNDO TABLESPACE ts1 ENGINE InnoDB"}) + void mySqlObjectTypes(String sql) throws JSQLParserException { + for (Drop drop : roundTrip(sql, Dialect.MYSQL)) { + assertEquals(sql, drop.toString()); + } + } + + @Test + void objectTypeFields() throws JSQLParserException { + Drop eventTrigger = parse("DROP EVENT TRIGGER etg", Dialect.POSTGRESQL); + assertEquals(Drop.ObjectType.EVENT_TRIGGER, eventTrigger.getObjectType()); + assertEquals("EVENT TRIGGER", eventTrigger.getType()); + assertEquals("etg", eventTrigger.getName().getName()); + + Drop event = parse("DROP EVENT IF EXISTS ev1", Dialect.MYSQL); + assertEquals(Drop.ObjectType.EVENT, event.getObjectType()); + + Drop type = parse("DROP TYPE mood, app.color CASCADE", Dialect.POSTGRESQL); + assertEquals(Drop.ObjectType.TYPE, type.getObjectType()); + assertEquals("app", type.getNames().get(1).getSchemaName()); + + Drop tablespace = parse("DROP UNDO TABLESPACE ts1 ENGINE=InnoDB", Dialect.MYSQL); + assertEquals(Drop.ObjectType.TABLESPACE, tablespace.getObjectType()); + assertTrue(tablespace.isUndo()); + assertEquals("InnoDB", tablespace.getEngine()); + assertTrue(tablespace.isEngineUseEquals()); + assertEquals("DROP UNDO TABLESPACE ts1 ENGINE = InnoDB", tablespace.toString()); + tablespace.withUndo(false).withEngine("NDB").setEngineUseEquals(false); + assertEquals("DROP TABLESPACE ts1 ENGINE NDB", tablespace.toString()); + + Drop database = parse("DROP DATABASE mydb (FORCE)", Dialect.POSTGRESQL); + assertTrue(database.isForce()); + assertEquals("DROP DATABASE mydb WITH (FORCE)", database.toString()); + database.setForce(false); + assertEquals("DROP DATABASE mydb", database.toString()); + + assertEquals("DROP DATABASE IF EXISTS d WITH (FORCE)", new Drop() + .withObjectType(Drop.ObjectType.DATABASE).withIfExists(true) + .withName(new Table("d")).withForce(true).toString()); + } + + @Test + void clauseWordsRemainNames() throws JSQLParserException { + assertEquals("engine", + parse("DROP TABLESPACE engine", Dialect.MYSQL).getName().getName()); + assertEquals("undo", parse("DROP TABLE undo", Dialect.MYSQL).getName().getName()); + assertEquals("owned", parse("DROP TABLE owned", Dialect.POSTGRESQL).getName().getName()); + assertEquals(2, CCJSqlParserUtil.parseStatements( + "DROP DATABASE d; WITH x AS (SELECT 1) SELECT * FROM x", + parser -> parser.withDialect(Dialect.POSTGRESQL)).size()); + } + + @Test + void rejectsMisplacedClauses() { + for (String sql : List.of("DROP TABLE t WITH (FORCE)", "DROP DATABASE d WITH (FORCE", + "DROP TABLE t ENGINE = InnoDB", "DROP TABLESPACE ts ENGINE", + "DROP TABLE t CASCADE x", + "DROP TRIGGER t ON a ON b", "DROP EVENT TRIGGER", "DROP TYPE")) { + assertThrows(JSQLParserException.class, () -> parse(sql, Dialect.MYSQL), sql); + } + } + + private static Drop parse(String sql, Dialect dialect) throws JSQLParserException { + return (Drop) CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(dialect)); + } + + private static List roundTrip(String sql, Dialect dialect) throws JSQLParserException { + Drop drop = (Drop) assertSqlCanBeParsedAndDeparsed(sql, true, + parser -> parser.withDialect(dialect)); + StringBuilder builder = new StringBuilder(); + drop.accept(new StatementDeParser(builder), null); + assertEquals(drop.toString(), builder.toString()); + return List.of(drop, parse(drop.toString(), dialect)); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/drop/DropRoleTest.java b/src/test/java/net/sf/jsqlparser/statement/drop/DropRoleTest.java new file mode 100644 index 000000000..4d98a3b42 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/drop/DropRoleTest.java @@ -0,0 +1,178 @@ +/*- + * #%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.drop; + +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.stream.Collectors; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.CascadeBehavior; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitorAdapter; +import net.sf.jsqlparser.statement.StmtFeature; +import net.sf.jsqlparser.statement.create.user.UserAccount; +import net.sf.jsqlparser.statement.role.ReassignOwned; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class DropRoleTest { + + @ParameterizedTest + @ValueSource(strings = {"DROP USER IF EXISTS 'app'@'%', 'u2'@'localhost'", "DROP USER app", + "DROP USER 'app'@'%'", "DROP USER app@localhost, `u`@`h`", + "DROP USER IF EXISTS app@'%', 'app'@localhost", + "DROP ROLE IF EXISTS 'reader'", + "DROP ROLE 'r'@'h', r2"}) + void mySqlAccounts(String sql) throws JSQLParserException { + for (Statement statement : roundTrip(sql, Dialect.MYSQL)) { + Drop drop = (Drop) statement; + assertEquals(sql, drop.toString()); + assertTrue(drop.getObjectType().hasAccountTargets()); + assertTrue(new TablesNamesFinder().getTables(drop).isEmpty()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"DROP USER IF EXISTS alice", "DROP USER \"Alice\", bob", + "DROP GROUP staff", "DROP ROLE IF EXISTS app_read, app_rw", + "DROP OWNED BY old_role", "DROP OWNED BY old_role, \"New\" CASCADE", + "DROP OWNED BY CURRENT_USER RESTRICT"}) + void postgreSqlRoles(String sql) throws JSQLParserException { + for (Statement statement : roundTrip(sql, Dialect.POSTGRESQL)) { + assertEquals(sql, statement.toString()); + } + } + + @Test + void accountParts() throws JSQLParserException { + Drop drop = parse("DROP USER IF EXISTS 'app'@'%', u2", Dialect.MYSQL); + assertEquals(Drop.ObjectType.USER, drop.getObjectType()); + assertTrue(drop.isIfExists()); + UserAccount first = drop.getAccounts().get(0); + assertEquals("app", first.getUser().getValue()); + assertEquals("%", first.getHost().getValue()); + assertNull(drop.getAccounts().get(1).getHost()); + + first.setHost(new StringValue("localhost")); + assertEquals("DROP USER IF EXISTS 'app'@'localhost', u2", drop.toString()); + assertEquals(List.of("'app'@'localhost'", "u2"), names(drop)); + assertThrows(UnsupportedOperationException.class, + () -> drop.getAccounts().add(new UserAccount())); + + Drop role = parse("DROP ROLE \"Admin\"", Dialect.POSTGRESQL); + assertEquals(Drop.ObjectType.ROLE, role.getObjectType()); + StringValue admin = role.getAccounts().get(0).getUser(); + assertEquals("Admin", admin.getValue()); + assertEquals("\"", admin.getQuoteStr()); + assertNull(role.getAccounts().get(0).getHost()); + assertEquals("\"Admin\"", role.getName().getName()); + assertEquals("DROP ROLE \"Admin\"", role.toString()); + } + + @Test + void dropOwnedKeepsRolesOutOfTheName() throws JSQLParserException { + Drop drop = parse("DROP OWNED BY old_role, other CASCADE", Dialect.POSTGRESQL); + assertEquals(Drop.ObjectType.OWNED, drop.getObjectType()); + assertEquals(List.of("old_role", "other"), drop.getAccounts().stream() + .map(Object::toString).collect(Collectors.toList())); + assertEquals(List.of("old_role", "other"), names(drop)); + assertEquals(CascadeBehavior.CASCADE, drop.getBehavior()); + assertEquals(List.of("CASCADE"), drop.getParameters()); + + drop.setName(new Table("renamed")); + assertEquals("DROP OWNED BY renamed CASCADE", drop.toString()); + + assertEquals("DROP OWNED BY r", new Drop().withObjectType(Drop.ObjectType.OWNED) + .addAccounts(new UserAccount().withUser(new StringValue("r").setQuoteStr(""))) + .toString()); + } + + @Test + void reassignOwned() throws JSQLParserException { + ReassignOwned statement = (ReassignOwned) CCJSqlParserUtil.parse( + "REASSIGN OWNED BY old_role, \"Old\" TO new_role", + parser -> parser.withDialect(Dialect.POSTGRESQL)); + assertEquals(List.of("old_role", "\"Old\""), statement.getRoles().stream() + .map(Object::toString).collect(Collectors.toList())); + assertEquals("new_role", statement.getNewRole().toString()); + assertEquals("Old", statement.getRoles().get(1).getUser().getValue()); + assertEquals("\"", statement.getRoles().get(1).getUser().getQuoteStr()); + + statement.setNewRole(new UserAccount().withUser(new StringValue("CURRENT_USER") + .setQuoteStr(""))); + assertEquals("REASSIGN OWNED BY old_role, \"Old\" TO CURRENT_USER", statement.toString()); + assertThrows(UnsupportedOperationException.class, + () -> statement.getRoles().add(new UserAccount())); + + ReassignOwned built = new ReassignOwned().withRoles(statement.getRoles()) + .withNewRole(statement.getNewRole()); + assertEquals(statement.toString(), built.toString()); + + assertTrue(new TablesNamesFinder().getTables((Statement) statement).isEmpty()); + assertNull(statement.accept(new StatementVisitorAdapter(), null)); + assertThat(statement.getFeatures().getCertain()) + .containsExactly(StmtFeature.MODIFIES_SCHEMA); + assertThat(parse("DROP OWNED BY r", Dialect.POSTGRESQL).getFeatures().getCertain()) + .contains(StmtFeature.MODIFIES_SCHEMA, StmtFeature.MODIFIES_DATA); + } + + @Test + void ownershipStatementsInScripts() throws JSQLParserException { + List statements = CCJSqlParserUtil.parseStatements( + "REASSIGN OWNED BY a TO b; DROP OWNED BY a; DROP ROLE a", + parser -> parser.withDialect(Dialect.POSTGRESQL)); + assertEquals(3, statements.size()); + assertInstanceOf(ReassignOwned.class, statements.get(0)); + assertEquals("reassign", ((Drop) parse("DROP TABLE reassign", Dialect.POSTGRESQL)) + .getName().getName()); + assertEquals("reassign", CCJSqlParserUtil.parse("SELECT reassign FROM owned") + .toString().split(" ")[1]); + } + + @Test + void rejectsMalformedRoleStatements() { + for (String sql : List.of("DROP OWNED IF EXISTS BY r", "DROP OWNED BY", "DROP USER", + "DROP USER 'app'@", "REASSIGN OWNED BY a", "REASSIGN OWNED a TO b", + "REASSIGN OWNED BY TO b", "DROP ROLE a.b")) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(Dialect.POSTGRESQL)), sql); + } + } + + private static List names(Drop drop) { + return drop.getNames().stream().map(Table::getFullyQualifiedName) + .collect(Collectors.toList()); + } + + private static Drop parse(String sql, Dialect dialect) throws JSQLParserException { + return (Drop) CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(dialect)); + } + + private static List roundTrip(String sql, Dialect dialect) + throws JSQLParserException { + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, true, + parser -> parser.withDialect(dialect)); + StringBuilder builder = new StringBuilder(); + statement.accept(new StatementDeParser(builder), null); + assertEquals(statement.toString(), builder.toString()); + return List.of(statement, CCJSqlParserUtil.parse(statement.toString(), + parser -> parser.withDialect(dialect))); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/drop/DropRoutineSignatureTest.java b/src/test/java/net/sf/jsqlparser/statement/drop/DropRoutineSignatureTest.java new file mode 100644 index 000000000..4259d3d07 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/drop/DropRoutineSignatureTest.java @@ -0,0 +1,236 @@ +/*- + * #%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.drop; + +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.CascadeBehavior; +import net.sf.jsqlparser.statement.RoutineReference; +import net.sf.jsqlparser.statement.create.table.ColDataType; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class DropRoutineSignatureTest { + + @ParameterizedTest + @ValueSource(strings = {"DROP PROCEDURE p1(int)", "DROP PROCEDURE p1()", + "DROP FUNCTION IF EXISTS f2(int), g2(text) CASCADE", + "DROP FUNCTION f2(int), g2(text)", "DROP FUNCTION a, b(int)", + "DROP ROUTINE f4", "DROP ROUTINE IF EXISTS s.f4(IN a int, OUT b text) RESTRICT", + "DROP FUNCTION f(VARIADIC a int[], double precision)", + "DROP AGGREGATE agg(int)", "DROP AGGREGATE agg(*)", + "DROP AGGREGATE IF EXISTS agg(int ORDER BY text), agg2(*) CASCADE", + "DROP FUNCTION IF EXISTS \"fin\".\"restore_fund_data\"(\"in_fund_id\" int8)"}) + void postgreSqlSignaturesSurviveRoundTrips(String sql) throws JSQLParserException { + for (Drop drop : roundTrip(sql, Dialect.POSTGRESQL)) { + assertEquals(sql, drop.toString()); + assertFalse(drop.getRoutines().isEmpty()); + assertTrue(drop.getObjectType().hasRoutineTargets()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"DROP PROCEDURE IF EXISTS p1", "DROP FUNCTION IF EXISTS db1.f1", + "DROP PROCEDURE `db.x`.`p.y`"}) + void mySqlRoutinesWithoutSignatures(String sql) throws JSQLParserException { + for (Drop drop : roundTrip(sql, Dialect.MYSQL)) { + assertEquals(sql, drop.toString()); + assertNull(drop.getRoutines().get(0).getArguments()); + } + } + + @Test + void procedureSignatureIsKeptInTheAst() throws JSQLParserException { + Drop drop = parse("DROP PROCEDURE p1(int)"); + RoutineReference procedure = drop.getRoutines().get(0); + assertEquals("p1", procedure.getName()); + assertEquals(1, procedure.getArguments().size()); + assertEquals("int", procedure.getArguments().get(0).getDataType().toString()); + assertEquals(Drop.ObjectType.PROCEDURE, drop.getObjectType()); + } + + @Test + void everyTargetKeepsItsOwnSignature() throws JSQLParserException { + Drop drop = parse("DROP FUNCTION IF EXISTS f2(int), g2(text), h CASCADE"); + assertEquals(List.of("f2(int)", "g2(text)", "h"), drop.getRoutines().stream() + .map(Object::toString).collect(Collectors.toList())); + assertNull(drop.getRoutines().get(2).getArguments()); + assertTrue(drop.isIfExists()); + assertEquals(CascadeBehavior.CASCADE, drop.getBehavior()); + + RoutineReference.Argument argument = drop.getRoutines().get(1).getArguments().get(0); + argument.setName("label"); + argument.setDataType(new ColDataType("varchar")); + drop.getRoutines().get(0).setName("app.f2"); + assertEquals("DROP FUNCTION IF EXISTS app.f2(int), g2(label varchar), h CASCADE", + drop.toString()); + assertRendersAlike(drop); + } + + @Test + void aggregateSignatureKeepsStarAndOrderBy() throws JSQLParserException { + Drop drop = parse("DROP AGGREGATE agg(int ORDER BY text), agg2(*)"); + assertEquals(Drop.ObjectType.AGGREGATE, drop.getObjectType()); + assertEquals("text", drop.getRoutines().get(0).getOrderByArguments().get(0) + .getDataType().toString()); + assertTrue(drop.getRoutines().get(1).isAllArguments()); + } + + @Test + void legacyNameAndParameterAccessorsAreDerived() throws JSQLParserException { + Drop function = + parse("DROP FUNCTION IF EXISTS \"fin\".\"restore_fund_data\"(in_fund_id int8)"); + Table name = function.getName(); + assertEquals("\"fin\"", name.getSchemaName()); + assertEquals("\"restore_fund_data\"", name.getName()); + assertEquals(List.of("in_fund_id int8"), function.getParamsByType("FUNCTION")); + assertEquals(Map.of("FUNCTION", List.of("in_fund_id int8")), + function.getTypeToParameters()); + assertThrows(UnsupportedOperationException.class, + () -> function.getTypeToParameters().put("FUNCTION", List.of())); + assertThrows(UnsupportedOperationException.class, + () -> function.getNames().add(new Table("x"))); + + // The legacy model holds one signature, written after the last name. + Drop several = parse("DROP FUNCTION a, b(int)"); + assertEquals(List.of("a", "b"), names(several)); + assertEquals(List.of("int"), several.getParamsByType("FUNCTION")); + + assertEquals(List.of("int"), parse("DROP PROCEDURE p1(int)").getParamsByType("PROCEDURE")); + assertEquals(List.of(), parse("DROP FUNCTION f()").getParamsByType("FUNCTION")); + Drop omitted = parse("DROP FUNCTION f"); + assertTrue(omitted.getTypeToParameters().containsKey("FUNCTION")); + assertNull(omitted.getParamsByType("FUNCTION")); + assertEquals(List.of("int"), parse("drop function f(int)").getParamsByType("FUNCTION")); + assertEquals(List.of("*"), parse("DROP AGGREGATE agg(*)").getParamsByType("AGGREGATE")); + } + + @Test + void legacySettersSwitchToTheLegacyRepresentation() throws JSQLParserException { + Drop drop = parse("DROP FUNCTION f(int) CASCADE"); + drop.setName(new Table("g")); + assertTrue(drop.getRoutines().isEmpty()); + assertEquals("DROP FUNCTION g(int) CASCADE", drop.toString()); + drop.getTypeToParameters().put("FUNCTION", List.of("bigint")); + assertEquals("DROP FUNCTION g(bigint) CASCADE", drop.toString()); + + Drop renamed = parse("DROP FUNCTION f(int)"); + renamed.setNames(List.of(new Table("app", "f"), new Table("app", "g"))); + assertEquals("DROP FUNCTION app.f, app.g(int)", renamed.toString()); + + Drop replaced = parse("DROP FUNCTION f(int)"); + replaced.setTypeToParameters(new HashMap<>(Map.of("FUNCTION", List.of("bigint")))); + assertEquals(List.of("f"), names(replaced)); + assertEquals("DROP FUNCTION f(bigint)", replaced.toString()); + + replaced.setRoutines(List.of(routine("f2", "text"))); + assertEquals("DROP FUNCTION f2(text)", replaced.toString()); + assertEquals(Map.of("FUNCTION", List.of("text")), replaced.getTypeToParameters()); + } + + @Test + void buildersRenderBothRepresentations() { + Drop legacy = new Drop().withType("FUNCTION").withName(new Table("myFunc")); + legacy.getTypeToParameters().put("FUNCTION", List.of("integer", "name varchar(255)")); + assertEquals("DROP FUNCTION myFunc(integer, name varchar(255))", legacy.toString()); + assertTrue(legacy.getRoutines().isEmpty()); + + Drop typed = new Drop().withObjectType(Drop.ObjectType.ROUTINE).withIfExists(true) + .addRoutines(routine("r1", "int"), routine("r2")) + .withBehavior(CascadeBehavior.RESTRICT); + assertEquals("DROP ROUTINE IF EXISTS r1(int), r2() RESTRICT", typed.toString()); + assertRendersAlike(typed); + + Drop switched = new Drop().withType("FUNCTION").withName(new Table("old")) + .withRoutines(List.of(routine("f", "int"))); + assertEquals(List.of("f"), names(switched)); + assertEquals("DROP FUNCTION f(int)", switched.toString()); + switched.addRoutines(); + assertEquals("DROP FUNCTION f(int)", switched.toString()); + } + + @Test + void routineNamesAreNotReportedAsTables() throws JSQLParserException { + for (String sql : List.of("DROP FUNCTION f(int)", "DROP PROCEDURE app.p(text)", + "DROP AGGREGATE agg(*)")) { + assertTrue(new TablesNamesFinder().getTables(parse(sql)).isEmpty()); + } + } + + @Test + void rejectsSignaturesOutsideTheirPlace() { + for (String sql : List.of("DROP AGGREGATE agg", "DROP FUNCTION f(*)", + "DROP FUNCTION f(int ORDER BY int)", "DROP TABLE t(int)", "DROP INDEX i(a)", + "DROP FUNCTION f(int) CASCADE RESTRICT", "DROP FUNCTION f(int), ")) { + assertThrows(JSQLParserException.class, () -> parse(sql), sql); + } + } + + @Test + void scriptsKeepFollowingStatements() throws JSQLParserException { + assertEquals(3, CCJSqlParserUtil.parseStatements( + "DROP PROCEDURE p1(int); DROP FUNCTION f(int), g(text) CASCADE; SELECT 1", + parser -> parser.withDialect(Dialect.POSTGRESQL)).size()); + assertSqlCanBeParsedAndDeparsed("DROP FUNCTION myFunc(amount integer, name varchar(255))", + true, parser -> parser.withDialect(Dialect.POSTGRESQL)); + } + + private static RoutineReference routine(String name, String... types) { + RoutineReference routine = new RoutineReference(); + routine.setName(name); + List arguments = new ArrayList<>(); + for (String type : types) { + RoutineReference.Argument argument = new RoutineReference.Argument(); + argument.setDataType(new ColDataType(type)); + arguments.add(argument); + } + routine.setArguments(arguments); + return routine; + } + + private static List names(Drop drop) { + return drop.getNames().stream().map(Table::getFullyQualifiedName) + .collect(Collectors.toList()); + } + + private static Drop parse(String sql) throws JSQLParserException { + return parse(sql, Dialect.POSTGRESQL); + } + + private static Drop parse(String sql, Dialect dialect) throws JSQLParserException { + return (Drop) CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(dialect)); + } + + private static void assertRendersAlike(Drop drop) { + StringBuilder builder = new StringBuilder(); + drop.accept(new StatementDeParser(builder), null); + assertEquals(drop.toString(), builder.toString()); + } + + private static List roundTrip(String sql, Dialect dialect) throws JSQLParserException { + Drop drop = (Drop) assertSqlCanBeParsedAndDeparsed(sql, true, + parser -> parser.withDialect(dialect)); + assertRendersAlike(drop); + return List.of(drop, parse(drop.toString(), dialect)); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/drop/DropTest.java b/src/test/java/net/sf/jsqlparser/statement/drop/DropTest.java index a2a25ab6f..ca35f4640 100644 --- a/src/test/java/net/sf/jsqlparser/statement/drop/DropTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/drop/DropTest.java @@ -16,6 +16,7 @@ import net.sf.jsqlparser.parser.CCJSqlParserManager; import net.sf.jsqlparser.parser.CCJSqlParserUtil; import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.CascadeBehavior; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.Statements; import static net.sf.jsqlparser.test.TestUtils.*; @@ -47,7 +48,7 @@ public void testDropIndex() throws JSQLParserException { assertEquals("CASCADE", parsed.getParameters().get(0)); assertEquals(statement, "" + parsed); Drop created = new Drop().withType("INDEX").withName(new Table("myindex")) - .addParameters("CASCADE"); + .withBehavior(CascadeBehavior.CASCADE); assertDeparse(created, statement); assertEqualsObjectTree(parsed, created); } @@ -89,7 +90,7 @@ public void testDropRestrictIssue510() throws JSQLParserException { String statement = "DROP TABLE TABLE2 RESTRICT"; Statement parsed = assertSqlCanBeParsedAndDeparsed(statement); Drop created = new Drop().withType("TABLE").withName(new Table("TABLE2")) - .addParameters(asList("RESTRICT")); + .withBehavior(CascadeBehavior.RESTRICT); assertDeparse(created, statement); assertEqualsObjectTree(parsed, created); } diff --git a/src/test/java/net/sf/jsqlparser/util/validation/validator/DropValidatorTest.java b/src/test/java/net/sf/jsqlparser/util/validation/validator/DropValidatorTest.java index 5cfa61fe1..9b6ed8a30 100644 --- a/src/test/java/net/sf/jsqlparser/util/validation/validator/DropValidatorTest.java +++ b/src/test/java/net/sf/jsqlparser/util/validation/validator/DropValidatorTest.java @@ -14,6 +14,8 @@ import net.sf.jsqlparser.util.validation.ValidationTestAsserts; import net.sf.jsqlparser.util.validation.feature.DatabaseType; import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed; +import net.sf.jsqlparser.util.validation.feature.MySqlVersion; +import net.sf.jsqlparser.util.validation.feature.PostgresqlVersion; import org.junit.jupiter.api.Test; public class DropValidatorTest extends ValidationTestAsserts { @@ -66,4 +68,21 @@ public void testValidationDropSequenceIfExists() throws JSQLParserException { validateNotSupported(sql, 1, 1, DatabaseType.MYSQL, Feature.dropSequence); } + @Test + public void testValidationDropRoutinesAndAccounts() throws JSQLParserException { + validateNoErrors("DROP FUNCTION IF EXISTS f(int), g(text) CASCADE", 1, + DatabaseType.POSTGRESQL); + validateNoErrors("DROP OWNED BY old_role CASCADE", 1, DatabaseType.POSTGRESQL); + validateNoErrors("DROP USER IF EXISTS 'app'@'%', u2", 1, DatabaseType.MYSQL, + DatabaseType.MARIADB); + } + + @Test + public void testValidationReassignOwned() throws JSQLParserException { + String sql = "REASSIGN OWNED BY old_role TO new_role"; + validateNoErrors(sql, 1, PostgresqlVersion.V14, DatabaseType.POSTGRESQL); + validateNotSupported(sql, 1, 1, MySqlVersion.V8_0, Feature.reassignOwned); + validateNotAllowed(sql, 1, 1, FeaturesAllowed.DDL, Feature.reassignOwned); + } + }