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 2210dbe9d..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() @@ -15502,11 +15518,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 +15628,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 +15778,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 +15788,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()); } ) } @@ -17844,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: { @@ -17905,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(); @@ -17913,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; } { @@ -17924,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= | ( @@ -17945,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="=" @@ -17970,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 { @@ -17985,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); @@ -17992,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); @@ -18002,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 901608d62..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 ----------------- @@ -1718,6 +1782,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(); + } +} 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); + } + }