From 1af2ff3e8656992fca98f24fcfb06aec5a001278 Mon Sep 17 00:00:00 2001 From: minleejae Date: Wed, 14 Oct 2026 13:17:49 +0900 Subject: [PATCH 1/3] fix: share MySQL account names across CREATE USER and DEFINER CREATE USER and DEFINER read user@host with their own copies of the same rule. Neither accepted app@'%' or 'app'@localhost, because the lexer reads app@ and @localhost as single tokens. Without a dialect a double-quoted part also lost its quotes, so CREATE USER "app"@"%" rendered as app@%. Add one MySqlAccountName() production that splits those tokens, keep the quote character of double-quoted and backquoted parts apart from their value, and use it for CREATE USER and DEFINER. Signed-off-by: minleejae --- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 70 ++++++++-- src/site/sphinx/usage.rst | 14 ++ .../create/user/MySqlAccountNameTest.java | 123 ++++++++++++++++++ 3 files changed, 197 insertions(+), 10 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/statement/create/user/MySqlAccountNameTest.java diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 2210dbe9d..9fcc539ce 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -15502,11 +15502,66 @@ StringValue MySqlAccountNamePart(): ( token= { value = stringValue(token); } | - name=RelObjectName() { value = new StringValue(name).setQuoteStr(""); } + name=RelObjectName() + { + // a double-quoted or backquoted part keeps its quote character separately from its + // value, like a string literal; new StringValue(name) would drop the double quotes + value = name.length() > 1 && (name.charAt(0) == '"' || name.charAt(0) == '`') + && name.charAt(name.length() - 1) == name.charAt(0) + ? new StringValue().withValue(name.substring(1, name.length() - 1)) + .setQuoteStr(name.substring(0, 1)) + : new StringValue().withValue(name).setQuoteStr(""); + } ) { return value; } } +/** + * A MySQL account name, user[@host], shared by CREATE USER, DEFINER and the account statements. + * Each part is unquoted or quoted with ', " or `. The lexer reads an unquoted user@host, and the + * user@ in front of a quoted host, as one identifier, and @host after a quoted user as one + * at-identifier, so those spellings are split here. + */ +UserAccount MySqlAccountName(): +{ + UserAccount account = new UserAccount(); + StringValue part; + Token token; + int at; +} +{ + ( + // user@ followed by a quoted host + LOOKAHEAD({ getToken(1).kind == S_IDENTIFIER && getToken(1).image.indexOf('@') > 0 + && getToken(1).image.indexOf('@') == getToken(1).image.length() - 1 }) + token= + { account.setUser(new StringValue().withValue(token.image.substring(0, token.image.length() - 1)) + .setQuoteStr("")); } + part=MySqlAccountNamePart() { account.setHost(part); } + | + // unquoted user@host + LOOKAHEAD({ getToken(1).kind == S_IDENTIFIER && getToken(1).image.indexOf('@') > 0 + && getToken(1).image.indexOf('@') == getToken(1).image.lastIndexOf('@') }) + token= + { + at = token.image.indexOf('@'); + account.setUser(new StringValue().withValue(token.image.substring(0, at)).setQuoteStr("")); + account.setHost(new StringValue().withValue(token.image.substring(at + 1)).setQuoteStr("")); + } + | + part=MySqlAccountNamePart() { account.setUser(part); } + [ + part=MySqlAccountNamePart() { account.setHost(part); } + | + // a quoted user followed by an unquoted @host + LOOKAHEAD({ getToken(1).kind == S_AT_IDENTIFIER && !getToken(1).image.startsWith("@@") }) + token= + { account.setHost(new StringValue().withValue(token.image.substring(1)).setQuoteStr("")); } + ] + ) + { return account; } +} + UserAuthentication MySqlUserAuthentication(): { UserAuthentication authentication = new UserAuthentication(); @@ -15557,14 +15612,11 @@ UserAuthentication MySqlUserAuthentication(): UserAccount MySqlUserAccount(): { - UserAccount account = new UserAccount(); - StringValue user = null; - StringValue host = null; + UserAccount account; UserAuthentication authentication = null; } { - user=MySqlAccountNamePart() { account.setUser(user); } - [ host=MySqlAccountNamePart() { account.setHost(host); } ] + account=MySqlAccountName() [ authentication=MySqlUserAuthentication() { account.setAuthentication(authentication); } ] { return account; } } @@ -15710,8 +15762,7 @@ AlterEvent AlterEvent(): /** Account spelling is shared, including CURRENT_USER and CURRENT_USER(). */ void MySqlDefiner(MySqlDefiner definer): { - StringValue user; - StringValue host; + UserAccount account; String name; } { @@ -15721,8 +15772,7 @@ void MySqlDefiner(MySqlDefiner definer): { definer.setUser(new StringValue(name).setQuoteStr("")); } [ "(" ")" { definer.setCurrentUserParentheses(true); } ] | - user=MySqlAccountNamePart() { definer.setUser(user); } - [ host=MySqlAccountNamePart() { definer.setHost(host); } ] + account=MySqlAccountName() { definer.setUser(account.getUser()); definer.setHost(account.getHost()); } ) } diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 901608d62..26f7ff5d8 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -1718,6 +1718,20 @@ the PostgreSQL ``CreateRole`` AST for this ambiguous form:: "CREATE USER app", parser -> parser.withDialect(AbstractJSqlParser.Dialect.POSTGRESQL)); +MySQL account names (``user[@host]``) of ``CREATE USER`` and of view, trigger and +routine ``DEFINER`` clauses share one grammar rule and are read into separate user +and host ``StringValue`` parts (``UserAccount`` and ``MySqlDefiner``). Every MySQL +spelling is accepted, including ``app@localhost``, ``app@'%'`` and +``'app'@localhost``. A part quoted with ``'``, ``"`` or a backquote keeps its quote +character in ``getQuoteStr()`` and its text in ``getValue()``:: + + CreateUser createUser = (CreateUser) CCJSqlParserUtil.parse( + "CREATE USER `app`@'%' IDENTIFIED BY 'secret'"); + UserAccount account = createUser.getAccounts().get(0); + account.getUser().getValue(); // app + account.getUser().getQuoteStr(); // ` + account.getHost().getValue(); // % + ``Grant`` retains its string-based getters, setters and fluent methods. Its ``PrivilegeClause`` exposes typed ``Privilege`` items, column lists, ``PrivilegeTarget`` kinds, multiple role memberships and grantor information. diff --git a/src/test/java/net/sf/jsqlparser/statement/create/user/MySqlAccountNameTest.java b/src/test/java/net/sf/jsqlparser/statement/create/user/MySqlAccountNameTest.java new file mode 100644 index 000000000..455f18b6a --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/user/MySqlAccountNameTest.java @@ -0,0 +1,123 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.create.user; + +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.function.Consumer; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.MySqlDefiner; +import net.sf.jsqlparser.statement.create.trigger.CreateTrigger; +import net.sf.jsqlparser.statement.create.view.CreateView; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import java.util.stream.Stream; + +class MySqlAccountNameTest { + + private static final Consumer MYSQL = p -> p.withDialect(Dialect.MYSQL); + private static final Consumer NO_DIALECT = p -> { + }; + + /** SQL spelling, user value, user quote, host value, host quote. */ + static Stream accounts() { + return Stream.of( + Arguments.of("app", "app", "", null, null), + Arguments.of("app@localhost", "app", "", "localhost", ""), + Arguments.of("app@'%'", "app", "", "%", "'"), + Arguments.of("app@'10.0.0.%'", "app", "", "10.0.0.%", "'"), + Arguments.of("'app'@localhost", "app", "'", "localhost", ""), + Arguments.of("'app'@'%'", "app", "'", "%", "'"), + Arguments.of("`app`@`%`", "app", "`", "%", "`"), + Arguments.of("\"app\"@\"%\"", "app", "\"", "%", "\""), + Arguments.of("'app'", "app", "'", null, null), + Arguments.of("`my app`@'%'", "my app", "`", "%", "'")); + } + + @ParameterizedTest + @MethodSource("accounts") + void createUserSplitsEveryAccountSpelling(String account, String user, String userQuote, + String host, String hostQuote) throws JSQLParserException { + for (Consumer mode : java.util.Arrays.asList(MYSQL, NO_DIALECT)) { + String sql = "CREATE USER " + account + " IDENTIFIED BY 'x'"; + CreateUser createUser = assertInstanceOf(CreateUser.class, + assertSqlCanBeParsedAndDeparsed(sql, true, mode)); + assertEquals(sql, createUser.toString()); + assertEquals(sql, deparse(createUser)); + assertPart(createUser.getAccounts().get(0).getUser(), user, userQuote); + assertPart(createUser.getAccounts().get(0).getHost(), host, hostQuote); + } + } + + @ParameterizedTest + @MethodSource("accounts") + void definerSplitsEveryAccountSpelling(String account, String user, String userQuote, + String host, String hostQuote) throws JSQLParserException { + String view = "CREATE DEFINER = " + account + " VIEW v AS SELECT 1"; + CreateView createView = assertInstanceOf(CreateView.class, + assertSqlCanBeParsedAndDeparsed(view, true, MYSQL)); + MySqlDefiner definer = createView.getMySqlOptions().getDefiner(); + assertPart(definer.getUser(), user, userQuote); + assertPart(definer.getHost(), host, hostQuote); + + String trigger = "CREATE DEFINER = " + account + + " TRIGGER trg BEFORE INSERT ON t FOR EACH ROW SET NEW.a = 1"; + CreateTrigger createTrigger = assertInstanceOf(CreateTrigger.class, + assertSqlCanBeParsedAndDeparsed(trigger, true, MYSQL)); + assertEquals(trigger, createTrigger.toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"CREATE USER app@", "CREATE USER @host", "CREATE USER 'app'@"}) + void rejectsMalformedAccounts(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, MYSQL)); + } + + @org.junit.jupiter.api.Test + void keepsDoubleQuotesWithoutDialect() throws JSQLParserException { + // before, the double quotes were dropped and the account rendered as app@% + String sql = "CREATE USER \"app\"@\"%\" IDENTIFIED BY 'x'"; + assertEquals(sql, CCJSqlParserUtil.parse(sql).toString()); + assertEquals(sql, CCJSqlParserUtil.parse(sql, MYSQL).toString()); + } + + @org.junit.jupiter.api.Test + void definerCurrentUserIsUnchanged() throws JSQLParserException { + CreateView view = assertInstanceOf(CreateView.class, assertSqlCanBeParsedAndDeparsed( + "CREATE DEFINER = CURRENT_USER() VIEW v AS SELECT 1", true, MYSQL)); + assertNull(view.getMySqlOptions().getDefiner().getHost()); + } + + private static void assertPart(StringValue part, String value, String quote) { + if (value == null) { + assertNull(part); + return; + } + assertEquals(value, part.getValue()); + assertEquals(quote, part.getQuoteStr()); + } + + private static String deparse(CreateUser createUser) { + StringBuilder builder = new StringBuilder(); + createUser.accept(new StatementDeParser(builder), null); + return builder.toString(); + } +} From 4980c94652ae6c6752a8e702d78be86505555e2d Mon Sep 17 00:00:00 2001 From: minleejae Date: Wed, 14 Oct 2026 12:57:04 +0900 Subject: [PATCH 2/3] 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); + } + } From 2dd27c9d46462a17898eb2ab207844858d990a99 Mon Sep 17 00:00:00 2001 From: minleejae Date: Wed, 14 Oct 2026 12:56:13 +0900 Subject: [PATCH 3/3] feat: share typed MySQL index options across CREATE INDEX and table definitions Parse MySQL index options with one IndexSpecOption production in CREATE INDEX, CREATE TABLE key definitions and ALTER TABLE ADD/ALTER INDEX, and keep them as ordered typed IndexSpecOption nodes on IndexOptions. The ALGORITHM and LOCK clauses of CREATE INDEX and DROP INDEX become AlgorithmOrLockOption nodes. PRIMARY KEY and named UNIQUE keys accept the index type before the key list. getIndexSpec(), CreateIndex.getTailParameters() and Drop.getParameters() remain available as derived token snapshots. Signed-off-by: minleejae --- .../create/index/AlgorithmOrLockOption.java | 85 ++++ .../statement/create/index/CreateIndex.java | 63 ++- .../statement/create/table/Index.java | 5 + .../create/table/IndexOptionSource.java | 8 + .../statement/create/table/IndexOptions.java | 57 ++- .../create/table/IndexSpecOption.java | 200 +++++++++ .../statement/create/table/KeyConstraint.java | 5 + .../sf/jsqlparser/statement/drop/Drop.java | 85 +++- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 209 +++++---- src/site/sphinx/usage.rst | 79 +++- .../jsqlparser/statement/alter/AlterTest.java | 31 +- .../create/MySqlIndexSpecOptionTest.java | 421 ++++++++++++++++++ 12 files changed, 1105 insertions(+), 143 deletions(-) create mode 100644 src/main/java/net/sf/jsqlparser/statement/create/index/AlgorithmOrLockOption.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/create/table/IndexSpecOption.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/create/MySqlIndexSpecOptionTest.java diff --git a/src/main/java/net/sf/jsqlparser/statement/create/index/AlgorithmOrLockOption.java b/src/main/java/net/sf/jsqlparser/statement/create/index/AlgorithmOrLockOption.java new file mode 100644 index 000000000..64bc22aef --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/index/AlgorithmOrLockOption.java @@ -0,0 +1,85 @@ +/*- + * #%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.index; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * MySQL's {@code ALGORITHM [=] value} or {@code LOCK [=] value} clause of CREATE INDEX and DROP + * INDEX. The value keeps its SQL spelling, such as INPLACE, COPY, NONE or SHARED. + */ +public class AlgorithmOrLockOption implements Serializable { + + public enum Kind { + ALGORITHM, LOCK + } + + private Kind kind; + private String value; + private boolean useEquals; + + public AlgorithmOrLockOption(Kind kind, String value, boolean useEquals) { + this.kind = Objects.requireNonNull(kind, "kind"); + this.value = Objects.requireNonNull(value, "value"); + this.useEquals = useEquals; + } + + public static AlgorithmOrLockOption algorithm(String value) { + return new AlgorithmOrLockOption(Kind.ALGORITHM, value, true); + } + + public static AlgorithmOrLockOption lock(String value) { + return new AlgorithmOrLockOption(Kind.LOCK, value, true); + } + + public Kind getKind() { + return kind; + } + + public void setKind(Kind kind) { + this.kind = Objects.requireNonNull(kind, "kind"); + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = Objects.requireNonNull(value, "value"); + } + + public boolean isUseEquals() { + return useEquals; + } + + public void setUseEquals(boolean useEquals) { + this.useEquals = useEquals; + } + + /** Returns an unmodifiable token snapshot, such as {@code [ALGORITHM, =, INPLACE]}. */ + public List getTokens() { + List tokens = new ArrayList<>(); + tokens.add(kind.name()); + if (useEquals) { + tokens.add("="); + } + tokens.add(value); + return Collections.unmodifiableList(tokens); + } + + @Override + public String toString() { + return String.join(" ", getTokens()); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/create/index/CreateIndex.java b/src/main/java/net/sf/jsqlparser/statement/create/index/CreateIndex.java index b2d277611..9e5739ae5 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/index/CreateIndex.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/index/CreateIndex.java @@ -23,7 +23,7 @@ public class CreateIndex implements Statement { private Table table; private Index index; private IndexOptions detachedOptions; - private List tailParameters; + private final List algorithmOrLockOptions = new ArrayList<>(); private boolean indexTypeBeforeOn = false; private boolean usingIfNotExists = false; private boolean concurrently; @@ -170,6 +170,7 @@ public void setIndex(Index index) { detachedOptions.setNullsDistinct(previousOptions.getNullsDistinct()); detachedOptions.setStorageParameters(previousOptions.getStorageParameters()); detachedOptions.setTableSpace(previousOptions.getTableSpace()); + detachedOptions.setSpecOptions(previousOptions.getSpecOptions()); } } else { if (previousOptions != null && previousOptions != index.getIndexOptions()) { @@ -185,6 +186,9 @@ public void setIndex(Index index) { if (index.getTableSpace() == null) { index.setTableSpace(previousOptions.getTableSpace()); } + if (index.getSpecOptions() == null) { + index.setSpecOptions(previousOptions.getSpecOptions()); + } } detachedOptions = null; } @@ -199,12 +203,56 @@ public void setTable(Table table) { this.table = table; } + /** + * Returns the mutable MySQL ALGORITHM and LOCK clauses in source order. They are rendered after + * the index options, as MySQL requires. + */ + public List getAlgorithmOrLockOptions() { + return algorithmOrLockOptions; + } + + /** Copies the list membership and retains the option nodes; null clears the clauses. */ + public void setAlgorithmOrLockOptions(List options) { + algorithmOrLockOptions.clear(); + if (options != null) { + algorithmOrLockOptions.addAll(options); + } + } + + public CreateIndex withAlgorithmOrLockOptions(List options) { + setAlgorithmOrLockOptions(options); + return this; + } + + public CreateIndex addAlgorithmOrLockOptions(AlgorithmOrLockOption... options) { + Collections.addAll(algorithmOrLockOptions, options); + return this; + } + + /** + * Returns an unmodifiable token snapshot of the trailing clauses: the index options after the + * key list ({@link Index#getSpecOptions()}), followed by {@link #getAlgorithmOrLockOptions()}. + * Edit those nodes instead of the snapshot. + */ public List getTailParameters() { - return tailParameters; + List tokens = new ArrayList<>(); + IndexOptions options = getOptions(); + if (options != null && options.getIndexSpec() != null) { + tokens.addAll(options.getIndexSpec()); + } + for (AlgorithmOrLockOption option : algorithmOrLockOptions) { + tokens.addAll(option.getTokens()); + } + return Collections.unmodifiableList(tokens); } + /** + * Replaces the index options after the key list and the ALGORITHM and LOCK clauses with one + * opaque option holding a copy of the tokens, without parsing them. Null clears them. + */ public void setTailParameters(List tailParameters) { - this.tailParameters = tailParameters; + algorithmOrLockOptions.clear(); + getOrCreateOptions().setIndexSpec(tailParameters); } @Override @@ -223,11 +271,14 @@ public StringBuilder appendTo(StringBuilder buffer, Consumer express appendIndexTarget(buffer); appendIndexColumns(buffer, expressionPrinter); appendPostgreSqlTail(buffer, expressionPrinter); - if (tailParameters != null) { - for (String param : tailParameters) { - buffer.append(" ").append(param); + if (index.getSpecOptions() != null) { + for (IndexSpecOption option : index.getSpecOptions()) { + buffer.append(" ").append(option); } } + for (AlgorithmOrLockOption option : algorithmOrLockOptions) { + buffer.append(" ").append(option); + } return buffer; } diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/Index.java b/src/main/java/net/sf/jsqlparser/statement/create/table/Index.java index f0b1a1dea..9c809447f 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/Index.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/Index.java @@ -183,6 +183,11 @@ public Index withIndexSpec(List value) { return this; } + public Index withSpecOptions(List value) { + setSpecOptions(value); + return this; + } + public Index withClustering(IndexOptions.Clustering value) { setClustering(value); return this; diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/IndexOptionSource.java b/src/main/java/net/sf/jsqlparser/statement/create/table/IndexOptionSource.java index 99037db13..4c016d32a 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/IndexOptionSource.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/IndexOptionSource.java @@ -33,6 +33,14 @@ default void setIndexSpec(List value) { getIndexOptions().setIndexSpec(value); } + default List getSpecOptions() { + return getIndexOptions().getSpecOptions(); + } + + default void setSpecOptions(List value) { + getIndexOptions().setSpecOptions(value); + } + default String getCommentText() { return getIndexOptions().getCommentText(); } diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/IndexOptions.java b/src/main/java/net/sf/jsqlparser/statement/create/table/IndexOptions.java index 2e6cc0788..f6050b4bc 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/IndexOptions.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/IndexOptions.java @@ -11,6 +11,9 @@ import java.io.Serializable; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.function.Consumer; import net.sf.jsqlparser.expression.Expression; @@ -23,7 +26,7 @@ public enum Clustering { } private String using; - private List indexSpec; + private List specOptions; private String commentText; private Clustering clustering; private Boolean nullsDistinct; @@ -39,12 +42,60 @@ public void setUsing(String using) { this.using = using; } + /** + * Returns the mutable, ordered options written after the key list, or null when unset. An index + * type written before the key list is {@link #getUsing()} instead. + */ + public List getSpecOptions() { + return specOptions; + } + + /** Copies the list membership and retains the option nodes; null clears the options. */ + public void setSpecOptions(List specOptions) { + this.specOptions = specOptions == null ? null : new ArrayList<>(specOptions); + } + + public IndexOptions addSpecOptions(IndexSpecOption... options) { + return addSpecOptions(Arrays.asList(options)); + } + + public IndexOptions addSpecOptions(Collection options) { + if (specOptions == null) { + specOptions = new ArrayList<>(); + } + specOptions.addAll(options); + return this; + } + + /** + * Returns an unmodifiable token snapshot of {@link #getSpecOptions()}, such as + * {@code [KEY_BLOCK_SIZE, =, 8, COMMENT, 'text']}, or null when unset. Edit the option nodes + * instead of the snapshot. + */ public List getIndexSpec() { - return indexSpec; + if (specOptions == null) { + return null; + } + List tokens = new ArrayList<>(); + for (IndexSpecOption option : specOptions) { + tokens.addAll(option.getTokens()); + } + return Collections.unmodifiableList(tokens); } + /** + * Replaces all options after the key list with one opaque option holding a copy of the tokens, + * without parsing them. Null clears the options; an empty list stores no options. + */ public void setIndexSpec(List indexSpec) { - this.indexSpec = indexSpec; + if (indexSpec == null) { + specOptions = null; + return; + } + specOptions = new ArrayList<>(); + if (!indexSpec.isEmpty()) { + specOptions.add(IndexSpecOption.raw(indexSpec)); + } } public String getCommentText() { diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/IndexSpecOption.java b/src/main/java/net/sf/jsqlparser/statement/create/table/IndexSpecOption.java new file mode 100644 index 000000000..9ff6fa655 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/IndexSpecOption.java @@ -0,0 +1,200 @@ +/*- + * #%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.table; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import net.sf.jsqlparser.expression.StringValue; + +/** + * An option written after the key list of an index or key definition. MySQL's index options + * (KEY_BLOCK_SIZE, USING index type, WITH PARSER, COMMENT, VISIBLE/INVISIBLE, ENGINE_ATTRIBUTE and + * SECONDARY_ENGINE_ATTRIBUTE) are typed; other accepted tokens are retained as opaque + * {@link Kind#OTHER} options. The kind is fixed; replace the node to change it. + */ +public class IndexSpecOption implements Serializable { + + public enum Kind { + KEY_BLOCK_SIZE, INDEX_TYPE, PARSER, COMMENT, VISIBILITY, ENGINE_ATTRIBUTE, SECONDARY_ENGINE_ATTRIBUTE, OTHER + } + + private final Kind kind; + private String value; + private StringValue comment; + private boolean useEquals; + private boolean visible; + private List tokens; + + private IndexSpecOption(Kind kind) { + this.kind = kind; + } + + /** + * Creates a valued option. {@code value} keeps its SQL spelling: a number for KEY_BLOCK_SIZE, + * an index type or parser name, or a quoted string literal for COMMENT and the engine + * attributes. + */ + public static IndexSpecOption of(Kind kind, String value) { + return of(kind, value, false); + } + + /** Like {@link #of(Kind, String)}; {@code useEquals} requires a kind that accepts '='. */ + public static IndexSpecOption of(Kind kind, String value, boolean useEquals) { + IndexSpecOption option = new IndexSpecOption(Objects.requireNonNull(kind, "kind")); + option.setValue(value); + option.setUseEquals(useEquals); + return option; + } + + /** + * Creates a COMMENT option. The literal keeps its quotes and escaping, so + * {@link StringValue#getNotExcapedValue()} returns the stored comment text. + */ + public static IndexSpecOption comment(StringValue comment) { + IndexSpecOption option = new IndexSpecOption(Kind.COMMENT); + option.setComment(comment); + return option; + } + + public static IndexSpecOption visibility(boolean visible) { + IndexSpecOption option = new IndexSpecOption(Kind.VISIBILITY); + option.visible = visible; + return option; + } + + /** Creates an opaque option from a copy of {@code tokens}; the tokens are never parsed. */ + public static IndexSpecOption raw(List tokens) { + IndexSpecOption option = new IndexSpecOption(Kind.OTHER); + option.setTokens(tokens); + return option; + } + + public static IndexSpecOption raw(String... tokens) { + return raw(Arrays.asList(tokens)); + } + + public Kind getKind() { + return kind; + } + + /** + * The SQL spelling of the option value, or null for VISIBILITY and opaque options. For COMMENT, + * this is the literal's SQL text, such as {@code 'it\'s'}; use {@link #getComment()} for its + * decoded text. + */ + public String getValue() { + return kind == Kind.COMMENT ? comment.toString() : value; + } + + /** + * Replaces the value with its SQL spelling. A COMMENT value is read as a string literal and + * keeps the escaping of the literal it replaces. + */ + public void setValue(String value) { + if (kind == Kind.VISIBILITY || kind == Kind.OTHER) { + throw new IllegalStateException(kind + " options have no value"); + } + Objects.requireNonNull(value, "value"); + if (kind == Kind.COMMENT) { + setComment(new StringValue(value) + .withEscaping(comment == null ? null : comment.getEscaping())); + } else { + this.value = value; + } + } + + /** The literal of a COMMENT option, otherwise null. */ + public StringValue getComment() { + return comment; + } + + public void setComment(StringValue comment) { + if (kind != Kind.COMMENT) { + throw new IllegalStateException(kind + " options have no comment"); + } + this.comment = Objects.requireNonNull(comment, "comment"); + } + + public boolean isUseEquals() { + return useEquals; + } + + /** + * Selects the optional '=' of KEY_BLOCK_SIZE, ENGINE_ATTRIBUTE and SECONDARY_ENGINE_ATTRIBUTE. + */ + public void setUseEquals(boolean useEquals) { + if (useEquals && kind != Kind.KEY_BLOCK_SIZE && kind != Kind.ENGINE_ATTRIBUTE + && kind != Kind.SECONDARY_ENGINE_ATTRIBUTE) { + throw new IllegalStateException(kind + " options do not use '='"); + } + this.useEquals = useEquals; + } + + /** VISIBLE (true) or INVISIBLE (false) for a VISIBILITY option, otherwise null. */ + public Boolean getVisible() { + return kind == Kind.VISIBILITY ? visible : null; + } + + public void setVisible(boolean visible) { + if (kind != Kind.VISIBILITY) { + throw new IllegalStateException(kind + " options have no visibility"); + } + this.visible = visible; + } + + /** + * Returns an unmodifiable, detached token snapshot: the keywords, an optional '=' and the value + * of a typed option, or a copy of an opaque option's tokens. + */ + public List getTokens() { + if (kind == Kind.OTHER) { + return Collections.unmodifiableList(new ArrayList<>(tokens)); + } + List result = new ArrayList<>(Arrays.asList(keyword().split(" "))); + if (useEquals) { + result.add("="); + } + if (getValue() != null) { + result.add(getValue()); + } + return Collections.unmodifiableList(result); + } + + /** Replaces the tokens of an opaque option with a copy; typed options reject raw tokens. */ + public void setTokens(List tokens) { + if (kind != Kind.OTHER) { + throw new IllegalStateException( + "Typed " + kind + " options cannot be replaced with raw tokens"); + } + this.tokens = new ArrayList<>(Objects.requireNonNull(tokens, "tokens")); + } + + private String keyword() { + switch (kind) { + case INDEX_TYPE: + return "USING"; + case PARSER: + return "WITH PARSER"; + case VISIBILITY: + return visible ? "VISIBLE" : "INVISIBLE"; + default: + return kind.name(); + } + } + + @Override + public String toString() { + return String.join(" ", getTokens()); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/KeyConstraint.java b/src/main/java/net/sf/jsqlparser/statement/create/table/KeyConstraint.java index e48b01434..979fe4476 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/KeyConstraint.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/KeyConstraint.java @@ -113,6 +113,11 @@ public KeyConstraint withIndexSpec(List value) { return this; } + public KeyConstraint withSpecOptions(List value) { + setSpecOptions(value); + return this; + } + public KeyConstraint withClustering(IndexOptions.Clustering value) { setClustering(value); return this; 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 d6dec72f0..57b0fdb4f 100644 --- a/src/main/java/net/sf/jsqlparser/statement/drop/Drop.java +++ b/src/main/java/net/sf/jsqlparser/statement/drop/Drop.java @@ -24,6 +24,7 @@ import net.sf.jsqlparser.statement.RoutineReference; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.StatementVisitor; +import net.sf.jsqlparser.statement.create.index.AlgorithmOrLockOption; import net.sf.jsqlparser.statement.create.user.UserAccount; import net.sf.jsqlparser.statement.select.PlainSelect; @@ -56,6 +57,8 @@ public boolean hasOwnerTable() { private List parameters; private Table table; private int tablePosition; + private final List algorithmOrLockOptions = new ArrayList<>(); + private Integer algorithmOrLockPosition; private CascadeBehavior behavior; private Map> typeToParameters = new HashMap<>(); private boolean ifExists = false; @@ -242,19 +245,16 @@ private static Table qualifiedTable(String name) { } /** - * 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. + * Returns the legacy tokens after the targets. With a structured ON table, ALGORITHM or LOCK + * clauses or CASCADE/RESTRICT behavior, this is a snapshot that includes them in source order; + * edit {@link #setTable}, {@link #getAlgorithmOrLockOptions()} or {@link #setBehavior} instead. */ public List getParameters() { - if (table == null && behavior == null) { + if (table == null && algorithmOrLockOptions.isEmpty() && behavior == null) { return parameters; } - List tokens = parameters == null ? new ArrayList<>() : new ArrayList<>(parameters); - if (table != null) { - tokens.add(tablePosition, "ON"); - tokens.add(tablePosition + 1, table.toString()); - } + List tokens = new ArrayList<>(); + appendParameters(tokens::add, table -> tokens.add(table.toString())); if (behavior != null) { tokens.add(behavior.name()); } @@ -299,13 +299,15 @@ public void visitTables(Consumer
visitor) { } /** - * Replaces all legacy parameters and clears the structured ON table and CASCADE/RESTRICT - * behavior. + * Replaces all legacy parameters and clears the structured ON table, the ALGORITHM and LOCK + * clauses and the CASCADE/RESTRICT behavior. */ public void setParameters(List list) { parameters = list; table = null; tablePosition = 0; + algorithmOrLockOptions.clear(); + algorithmOrLockPosition = null; behavior = null; } @@ -490,7 +492,10 @@ public StringBuilder appendTo(StringBuilder builder, Consumer
tablePrinte if (engine != null) { builder.append(engineUseEquals ? " ENGINE = " : " ENGINE ").append(engine); } - appendParameters(builder, tablePrinter); + appendParameters(token -> builder.append(' ').append(token), table -> { + builder.append(' '); + tablePrinter.accept(table); + }); if (behavior != null) { builder.append(' ').append(behavior); } @@ -526,15 +531,21 @@ private void appendNames(StringBuilder builder, Consumer
tablePrinter) { } } - private void appendParameters(StringBuilder builder, Consumer
tablePrinter) { + /** Emits the parameter tokens with ON and the ALGORITHM/LOCK clauses at their positions. */ + private void appendParameters(Consumer tokens, Consumer
onTable) { int size = parameters == null ? 0 : parameters.size(); + int optionPosition = algorithmOrLockPosition == null ? size + : Math.min(algorithmOrLockPosition, size); for (int i = 0; i <= size; i++) { if (table != null && i == tablePosition) { - builder.append(" ON "); - tablePrinter.accept(table); + tokens.accept("ON"); + onTable.accept(table); + } + if (i == optionPosition) { + algorithmOrLockOptions.forEach(option -> option.getTokens().forEach(tokens)); } if (i < size) { - builder.append(' ').append(parameters.get(i)); + tokens.accept(parameters.get(i)); } } } @@ -596,4 +607,46 @@ public Drop addParameters(Collection parameters) { this.parameters.addAll(parameters); return this; } + + /** + * Returns the mutable MySQL ALGORITHM and LOCK clauses of DROP INDEX in source order. They + * follow the parameter tokens unless positioned with + * {@link #setAlgorithmOrLockOptions(List, int)}. + */ + public List getAlgorithmOrLockOptions() { + return algorithmOrLockOptions; + } + + /** Copies the list membership and places the clauses after all parameter tokens. */ + public void setAlgorithmOrLockOptions(List options) { + setAlgorithmOrLockOptions(options, -1); + } + + /** + * Copies the list membership and places the clauses before the parameter token at + * {@code parameterPosition}; -1 places them after all parameter tokens. + */ + public void setAlgorithmOrLockOptions(List options, + int parameterPosition) { + int size = parameters == null ? 0 : parameters.size(); + if (parameterPosition < -1 || parameterPosition > size) { + throw new IllegalArgumentException( + "Invalid ALGORITHM/LOCK position: " + parameterPosition); + } + algorithmOrLockOptions.clear(); + if (options != null) { + algorithmOrLockOptions.addAll(options); + } + algorithmOrLockPosition = parameterPosition < 0 ? null : parameterPosition; + } + + public Drop withAlgorithmOrLockOptions(List options) { + setAlgorithmOrLockOptions(options); + return this; + } + + public Drop addAlgorithmOrLockOptions(AlgorithmOrLockOption... options) { + Collections.addAll(algorithmOrLockOptions, options); + return this; + } } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 39e3b3d5b..26ea4322f 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -2496,13 +2496,13 @@ public class CCJSqlParser extends AbstractJSqlParser { // Preserve generic ALTER's historical placement of USING and COMMENT options. if (!Dialect.MYSQL.name().equals(getAsString(Feature.dialect)) && !Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect))) { - for (String option : new ArrayList(key.getIndexSpec())) { - if (option.toUpperCase(Locale.ROOT).startsWith("USING ")) { - alterExp.addParameters("USING", option.substring("USING ".length())); - key.getIndexSpec().remove(option); - } else if (option.toUpperCase(Locale.ROOT).startsWith("COMMENT ")) { - key.setCommentText(option.substring("COMMENT ".length())); - key.getIndexSpec().remove(option); + for (IndexSpecOption option : new ArrayList(key.getSpecOptions())) { + if (option.getKind() == IndexSpecOption.Kind.INDEX_TYPE) { + alterExp.addParameters("USING", option.getValue()); + key.getSpecOptions().remove(option); + } else if (option.getKind() == IndexSpecOption.Kind.COMMENT) { + key.setCommentText(option.getValue()); + key.getSpecOptions().remove(option); } } } @@ -15195,8 +15195,6 @@ CreateIndex CreateIndex(): Expression where = null; String indexType; Index index; - List parameter = new ArrayList(); - List tailParameters = new ArrayList(); ObjectNames name; } { @@ -15241,45 +15239,54 @@ CreateIndex CreateIndex(): { createIndex.setTableSpace(tableSpace); } ] [ LOOKAHEAD(2) where=Expression() { createIndex.setWhere(where); } ] ( LOOKAHEAD(2, { getToken(1).kind != K_LOCK || getToken(2).kind != K_TABLE }) - parameter=CreateIndexTailOption() { tailParameters.addAll(parameter); } )* + CreateIndexTailOption(createIndex, index) )* { index.setColumns(colNames); createIndex.setIndex(index); createIndex.setTable(table); - createIndex.setTailParameters(tailParameters); return createIndex; } } -List CreateIndexTailOption(): +/** + * Index options shared with table key definitions are kept on the index, ALGORITHM and LOCK on + * the statement. Other trailing tokens remain opaque index options. + */ +void CreateIndexTailOption(CreateIndex statement, Index index): { - List option; + AlgorithmOrLockOption algorithmOrLock; + IndexSpecOption option; + List parameter; } { ( LOOKAHEAD({ getToken(1).kind == K_ALGORITHM || getToken(1).kind == K_LOCK }) - option=IndexAlgorithmOrLockOption() + algorithmOrLock=IndexAlgorithmOrLockOption() + { statement.addAlgorithmOrLockOptions(algorithmOrLock); } | - option=CreateParameter() + LOOKAHEAD(IndexSpecOption(), { !(getToken(1).kind == K_USING && getToken(2).kind == K_INDEX) }) + option=IndexSpecOption() { index.getIndexOptions().addSpecOptions(option); } + | + parameter=CreateParameter() + { index.getIndexOptions().addSpecOptions(IndexSpecOption.raw(parameter)); } ) - { return option; } } // CREATE INDEX and DROP INDEX share MySQL's algorithm_option / lock_option syntax. -// Keep the legacy token list, including optional equals signs and the original order. -List IndexAlgorithmOrLockOption(): +AlgorithmOrLockOption IndexAlgorithmOrLockOption(): { - List option = new ArrayList(); - Token keyword; + AlgorithmOrLockOption.Kind kind; + boolean useEquals = false; Token value; } { - ( keyword= | keyword= ) { option.add(keyword.image); } - [ "=" { option.add("="); } ] + ( { kind = AlgorithmOrLockOption.Kind.ALGORITHM; } + | { kind = AlgorithmOrLockOption.Kind.LOCK; } ) + [ "=" { useEquals = true; } ] // COPY is one of MySQL's ALGORITHM values and also starts the COPY statement ( value= | value= | value= | value= | value= ) - { option.add(value.image); return option; } + { return new AlgorithmOrLockOption(kind, value.image, useEquals); } } List ColumnDefinitionParameter(): { @@ -16058,7 +16065,7 @@ TableElement TableIndexSpec(boolean createContext): String indexName = null; String using = null; List columns; - List indexOptions = new ArrayList(); + List indexOptions = new ArrayList(); TableElement index; Boolean nullsDistinct = null; IndexOptions.Clustering clustering = null; @@ -16067,15 +16074,19 @@ TableElement TableIndexSpec(boolean createContext): ( typeToken= keywordToken= clustering=SqlServerIndexClustering() - [ LOOKAHEAD({ clustering == null && getToken(1).kind != OPENING_BRACKET }) indexName=RelObjectName() ] + [ LOOKAHEAD({ clustering == null && getToken(1).kind != OPENING_BRACKET + && getToken(1).kind != K_USING }) indexName=RelObjectName() ] + // MySQL's index_type may also precede the key list: PRIMARY KEY USING BTREE (id) + [ LOOKAHEAD({ getToken(1).kind == K_USING && getToken(2).kind != K_INDEX + && getToken(2).kind != K_COLUMNS }) using=UsingIndexType() ] columns=IndexKeyColumns(true) TableIndexOptions(createContext, indexOptions) { index = new KeyConstraint() .withIndexName(indexName) .withType(typeToken.image + " " + keywordToken.image) - .withColumns(columns) - .withIndexSpec(indexOptions); + .withUsing(using).withColumns(columns) + .withSpecOptions(indexOptions); } | typeToken= @@ -16097,12 +16108,12 @@ TableElement TableIndexSpec(boolean createContext): .withIndexName(indexName) .withType(typeToken.image + (keywordToken != null ? " " + keywordToken.image : "")) - .withUsing(using).withColumns(columns).withIndexSpec(indexOptions); + .withUsing(using).withColumns(columns).withSpecOptions(indexOptions); } else { index = new KeyConstraint().withType(typeToken.image) .withIndexKeyword(keywordToken != null ? keywordToken.image : null) .withIndexName(indexName).withUsing(using).withColumns(columns) - .withIndexSpec(indexOptions); + .withSpecOptions(indexOptions); } } | @@ -16116,7 +16127,7 @@ TableElement TableIndexSpec(boolean createContext): index = new Index().withType(createContext ? typeToken.image : null) .withIndexKeyword(typeToken.image).withKind(Index.Kind.INDEX) .withName(indexName).withUsing(using).withColumns(columns) - .withIndexSpec(indexOptions); + .withSpecOptions(indexOptions); } | typeToken= @@ -16129,7 +16140,7 @@ TableElement TableIndexSpec(boolean createContext): index = new Index().withType(createContext ? typeToken.image : null) .withIndexKeyword(typeToken.image).withKind(Index.Kind.INDEX) .withName(indexName).withUsing(using).withColumns(columns) - .withIndexSpec(indexOptions); + .withSpecOptions(indexOptions); } | (typeToken= | typeToken=) @@ -16143,7 +16154,7 @@ TableElement TableIndexSpec(boolean createContext): ? " " + keywordToken.image : "")) .withIndexKeyword(!createContext && keywordToken != null ? keywordToken.image : null) - .withName(indexName).withColumns(columns).withIndexSpec(indexOptions); + .withName(indexName).withColumns(columns).withSpecOptions(indexOptions); } ) { ((IndexOptionSource) index).setNullsDistinct(nullsDistinct); @@ -16152,8 +16163,13 @@ TableElement TableIndexSpec(boolean createContext): { return index; } } -void TableIndexOptions(boolean createContext, List options): +/** + * Index options after the key list, shared by CREATE TABLE and ALTER TABLE key definitions. + * CREATE TABLE also retains other accepted tokens as opaque options. + */ +void TableIndexOptions(boolean createContext, List options): { + IndexSpecOption option; List parameter; } { @@ -16164,9 +16180,18 @@ void TableIndexOptions(boolean createContext, List options): && getToken(1).kind != K_DEFERRABLE && !isKeywordAhead("INITIALLY") && !(getToken(1).kind == K_NOT && (getToken(2).kind == K_DEFERRABLE || "VALID".equalsIgnoreCase(getToken(2).image))) }) - parameter=CreateParameter() { options.addAll(parameter); } )* + ( + LOOKAHEAD(IndexSpecOption()) option=IndexSpecOption() { options.add(option); } + | + parameter=CreateParameter() { options.add(IndexSpecOption.raw(parameter)); } + ) + )* | - LOOKAHEAD({ !createContext }) IndexOptionList(options) + LOOKAHEAD({ !createContext }) + ( + LOOKAHEAD(IndexSpecOption(), { !(getToken(1).kind == K_USING && getToken(2).kind == K_INDEX) }) + option=IndexSpecOption() { options.add(option); } + )* ) } @@ -17960,7 +17985,9 @@ Drop Drop(): Table indexTable = null; int indexTablePosition = 0; List dropArgs = new ArrayList(); - List indexOption; + AlgorithmOrLockOption indexOption; + List indexOptions = new ArrayList(); + int indexOptionPosition = 0; boolean useTemporary = false; boolean foreign = false; boolean eventTrigger = false; @@ -18039,16 +18066,19 @@ Drop Drop(): [ "=" { drop.setEngineUseEquals(true); } ] engine=RelObjectName() { drop.setEngine(engine); } ] ( + // MySQL DROP INDEX accepts a trailing algorithm_option / lock_option, e.g. + // "DROP INDEX i ON t ALGORITHM = INPLACE LOCK = NONE". LOCK also starts a LOCK TABLE + // statement, so it is only taken here when it cannot begin the next statement. LOOKAHEAD({ getToken(1).kind == K_ALGORITHM || (getToken(1).kind == K_LOCK && getToken(2).kind != K_TABLE) }) - indexOption=IndexAlgorithmOrLockOption() { dropArgs.addAll(indexOption); } + indexOption=IndexAlgorithmOrLockOption() { + if (indexOptions.isEmpty()) { + indexOptionPosition = dropArgs.size(); + } + indexOptions.add(indexOption); + } | - ( - 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="=" - ) { dropArgs.add(tk.image); } + tk= { dropArgs.add(tk.image); } | ( name = DropObjectName() { @@ -18061,11 +18091,6 @@ Drop Drop(): } } ) - | - // The lock_option of DROP INDEX. LOCK also starts a LOCK TABLE statement, so it is only - // taken as a DROP argument when it cannot be the beginning of the next statement. - LOOKAHEAD({ getToken(1).kind == K_LOCK && getToken(2).kind != K_TABLE }) - tk= { dropArgs.add(tk.image); } )* [ { behavior = CascadeBehavior.CASCADE; } | { behavior = CascadeBehavior.RESTRICT; } ] { @@ -18075,6 +18100,9 @@ Drop Drop(): if (indexTable != null) { drop.setTable(indexTable, indexTablePosition); } + if (!indexOptions.isEmpty()) { + drop.setAlgorithmOrLockOptions(indexOptions, indexOptionPosition); + } drop.setBehavior(behavior); drop.setUsingTemporary(useTemporary); @@ -18368,15 +18396,6 @@ void IndexWithComment(IndexOptionSource index): } } -void IndexOptionList(List list) : -{} -{ - ( - LOOKAHEAD(2, { !(getToken(1).kind == K_USING && getToken(2).kind == K_INDEX) }) - IndexOption(list) - )* -} - String UsingIndexType() : { String sk = null; @@ -18388,48 +18407,35 @@ String UsingIndexType() : } } -void IndexOption(List list) : +/** MySQL's index_option, shared by CREATE INDEX and the key definitions of CREATE/ALTER TABLE. */ +IndexSpecOption IndexSpecOption(): { - Token tk1 = null; - Token tk2 = null; - String sk1 = null; - boolean useEqual = false; - TableOption attribute; + Token token; + String value; + TableOption scalar; + IndexSpecOption option; } { ( - LOOKAHEAD({ isMySqlEngineAttributeAhead() }) attribute=MySqlScalarTableOption() - { list.addAll(attribute.getTokens()); } - | - tk1= ["=" { useEqual = true; } ] tk2= - { - list.add("KEY_BLOCK_SIZE" + (useEqual ? " = " : "") + tk2.image); - } - | - tk1= tk2= - { - list.add("WITH PARSER " + tk2.image); - } - | - tk1= tk2= - { - list.add("COMMENT " + tk2.image); - } - | - tk1= - { - list.add("VISIBLE"); - } - | - tk1= - { - list.add("INVISIBLE"); - } - | - sk1 = UsingIndexType(){ - list.add("USING " + sk1); - } + // KEY_BLOCK_SIZE and the engine attributes share the scalar syntax of table options. + LOOKAHEAD({ getToken(1).kind == K_KEY_BLOCK_SIZE || isMySqlEngineAttributeAhead() }) + scalar=MySqlScalarTableOption() { + option = IndexSpecOption.of(IndexSpecOption.Kind.valueOf(scalar.getKind().name()), + scalar.getValue(), scalar.isUseEquals()); + } + | + value=UsingIndexType() { option = IndexSpecOption.of(IndexSpecOption.Kind.INDEX_TYPE, value); } + | + value=RelObjectName() + { option = IndexSpecOption.of(IndexSpecOption.Kind.PARSER, value); } + | + token= { option = IndexSpecOption.comment(stringValue(token)); } + | + { option = IndexSpecOption.visibility(true); } + | + { option = IndexSpecOption.visibility(false); } ) + { return option; } } TablePartitioning CreateTablePartitioning(): @@ -18922,6 +18928,7 @@ void AlterExpressionAddConstraint(AlterExpression alterExp): List constraints = null; CheckConstraint checkCs = null; IndexOptions.Clustering clustering = null; + String using = null; } { @@ -18951,12 +18958,14 @@ void AlterExpressionAddConstraint(AlterExpression alterExp): | ( tk= tk2= clustering=SqlServerIndexClustering() + [ LOOKAHEAD({ getToken(1).kind == K_USING && getToken(2).kind != K_INDEX }) + using=UsingIndexType() ] columnNames=ColumnsNamesList() { index = new KeyConstraint() .withName(sk3) .withType(tk.image + " " + tk2.image) - .withClustering(clustering) + .withClustering(clustering).withUsing(using) .withColumnsNames(columnNames); alterExp.setConstraint(index); } @@ -18981,12 +18990,14 @@ void AlterExpressionAddConstraint(AlterExpression alterExp): ( tk= (tk2= | tk2=)? clustering=SqlServerIndexClustering() + [ LOOKAHEAD({ getToken(1).kind == K_USING && getToken(2).kind != K_INDEX }) + using=UsingIndexType() ] columnNames=ColumnsNamesList() { index = new KeyConstraint() .withName(sk3) .withType(tk.image + (tk2!=null?" " + tk2.image:"")) - .withClustering(clustering) + .withClustering(clustering).withUsing(using) .withColumnsNames(columnNames); alterExp.setConstraint(index); } @@ -19197,7 +19208,7 @@ AlterExpression AlterExpressionAddAlterModify(): TableElement element; AlterExpression.ColumnDataType alterExpressionColumnDataType = null; AlterExpression.ColumnDropNotNull alterExpressionColumnDropNotNull = null; - List indexSpec = new ArrayList(); + List indexSpec = new ArrayList(); List partitionDefinition = null; } { @@ -19219,9 +19230,9 @@ AlterExpression AlterExpressionAddAlterModify(): | LOOKAHEAD({ alterExp.getOperation() == AlterOperation.ALTER && getToken(1).kind == K_INDEX }) - tk= sk3=RelObjectName() IndexOptionList(indexSpec) { + tk= sk3=RelObjectName() TableIndexOptions(false, indexSpec) { index = new Index().withIndexKeyword(tk.image).withName(sk3) - .withKind(Index.Kind.INDEX).withIndexSpec(indexSpec); + .withKind(Index.Kind.INDEX).withSpecOptions(indexSpec); alterExp.setIndex(index); } | diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 66ab6ad87..c9abba5bf 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -293,10 +293,11 @@ The legacy accessors remain and are derived from these properties: 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. +* ``getParameters()`` is a snapshot that includes ON with the owner table, the + MySQL ALGORITHM and LOCK clauses and the CASCADE or RESTRICT keyword, in + source order. ``setParameters()`` replaces the clause with raw tokens and + clears all three; ``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 @@ -328,7 +329,8 @@ setters do not parse SQL or infer a table. Table discovery and statement visitors traverse DROP TABLE/VIEW targets and explicit index owners, without reporting catalog-only object names as tables. The statement deparser delegates real tables to its configured select deparser. -MySQL ALGORITHM/LOCK tokens retain their order and optional equals signs. +MySQL ALGORITHM and LOCK clauses retain their order and optional equals signs as +typed nodes; see :ref:`Share MySQL index options`. MySQL named constraint drops ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -440,6 +442,73 @@ TABLESPACE; supply its tablespace name with ``setValue``. Choose the new kind an when using the scalar setters. These methods edit the AST rather than checking database-specific DDL validity. +Share MySQL index options +------------------------- + +CREATE INDEX, the index and key definitions of CREATE TABLE, and ALTER TABLE +``ADD`` or ``ALTER INDEX`` parse MySQL index options with one grammar rule into +one model. ``IndexOptions.getSpecOptions()``, also available on ``Index`` and +``KeyConstraint``, returns the options written after the key list as mutable, +ordered ``IndexSpecOption`` nodes. ``getKind()`` selects the payload: + +* ``KEY_BLOCK_SIZE``, ``ENGINE_ATTRIBUTE`` and ``SECONDARY_ENGINE_ATTRIBUTE``: + ``getValue()`` and the optional ``=`` reported by ``isUseEquals()`` +* ``INDEX_TYPE`` (``USING BTREE``) and ``PARSER`` (``WITH PARSER ngram``): + ``getValue()`` +* ``COMMENT``: ``getComment()`` returns the literal as a ``StringValue`` that + records how the dialect escapes it, so ``getNotExcapedValue()`` returns the + stored text, for example ``it's`` for ``'it''s'`` or, with MySQL backslash + escapes, ``'it\'s'``. ``getValue()`` remains the literal's SQL text. +* ``VISIBILITY``: ``getVisible()`` for VISIBLE or INVISIBLE +* ``OTHER``: opaque tokens that CREATE TABLE and CREATE INDEX accept for other + dialects + +Values keep their SQL spelling, including the quotes of string literals. An +index type written before the key list, as in ``KEY k USING BTREE (a)``, +``PRIMARY KEY USING BTREE (id)`` or ``CREATE INDEX k USING HASH ON t (a)``, +remains ``getUsing()``; ``CreateIndex.isIndexTypeBeforeOn()`` still distinguishes +the two CREATE INDEX positions. After the key list it is an ``INDEX_TYPE`` option +at its source position. PostgreSQL's INCLUDE, NULLS DISTINCT, storage parameters +and tablespace keep their dedicated properties. + +CREATE INDEX keeps these options on ``getIndex()``. Its ALGORITHM and LOCK +clauses are ``AlgorithmOrLockOption`` nodes in +``CreateIndex.getAlgorithmOrLockOptions()``; DROP INDEX exposes the same type +through ``Drop.getAlgorithmOrLockOptions()``. CREATE INDEX renders them after the +index options, as MySQL requires. + +.. code-block:: java + + CreateIndex create = (CreateIndex) CCJSqlParserUtil.parse( + "CREATE UNIQUE INDEX uk ON t (a) USING BTREE COMMENT 'x' ALGORITHM = INPLACE", + parser -> parser.withDialect(Dialect.MYSQL)); + List options = create.getIndex().getSpecOptions(); + options.get(0).setValue("HASH"); // INDEX_TYPE + options.add(IndexSpecOption.visibility(false)); + create.getAlgorithmOrLockOptions().get(0).setValue("COPY"); + // CREATE UNIQUE INDEX uk ON t (a) USING HASH COMMENT 'x' INVISIBLE ALGORITHM = COPY + +The kind of an option is fixed. Setters for a payload that the kind does not have, +and ``setTokens`` on a typed option, throw ``IllegalStateException``; replace the +node to change its kind. ``IndexSpecOption.raw`` copies its tokens and never +parses them. + +The legacy accessors are derived from these nodes. ``getIndexSpec()`` returns an +unmodifiable token snapshot of ``getSpecOptions()``, or null when the options are +unset. ``CreateIndex.getTailParameters()`` returns the tokens of the index options +followed by ALGORITHM and LOCK, and ``Drop.getParameters()`` includes ALGORITHM +and LOCK at their source position. ``setIndexSpec`` and +``CreateIndex.setTailParameters`` replace these clauses with one opaque option; +``Drop.setParameters`` also clears ALGORITHM and LOCK. + +Migration: edits of the ``getIndexSpec()`` and ``getTailParameters()`` snapshots +now throw ``UnsupportedOperationException``; edit the nodes or call the setters. +ALTER TABLE key definitions report the same tokens as CREATE TABLE, for example +``KEY_BLOCK_SIZE``, ``=`` and ``8`` instead of the single entry +``KEY_BLOCK_SIZE = 8``. For a constructed statement without trailing clauses, +``getTailParameters()`` returns an empty list instead of null. Typed option +keywords are rendered in upper case. + Inspect PostgreSQL schema statements ------------------------------------ diff --git a/src/test/java/net/sf/jsqlparser/statement/alter/AlterTest.java b/src/test/java/net/sf/jsqlparser/statement/alter/AlterTest.java index 04ee07179..55baedf75 100644 --- a/src/test/java/net/sf/jsqlparser/statement/alter/AlterTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/alter/AlterTest.java @@ -40,6 +40,7 @@ import net.sf.jsqlparser.statement.create.table.CheckConstraint; import net.sf.jsqlparser.statement.create.table.ForeignKeyConstraint; import net.sf.jsqlparser.statement.create.table.Index; +import net.sf.jsqlparser.statement.create.table.IndexSpecOption; import net.sf.jsqlparser.statement.create.table.KeyElement; import net.sf.jsqlparser.statement.create.table.KeyConstraint; import net.sf.jsqlparser.statement.create.table.PartitionDefinition; @@ -2072,13 +2073,14 @@ public void testAlterTableAddFullTextIndexWithOptions() throws JSQLParserExcepti assertEquals("col1", columnNames.get(0)); assertEquals("col2", columnNames.get(1)); - List indexSpec = index.getIndexSpec(); - assertNotNull(indexSpec); - assertEquals(4, indexSpec.size()); - assertEquals("KEY_BLOCK_SIZE = 8", indexSpec.get(0)); - assertEquals("WITH PARSER ngram", indexSpec.get(1)); - assertEquals("COMMENT 'fulltext'", indexSpec.get(2)); - assertEquals("INVISIBLE", indexSpec.get(3)); + List options = index.getSpecOptions(); + assertEquals(4, options.size()); + assertEquals("KEY_BLOCK_SIZE = 8", options.get(0).toString()); + assertEquals("WITH PARSER ngram", options.get(1).toString()); + assertEquals("COMMENT 'fulltext'", options.get(2).toString()); + assertEquals("INVISIBLE", options.get(3).toString()); + assertEquals(List.of("KEY_BLOCK_SIZE", "=", "8", "WITH", "PARSER", "ngram", "COMMENT", + "'fulltext'", "INVISIBLE"), index.getIndexSpec()); assertSqlCanBeParsedAndDeparsed(sql); } @@ -2134,13 +2136,14 @@ public void testAlterTableAddIndexWithOptions() throws JSQLParserException { assertEquals("idx_lastname", index.getName()); assertEquals("last_name", index.getColumnsNames().get(0)); - List indexSpec = index.getIndexSpec(); - assertNotNull(indexSpec); - assertEquals(4, indexSpec.size()); - assertEquals("USING BTREE", indexSpec.get(0)); - assertEquals("KEY_BLOCK_SIZE = 16", indexSpec.get(1)); - assertEquals("COMMENT 'Performance tuning'", indexSpec.get(2)); - assertEquals("VISIBLE", indexSpec.get(3)); + List options = index.getSpecOptions(); + assertEquals(4, options.size()); + assertEquals("USING BTREE", options.get(0).toString()); + assertEquals("KEY_BLOCK_SIZE = 16", options.get(1).toString()); + assertEquals("COMMENT 'Performance tuning'", options.get(2).toString()); + assertEquals("VISIBLE", options.get(3).toString()); + assertEquals(List.of("USING", "BTREE", "KEY_BLOCK_SIZE", "=", "16", "COMMENT", + "'Performance tuning'", "VISIBLE"), index.getIndexSpec()); assertSqlCanBeParsedAndDeparsed(sql); } diff --git a/src/test/java/net/sf/jsqlparser/statement/create/MySqlIndexSpecOptionTest.java b/src/test/java/net/sf/jsqlparser/statement/create/MySqlIndexSpecOptionTest.java new file mode 100644 index 000000000..4ea4f93b1 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/MySqlIndexSpecOptionTest.java @@ -0,0 +1,421 @@ +/*- + * #%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; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +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.CascadeBehavior; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.alter.Alter; +import net.sf.jsqlparser.statement.alter.AlterExpression; +import net.sf.jsqlparser.statement.create.index.AlgorithmOrLockOption; +import net.sf.jsqlparser.statement.create.index.CreateIndex; +import net.sf.jsqlparser.statement.create.table.CreateTable; +import net.sf.jsqlparser.statement.create.table.Index; +import net.sf.jsqlparser.statement.create.table.IndexOptionSource; +import net.sf.jsqlparser.statement.create.table.IndexSpecOption; +import net.sf.jsqlparser.statement.create.table.IndexSpecOption.Kind; +import net.sf.jsqlparser.statement.create.table.KeyConstraint; +import net.sf.jsqlparser.statement.drop.Drop; +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.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class MySqlIndexSpecOptionTest { + private static final String OPTIONS = "KEY_BLOCK_SIZE = 8 USING HASH WITH PARSER ngram " + + "COMMENT 'c' INVISIBLE ENGINE_ATTRIBUTE '{}' SECONDARY_ENGINE_ATTRIBUTE = '{}'"; + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void createIndexKeepsIndexOptionsOnItsIndexAndAlgorithmLockOnTheStatement(boolean mysql) + throws Exception { + String sql = "CREATE UNIQUE INDEX uk ON t1 (a DESC, b (10)) USING BTREE COMMENT 'x' " + + "ALGORITHM = INPLACE LOCK = NONE"; + CreateIndex create = (CreateIndex) roundTrip(sql, mysql); + Index index = create.getIndex(); + + assertNull(index.getUsing()); + assertOptions(index, "INDEX_TYPE BTREE", "COMMENT 'x'"); + assertEquals(2, create.getAlgorithmOrLockOptions().size()); + assertAlgorithmOrLock(create.getAlgorithmOrLockOptions().get(0), + AlgorithmOrLockOption.Kind.ALGORITHM, "INPLACE"); + assertAlgorithmOrLock(create.getAlgorithmOrLockOptions().get(1), + AlgorithmOrLockOption.Kind.LOCK, "NONE"); + assertEquals(List.of("USING", "BTREE", "COMMENT", "'x'"), index.getIndexSpec()); + assertEquals(List.of("USING", "BTREE", "COMMENT", "'x'", "ALGORITHM", "=", "INPLACE", + "LOCK", "=", "NONE"), create.getTailParameters()); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', value = { + "CREATE FULLTEXT INDEX ft ON t1 (body) WITH PARSER ngram|PARSER ngram", + "CREATE INDEX i ON t1 (a) INVISIBLE|VISIBILITY false", + "CREATE INDEX i ON t1 (a) VISIBLE|VISIBILITY true", + "CREATE INDEX i ON db1.t1 (a, b) KEY_BLOCK_SIZE = 8|KEY_BLOCK_SIZE 8 =", + "CREATE INDEX i ON t1 (a) KEY_BLOCK_SIZE 8 ENGINE_ATTRIBUTE '{}'" + + "|KEY_BLOCK_SIZE 8,ENGINE_ATTRIBUTE '{}'"}) + void createIndexOptionsAreTyped(String sql, String expected) throws Exception { + for (boolean mysql : List.of(true, false)) { + CreateIndex create = (CreateIndex) roundTrip(sql, mysql); + assertOptions(create.getIndex(), expected.split(",")); + assertTrue(create.getAlgorithmOrLockOptions().isEmpty()); + } + } + + @Test + void indexTypeKeepsItsPositionRelativeToTheKeyList() throws Exception { + CreateIndex beforeOn = (CreateIndex) roundTrip("CREATE INDEX i USING HASH ON t1 (a)", true); + assertEquals("HASH", beforeOn.getIndex().getUsing()); + assertTrue(beforeOn.isIndexTypeBeforeOn()); + assertNull(beforeOn.getIndex().getSpecOptions()); + + CreateTable table = (CreateTable) roundTrip("CREATE TABLE t20 (a INT, b VARCHAR (100), " + + "KEY idx_a USING BTREE (a) KEY_BLOCK_SIZE = 4 COMMENT 'idx', " + + "UNIQUE INDEX uk_b (b (20)) USING HASH)", true); + Index key = table.getIndexes().get(0); + assertEquals("BTREE", key.getUsing()); + assertOptions(key, "KEY_BLOCK_SIZE 4 =", "COMMENT 'idx'"); + assertEquals(List.of("KEY_BLOCK_SIZE", "=", "4", "COMMENT", "'idx'"), key.getIndexSpec()); + KeyConstraint unique = (KeyConstraint) table.getTableConstraints().get(0); + assertNull(unique.getUsing()); + assertOptions(unique, "INDEX_TYPE HASH"); + assertEquals(List.of("USING", "HASH"), unique.getIndexSpec()); + } + + @ParameterizedTest + @ValueSource(strings = {"ALTER TABLE t1 ADD CONSTRAINT pk_t1 PRIMARY KEY USING BTREE (id)", + "ALTER TABLE t1 ADD PRIMARY KEY USING BTREE (id)", + "ALTER TABLE t1 ADD CONSTRAINT uk UNIQUE KEY uk_idx USING BTREE (id)", + "CREATE TABLE t1 (id INT, PRIMARY KEY USING BTREE (id) COMMENT 'pk')", + "CREATE TABLE t1 (id INT, CONSTRAINT pk_t1 PRIMARY KEY USING HASH (id))"}) + void keysAcceptTheIndexTypeBeforeTheKeyList(String sql) throws Exception { + KeyConstraint key = keyConstraint(roundTrip(sql, true)); + assertEquals(sql.contains("HASH") ? "HASH" : "BTREE", key.getUsing()); + assertEquals(List.of("id"), key.getColumnsNames()); + assertFalse(key.getSpecOptions().stream() + .anyMatch(option -> option.getKind() == Kind.INDEX_TYPE)); + } + + @Test + void defaultModeAcceptsTheIndexTypeBeforeNamedConstraintKeys() throws Exception { + for (String sql : List.of( + "ALTER TABLE t1 ADD CONSTRAINT pk_t1 PRIMARY KEY USING BTREE (id)", + "ALTER TABLE t1 ADD CONSTRAINT uk UNIQUE KEY USING BTREE (id)")) { + Statement statement = roundTrip(sql, false); + KeyConstraint key = keyConstraint(statement); + assertEquals("BTREE", key.getUsing()); + key.setUsing("HASH"); + assertRendered(statement, sql.replace("BTREE", "HASH")); + } + } + + @ParameterizedTest + @ValueSource(strings = {"CREATE INDEX ix ON t (a) %s", + "CREATE TABLE t (a INT, KEY ix (a) %s)", + "CREATE TABLE t (a INT, UNIQUE KEY ix (a) %s)", + "ALTER TABLE t ADD INDEX ix (a) %s", "ALTER TABLE t ADD UNIQUE KEY ix (a) %s", + "ALTER TABLE t ADD CONSTRAINT pk PRIMARY KEY (a) %s"}) + void everyDefinitionSharesTheSameOptionModel(String template) throws Exception { + IndexOptionSource source = definition(roundTrip(String.format(template, OPTIONS), true)); + assertOptions(source, "KEY_BLOCK_SIZE 8 =", "INDEX_TYPE HASH", "PARSER ngram", + "COMMENT 'c'", "VISIBILITY false", "ENGINE_ATTRIBUTE '{}'", + "SECONDARY_ENGINE_ATTRIBUTE '{}' ="); + assertEquals(List.of("KEY_BLOCK_SIZE", "=", "8", "USING", "HASH", "WITH", "PARSER", + "ngram", "COMMENT", "'c'", "INVISIBLE", "ENGINE_ATTRIBUTE", "'{}'", + "SECONDARY_ENGINE_ATTRIBUTE", "=", "'{}'"), source.getIndexSpec()); + } + + @Test + void alterKeyBlockSizeWithoutEqualsKeepsItsSeparator() throws Exception { + // ALTER used to render this option as KEY_BLOCK_SIZE8 + Alter alter = (Alter) roundTrip("ALTER TABLE t ADD INDEX i (a) KEY_BLOCK_SIZE 8", true); + assertOptions(alter.getAlterExpressions().get(0).getIndex(), "KEY_BLOCK_SIZE 8"); + } + + @Test + void commentsAreStringValuesThatDecodeTheStoredText() throws Exception { + String[][] comments = {{"'it''s'", "it's"}, {"'it\\'s'", "it's"}, {"\"dq\"", "dq"}, + {"'a\\\\b'", "a\\b"}, {"'인덱스 설명'", "인덱스 설명"}}; + for (String template : List.of("CREATE INDEX ix ON t (a) COMMENT %s", + "CREATE TABLE t (a INT, KEY ix (a) COMMENT %s)", + "ALTER TABLE t ADD INDEX ix (a) COMMENT %s")) { + for (String[] comment : comments) { + String sql = String.format(template, comment[0]); + IndexSpecOption option = + definition(roundTrip(sql, true)).getSpecOptions().get(0); + assertEquals(Kind.COMMENT, option.getKind()); + assertEquals(comment[0], option.getValue(), sql); + assertEquals(comment[0], option.getComment().toString(), sql); + assertEquals(comment[1], option.getComment().getNotExcapedValue(), sql); + } + } + + Alter alter = (Alter) roundTrip("ALTER TABLE t ADD INDEX ix (a) COMMENT 'it\\'s'", true); + IndexSpecOption comment = alter.getAlterExpressions().get(0).getIndex() + .getSpecOptions().get(0); + assertEquals(StringValue.Escaping.MYSQL_BACKSLASH, comment.getComment().getEscaping()); + comment.setValue("'don\\'t'"); + assertEquals("don't", comment.getComment().getNotExcapedValue()); + comment.setComment(new StringValue("'changed'")); + assertRendered(alter, "ALTER TABLE t ADD INDEX ix (a) COMMENT 'changed'"); + assertEquals(List.of("COMMENT", "'changed'"), + alter.getAlterExpressions().get(0).getIndex().getIndexSpec()); + } + + @Test + void typedEditsReachBothRenderers() throws Exception { + Alter alter = (Alter) roundTrip( + "ALTER TABLE t1 ADD INDEX i3 (a) USING BTREE COMMENT 'c' INVISIBLE", true); + List options = alter.getAlterExpressions().get(0).getIndex() + .getSpecOptions(); + options.get(0).setValue("HASH"); + options.get(1).setValue("'changed'"); + options.get(2).setVisible(true); + options.add(0, IndexSpecOption.of(Kind.KEY_BLOCK_SIZE, "16", true)); + assertRendered(alter, "ALTER TABLE t1 ADD INDEX i3 (a) KEY_BLOCK_SIZE = 16 USING HASH " + + "COMMENT 'changed' VISIBLE"); + + CreateIndex create = (CreateIndex) roundTrip( + "CREATE INDEX i ON t (a) LOCK EXCLUSIVE ALGORITHM DEFAULT", true); + create.getAlgorithmOrLockOptions().get(0).setValue("SHARED"); + create.getAlgorithmOrLockOptions().get(1).setUseEquals(true); + create.getIndex().getIndexOptions().addSpecOptions(IndexSpecOption.of(Kind.COMMENT, "'x'")); + assertRendered(create, + "CREATE INDEX i ON t (a) COMMENT 'x' LOCK SHARED ALGORITHM = DEFAULT"); + assertEquals(List.of("COMMENT", "'x'", "LOCK", "SHARED", "ALGORITHM", "=", "DEFAULT"), + create.getTailParameters()); + } + + @Test + void legacyAccessorsAreSnapshotsAndSettersReplaceWithOpaqueOptions() throws Exception { + CreateIndex create = (CreateIndex) roundTrip( + "CREATE INDEX i ON t (a) parallel compress nologging", false); + assertEquals(List.of("parallel", "compress", "nologging"), create.getTailParameters()); + assertTrue(create.getIndex().getSpecOptions().stream() + .allMatch(option -> option.getKind() == Kind.OTHER)); + assertThrows(UnsupportedOperationException.class, + () -> create.getTailParameters().add("x")); + assertThrows(UnsupportedOperationException.class, + () -> create.getIndex().getIndexSpec().add("x")); + + create.addAlgorithmOrLockOptions(AlgorithmOrLockOption.lock("NONE")); + List tail = new ArrayList<>(List.of("KEY_BLOCK_SIZE", "=", "4")); + create.setTailParameters(tail); + tail.clear(); + assertTrue(create.getAlgorithmOrLockOptions().isEmpty()); + assertEquals(Kind.OTHER, create.getIndex().getSpecOptions().get(0).getKind()); + assertRendered(create, "CREATE INDEX i ON t (a) KEY_BLOCK_SIZE = 4"); + + Index index = new Index().withName("i").withColumnsNames(List.of("a")); + assertNull(index.getIndexSpec()); + index.setIndexSpec(List.of("USING", "BTREE")); + assertEquals(List.of("USING", "BTREE"), index.getIndexSpec()); + assertEquals("i (a) USING BTREE", index.toString()); + index.setIndexSpec(List.of()); + assertTrue(index.getSpecOptions().isEmpty()); + index.setIndexSpec(null); + assertNull(index.getSpecOptions()); + assertEquals(List.of(), new CreateIndex().getTailParameters()); + } + + @Test + void typedOptionsRejectPayloadsOfOtherKinds() { + IndexSpecOption comment = IndexSpecOption.comment(new StringValue("'c'")); + assertEquals("'c'", comment.getValue()); + assertThrows(IllegalStateException.class, + () -> IndexSpecOption.of(Kind.PARSER, "ngram").setComment(new StringValue("'x'"))); + assertNull(IndexSpecOption.of(Kind.PARSER, "ngram").getComment()); + assertThrows(NullPointerException.class, () -> comment.setComment(null)); + assertThrows(IllegalStateException.class, () -> comment.setTokens(List.of("x"))); + assertThrows(IllegalStateException.class, () -> comment.setUseEquals(true)); + assertThrows(IllegalStateException.class, () -> comment.setVisible(true)); + assertNull(comment.getVisible()); + IndexSpecOption visible = IndexSpecOption.visibility(true); + assertThrows(IllegalStateException.class, () -> visible.setValue("x")); + assertEquals(List.of("VISIBLE"), visible.getTokens()); + assertThrows(IllegalStateException.class, + () -> IndexSpecOption.of(Kind.OTHER, "x")); + assertThrows(NullPointerException.class, () -> IndexSpecOption.of(Kind.PARSER, null)); + + List tokens = new ArrayList<>(List.of("ON", "[PRIMARY]")); + IndexSpecOption raw = IndexSpecOption.raw(tokens); + tokens.set(1, "ignored"); + assertEquals("ON [PRIMARY]", raw.toString()); + assertThrows(UnsupportedOperationException.class, () -> raw.getTokens().add("x")); + assertThrows(IllegalStateException.class, () -> raw.setValue("x")); + raw.setTokens(List.of("ON", "fg")); + assertEquals(List.of("ON", "fg"), raw.getTokens()); + } + + @Test + void dropIndexAlgorithmAndLockAreTyped() throws Exception { + Drop drop = (Drop) roundTrip("DROP INDEX idx ON t1 ALGORITHM = INPLACE LOCK = NONE", true); + assertEquals("t1", drop.getTable().getName()); + assertAlgorithmOrLock(drop.getAlgorithmOrLockOptions().get(0), + AlgorithmOrLockOption.Kind.ALGORITHM, "INPLACE"); + assertAlgorithmOrLock(drop.getAlgorithmOrLockOptions().get(1), + AlgorithmOrLockOption.Kind.LOCK, "NONE"); + assertEquals(List.of("ON", "t1", "ALGORITHM", "=", "INPLACE", "LOCK", "=", "NONE"), + drop.getParameters()); + + drop.getAlgorithmOrLockOptions().remove(1); + drop.getAlgorithmOrLockOptions().get(0).setValue("COPY"); + drop.setBehavior(CascadeBehavior.RESTRICT); + assertRendered(drop, "DROP INDEX idx ON t1 ALGORITHM = COPY RESTRICT"); + assertEquals(List.of("ON", "t1", "ALGORITHM", "=", "COPY", "RESTRICT"), + drop.getParameters()); + + drop.addParameters("legacy"); + drop.setAlgorithmOrLockOptions(List.of(AlgorithmOrLockOption.lock("SHARED"))); + assertRendered(drop, "DROP INDEX idx ON t1 legacy LOCK = SHARED RESTRICT"); + drop.setAlgorithmOrLockOptions(List.of(AlgorithmOrLockOption.lock("SHARED")), 0); + assertEquals(List.of("ON", "t1", "LOCK", "=", "SHARED", "legacy", "RESTRICT"), + drop.getParameters()); + assertThrows(IllegalArgumentException.class, + () -> drop.setAlgorithmOrLockOptions(List.of(), 2)); + + drop.setParameters(List.of("CASCADE")); + assertTrue(drop.getAlgorithmOrLockOptions().isEmpty()); + assertNull(drop.getBehavior()); + assertNull(drop.getTable()); + assertEquals("DROP INDEX idx CASCADE", drop.toString()); + + Drop single = (Drop) roundTrip("DROP INDEX idx ON t1 ALGORITHM = INPLACE", false); + assertEquals(1, single.getAlgorithmOrLockOptions().size()); + } + + @Test + void replacingTheCreateIndexDefinitionKeepsOrInheritsItsOptions() throws Exception { + CreateIndex create = (CreateIndex) roundTrip( + "CREATE INDEX i ON t (a) KEY_BLOCK_SIZE = 8", true); + Index removed = create.getIndex(); + IndexSpecOption keyBlockSize = removed.getSpecOptions().get(0); + create.setIndex(null); + assertEquals(List.of("KEY_BLOCK_SIZE", "=", "8"), create.getTailParameters()); + + create.setIndex(new Index().withName("j").withColumnsNames(List.of("b"))); + assertSame(keyBlockSize, create.getIndex().getSpecOptions().get(0)); + assertNotSame(removed.getSpecOptions(), create.getIndex().getSpecOptions()); + assertRendered(create, "CREATE INDEX j ON t (b) KEY_BLOCK_SIZE = 8"); + + create.setIndex(new Index().withName("k").withColumnsNames(List.of("c")) + .withSpecOptions(List.of(IndexSpecOption.visibility(false)))); + assertRendered(create, "CREATE INDEX k ON t (c) INVISIBLE"); + } + + @Test + void postgreSqlIndexOptionsKeepTheirProperties() throws Exception { + String sql = "CREATE UNIQUE INDEX CONCURRENTLY ix ON ONLY t USING btree (id) " + + "INCLUDE (v) NULLS NOT DISTINCT WITH (fillfactor = 80) TABLESPACE fast " + + "WHERE active"; + CreateIndex create = (CreateIndex) CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(Dialect.POSTGRESQL)); + assertEquals(sql, create.toString()); + assertEquals("btree", create.getIndex().getUsing()); + assertEquals(List.of("v"), create.getIncludeColumns()); + assertEquals("fast", create.getTableSpace()); + assertNull(create.getIndex().getSpecOptions()); + assertTrue(create.getTailParameters().isEmpty()); + + Alter alter = (Alter) CCJSqlParserUtil.parse("ALTER TABLE t ADD CONSTRAINT uk UNIQUE (id) " + + "INCLUDE (v) WITH (fillfactor = 70) USING INDEX TABLESPACE fast", + parser -> parser.withDialect(Dialect.POSTGRESQL)); + KeyConstraint key = alter.getAlterExpressions().get(0).getKeyConstraint(); + assertEquals("fast", key.getTableSpace()); + assertEquals(1, key.getStorageParameters().size()); + assertTrue(key.getSpecOptions().isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = {"ALTER TABLE t ADD PRIMARY KEY USING (id)", + "ALTER TABLE t ADD PRIMARY KEY USING BTREE USING HASH (id)", + "ALTER TABLE t ADD INDEX i (a) KEY_BLOCK_SIZE", + "ALTER TABLE t ADD INDEX i (a) WITH PARSER", + "ALTER TABLE t ADD INDEX i (a) COMMENT", + "CREATE INDEX i ON t (a) LOCK = NONE ALGORITHM"}) + void rejectsIncompleteOptions(String sql) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(Dialect.MYSQL))); + } + + private static void assertOptions(IndexOptionSource source, String... expected) { + List actual = new ArrayList<>(); + for (IndexSpecOption option : source.getSpecOptions()) { + Object payload = option.getKind() == Kind.VISIBILITY ? option.getVisible() + : option.getValue(); + actual.add(option.getKind() + " " + payload + (option.isUseEquals() ? " =" : "")); + } + assertEquals(List.of(expected), actual); + } + + private static void assertAlgorithmOrLock(AlgorithmOrLockOption option, + AlgorithmOrLockOption.Kind kind, String value) { + assertEquals(kind, option.getKind()); + assertEquals(value, option.getValue()); + assertTrue(option.isUseEquals()); + } + + private static IndexOptionSource definition(Statement statement) { + if (statement instanceof CreateIndex) { + return ((CreateIndex) statement).getIndex(); + } + if (statement instanceof CreateTable) { + CreateTable table = (CreateTable) statement; + return (IndexOptionSource) (table.getIndexes().isEmpty() + ? table.getTableConstraints().get(0) + : table.getIndexes().get(0)); + } + AlterExpression action = ((Alter) statement).getAlterExpressions().get(0); + return (IndexOptionSource) (action.getConstraint() != null ? action.getConstraint() + : action.getIndex()); + } + + private static KeyConstraint keyConstraint(Statement statement) { + return (KeyConstraint) definition(statement); + } + + private static Statement roundTrip(String sql, boolean mysql) throws Exception { + Consumer dialect = mysql ? parser -> parser.withDialect(Dialect.MYSQL) + : parser -> { + }; + Statement statement = CCJSqlParserUtil.parse(sql, dialect); + assertEquals(sql, statement.toString()); + StringBuilder deparsed = new StringBuilder(); + statement.accept(new StatementDeParser(deparsed)); + assertEquals(sql, deparsed.toString()); + assertEquals(sql, CCJSqlParserUtil.parse(sql, dialect).toString()); + return statement; + } + + private static void assertRendered(Statement statement, String expected) throws Exception { + assertEquals(expected, statement.toString()); + StringBuilder deparsed = new StringBuilder(); + statement.accept(new StatementDeParser(deparsed)); + assertEquals(expected, deparsed.toString()); + assertEquals(expected, CCJSqlParserUtil.parse(expected, + parser -> parser.withDialect(Dialect.MYSQL)).toString()); + } +}