From 74ce2dfc678579e0b0e2cb5ab9447130ac1484ac Mon Sep 17 00:00:00 2001 From: minleejae Date: Wed, 14 Oct 2026 12:58:58 +0900 Subject: [PATCH 1/3] fix: parse PostgreSQL ALTER DATABASE and share typed database options Since #2700, every PostgreSQL ALTER DATABASE fails to parse, while 5.4 returned an UnsupportedStatement. Model RENAME TO, OWNER TO, SET TABLESPACE, REFRESH COLLATION VERSION, the [WITH] options and SET/RESET of configuration parameters. Outside the MySQL dialect, other ALTER DATABASE forms are unsupported statements again, and ALTER SCHEMA with MySQL options no longer requires that dialect. Extract the SET/RESET state of AlterRole into ConfigurationParameterChange, which ALTER DATABASE shares; AlterRole keeps its accessors. Type each PostgreSQL CREATE DATABASE option, accept the CHAR SET and CHARSET spellings, keep CreateDatabase options in one list and drop the empty raw option that added a trailing space. Signed-off-by: minleejae --- .../sf/jsqlparser/parser/feature/Feature.java | 6 + .../ConfigurationParameterChange.java | 174 +++++++++++ .../alter/database/AlterDatabase.java | 134 ++++++++- .../create/database/CreateDatabase.java | 132 ++++++--- .../create/database/DatabaseOption.java | 11 +- .../jsqlparser/statement/role/AlterRole.java | 143 ++++++--- .../util/deparser/StatementDeParser.java | 3 +- .../validation/feature/MariaDbVersion.java | 4 + .../util/validation/feature/MySqlVersion.java | 4 + .../validation/feature/PostgresqlVersion.java | 4 + .../validator/StatementValidator.java | 11 + .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 275 +++++++++++++++--- src/site/sphinx/usage.rst | 54 ++++ .../ConfigurationParameterChangeTest.java | 166 +++++++++++ ...tatementFeatureVisitorMetadataDdlTest.java | 2 + .../alter/database/AlterDatabaseTest.java | 187 ++++++++++++ .../create/database/CreateDatabaseTest.java | 184 ++++++++++++ .../create/schema/CreateSchemaTest.java | 17 ++ 18 files changed, 1394 insertions(+), 117 deletions(-) create mode 100644 src/main/java/net/sf/jsqlparser/statement/ConfigurationParameterChange.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/ConfigurationParameterChangeTest.java 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..65ee61461 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -509,6 +509,12 @@ public enum Feature { * @see CreateDatabase */ createDatabase, + /** + * SQL "ALTER DATABASE" statement is allowed + * + * @see net.sf.jsqlparser.statement.alter.database.AlterDatabase + */ + alterDatabase, /** * SQL "CREATE SCHEMA" statement is allowed * diff --git a/src/main/java/net/sf/jsqlparser/statement/ConfigurationParameterChange.java b/src/main/java/net/sf/jsqlparser/statement/ConfigurationParameterChange.java new file mode 100644 index 000000000..868c7f6e8 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/ConfigurationParameterChange.java @@ -0,0 +1,174 @@ +/*- + * #%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; + +import java.io.Serializable; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; + +/** + * A PostgreSQL {@code SET} or {@code RESET} of a configuration parameter, shared by statements that + * store parameter defaults, such as {@code ALTER ROLE} and {@code ALTER DATABASE}: + * + *
+ * SET parameter { TO | = } { value [, ...] | DEFAULT }
+ * SET parameter FROM CURRENT
+ * RESET { parameter | ALL }
+ * 
+ * + * Parameter names keep their SQL spelling, including qualifiers and identifier quotes. + * {@code RESET ALL} uses the parameter name {@code ALL}. + */ +public class ConfigurationParameterChange implements Serializable { + + public enum Action { + SET, RESET + } + + /** What a {@code SET} assigns. Only the selected kind is rendered. */ + public enum ValueKind { + /** The explicit values of {@link #getValues()}. */ + VALUES, + /** {@code TO DEFAULT} or {@code = DEFAULT}. */ + DEFAULT, + /** {@code FROM CURRENT}. */ + FROM_CURRENT + } + + private Action action = Action.SET; + private String parameter; + private ValueKind valueKind = ValueKind.VALUES; + private boolean useEquals; + private ExpressionList values; + + public ConfigurationParameterChange() {} + + public ConfigurationParameterChange(Action action, String parameter) { + this.action = action; + this.parameter = parameter; + } + + public Action getAction() { + return action; + } + + public void setAction(Action action) { + this.action = action; + } + + public String getParameter() { + return parameter; + } + + public void setParameter(String parameter) { + this.parameter = parameter; + } + + public ValueKind getValueKind() { + return valueKind; + } + + /** + * Selects what a {@code SET} assigns. The values are retained when another kind is selected. + * + * @param valueKind the assigned value kind + */ + public void setValueKind(ValueKind valueKind) { + this.valueKind = valueKind; + } + + /** + * Whether {@code =} rather than {@code TO} precedes the value. + * + * @return {@code true} for {@code =} + */ + public boolean isUseEquals() { + return useEquals; + } + + public void setUseEquals(boolean useEquals) { + this.useEquals = useEquals; + } + + /** + * The values assigned by {@link ValueKind#VALUES}, as a mutable list. + * + * @return the values, or {@code null} if none were set + */ + public ExpressionList getValues() { + return values; + } + + public void setValues(ExpressionList values) { + this.values = values; + } + + public ConfigurationParameterChange withAction(Action action) { + setAction(action); + return this; + } + + public ConfigurationParameterChange withParameter(String parameter) { + setParameter(parameter); + return this; + } + + public ConfigurationParameterChange withValueKind(ValueKind valueKind) { + setValueKind(valueKind); + return this; + } + + public ConfigurationParameterChange withUseEquals(boolean useEquals) { + setUseEquals(useEquals); + return this; + } + + public ConfigurationParameterChange withValues(ExpressionList values) { + setValues(values); + return this; + } + + /** + * Appends this clause to a builder. + * + * @param builder the target + * @param valueRenderer appends each value, for example through an expression deparser + * @return the builder + */ + public StringBuilder appendTo(StringBuilder builder, Consumer valueRenderer) { + if (action == Action.RESET) { + return builder.append("RESET ").append(parameter); + } + builder.append("SET ").append(parameter); + if (valueKind == ValueKind.FROM_CURRENT) { + return builder.append(" FROM CURRENT"); + } + builder.append(useEquals ? " = " : " TO "); + if (valueKind == ValueKind.DEFAULT) { + return builder.append("DEFAULT"); + } + if (values != null) { + for (int i = 0; i < values.size(); i++) { + if (i > 0) { + builder.append(", "); + } + valueRenderer.accept(values.get(i)); + } + } + return builder; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/alter/database/AlterDatabase.java b/src/main/java/net/sf/jsqlparser/statement/alter/database/AlterDatabase.java index d0c7a79ea..540d1fb1c 100644 --- a/src/main/java/net/sf/jsqlparser/statement/alter/database/AlterDatabase.java +++ b/src/main/java/net/sf/jsqlparser/statement/alter/database/AlterDatabase.java @@ -12,15 +12,44 @@ import java.util.ArrayList; import java.util.List; import java.util.Optional; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.statement.ConfigurationParameterChange; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.StatementVisitor; import net.sf.jsqlparser.statement.create.database.DatabaseOption; -/** MySQL ALTER DATABASE (or SCHEMA); a missing name selects the current database. */ +/** + * MySQL or PostgreSQL {@code ALTER DATABASE}. MySQL's {@code ALTER SCHEMA} synonym only changes + * options, and a missing name selects the current database. The options use the same + * {@link DatabaseOption} model as {@code CREATE DATABASE}. Names retain SQL identifier quoting. + */ public class AlterDatabase implements Statement { + + public enum Action { + /** MySQL options or PostgreSQL {@code [WITH] option ...}, see {@link #getOptions()}. */ + OPTIONS, + /** {@code RENAME TO}, see {@link #getNewName()}. */ + RENAME, + /** {@code OWNER TO}, see {@link #getOwner()}. */ + OWNER, + /** {@code SET TABLESPACE}, see {@link #getTablespace()}. */ + SET_TABLESPACE, + /** {@code REFRESH COLLATION VERSION}. */ + REFRESH_COLLATION_VERSION, + /** {@code SET} or {@code RESET}, see {@link #getParameterChange()}. */ + CONFIGURATION_PARAMETER + } + private String databaseName; private boolean useSchemaKeyword; + private Action action = Action.OPTIONS; + private boolean useWith; private final List options = new ArrayList<>(); + private String newName; + private String owner; + private String tablespace; + private ConfigurationParameterChange parameterChange; public String getDatabaseName() { return databaseName; @@ -38,6 +67,37 @@ public void setUseSchemaKeyword(boolean useSchemaKeyword) { this.useSchemaKeyword = useSchemaKeyword; } + /** + * The rendered alteration. Payloads of other actions are retained for a later switch back. + * + * @return the action, {@link Action#OPTIONS} unless changed + */ + public Action getAction() { + return action; + } + + public void setAction(Action action) { + this.action = action; + } + + /** + * Whether PostgreSQL's optional {@code WITH} precedes the options. + * + * @return {@code true} if {@code WITH} is rendered + */ + public boolean isUseWith() { + return useWith; + } + + public void setUseWith(boolean useWith) { + this.useWith = useWith; + } + + /** + * The options of {@link Action#OPTIONS}, as a mutable list. + * + * @return the options in source order + */ public List getOptions() { return options; } @@ -46,13 +106,81 @@ public Optional getOption(DatabaseOption.Kind kind) { return options.stream().filter(option -> option.getKind() == kind).findFirst(); } + public String getNewName() { + return newName; + } + + public void setNewName(String newName) { + this.newName = newName; + } + + /** + * The new owner, a role name or {@code CURRENT_USER}, {@code CURRENT_ROLE} or + * {@code SESSION_USER}. + * + * @return the owner + */ + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public String getTablespace() { + return tablespace; + } + + public void setTablespace(String tablespace) { + this.tablespace = tablespace; + } + + public ConfigurationParameterChange getParameterChange() { + return parameterChange; + } + + public void setParameterChange(ConfigurationParameterChange parameterChange) { + this.parameterChange = parameterChange; + } + public StringBuilder appendTo(StringBuilder builder) { + return appendTo(builder, builder::append); + } + + /** + * Appends this statement to a builder. + * + * @param builder the target + * @param valueRenderer appends each configuration parameter value + * @return the builder + */ + public StringBuilder appendTo(StringBuilder builder, Consumer valueRenderer) { builder.append(useSchemaKeyword ? "ALTER SCHEMA" : "ALTER DATABASE"); if (databaseName != null) { builder.append(' ').append(databaseName); } - options.forEach(option -> builder.append(' ').append(option)); - return builder; + switch (action) { + case RENAME: + return builder.append(" RENAME TO ").append(newName); + case OWNER: + return builder.append(" OWNER TO ").append(owner); + case SET_TABLESPACE: + return builder.append(" SET TABLESPACE ").append(tablespace); + case REFRESH_COLLATION_VERSION: + return builder.append(" REFRESH COLLATION VERSION"); + case CONFIGURATION_PARAMETER: + if (parameterChange == null) { + throw new IllegalStateException("Expected a configuration parameter change"); + } + return parameterChange.appendTo(builder.append(' '), valueRenderer); + default: + if (useWith) { + builder.append(" WITH"); + } + options.forEach(option -> builder.append(' ').append(option)); + return builder; + } } @Override diff --git a/src/main/java/net/sf/jsqlparser/statement/create/database/CreateDatabase.java b/src/main/java/net/sf/jsqlparser/statement/create/database/CreateDatabase.java index e1fb9ea05..76a447fa2 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/database/CreateDatabase.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/database/CreateDatabase.java @@ -10,25 +10,26 @@ package net.sf.jsqlparser.statement.create.database; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Optional; -import java.util.stream.Collectors; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.StatementVisitor; /** - * A {@code CREATE DATABASE} statement. Modelled after MySQL, where the statement takes an optional - * {@code IF NOT EXISTS} and vendor-specific options, which are captured verbatim. + * A {@code CREATE DATABASE} statement with an optional {@code IF NOT EXISTS} and options. MySQL and + * PostgreSQL options are typed {@link DatabaseOption}s; other option text is kept as a raw + * {@link DatabaseOption.Kind#OTHER} option. */ public class CreateDatabase implements Statement { private String databaseName; private boolean hasIfNotExists = false; - private List databaseOptions = null; - private List options; + private boolean useWith; + private final List options = new ArrayList<>(); @Override public T accept(StatementVisitor statementVisitor, S context) { @@ -59,35 +60,79 @@ public CreateDatabase setIfNotExists(boolean hasIfNotExists) { } /** - * The vendor-specific options of the database, captured as raw tokens. + * Whether PostgreSQL's optional {@code WITH} precedes the options. * - * @return the list of option tokens, {@code null} when no options were given + * @return {@code true} if {@code WITH} is rendered + */ + public boolean isUseWith() { + return useWith; + } + + public CreateDatabase setUseWith(boolean useWith) { + this.useWith = useWith; + return this; + } + + /** + * The options in their legacy flat-token form, including a leading {@code WITH}. This is an + * unmodifiable snapshot of {@link #getOptions()}; edit the options or use + * {@link #setDatabaseOptions(List)} instead. + * + * @return the option tokens, {@code null} when no options were given */ public List getDatabaseOptions() { - return databaseOptions; + if (options.isEmpty() && !useWith) { + return null; + } + List tokens = new ArrayList<>(); + if (useWith) { + tokens.add("WITH"); + } + options.forEach(option -> tokens.addAll(option.getTokens())); + return Collections.unmodifiableList(tokens); } + /** + * Replaces all options, including {@code WITH}, with one raw option of these tokens. The tokens + * are not parsed. + * + * @param databaseOptions the option tokens, or {@code null} to remove all options + * @return this statement + */ public CreateDatabase setDatabaseOptions(List databaseOptions) { - this.databaseOptions = databaseOptions; - this.options = null; + options.clear(); + useWith = false; + if (databaseOptions != null && !databaseOptions.isEmpty()) { + options.add(DatabaseOption.raw(databaseOptions)); + } return this; } + /** + * The options in source order, as a mutable list. + * + * @return the options, empty if there are none + */ public List getOptions() { return options; } + /** + * Replaces the options with a copy of the given list. + * + * @param options the options, or {@code null} to remove all options + * @return this statement + */ public CreateDatabase setOptions(List options) { - this.options = options; - this.databaseOptions = options == null ? null - : options.stream().flatMap(option -> option.getTokens().stream()) - .collect(Collectors.toList()); + List copy = + options == null ? Collections.emptyList() : new ArrayList<>(options); + this.options.clear(); + this.options.addAll(copy); return this; } public Optional getOption(DatabaseOption.Kind kind) { - return Optional.ofNullable(options).orElseGet(Collections::emptyList).stream() - .filter(option -> option.getKind() == kind).findFirst(); + return options.stream().filter(option -> option.getKind() == kind).findFirst(); } public CreateDatabase withDatabaseName(String databaseName) { @@ -98,6 +143,10 @@ public CreateDatabase withIfNotExists(boolean hasIfNotExists) { return this.setIfNotExists(hasIfNotExists); } + public CreateDatabase withUseWith(boolean useWith) { + return this.setUseWith(useWith); + } + public CreateDatabase withDatabaseOptions(List databaseOptions) { return this.setDatabaseOptions(databaseOptions); } @@ -107,40 +156,51 @@ public CreateDatabase withOptions(List options) { } public CreateDatabase addOptions(DatabaseOption... options) { - List collection = - Optional.ofNullable(getOptions()).orElseGet(ArrayList::new); - Collections.addAll(collection, options); - return withOptions(collection); + Collections.addAll(this.options, options); + return this; } + /** + * Appends one raw option of these tokens, keeping the existing options. + * + * @param databaseOptions the option tokens + * @return this statement + */ public CreateDatabase addDatabaseOptions(String... databaseOptions) { - List collection = - Optional.ofNullable(getDatabaseOptions()).orElseGet(ArrayList::new); - Collections.addAll(collection, databaseOptions); - return this.withDatabaseOptions(collection); + return addDatabaseOptions(Arrays.asList(databaseOptions)); } + /** + * Appends one raw option of these tokens, keeping the existing options. + * + * @param databaseOptions the option tokens + * @return this statement + */ public CreateDatabase addDatabaseOptions(Collection databaseOptions) { - List collection = - Optional.ofNullable(getDatabaseOptions()).orElseGet(ArrayList::new); - collection.addAll(databaseOptions); - return this.withDatabaseOptions(collection); + if (!databaseOptions.isEmpty()) { + options.add(DatabaseOption.raw(new ArrayList<>(databaseOptions))); + } + return this; } @Override public String toString() { - String sql = "CREATE DATABASE"; + StringBuilder sql = new StringBuilder("CREATE DATABASE"); if (hasIfNotExists) { - sql += " IF NOT EXISTS"; + sql.append(" IF NOT EXISTS"); } if (databaseName != null) { - sql += " " + databaseName; + sql.append(' ').append(databaseName); + } + if (useWith) { + sql.append(" WITH"); } - if (options != null && !options.isEmpty()) { - sql += " " + options.stream().map(Object::toString).collect(Collectors.joining(" ")); - } else if (databaseOptions != null && !databaseOptions.isEmpty()) { - sql += " " + String.join(" ", databaseOptions); + for (DatabaseOption option : options) { + String text = option.toString(); + if (!text.isEmpty()) { + sql.append(' ').append(text); + } } - return sql; + return sql.toString(); } } diff --git a/src/main/java/net/sf/jsqlparser/statement/create/database/DatabaseOption.java b/src/main/java/net/sf/jsqlparser/statement/create/database/DatabaseOption.java index 497aa91d7..4a6c2b761 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/database/DatabaseOption.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/database/DatabaseOption.java @@ -16,12 +16,19 @@ /** * A structured option shared by MySQL {@code CREATE/ALTER DATABASE} and - * {@code CREATE/ALTER SCHEMA}. + * {@code CREATE/ALTER SCHEMA}, and by PostgreSQL {@code CREATE/ALTER DATABASE}. The name keeps the + * written spelling, for example {@code CHARSET} for {@link Kind#CHARACTER_SET}. Values keep their + * SQL text, including string quotes and signs. Unrecognized option text is an {@link Kind#OTHER} + * option with raw tokens. */ public class DatabaseOption implements Serializable { + /** + * The option. MySQL uses {@code CHARACTER_SET} to {@code READ_ONLY}, PostgreSQL uses + * {@code OWNER} to {@code OID}, and {@code OTHER} holds raw tokens. + */ public enum Kind { - CHARACTER_SET, COLLATE, ENCRYPTION, READ_ONLY, OTHER + CHARACTER_SET, COLLATE, ENCRYPTION, READ_ONLY, OWNER, TEMPLATE, ENCODING, STRATEGY, LOCALE, LC_COLLATE, LC_CTYPE, BUILTIN_LOCALE, ICU_LOCALE, ICU_RULES, LOCALE_PROVIDER, COLLATION_VERSION, TABLESPACE, ALLOW_CONNECTIONS, CONNECTION_LIMIT, IS_TEMPLATE, OID, OTHER } private Kind kind = Kind.OTHER; diff --git a/src/main/java/net/sf/jsqlparser/statement/role/AlterRole.java b/src/main/java/net/sf/jsqlparser/statement/role/AlterRole.java index e2e472b7a..27b452782 100644 --- a/src/main/java/net/sf/jsqlparser/statement/role/AlterRole.java +++ b/src/main/java/net/sf/jsqlparser/statement/role/AlterRole.java @@ -14,9 +14,16 @@ import java.util.function.Consumer; import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.statement.ConfigurationParameterChange; +import net.sf.jsqlparser.statement.ConfigurationParameterChange.ValueKind; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.StatementVisitor; +/** + * PostgreSQL {@code ALTER ROLE}, {@code ALTER USER} or {@code ALTER GROUP}. The {@code SET} and + * {@code RESET} actions use the shared {@link ConfigurationParameterChange}; the parameter + * accessors of this class read and edit that change. + */ public class AlterRole implements Statement { public enum Action { OPTIONS, RENAME, SET, RESET, ADD_USER, DROP_USER @@ -28,11 +35,7 @@ public enum Action { private boolean useWith; private String newName; private String database; - private String parameter; - private boolean useEquals; - private boolean fromCurrent; - private boolean useDefault; - private ExpressionList values; + private ConfigurationParameterChange parameterChange; private List users; private List options = new ArrayList<>(); @@ -52,12 +55,68 @@ public void setName(String name) { this.name = name; } + /** + * The role action. For {@code SET} and {@code RESET}, the action of the parameter change + * decides which of the two is returned. + * + * @return the action + */ public Action getAction() { + if (parameterChange != null && (action == Action.SET || action == Action.RESET)) { + return parameterChange.getAction() == ConfigurationParameterChange.Action.RESET + ? Action.RESET + : Action.SET; + } return action; } + /** + * Selects the role action. {@code SET} and {@code RESET} are also applied to the parameter + * change, which is created when missing. Other actions keep the parameter change for a later + * switch back. + * + * @param action the action + */ public void setAction(Action action) { this.action = action; + if (action == Action.SET || action == Action.RESET) { + ensureParameterChange().setAction(action == Action.RESET + ? ConfigurationParameterChange.Action.RESET + : ConfigurationParameterChange.Action.SET); + } + } + + /** + * The configuration parameter change of the {@code SET} and {@code RESET} actions. + * + * @return the change, or {@code null} if none was set + */ + public ConfigurationParameterChange getParameterChange() { + return parameterChange; + } + + /** + * Sets the configuration parameter change; a non-null change also selects its {@code SET} or + * {@code RESET} action. + * + * @param parameterChange the change + */ + public void setParameterChange(ConfigurationParameterChange parameterChange) { + this.parameterChange = parameterChange; + if (parameterChange != null) { + action = parameterChange.getAction() == ConfigurationParameterChange.Action.RESET + ? Action.RESET + : Action.SET; + } + } + + private ConfigurationParameterChange ensureParameterChange() { + if (parameterChange == null) { + parameterChange = new ConfigurationParameterChange(action == Action.RESET + ? ConfigurationParameterChange.Action.RESET + : ConfigurationParameterChange.Action.SET, null); + } + return parameterChange; } public boolean isUseWith() { @@ -93,43 +152,75 @@ public void setDatabase(String database) { } public String getParameter() { - return parameter; + return parameterChange == null ? null : parameterChange.getParameter(); } public void setParameter(String parameter) { - this.parameter = parameter; + ensureParameterChange().setParameter(parameter); } public boolean isUseEquals() { - return useEquals; + return parameterChange != null && parameterChange.isUseEquals(); } public void setUseEquals(boolean useEquals) { - this.useEquals = useEquals; + ensureParameterChange().setUseEquals(useEquals); } + /** + * Whether the parameter change assigns {@code FROM CURRENT}. + * + * @return {@code true} for {@link ValueKind#FROM_CURRENT} + */ public boolean isFromCurrent() { - return fromCurrent; + return hasValueKind(ValueKind.FROM_CURRENT); } + /** + * Selects {@link ValueKind#FROM_CURRENT}; {@code false} returns from it to the explicit values. + * + * @param fromCurrent whether to assign {@code FROM CURRENT} + */ public void setFromCurrent(boolean fromCurrent) { - this.fromCurrent = fromCurrent; + setValueKind(ValueKind.FROM_CURRENT, fromCurrent); } + /** + * Whether the parameter change assigns {@code DEFAULT}. + * + * @return {@code true} for {@link ValueKind#DEFAULT} + */ public boolean isUseDefault() { - return useDefault; + return hasValueKind(ValueKind.DEFAULT); } + /** + * Selects {@link ValueKind#DEFAULT}; {@code false} returns from it to the explicit values. + * + * @param useDefault whether to assign {@code DEFAULT} + */ public void setUseDefault(boolean useDefault) { - this.useDefault = useDefault; + setValueKind(ValueKind.DEFAULT, useDefault); + } + + private boolean hasValueKind(ValueKind kind) { + return parameterChange != null && parameterChange.getValueKind() == kind; + } + + private void setValueKind(ValueKind kind, boolean selected) { + if (selected) { + ensureParameterChange().setValueKind(kind); + } else if (hasValueKind(kind)) { + parameterChange.setValueKind(ValueKind.VALUES); + } } public ExpressionList getValues() { - return values; + return parameterChange == null ? null : parameterChange.getValues(); } public void setValues(ExpressionList values) { - this.values = values; + ensureParameterChange().setValues(values); } public List getUsers() { @@ -145,7 +236,7 @@ public void appendTo(StringBuilder sql, Consumer visitor) { if (database != null) { sql.append(" IN DATABASE ").append(database); } - switch (action) { + switch (getAction()) { case OPTIONS: if (useWith) { sql.append(" WITH"); @@ -159,25 +250,11 @@ public void appendTo(StringBuilder sql, Consumer visitor) { sql.append(" RENAME TO ").append(newName); break; case RESET: - sql.append(" RESET ").append(parameter); - break; case SET: - sql.append(" SET ").append(parameter); - if (fromCurrent) { - sql.append(" FROM CURRENT"); - } else { - sql.append(useEquals ? " = " : " TO "); - if (useDefault) { - sql.append("DEFAULT"); - } else { - for (int i = 0; i < values.size(); i++) { - if (i > 0) { - sql.append(", "); - } - visitor.accept(values.get(i)); - } - } + if (parameterChange == null) { + throw new IllegalStateException("Expected a configuration parameter change"); } + parameterChange.appendTo(sql.append(' '), visitor); break; case ADD_USER: case DROP_USER: 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..c06ff88b5 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -838,7 +838,8 @@ public StringBuilder visit(AlterRelation statement, S context) { @Override public StringBuilder visit(AlterDatabase statement, S context) { - return statement.appendTo(builder); + return statement.appendTo(builder, + expression -> expression.accept(expressionDeParser, context)); } @Override diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java index de84d3f82..b9bfba466 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java @@ -116,6 +116,10 @@ public enum MariaDbVersion implements Version { Feature.createSchema, // https://mariadb.com/kb/en/create-trigger/ Feature.createTrigger, + // https://mariadb.com/kb/en/create-database/ + // https://mariadb.com/kb/en/alter-database/ + Feature.createDatabase, Feature.alterDatabase, + // https://mariadb.com/kb/en/describe/ Feature.describe, // https://mariadb.com/kb/en/explain/ Feature.explain, // https://mariadb.com/kb/en/show/ diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java index 6aabf1fe0..270fbbf35 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java @@ -93,6 +93,10 @@ public enum MySqlVersion implements Version { Feature.alterTable, // https://dev.mysql.com/doc/refman/8.0/en/alter-view.html Feature.alterView, + // https://dev.mysql.com/doc/refman/8.0/en/create-database.html + // https://dev.mysql.com/doc/refman/8.0/en/alter-database.html + Feature.createDatabase, Feature.alterDatabase, + // https://dev.mysql.com/doc/refman/8.0/en/create-database.html Feature.createSchema, // https://dev.mysql.com/doc/refman/8.0/en/create-view.html Feature.createView, 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..4e91f10c8 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 @@ -120,6 +120,10 @@ public enum PostgresqlVersion implements Version { Feature.createViewMaterialized, // https://www.postgresql.org/docs/current/sql-alterview.html // Feature.alterView, + // https://www.postgresql.org/docs/current/sql-createdatabase.html + // https://www.postgresql.org/docs/current/sql-alterdatabase.html + Feature.createDatabase, Feature.alterDatabase, + // https://www.postgresql.org/docs/16/sql-refreshmaterializedview.html Feature.refreshMaterializedView, Feature.refreshMaterializedWithNoDataView, 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..654331c98 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 @@ -85,6 +85,7 @@ import net.sf.jsqlparser.statement.analyze.Analyze; import net.sf.jsqlparser.statement.comment.Comment; import net.sf.jsqlparser.statement.create.database.CreateDatabase; +import net.sf.jsqlparser.statement.alter.database.AlterDatabase; import net.sf.jsqlparser.statement.create.event.AlterEvent; import net.sf.jsqlparser.statement.create.event.CreateEvent; import net.sf.jsqlparser.statement.create.function.CreateFunction; @@ -440,6 +441,16 @@ public Void visit(CreateDatabase aThis, S context) { return null; } + @Override + public Void visit(AlterDatabase statement, S context) { + validateFeature(Feature.alterDatabase); + if (statement.getParameterChange() != null + && statement.getParameterChange().getValues() != null) { + statement.getParameterChange().getValues().forEach(this::validateOptionalExpression); + } + return null; + } + @Override public Void visit(CreateUser createUser, S context) { return 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..bcd2ee199 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -269,9 +269,54 @@ public class CCJSqlParser extends AbstractJSqlParser { } private boolean isMySqlDatabaseOptionAhead() { - int offset = getToken(1).kind == K_DEFAULT ? 2 : 1; - int kind = getToken(offset).kind; - return kind == K_CHARACTER || kind == K_COLLATE || kind == K_ENCRYPTION; + return isMySqlDatabaseOptionAhead(1); + } + + private boolean isMySqlDatabaseOptionAhead(int offset) { + int start = getToken(offset).kind == K_DEFAULT ? offset + 1 : offset; + Token token = getToken(start); + return token.kind == K_CHARACTER || token.kind == K_COLLATE || token.kind == K_ENCRYPTION + || token.kind == K_CHAR && getToken(start + 1).kind == K_SET + || token.kind == S_IDENTIFIER && "CHARSET".equalsIgnoreCase(token.image); + } + + /** + * Whether MySQL's ALTER DATABASE omits the database name before its options. CHARSET can also + * be a database name, so it starts an option only when "=" follows, or when its value ends the + * statement or precedes another option. + */ + private boolean isAlterDatabaseNameOmitted() { + Token first = getToken(1); + if (first.kind == K_READ) { + return true; + } + if (!isMySqlDatabaseOptionAhead(1)) { + return false; + } + if (first.kind != S_IDENTIFIER || "=".equals(getToken(2).image)) { + return true; + } + Token afterValue = getToken(3); + return afterValue.kind == EOF || afterValue.kind == ST_SEMICOLON || afterValue.kind == K_READ + || isMySqlDatabaseOptionAhead(3); + } + + /** Whether a PostgreSQL CREATE/ALTER DATABASE option name starts at the offset. */ + private boolean isPostgreSqlDatabaseOptionAhead(int offset) { + Token token = getToken(offset); + if (token.kind == K_ENCODING || token.kind == K_TABLESPACE) { + return true; + } + if (token.kind != S_IDENTIFIER) { + return false; + } + String name = token.image.toUpperCase(Locale.ROOT); + if ("CONNECTION".equals(name)) { + return getToken(offset + 1).kind == K_LIMIT; + } + return Arrays.asList("OWNER", "TEMPLATE", "STRATEGY", "LOCALE", "LC_COLLATE", "LC_CTYPE", + "BUILTIN_LOCALE", "ICU_LOCALE", "ICU_RULES", "LOCALE_PROVIDER", "COLLATION_VERSION", + "ALLOW_CONNECTIONS", "IS_TEMPLATE", "OID").contains(name); } private static class ObjectNames { @@ -15888,6 +15933,16 @@ DatabaseOption MySqlDatabaseOption(): name = "CHARACTER SET"; } | + { + kind = DatabaseOption.Kind.CHARACTER_SET; + name = "CHAR SET"; + } + | + LOOKAHEAD({ isKeywordAhead("CHARSET") }) ContextualKeyword("CHARSET") { + kind = DatabaseOption.Kind.CHARACTER_SET; + name = "CHARSET"; + } + | { kind = DatabaseOption.Kind.COLLATE; name = "COLLATE"; @@ -15903,35 +15958,144 @@ DatabaseOption MySqlDatabaseOption(): { return new DatabaseOption(kind, name, value, useDefault, useEquals); } } -/** Reuses CREATE's database options; READ ONLY is specific to ALTER. */ +/** PostgreSQL option of CREATE DATABASE; ALTER DATABASE accepts the same option list. */ +DatabaseOption PostgreSqlDatabaseOption(): +{ + Token token; + DatabaseOption.Kind kind; + String name; + String value; + boolean useEquals = false; +} +{ + ( + LOOKAHEAD({ isKeywordAhead("CONNECTION") }) ContextualKeyword("CONNECTION") + { kind = DatabaseOption.Kind.CONNECTION_LIMIT; name = "CONNECTION LIMIT"; } + | + { kind = DatabaseOption.Kind.ENCODING; name = "ENCODING"; } + | + { kind = DatabaseOption.Kind.TABLESPACE; name = "TABLESPACE"; } + | + LOOKAHEAD({ isPostgreSqlDatabaseOptionAhead(1) }) token= { + name = token.image.toUpperCase(Locale.ROOT); + kind = DatabaseOption.Kind.valueOf(name); + } + ) + [ "=" { useEquals = true; } ] + value=PostgreSqlDatabaseOptionValue() + { return new DatabaseOption(kind, name, value, false, useEquals); } +} + +/** A name, string, signed number, boolean or DEFAULT, as written. */ +String PostgreSqlDatabaseOptionValue(): +{ + Token token; + String value; + String sign = ""; +} +{ + ( + LOOKAHEAD(1) token= { value = token.image; } + | + LOOKAHEAD(1) token= { value = token.image; } + | + ( token= | token= | token= ) { value = token.image; } + | + [ "+" { sign = "+"; } | "-" { sign = "-"; } ] + ( token= | token= ) { value = sign + token.image; } + | + value=RelObjectName() + ) + { return value; } +} + +/** + * MySQL ALTER DATABASE/SCHEMA options and the PostgreSQL ALTER DATABASE forms. Options reuse the + * CREATE DATABASE productions; READ ONLY is specific to MySQL's ALTER. Only MySQL options may + * follow an omitted name. + */ AlterDatabase AlterDatabase(): { AlterDatabase result = new AlterDatabase(); String name; DatabaseOption option; - Token value; - boolean equals; + ConfigurationParameterChange change; } { - ( | { result.setUseSchemaKeyword(true); } ) - [ LOOKAHEAD({ !isMySqlDatabaseOptionAhead() && getToken(1).kind != K_READ }) - name=RelObjectName() { result.setDatabaseName(name); } ] ( + { result.setUseSchemaKeyword(true); } ( - LOOKAHEAD({ isMySqlDatabaseOptionAhead() }) option=MySqlDatabaseOption() + LOOKAHEAD({ isAlterDatabaseNameOmitted() }) MySqlAlterDatabaseOptions(result) | - { equals = false; } - [ "=" { equals = true; } ] - ( value= | value= ) { - requireDdlSyntax(value.kind == K_DEFAULT || "0".equals(value.image) || "1".equals(value.image), - "READ ONLY requires DEFAULT, 0, or 1"); - option = new DatabaseOption(DatabaseOption.Kind.READ_ONLY, "READ ONLY", value.image, false, equals); - } - ) { result.getOptions().add(option); } - )+ + name=RelObjectName() { result.setDatabaseName(name); } MySqlAlterDatabaseOptions(result) + ) + | + + ( + LOOKAHEAD({ isAlterDatabaseNameOmitted() }) MySqlAlterDatabaseOptions(result) + | + name=RelObjectName() { result.setDatabaseName(name); } + ( + name=RelObjectName() + { result.setAction(AlterDatabase.Action.RENAME); result.setNewName(name); } + | + LOOKAHEAD({ isKeywordAhead("OWNER") && getToken(2).kind == K_TO }) + ContextualKeyword("OWNER") name=RelObjectName() + { result.setAction(AlterDatabase.Action.OWNER); result.setOwner(name); } + | + LOOKAHEAD({ getToken(1).kind == K_SET && getToken(2).kind == K_TABLESPACE + && getToken(3).kind != K_TO && getToken(3).kind != K_FROM && !"=".equals(getToken(3).image) }) + name=RelObjectName() + { result.setAction(AlterDatabase.Action.SET_TABLESPACE); result.setTablespace(name); } + | + ContextualKeyword("COLLATION") + { result.setAction(AlterDatabase.Action.REFRESH_COLLATION_VERSION); } + | + change=ConfigurationParameterChange() + { result.setAction(AlterDatabase.Action.CONFIGURATION_PARAMETER); result.setParameterChange(change); } + | + [ { result.setUseWith(true); } ] + ( + ( + LOOKAHEAD({ isPostgreSqlDatabaseOptionAhead(1) }) option=PostgreSqlDatabaseOption() + | + option=MySqlAlterDatabaseOption() + ) { result.getOptions().add(option); } + )+ + ) + ) + ) { return result; } } +void MySqlAlterDatabaseOptions(AlterDatabase result): +{ DatabaseOption option; } +{ + ( option=MySqlAlterDatabaseOption() { result.getOptions().add(option); } )+ +} + +/** MySQL ALTER DATABASE option: a CREATE DATABASE option or READ ONLY. */ +DatabaseOption MySqlAlterDatabaseOption(): +{ + DatabaseOption option; + Token value; + boolean equals = false; +} +{ + ( + LOOKAHEAD({ isMySqlDatabaseOptionAhead() }) option=MySqlDatabaseOption() + | + + [ "=" { equals = true; } ] + ( value= | value= ) { + requireDdlSyntax(value.kind == K_DEFAULT || "0".equals(value.image) || "1".equals(value.image), + "READ ONLY requires DEFAULT, 0, or 1"); + option = new DatabaseOption(DatabaseOption.Kind.READ_ONLY, "READ ONLY", value.image, false, equals); + } + ) + { return option; } +} + List PathSpecification(): { Token tk; @@ -15945,6 +16109,7 @@ List PathSpecification(): } } +/** Typed MySQL and PostgreSQL options; other option text is kept as one raw option. */ CreateDatabase CreateDatabase(): { CreateDatabase createDatabase = new CreateDatabase(); @@ -15956,12 +16121,20 @@ CreateDatabase CreateDatabase(): [ LOOKAHEAD(2) { createDatabase.setIfNotExists(true); } ] databaseName = RelObjectName() { createDatabase.setDatabaseName(databaseName); } + [ LOOKAHEAD({ getToken(1).kind == K_WITH && isPostgreSqlDatabaseOptionAhead(2) }) + { createDatabase.setUseWith(true); } ] ( - LOOKAHEAD({ isMySqlDatabaseOptionAhead() }) - databaseOption=MySqlDatabaseOption() { createDatabase.addOptions(databaseOption); } + LOOKAHEAD({ isMySqlDatabaseOptionAhead() || isPostgreSqlDatabaseOptionAhead(1) }) + ( + LOOKAHEAD({ isMySqlDatabaseOptionAhead() }) databaseOption=MySqlDatabaseOption() + | + databaseOption=PostgreSqlDatabaseOption() + ) { createDatabase.addOptions(databaseOption); } )* [ databaseOptions = captureRest() { - createDatabase.addOptions(DatabaseOption.raw(databaseOptions)); + if (!databaseOptions.isEmpty()) { + createDatabase.addOptions(DatabaseOption.raw(databaseOptions)); + } } ] { return createDatabase; @@ -19591,10 +19764,13 @@ Statement Alter(): | statement = AlterDefaultPrivileges() | - LOOKAHEAD({ getToken(1).kind == K_DATABASE || (getToken(1).kind == K_SCHEMA - && Dialect.MYSQL.name().equals(getAsString(Feature.dialect))) }) + LOOKAHEAD({ (getToken(1).kind == K_DATABASE || getToken(1).kind == K_SCHEMA) + && Dialect.MYSQL.name().equals(getAsString(Feature.dialect)) }) statement = AlterDatabase() | + // Other dialects keep unmodelled ALTER DATABASE forms as unsupported statements. + LOOKAHEAD(AlterDatabase()) statement = AlterDatabase() + | LOOKAHEAD() statement = AlterSchema() | LOOKAHEAD() statement = AlterType() @@ -20412,8 +20588,8 @@ AlterSchema AlterSchema(): AlterRole AlterRole(): { - AlterRole role = new AlterRole(); String name; RoleOption option; Token token; Expression value; - ExpressionList values = new ExpressionList(); List names; + AlterRole role = new AlterRole(); String name; RoleOption option; Token token; + ConfigurationParameterChange change; List names; } { ( token= | token= | token= ) { role.setCommand(accessEnum(CreateRole.Command.class, token.image)); } @@ -20425,22 +20601,7 @@ AlterRole AlterRole(): ( { role.setAction(AlterRole.Action.ADD_USER); } | { role.setAction(AlterRole.Action.DROP_USER); } ) names=AccessNames() { requireAccessSyntax(role.getCommand() == CreateRole.Command.GROUP, "ADD/DROP USER requires ALTER GROUP"); role.setUsers(names); } | - name=AccessQualifiedName() { role.setAction(AlterRole.Action.SET); role.setParameter(name); } - ( - { role.setFromCurrent(true); } - | - ( | "=" { role.setUseEquals(true); } ) - ( - LOOKAHEAD(1) { role.setUseDefault(true); } - | - value=AccessSettingValue() { values.add(value); } - ( "," value=AccessSettingValue() { values.add(value); } )* - { role.setValues(values); } - ) - ) - | - ( LOOKAHEAD(1) { role.setParameter("ALL"); } | name=AccessQualifiedName() { role.setParameter(name); } ) - { role.setAction(AlterRole.Action.RESET); } + change=ConfigurationParameterChange() { role.setParameterChange(change); } | [ { role.setUseWith(true); } ] option=RoleOption(false) { role.getOptions().add(option); role.setAction(AlterRole.Action.OPTIONS); } @@ -20456,6 +20617,36 @@ AlterRole AlterRole(): } } +/** PostgreSQL SET/RESET of a configuration parameter, shared by ALTER ROLE and ALTER DATABASE. */ +ConfigurationParameterChange ConfigurationParameterChange(): +{ + ConfigurationParameterChange change = new ConfigurationParameterChange(); + String name; + Expression value; + ExpressionList values = new ExpressionList(); +} +{ + ( + name=AccessQualifiedName() { change.setParameter(name); } + ( + { change.setValueKind(ConfigurationParameterChange.ValueKind.FROM_CURRENT); } + | + ( | "=" { change.setUseEquals(true); } ) + ( + LOOKAHEAD(1) { change.setValueKind(ConfigurationParameterChange.ValueKind.DEFAULT); } + | + value=AccessSettingValue() { values.add(value); } + ( "," value=AccessSettingValue() { values.add(value); } )* + { change.setValues(values); } + ) + ) + | + { change.setAction(ConfigurationParameterChange.Action.RESET); } + ( LOOKAHEAD(1) { change.setParameter("ALL"); } | name=AccessQualifiedName() { change.setParameter(name); } ) + ) + { return change; } +} + Expression AccessSettingValue(): { Expression value; Token token; } { ( LOOKAHEAD(1) token= { value = new Column(token.image); } | value=PrimaryExpression() ) { return value; } } diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 901608d62..d887ffe86 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -1868,6 +1868,60 @@ visitors, both SQL renderers and metadata validation use this structured target. Feature analysis reports ``MODIFIES_SESSION``; the directive itself inserts no rows. The dedicated validation capability is ``setIdentityInsert``. +Database options and configuration parameters +--------------------------------------------- + +``CreateDatabase``, MySQL's ``CREATE SCHEMA`` and ``AlterDatabase`` share the +``DatabaseOption`` model. MySQL options are ``CHARACTER_SET`` (``getName()`` keeps +the ``CHARACTER SET``, ``CHAR SET`` or ``CHARSET`` spelling), ``COLLATE``, +``ENCRYPTION`` and, for ALTER only, ``READ_ONLY``. PostgreSQL options have one kind +each, from ``OWNER`` and ``TEMPLATE`` to ``CONNECTION_LIMIT``, ``IS_TEMPLATE`` and +``OID``; ``isUseWith()`` records the optional ``WITH``. Values keep their SQL text, +such as ``'UTF8'`` or ``-1``. Other option text remains one raw ``OTHER`` option. + +``AlterDatabase.getAction()`` selects what is rendered: ``OPTIONS`` (the default), +``RENAME``, ``OWNER``, ``SET_TABLESPACE``, ``REFRESH_COLLATION_VERSION`` or +``CONFIGURATION_PARAMETER``. Payloads of the other actions are kept. ``SET`` and +``RESET`` of a configuration parameter use ``ConfigurationParameterChange``, which +``AlterRole`` shares: + +.. code-block:: java + + AlterDatabase alter = (AlterDatabase) CCJSqlParserUtil.parse( + "ALTER DATABASE app SET search_path TO app, public", + parser -> parser.withDialect(Dialect.POSTGRESQL)); + ConfigurationParameterChange change = alter.getParameterChange(); + change.getParameter(); // search_path + change.getValues(); // [app, public], a mutable ExpressionList + change.setValueKind(ConfigurationParameterChange.ValueKind.DEFAULT); + // ALTER DATABASE app SET search_path TO DEFAULT + +``getValueKind()`` is ``VALUES``, ``DEFAULT`` or ``FROM_CURRENT``; ``RESET ALL`` uses +the parameter name ``ALL``. ``AlterRole`` keeps ``getParameter()``, ``isUseEquals()``, +``isFromCurrent()``, ``isUseDefault()``, ``getValues()`` and their setters; they read +and edit ``getParameterChange()``. ``FROM CURRENT`` and ``DEFAULT`` are now mutually +exclusive: ``setFromCurrent(false)`` and ``setUseDefault(false)`` return to the values. + +Without the MySQL dialect, ``ALTER DATABASE`` forms that are neither MySQL nor +PostgreSQL syntax, such as ``ALTER DATABASE d SET RECOVERY SIMPLE``, remain an +``UnsupportedStatement``; ``Dialect.MYSQL`` reports them as errors. ``ALTER SCHEMA`` +followed by MySQL options is an ``AlterDatabase`` in every dialect, while PostgreSQL's +``ALTER SCHEMA ... RENAME TO/OWNER TO`` remains an ``AlterSchema``. Validation +reports the ``createDatabase`` and ``alterDatabase`` features, which the MySQL, +MariaDB and PostgreSQL capabilities enable. + +``CreateDatabase.getOptions()`` is now a live list that is never null, and +``setOptions`` copies its argument. ``getDatabaseOptions()`` is an unmodifiable +token snapshot derived from the options, including ``WITH``, and is null without +options. ``setDatabaseOptions`` replaces all options with one raw option, and +``addDatabaseOptions`` appends one while keeping the typed options. Parsed +statements no longer end with an empty raw option. + +References: MySQL `CREATE DATABASE `_, +`ALTER DATABASE `_; +PostgreSQL `CREATE DATABASE `_, +`ALTER DATABASE `_. + MySQL table maintenance statements ---------------------------------- diff --git a/src/test/java/net/sf/jsqlparser/statement/ConfigurationParameterChangeTest.java b/src/test/java/net/sf/jsqlparser/statement/ConfigurationParameterChangeTest.java new file mode 100644 index 000000000..4f70e7746 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/ConfigurationParameterChangeTest.java @@ -0,0 +1,166 @@ +/*- + * #%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; + +import static org.assertj.core.api.Assertions.assertThat; + +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.statement.ConfigurationParameterChange.Action; +import net.sf.jsqlparser.statement.ConfigurationParameterChange.ValueKind; +import net.sf.jsqlparser.statement.alter.database.AlterDatabase; +import net.sf.jsqlparser.statement.role.AlterRole; +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 ConfigurationParameterChangeTest { + + @ParameterizedTest + @ValueSource(strings = {"SET search_path TO app, public", "SET work_mem = '64MB'", + "SET work_mem TO DEFAULT", "SET work_mem = DEFAULT", "SET work_mem FROM CURRENT", + "SET myext.setting = 1", "RESET work_mem", "RESET ALL"}) + void testAlterRoleAndDatabaseShareTheClause(String clause) throws JSQLParserException { + AlterRole role = (AlterRole) CCJSqlParserUtil.parse("ALTER ROLE app " + clause); + AlterDatabase database = + (AlterDatabase) CCJSqlParserUtil.parse("ALTER DATABASE app " + clause); + + assertThat(role.getParameterChange().toString()).isEqualTo(clause); + assertThat(database.getParameterChange().toString()).isEqualTo(clause); + assertThat(database.getAction()) + .isEqualTo(AlterDatabase.Action.CONFIGURATION_PARAMETER); + assertThat(role.getAction().name()) + .isEqualTo(role.getParameterChange().getAction().name()); + assertThat(deparse(role)).isEqualTo("ALTER ROLE app " + clause); + assertThat(deparse(database)).isEqualTo("ALTER DATABASE app " + clause); + } + + @Test + void testParsedState() throws JSQLParserException { + ConfigurationParameterChange set = parseChange("SET search_path TO app, public"); + assertThat(set.getAction()).isEqualTo(Action.SET); + assertThat(set.getParameter()).isEqualTo("search_path"); + assertThat(set.getValueKind()).isEqualTo(ValueKind.VALUES); + assertThat(set.isUseEquals()).isFalse(); + assertThat(set.getValues()).extracting(Object::toString).containsExactly("app", "public"); + + ConfigurationParameterChange setDefault = parseChange("SET work_mem = DEFAULT"); + assertThat(setDefault.getValueKind()).isEqualTo(ValueKind.DEFAULT); + assertThat(setDefault.isUseEquals()).isTrue(); + assertThat(setDefault.getValues()).isNull(); + + assertThat(parseChange("SET work_mem FROM CURRENT").getValueKind()) + .isEqualTo(ValueKind.FROM_CURRENT); + + ConfigurationParameterChange resetAll = parseChange("RESET ALL"); + assertThat(resetAll.getAction()).isEqualTo(Action.RESET); + assertThat(resetAll.getParameter()).isEqualTo("ALL"); + } + + @Test + void testBuildAndEdit() { + ConfigurationParameterChange change = + new ConfigurationParameterChange(Action.SET, "statement_timeout") + .withUseEquals(true) + .withValues(new ExpressionList(new StringValue("5s"))); + assertThat(change.toString()).isEqualTo("SET statement_timeout = '5s'"); + + change.setValueKind(ValueKind.DEFAULT); + assertThat(change.toString()).isEqualTo("SET statement_timeout = DEFAULT"); + change.setValueKind(ValueKind.VALUES); + assertThat(change.toString()).isEqualTo("SET statement_timeout = '5s'"); + change.setValueKind(ValueKind.FROM_CURRENT); + assertThat(change.toString()).isEqualTo("SET statement_timeout FROM CURRENT"); + + change.setAction(Action.RESET); + assertThat(change.toString()).isEqualTo("RESET statement_timeout"); + + AlterDatabase database = new AlterDatabase(); + database.setDatabaseName("app"); + database.setAction(AlterDatabase.Action.CONFIGURATION_PARAMETER); + database.setParameterChange(change); + assertThat(database.toString()).isEqualTo("ALTER DATABASE app RESET statement_timeout"); + } + + @Test + void testAlterRoleLegacyAccessorsReadTheChange() throws JSQLParserException { + AlterRole role = (AlterRole) CCJSqlParserUtil + .parse("ALTER ROLE ALL IN DATABASE appdb SET search_path = app, public"); + assertThat(role.getAction()).isEqualTo(AlterRole.Action.SET); + assertThat(role.getParameter()).isEqualTo("search_path"); + assertThat(role.isUseEquals()).isTrue(); + assertThat(role.isUseDefault()).isFalse(); + assertThat(role.isFromCurrent()).isFalse(); + assertThat(role.getValues()).isSameAs(role.getParameterChange().getValues()); + + role.getValues().set(0, new Column("new_schema")); + role.setUseEquals(false); + assertThat(role.toString()) + .isEqualTo( + "ALTER ROLE ALL IN DATABASE appdb SET search_path TO new_schema, public"); + + role.setUseDefault(true); + assertThat(role.getParameterChange().getValueKind()).isEqualTo(ValueKind.DEFAULT); + assertThat(role.toString()).endsWith("SET search_path TO DEFAULT"); + role.setFromCurrent(true); + assertThat(role.isUseDefault()).isFalse(); + assertThat(role.toString()).endsWith("SET search_path FROM CURRENT"); + role.setFromCurrent(false); + assertThat(role.toString()).endsWith("SET search_path TO new_schema, public"); + + role.setAction(AlterRole.Action.RESET); + assertThat(role.getParameterChange().getAction()).isEqualTo(Action.RESET); + assertThat(role.toString()).isEqualTo("ALTER ROLE ALL IN DATABASE appdb RESET search_path"); + + role.getParameterChange().setAction(Action.SET); + assertThat(role.getAction()).isEqualTo(AlterRole.Action.SET); + } + + @Test + void testAlterRoleLegacySetters() { + AlterRole role = new AlterRole(); + role.setName("app"); + assertThat(role.getParameterChange()).isNull(); + assertThat(role.getParameter()).isNull(); + assertThat(role.getValues()).isNull(); + assertThat(role.isUseEquals()).isFalse(); + + role.setParameter("work_mem"); + role.setAction(AlterRole.Action.SET); + role.setValues(new ExpressionList(new StringValue("64MB"))); + assertThat(role.toString()).isEqualTo("ALTER ROLE app SET work_mem TO '64MB'"); + + role.setAction(AlterRole.Action.RENAME); + role.setNewName("app2"); + assertThat(role.toString()).isEqualTo("ALTER ROLE app RENAME TO app2"); + assertThat(role.getParameter()).isEqualTo("work_mem"); + + role.setParameterChange(new ConfigurationParameterChange(Action.RESET, "ALL")); + assertThat(role.getAction()).isEqualTo(AlterRole.Action.RESET); + assertThat(role.toString()).isEqualTo("ALTER ROLE app RESET ALL"); + } + + private static ConfigurationParameterChange parseChange(String clause) + throws JSQLParserException { + return ((AlterDatabase) CCJSqlParserUtil.parse("ALTER DATABASE d " + clause)) + .getParameterChange(); + } + + private static String deparse(Statement statement) { + StringBuilder builder = new StringBuilder(); + statement.accept(new StatementDeParser(builder)); + return builder.toString(); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/StatementFeatureVisitorMetadataDdlTest.java b/src/test/java/net/sf/jsqlparser/statement/StatementFeatureVisitorMetadataDdlTest.java index b15eb6580..304d6da92 100644 --- a/src/test/java/net/sf/jsqlparser/statement/StatementFeatureVisitorMetadataDdlTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/StatementFeatureVisitorMetadataDdlTest.java @@ -39,6 +39,8 @@ static Stream definitions() { Arguments.of("ALTER DATABASE d READ ONLY = 0", AlterDatabase.class), Arguments.of("ALTER DATABASE d CHARACTER SET utf8mb4 COLLATE utf8mb4_bin", AlterDatabase.class), + Arguments.of("ALTER DATABASE d SET work_mem = '64MB'", AlterDatabase.class), + Arguments.of("ALTER DATABASE d OWNER TO CURRENT_USER", AlterDatabase.class), Arguments.of("ALTER SCHEMA s RENAME TO s2", AlterSchema.class), Arguments.of("ALTER SCHEMA s OWNER TO CURRENT_USER", AlterSchema.class), Arguments.of("ALTER STATISTICS s RENAME TO s2", AlterStatistics.class), diff --git a/src/test/java/net/sf/jsqlparser/statement/alter/database/AlterDatabaseTest.java b/src/test/java/net/sf/jsqlparser/statement/alter/database/AlterDatabaseTest.java index 56b46adde..48a10da95 100644 --- a/src/test/java/net/sf/jsqlparser/statement/alter/database/AlterDatabaseTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/alter/database/AlterDatabaseTest.java @@ -9,14 +9,27 @@ */ package net.sf.jsqlparser.statement.alter.database; +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static net.sf.jsqlparser.util.validation.ValidationTestAsserts.validateNoErrors; import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.function.Consumer; import net.sf.jsqlparser.JSQLParserException; import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.StatementVisitorAdapter; +import net.sf.jsqlparser.statement.UnsupportedStatement; +import net.sf.jsqlparser.statement.alter.schema.AlterSchema; import net.sf.jsqlparser.statement.create.database.DatabaseOption; import net.sf.jsqlparser.util.TablesNamesFinder; import net.sf.jsqlparser.util.deparser.StatementDeParser; +import net.sf.jsqlparser.util.validation.Validation; +import net.sf.jsqlparser.util.validation.feature.DatabaseType; +import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; @@ -63,6 +76,180 @@ void rejectIncompleteAndInvalidReadOnly() { } } + @ParameterizedTest + @ValueSource(strings = {"ALTER DATABASE db1 DEFAULT CHARSET = utf8mb4", + "ALTER SCHEMA db1 DEFAULT ENCRYPTION = 'Y'", "ALTER SCHEMA db1 CHAR SET utf8mb4", + "ALTER DATABASE CHARSET utf8mb4 COLLATE utf8mb4_bin", + "ALTER DATABASE charset CHARSET = utf8mb4", + "ALTER DATABASE READ ONLY = DEFAULT", "ALTER SCHEMA DEFAULT CHARACTER SET utf8mb4"}) + void testMySqlOptionsWithAndWithoutDialect(String sql) throws JSQLParserException { + assertRoundTrip(sql, Dialect.MYSQL); + assertRoundTrip(sql, null); + } + + @Test + void testCharsetSpellingAndOmittedName() throws JSQLParserException { + AlterDatabase statement = + (AlterDatabase) CCJSqlParserUtil.parse("ALTER SCHEMA DEFAULT CHARSET utf8mb4"); + assertTrue(statement.isUseSchemaKeyword()); + assertNull(statement.getDatabaseName()); + DatabaseOption charset = + statement.getOption(DatabaseOption.Kind.CHARACTER_SET).orElseThrow(); + assertEquals("CHARSET", charset.getName()); + assertEquals("utf8mb4", charset.getValue()); + assertTrue(charset.isUseDefault()); + + // CHARSET is also a valid database name. + statement = + (AlterDatabase) CCJSqlParserUtil.parse("ALTER DATABASE charset CHARSET utf8mb4"); + assertEquals("charset", statement.getDatabaseName()); + statement = (AlterDatabase) CCJSqlParserUtil.parse("ALTER DATABASE charset RENAME TO c2", + parser -> parser.withDialect(Dialect.POSTGRESQL)); + assertEquals("charset", statement.getDatabaseName()); + assertEquals("c2", statement.getNewName()); + } + + @ParameterizedTest + @ValueSource(strings = {"ALTER DATABASE mydb RENAME TO mydb2", + "ALTER DATABASE \"My Db\" RENAME TO \"Other Db\"", "ALTER DATABASE mydb OWNER TO u1", + "ALTER DATABASE mydb OWNER TO CURRENT_USER", + "ALTER DATABASE mydb OWNER TO CURRENT_ROLE", + "ALTER DATABASE mydb OWNER TO SESSION_USER", "ALTER DATABASE mydb SET TABLESPACE fast", + "ALTER DATABASE mydb REFRESH COLLATION VERSION", + "ALTER DATABASE mydb SET search_path TO app, public", + "ALTER DATABASE mydb SET search_path = \"$user\", public", + "ALTER DATABASE mydb SET work_mem = '64MB'", + "ALTER DATABASE mydb SET work_mem TO DEFAULT", + "ALTER DATABASE mydb SET work_mem FROM CURRENT", "ALTER DATABASE mydb RESET work_mem", + "ALTER DATABASE mydb RESET ALL", "ALTER DATABASE mydb WITH ALLOW_CONNECTIONS false", + "ALTER DATABASE mydb CONNECTION LIMIT = -1 IS_TEMPLATE true", + "ALTER DATABASE mydb WITH IS_TEMPLATE = true ALLOW_CONNECTIONS = false CONNECTION LIMIT 5"}) + void testPostgreSqlForms(String sql) throws JSQLParserException { + assertRoundTrip(sql, Dialect.POSTGRESQL); + assertRoundTrip(sql, null); + } + + @Test + void testPostgreSqlActions() throws JSQLParserException { + AlterDatabase rename = parsePostgreSql("ALTER DATABASE mydb RENAME TO mydb2"); + assertEquals(AlterDatabase.Action.RENAME, rename.getAction()); + assertEquals("mydb", rename.getDatabaseName()); + assertEquals("mydb2", rename.getNewName()); + assertTrue(rename.getOptions().isEmpty()); + + AlterDatabase owner = parsePostgreSql("ALTER DATABASE mydb OWNER TO CURRENT_USER"); + assertEquals(AlterDatabase.Action.OWNER, owner.getAction()); + assertEquals("CURRENT_USER", owner.getOwner()); + + AlterDatabase tablespace = parsePostgreSql("ALTER DATABASE mydb SET TABLESPACE fast"); + assertEquals(AlterDatabase.Action.SET_TABLESPACE, tablespace.getAction()); + assertEquals("fast", tablespace.getTablespace()); + assertNull(tablespace.getParameterChange()); + + assertEquals(AlterDatabase.Action.REFRESH_COLLATION_VERSION, + parsePostgreSql("ALTER DATABASE mydb REFRESH COLLATION VERSION").getAction()); + + // A configuration parameter named "tablespace" is a SET, not SET TABLESPACE. + AlterDatabase parameter = parsePostgreSql("ALTER DATABASE mydb SET tablespace = fast"); + assertEquals(AlterDatabase.Action.CONFIGURATION_PARAMETER, parameter.getAction()); + assertEquals("tablespace", parameter.getParameterChange().getParameter()); + } + + @Test + void testPostgreSqlOptions() throws JSQLParserException { + AlterDatabase statement = parsePostgreSql( + "ALTER DATABASE mydb WITH ALLOW_CONNECTIONS false CONNECTION LIMIT = -1 IS_TEMPLATE true"); + assertEquals(AlterDatabase.Action.OPTIONS, statement.getAction()); + assertTrue(statement.isUseWith()); + List options = statement.getOptions(); + assertEquals(3, options.size()); + assertOption(options.get(0), DatabaseOption.Kind.ALLOW_CONNECTIONS, "ALLOW_CONNECTIONS", + "false", false); + assertOption(options.get(1), DatabaseOption.Kind.CONNECTION_LIMIT, "CONNECTION LIMIT", + "-1", true); + assertOption(options.get(2), DatabaseOption.Kind.IS_TEMPLATE, "IS_TEMPLATE", "true", + false); + + statement.getOption(DatabaseOption.Kind.CONNECTION_LIMIT).orElseThrow().setValue("10"); + statement.setUseWith(false); + assertEquals("ALTER DATABASE mydb ALLOW_CONNECTIONS false CONNECTION LIMIT = 10 " + + "IS_TEMPLATE true", statement.toString()); + } + + @Test + void testSwitchingActionsKeepsPayloads() throws JSQLParserException { + AlterDatabase statement = parsePostgreSql("ALTER DATABASE mydb RENAME TO mydb2"); + statement.setAction(AlterDatabase.Action.OWNER); + statement.setOwner("SESSION_USER"); + assertEquals("ALTER DATABASE mydb OWNER TO SESSION_USER", statement.toString()); + statement.setAction(AlterDatabase.Action.RENAME); + assertEquals("ALTER DATABASE mydb RENAME TO mydb2", statement.toString()); + + statement.setAction(AlterDatabase.Action.CONFIGURATION_PARAMETER); + assertThrows(IllegalStateException.class, statement::toString); + } + + @ParameterizedTest + @ValueSource(strings = {"ALTER DATABASE d", "ALTER DATABASE d SET TIME ZONE 'UTC'", + "ALTER DATABASE d SET RECOVERY SIMPLE", "ALTER DATABASE d MODIFY NAME = d2", + "ALTER DATABASE WITH ALLOW_CONNECTIONS true"}) + void testUnmodelledFormsRemainUnsupportedOutsideMySql(String sql) + throws JSQLParserException { + assertInstanceOf(UnsupportedStatement.class, CCJSqlParserUtil.parse(sql)); + assertInstanceOf(UnsupportedStatement.class, CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(Dialect.POSTGRESQL))); + assertThrows(JSQLParserException.class, () -> parse(sql)); + } + + @Test + void testPostgreSqlAlterSchemaIsUnchanged() throws JSQLParserException { + assertInstanceOf(AlterSchema.class, CCJSqlParserUtil.parse("ALTER SCHEMA s RENAME TO t")); + assertInstanceOf(AlterSchema.class, + CCJSqlParserUtil.parse("ALTER SCHEMA s OWNER TO CURRENT_USER")); + assertInstanceOf(AlterDatabase.class, + CCJSqlParserUtil.parse("ALTER SCHEMA s DEFAULT ENCRYPTION = 'Y'")); + } + + @Test + void testValidation() { + validateNoErrors("ALTER DATABASE mydb SET work_mem = '64MB'", 1, DatabaseType.POSTGRESQL); + validateNoErrors("ALTER DATABASE mydb OWNER TO CURRENT_USER", 1, DatabaseType.POSTGRESQL); + validateNoErrors("ALTER DATABASE d CHARACTER SET utf8mb4", 1, DatabaseType.MYSQL, + DatabaseType.MARIADB); + assertFalse(new Validation(List.of(FeaturesAllowed.SELECT), + "ALTER DATABASE mydb RESET ALL").validate().isEmpty()); + assertTrue(new Validation(List.of(new FeaturesAllowed().add(Feature.alterDatabase)), + "ALTER DATABASE mydb RESET ALL").validate().isEmpty()); + } + + private static void assertOption(DatabaseOption option, DatabaseOption.Kind kind, String name, + String value, boolean useEquals) { + assertEquals(kind, option.getKind()); + assertEquals(name, option.getName()); + assertEquals(value, option.getValue()); + assertEquals(useEquals, option.isUseEquals()); + assertFalse(option.isUseDefault()); + } + + /** Parses, deparses and reparses with the dialect, or without one if it is null. */ + private static void assertRoundTrip(String sql, Dialect dialect) throws JSQLParserException { + Consumer consumer = + dialect == null ? parser -> { + } : parser -> parser.withDialect(dialect); + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, false, consumer); + assertInstanceOf(AlterDatabase.class, statement); + StringBuilder deparsed = new StringBuilder(); + statement.accept(new StatementDeParser(deparsed)); + assertEquals(sql, CCJSqlParserUtil.parse(deparsed.toString(), consumer).toString()); + assertTrue(new TablesNamesFinder().getTables(statement).isEmpty()); + assertEquals(2, CCJSqlParserUtil.parseStatements(sql + "; SELECT 1", consumer).size()); + } + + private static AlterDatabase parsePostgreSql(String sql) throws JSQLParserException { + return (AlterDatabase) CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(Dialect.POSTGRESQL)); + } + private static AlterDatabase parse(String sql) throws JSQLParserException { return (AlterDatabase) CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.MYSQL)); } diff --git a/src/test/java/net/sf/jsqlparser/statement/create/database/CreateDatabaseTest.java b/src/test/java/net/sf/jsqlparser/statement/create/database/CreateDatabaseTest.java index 20f7db0ee..edeb7861c 100644 --- a/src/test/java/net/sf/jsqlparser/statement/create/database/CreateDatabaseTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/create/database/CreateDatabaseTest.java @@ -10,17 +10,29 @@ package net.sf.jsqlparser.statement.create.database; import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; import net.sf.jsqlparser.parser.CCJSqlParserUtil; import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import net.sf.jsqlparser.util.validation.feature.DatabaseType; import static net.sf.jsqlparser.test.TestUtils.assertDeparse; import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static net.sf.jsqlparser.util.validation.ValidationTestAsserts.validateNoErrors; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; 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 static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.ArrayList; import java.util.Arrays; +import java.util.List; +import java.util.function.Consumer; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; public class CreateDatabaseTest { @@ -79,4 +91,176 @@ void testCreateDatabaseAST() throws JSQLParserException { assertEquals("mydb", createDatabase.getDatabaseName()); assertTrue(createDatabase.hasIfNotExists()); } + + @ParameterizedTest + @ValueSource(strings = {"CREATE DATABASE mydb2", + "CREATE DATABASE db1 DEFAULT CHARSET = utf8mb4", + "CREATE DATABASE db1 CHAR SET utf8mb4 DEFAULT ENCRYPTION = 'N'", + "CREATE DATABASE IF NOT EXISTS db1 CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci"}) + void testMySqlForms(String sql) throws JSQLParserException { + assertRoundTrip(sql, Dialect.MYSQL); + assertRoundTrip(sql, null); + } + + @ParameterizedTest + @ValueSource(strings = { + "CREATE DATABASE mydb WITH OWNER = app ENCODING = 'UTF8' LC_COLLATE = 'C' " + + "LC_CTYPE = 'C' TEMPLATE = template0 CONNECTION LIMIT = 10", + "CREATE DATABASE mydb OWNER app STRATEGY = file_copy LOCALE 'C' " + + "BUILTIN_LOCALE = 'C.UTF-8' LOCALE_PROVIDER = builtin " + + "COLLATION_VERSION = '1' TABLESPACE = fast ALLOW_CONNECTIONS = true " + + "CONNECTION LIMIT = -1 IS_TEMPLATE false OID = 16384", + "CREATE DATABASE mydb WITH TEMPLATE DEFAULT ENCODING UTF8 LOCALE_PROVIDER icu " + + "ICU_LOCALE 'de-DE' ICU_RULES = '&a < b'"}) + void testPostgreSqlForms(String sql) throws JSQLParserException { + assertRoundTrip(sql, Dialect.POSTGRESQL); + assertRoundTrip(sql, null); + } + + @Test + void testPostgreSqlOptionsAreTyped() throws JSQLParserException { + CreateDatabase createDatabase = parse("CREATE DATABASE mydb WITH OWNER = app " + + "ENCODING = 'UTF8' TEMPLATE template0 CONNECTION LIMIT = -1 IS_TEMPLATE false", + Dialect.POSTGRESQL); + assertTrue(createDatabase.isUseWith()); + List options = createDatabase.getOptions(); + assertEquals(Arrays.asList(DatabaseOption.Kind.OWNER, DatabaseOption.Kind.ENCODING, + DatabaseOption.Kind.TEMPLATE, DatabaseOption.Kind.CONNECTION_LIMIT, + DatabaseOption.Kind.IS_TEMPLATE), + options.stream().map(DatabaseOption::getKind).collect( + java.util.stream.Collectors.toList())); + assertEquals("app", options.get(0).getValue()); + assertTrue(options.get(0).isUseEquals()); + assertEquals("'UTF8'", options.get(1).getValue()); + assertFalse(options.get(2).isUseEquals()); + assertEquals("CONNECTION LIMIT", options.get(3).getName()); + assertEquals("-1", options.get(3).getValue()); + assertEquals("false", options.get(4).getValue()); + + createDatabase.getOption(DatabaseOption.Kind.OWNER).orElseThrow().setValue("CURRENT_USER"); + options.remove(4); + assertEquals("CREATE DATABASE mydb WITH OWNER = CURRENT_USER ENCODING = 'UTF8' " + + "TEMPLATE template0 CONNECTION LIMIT = -1", createDatabase.toString()); + } + + @Test + void testCharsetSpellingIsTyped() throws JSQLParserException { + CreateDatabase createDatabase = + parse("CREATE DATABASE db1 DEFAULT CHARSET = utf8mb4", Dialect.MYSQL); + assertEquals(1, createDatabase.getOptions().size()); + DatabaseOption charset = createDatabase.getOptions().get(0); + assertEquals(DatabaseOption.Kind.CHARACTER_SET, charset.getKind()); + assertEquals("CHARSET", charset.getName()); + assertEquals("utf8mb4", charset.getValue()); + assertTrue(charset.isUseDefault()); + assertTrue(charset.isUseEquals()); + } + + @Test + void testNoEmptyRawOption() throws JSQLParserException { + CreateDatabase createDatabase = parse("CREATE DATABASE mydb2", null); + assertTrue(createDatabase.getOptions().isEmpty()); + assertNull(createDatabase.getDatabaseOptions()); + assertEquals("CREATE DATABASE mydb2", createDatabase.toString()); + + createDatabase = parse("CREATE DATABASE db1 CHARACTER SET utf8mb4", Dialect.MYSQL); + assertEquals(1, createDatabase.getOptions().size()); + assertEquals("CREATE DATABASE db1 CHARACTER SET utf8mb4", createDatabase.toString()); + } + + @Test + void testUnknownOptionTextStaysRaw() throws JSQLParserException { + CreateDatabase createDatabase = + assertInstanceOf(CreateDatabase.class, assertSqlCanBeParsedAndDeparsed( + "CREATE DATABASE mydb WITH OWNER app LOCATION 'x'")); + assertEquals(2, createDatabase.getOptions().size()); + assertEquals(DatabaseOption.Kind.OWNER, createDatabase.getOptions().get(0).getKind()); + DatabaseOption raw = createDatabase.getOptions().get(1); + assertEquals(DatabaseOption.Kind.OTHER, raw.getKind()); + assertEquals(Arrays.asList("LOCATION", "'x'"), raw.getTokens()); + + // WITH is kept with the raw text when no known option follows it. + createDatabase = assertInstanceOf(CreateDatabase.class, + assertSqlCanBeParsedAndDeparsed("CREATE DATABASE mydb WITH LOCATION 'x'")); + assertFalse(createDatabase.isUseWith()); + assertEquals(Arrays.asList("WITH", "LOCATION", "'x'"), + createDatabase.getOptions().get(0).getTokens()); + } + + @Test + void testLegacyDatabaseOptionTokens() throws JSQLParserException { + CreateDatabase createDatabase = parse( + "CREATE DATABASE mydb WITH OWNER = app CONNECTION LIMIT 5", Dialect.POSTGRESQL); + List tokens = createDatabase.getDatabaseOptions(); + assertEquals(Arrays.asList("WITH", "OWNER", "=", "app", "CONNECTION", "LIMIT", "5"), + tokens); + assertThrows(UnsupportedOperationException.class, () -> tokens.add("x")); + + createDatabase.getOptions().get(1).setValue("10"); + assertEquals("10", createDatabase.getDatabaseOptions().get(6)); + + createDatabase.addDatabaseOptions("IS_TEMPLATE", "true"); + assertEquals(DatabaseOption.Kind.OWNER, createDatabase.getOptions().get(0).getKind()); + assertEquals(DatabaseOption.Kind.OTHER, createDatabase.getOptions().get(2).getKind()); + assertEquals("CREATE DATABASE mydb WITH OWNER = app CONNECTION LIMIT 10 IS_TEMPLATE true", + createDatabase.toString()); + + List raw = new ArrayList<>(Arrays.asList("ENCODING", "'UTF8'")); + createDatabase.setDatabaseOptions(raw); + assertFalse(createDatabase.isUseWith()); + assertEquals(DatabaseOption.Kind.OTHER, createDatabase.getOptions().get(0).getKind()); + assertEquals("CREATE DATABASE mydb ENCODING 'UTF8'", createDatabase.toString()); + createDatabase.setDatabaseOptions(null); + assertTrue(createDatabase.getOptions().isEmpty()); + assertEquals("CREATE DATABASE mydb", createDatabase.toString()); + } + + @Test + void testOptionListOwnership() { + List options = new ArrayList<>(); + options.add(new DatabaseOption(DatabaseOption.Kind.OWNER, "OWNER", "app", false, true)); + CreateDatabase createDatabase = + new CreateDatabase().withDatabaseName("mydb").withUseWith(true) + .withOptions(options); + options.clear(); + assertEquals("CREATE DATABASE mydb WITH OWNER = app", createDatabase.toString()); + createDatabase.getOptions().add( + new DatabaseOption(DatabaseOption.Kind.IS_TEMPLATE, "IS_TEMPLATE", "true", false, + false)); + assertEquals("CREATE DATABASE mydb WITH OWNER = app IS_TEMPLATE true", + createDatabase.toString()); + createDatabase.setOptions(createDatabase.getOptions()); + assertEquals(2, createDatabase.getOptions().size()); + createDatabase.setOptions(null); + assertTrue(createDatabase.getOptions().isEmpty()); + } + + @Test + void testValidation() { + validateNoErrors("CREATE DATABASE mydb WITH OWNER = app", 1, DatabaseType.POSTGRESQL); + validateNoErrors("CREATE DATABASE db1 DEFAULT CHARSET = utf8mb4", 1, DatabaseType.MYSQL, + DatabaseType.MARIADB); + } + + /** Parses, deparses and reparses with the dialect, or without one if it is null. */ + private static void assertRoundTrip(String sql, Dialect dialect) throws JSQLParserException { + Consumer consumer = consumer(dialect); + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, false, consumer); + CreateDatabase createDatabase = assertInstanceOf(CreateDatabase.class, statement); + assertTrue(createDatabase.getOptions().stream() + .noneMatch(option -> option.getKind() == DatabaseOption.Kind.OTHER)); + StringBuilder deparsed = new StringBuilder(); + statement.accept(new StatementDeParser(deparsed)); + assertEquals(sql, CCJSqlParserUtil.parse(deparsed.toString(), consumer).toString()); + assertEquals(2, CCJSqlParserUtil.parseStatements(sql + "; SELECT 1", consumer).size()); + } + + private static CreateDatabase parse(String sql, Dialect dialect) throws JSQLParserException { + return (CreateDatabase) CCJSqlParserUtil.parse(sql, consumer(dialect)); + } + + private static Consumer consumer(Dialect dialect) { + return dialect == null ? parser -> { + } : parser -> parser.withDialect(dialect); + } } diff --git a/src/test/java/net/sf/jsqlparser/statement/create/schema/CreateSchemaTest.java b/src/test/java/net/sf/jsqlparser/statement/create/schema/CreateSchemaTest.java index 5d6093f18..80b5db9d7 100644 --- a/src/test/java/net/sf/jsqlparser/statement/create/schema/CreateSchemaTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/create/schema/CreateSchemaTest.java @@ -15,6 +15,7 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; import net.sf.jsqlparser.parser.CCJSqlParserUtil; import net.sf.jsqlparser.statement.create.database.DatabaseOption; import org.junit.jupiter.api.Test; @@ -77,4 +78,20 @@ void testMySqlCreateSchemaOptions() throws JSQLParserException { assertEquals(sql, CCJSqlParserUtil.parse(sql).toString()); } + + @Test + void testMySqlCreateSchemaCharset() throws JSQLParserException { + String sql = "CREATE SCHEMA db1 DEFAULT CHARSET = utf8mb4 DEFAULT ENCRYPTION = 'N'"; + for (Dialect dialect : new Dialect[] {Dialect.MYSQL, null}) { + CreateSchema schema = (CreateSchema) assertSqlCanBeParsedAndDeparsed(sql, false, + dialect == null ? parser -> { + } : parser -> parser.withDialect(dialect)); + DatabaseOption charset = + schema.getDatabaseOption(DatabaseOption.Kind.CHARACTER_SET).orElseThrow(); + assertEquals("CHARSET", charset.getName()); + assertEquals("utf8mb4", charset.getValue()); + assertTrue(charset.isUseDefault()); + } + assertSqlCanBeParsedAndDeparsed("CREATE SCHEMA db1 CHAR SET utf8mb4"); + } } From c458ecb7d7be418ec11d180fce47c2dd0ddd0ded Mon Sep 17 00:00:00 2001 From: minleejae Date: Wed, 14 Oct 2026 16:16:55 +0900 Subject: [PATCH 2/3] feat: parse ALTER FUNCTION, PROCEDURE and TRIGGER actions and MySQL routine definers Model PostgreSQL ALTER FUNCTION/PROCEDURE/ROUTINE and MySQL ALTER FUNCTION/PROCEDURE as AlterRoutine with typed RoutineCharacteristic items shared by both dialects; SET/RESET reuse ConfigurationParameterChange and the MySQL COMMENT keeps its literal as a StringValue. Add PostgreSQL ALTER TRIGGER as AlterTrigger. Unmodelled forms remain unsupported statements. Keep the MySQL DEFINER of CREATE FUNCTION/PROCEDURE, accept CREATE TRIGGER IF NOT EXISTS, keep MariaDB's OR REPLACE, and capture procedural trigger bodies with the routine body boundary so the next statement is split correctly. Signed-off-by: minleejae --- README.md | 1 + .../parser/RoutineBodyBoundary.java | 10 + .../sf/jsqlparser/parser/feature/Feature.java | 12 + .../ConfigurationParameterChange.java | 3 +- .../statement/CreateFunctionalStatement.java | 17 + .../statement/RoutineCharacteristic.java | 263 ++++++++++ .../statement/StatementFeatureVisitor.java | 12 + .../statement/StatementVisitor.java | 18 + .../statement/StatementVisitorAdapter.java | 14 + .../statement/alter/AlterRoutine.java | 244 +++++++++ .../statement/alter/AlterTrigger.java | 135 +++++ .../create/trigger/CreateTrigger.java | 31 +- .../sf/jsqlparser/util/TablesNamesFinder.java | 14 + .../util/deparser/StatementDeParser.java | 13 + .../validation/feature/MariaDbVersion.java | 4 + .../util/validation/feature/MySqlVersion.java | 4 + .../validation/feature/PostgresqlVersion.java | 7 + .../validator/StatementValidator.java | 25 + .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 486 +++++++++++++++++- src/site/sphinx/index.rst | 2 + src/site/sphinx/usage.rst | 52 ++ ...tatementFeatureVisitorMetadataDdlTest.java | 6 + .../statement/alter/AlterRoutineTest.java | 334 ++++++++++++ .../statement/alter/AlterTriggerTest.java | 109 ++++ .../create/CreateFunctionalStatementTest.java | 69 +++ .../create/trigger/CreateTriggerTest.java | 136 +++++ 26 files changed, 2010 insertions(+), 11 deletions(-) create mode 100644 src/main/java/net/sf/jsqlparser/statement/RoutineCharacteristic.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/alter/AlterRoutine.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/alter/AlterTrigger.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/alter/AlterRoutineTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/alter/AlterTriggerTest.java diff --git a/README.md b/README.md index d39e81ea6..d54e335db 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,7 @@ and missing syntax gets added on demand β€” [open an issue](https://github.com/J | **DDL** | `CREATE …` Β· `ALTER …` Β· `DROP …` | | **MySQL table maintenance** | `ANALYZE` Β· `CHECK` Β· `OPTIMIZE` Β· `REPAIR TABLE` | | **PostgreSQL RLS** | `CREATE POLICY` Β· `ALTER TABLE … ENABLE`/`DISABLE`/`FORCE`/`NO FORCE ROW LEVEL SECURITY` | +| **Routine changes** | `ALTER FUNCTION` Β· `ALTER PROCEDURE` Β· `ALTER ROUTINE` Β· PostgreSQL `ALTER TRIGGER` | | **Informix constraints** | `ALTER TABLE … ADD CONSTRAINT` with trailing constraint names for primary, unique, foreign and check constraints; enable with `parser.withDialect(Dialect.INFORMIX)` | | **Salesforce SOQL** | `INCLUDES` Β· `EXCLUDES` | diff --git a/src/main/java/net/sf/jsqlparser/parser/RoutineBodyBoundary.java b/src/main/java/net/sf/jsqlparser/parser/RoutineBodyBoundary.java index 4a5935fd4..a8d3d8825 100644 --- a/src/main/java/net/sf/jsqlparser/parser/RoutineBodyBoundary.java +++ b/src/main/java/net/sf/jsqlparser/parser/RoutineBodyBoundary.java @@ -39,6 +39,16 @@ private enum Block { private boolean handlerAction; private boolean statementStart = true; + /** + * Tracks a compound statement without a routine header, such as a MySQL trigger body. Its + * tokens belong to the body from the start, as after a routine's parameter list. + */ + static RoutineBodyBoundary forCompoundStatement() { + RoutineBodyBoundary boundary = new RoutineBodyBoundary(); + boundary.hasParameters = true; + return boundary; + } + boolean endsAt(Token token) throws ParseException { if (token.kind == EOF) { if (!blocks.isEmpty()) { 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 65ee61461..50ebf078e 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -717,6 +717,18 @@ public enum Feature { functionalStatement, alterFunction, alterProcedure, createOrAlterRoutine, + /** + * PostgreSQL "ALTER ROUTINE" statement is allowed + * + * @see net.sf.jsqlparser.statement.alter.AlterRoutine + */ + alterRoutine, + /** + * PostgreSQL "ALTER TRIGGER" statement is allowed + * + * @see net.sf.jsqlparser.statement.alter.AlterTrigger + */ + alterTrigger, /** * SQL block starting with "BEGIN" and ends with "END" statement is allowed * diff --git a/src/main/java/net/sf/jsqlparser/statement/ConfigurationParameterChange.java b/src/main/java/net/sf/jsqlparser/statement/ConfigurationParameterChange.java index 868c7f6e8..913cace5e 100644 --- a/src/main/java/net/sf/jsqlparser/statement/ConfigurationParameterChange.java +++ b/src/main/java/net/sf/jsqlparser/statement/ConfigurationParameterChange.java @@ -16,7 +16,8 @@ /** * A PostgreSQL {@code SET} or {@code RESET} of a configuration parameter, shared by statements that - * store parameter defaults, such as {@code ALTER ROLE} and {@code ALTER DATABASE}: + * store parameter defaults: {@code ALTER ROLE}, {@code ALTER DATABASE} and the routine + * characteristics of {@code ALTER FUNCTION}, {@code ALTER PROCEDURE} and {@code ALTER ROUTINE}: * *
  * SET parameter { TO | = } { value [, ...] | DEFAULT }
diff --git a/src/main/java/net/sf/jsqlparser/statement/CreateFunctionalStatement.java b/src/main/java/net/sf/jsqlparser/statement/CreateFunctionalStatement.java
index 0845742e9..c34037ada 100644
--- a/src/main/java/net/sf/jsqlparser/statement/CreateFunctionalStatement.java
+++ b/src/main/java/net/sf/jsqlparser/statement/CreateFunctionalStatement.java
@@ -31,6 +31,7 @@ public enum Operation {
     }
 
     private Operation operation = Operation.CREATE;
+    private MySqlDefiner definer;
     private FunctionReturnType returnType;
     private List routineBodyParts;
 
@@ -42,6 +43,19 @@ public void setOperation(Operation operation) {
         this.operation = operation;
     }
 
+    /**
+     * The MySQL {@code DEFINER} account, rendered after {@code CREATE [OR REPLACE]}.
+     *
+     * @return the definer, or {@code null}
+     */
+    public MySqlDefiner getDefiner() {
+        return definer;
+    }
+
+    public void setDefiner(MySqlDefiner definer) {
+        this.definer = definer;
+    }
+
     public FunctionReturnType getReturnType() {
         return returnType;
     }
@@ -139,6 +153,9 @@ public StringBuilder appendTo(StringBuilder builder, Consumer prin
         if (orReplace && operation == Operation.CREATE) {
             builder.append("OR REPLACE ");
         }
+        if (definer != null) {
+            builder.append("DEFINER = ").append(definer).append(' ');
+        }
         builder.append(kind).append(' ');
         return appendDeclarationTo(builder, printer);
     }
diff --git a/src/main/java/net/sf/jsqlparser/statement/RoutineCharacteristic.java b/src/main/java/net/sf/jsqlparser/statement/RoutineCharacteristic.java
new file mode 100644
index 000000000..93df5a7aa
--- /dev/null
+++ b/src/main/java/net/sf/jsqlparser/statement/RoutineCharacteristic.java
@@ -0,0 +1,263 @@
+/*-
+ * #%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;
+
+import java.io.Serializable;
+import java.util.function.Consumer;
+import net.sf.jsqlparser.expression.Expression;
+import net.sf.jsqlparser.expression.StringValue;
+
+/**
+ * A characteristic set by {@code ALTER FUNCTION}, {@code ALTER PROCEDURE} or {@code ALTER ROUTINE}.
+ * PostgreSQL and MySQL share the model, and each {@link Kind} renders its own SQL:
+ *
+ * 
    + *
  • PostgreSQL: the volatility, the null input behavior, {@code [NOT] LEAKPROOF}, + * {@code [EXTERNAL] SECURITY}, {@code PARALLEL}, {@code COST}, {@code ROWS}, {@code SUPPORT} and + * the {@code SET} or {@code RESET} of a configuration parameter;
  • + *
  • MySQL: {@code COMMENT}, {@code LANGUAGE SQL}, the SQL data access and + * {@code SQL SECURITY}.
  • + *
+ * + * The security kinds are shared; {@link #getSecurityClause()} keeps the spelling. Values keep their + * SQL text, such as {@code 100}. The {@code COMMENT} literal is a {@link StringValue}. + */ +public class RoutineCharacteristic implements Serializable { + + public enum Kind { + /** PostgreSQL {@code IMMUTABLE}. */ + IMMUTABLE("IMMUTABLE"), + /** PostgreSQL {@code STABLE}. */ + STABLE("STABLE"), + /** PostgreSQL {@code VOLATILE}. */ + VOLATILE("VOLATILE"), + /** PostgreSQL {@code CALLED ON NULL INPUT}. */ + CALLED_ON_NULL_INPUT("CALLED ON NULL INPUT"), + /** PostgreSQL {@code RETURNS NULL ON NULL INPUT}. */ + RETURNS_NULL_ON_NULL_INPUT("RETURNS NULL ON NULL INPUT"), + /** PostgreSQL {@code STRICT}. */ + STRICT("STRICT"), + /** PostgreSQL {@code LEAKPROOF}. */ + LEAKPROOF("LEAKPROOF"), + /** PostgreSQL {@code NOT LEAKPROOF}. */ + NOT_LEAKPROOF("NOT LEAKPROOF"), + /** {@code DEFINER}, preceded by the {@link SecurityClause}. */ + SECURITY_DEFINER("DEFINER"), + /** {@code INVOKER}, preceded by the {@link SecurityClause}. */ + SECURITY_INVOKER("INVOKER"), + /** PostgreSQL {@code PARALLEL UNSAFE}. */ + PARALLEL_UNSAFE("PARALLEL UNSAFE"), + /** PostgreSQL {@code PARALLEL RESTRICTED}. */ + PARALLEL_RESTRICTED("PARALLEL RESTRICTED"), + /** PostgreSQL {@code PARALLEL SAFE}. */ + PARALLEL_SAFE("PARALLEL SAFE"), + /** PostgreSQL {@code COST} followed by the numeric {@link #getValue()}. */ + COST("COST"), + /** PostgreSQL {@code ROWS} followed by the numeric {@link #getValue()}. */ + ROWS("ROWS"), + /** PostgreSQL {@code SUPPORT} followed by the function name in {@link #getValue()}. */ + SUPPORT("SUPPORT"), + /** PostgreSQL {@code SET} or {@code RESET}, see {@link #getParameterChange()}. */ + CONFIGURATION_PARAMETER(null), + /** MySQL {@code COMMENT} followed by the string literal of {@link #getComment()}. */ + COMMENT("COMMENT"), + /** MySQL {@code LANGUAGE SQL}. */ + LANGUAGE_SQL("LANGUAGE SQL"), + /** MySQL {@code CONTAINS SQL}. */ + CONTAINS_SQL("CONTAINS SQL"), + /** MySQL {@code NO SQL}. */ + NO_SQL("NO SQL"), + /** MySQL {@code READS SQL DATA}. */ + READS_SQL_DATA("READS SQL DATA"), + /** MySQL {@code MODIFIES SQL DATA}. */ + MODIFIES_SQL_DATA("MODIFIES SQL DATA"); + + private final String keywords; + + Kind(String keywords) { + this.keywords = keywords; + } + + private boolean hasValue() { + return this == COST || this == ROWS || this == SUPPORT || this == COMMENT; + } + } + + /** The words before {@code DEFINER} or {@code INVOKER}. */ + public enum SecurityClause { + /** PostgreSQL {@code SECURITY}. */ + SECURITY("SECURITY"), + /** PostgreSQL {@code EXTERNAL SECURITY}; {@code EXTERNAL} has no effect. */ + EXTERNAL_SECURITY("EXTERNAL SECURITY"), + /** MySQL {@code SQL SECURITY}. */ + SQL_SECURITY("SQL SECURITY"); + + private final String keywords; + + SecurityClause(String keywords) { + this.keywords = keywords; + } + } + + private Kind kind; + private String value; + private StringValue comment; + private SecurityClause securityClause; + private ConfigurationParameterChange parameterChange; + + public RoutineCharacteristic(Kind kind) { + this.kind = kind; + } + + public RoutineCharacteristic(Kind kind, String value) { + this.kind = kind; + setValue(value); + } + + public RoutineCharacteristic(ConfigurationParameterChange parameterChange) { + this.kind = Kind.CONFIGURATION_PARAMETER; + this.parameterChange = parameterChange; + } + + /** + * Creates a MySQL {@code COMMENT} characteristic. + * + * @param comment the string literal + * @return the characteristic + */ + public static RoutineCharacteristic comment(StringValue comment) { + return new RoutineCharacteristic(Kind.COMMENT).withComment(comment); + } + + public Kind getKind() { + return kind; + } + + public void setKind(Kind kind) { + this.kind = kind; + } + + /** + * The SQL text after the keyword of {@link Kind#COST}, {@link Kind#ROWS}, {@link Kind#SUPPORT} + * and {@link Kind#COMMENT}. For {@code COMMENT} it is derived from {@link #getComment()}, such + * as {@code 'it\'s'}. + * + * @return the value as written, or {@code null} for other kinds + */ + public String getValue() { + if (kind == Kind.COMMENT) { + return comment == null ? null : comment.toString(); + } + return value; + } + + /** + * Sets the SQL text of the value. For {@link Kind#COMMENT} it replaces {@link #getComment()} + * with a literal read without dialect escaping; use {@link #setComment(StringValue)} to keep + * MySQL backslash escapes. + * + * @param value the SQL text + */ + public void setValue(String value) { + if (kind == Kind.COMMENT) { + comment = value == null ? null : new StringValue(value); + } else { + this.value = value; + } + } + + /** + * The string literal of {@link Kind#COMMENT}. {@link StringValue#getNotExcapedValue()} returns + * the text that MySQL stores. + * + * @return the literal, or {@code null} for other kinds + */ + public StringValue getComment() { + return comment; + } + + public void setComment(StringValue comment) { + this.comment = comment; + } + + public RoutineCharacteristic withComment(StringValue comment) { + setComment(comment); + return this; + } + + /** + * The spelling of {@link Kind#SECURITY_DEFINER} and {@link Kind#SECURITY_INVOKER}; {@code null} + * renders {@link SecurityClause#SECURITY}. + * + * @return the clause, or {@code null} + */ + public SecurityClause getSecurityClause() { + return securityClause; + } + + public void setSecurityClause(SecurityClause securityClause) { + this.securityClause = securityClause; + } + + /** + * The change of {@link Kind#CONFIGURATION_PARAMETER}. + * + * @return the change, or {@code null} for other kinds + */ + public ConfigurationParameterChange getParameterChange() { + return parameterChange; + } + + public void setParameterChange(ConfigurationParameterChange parameterChange) { + this.parameterChange = parameterChange; + } + + public RoutineCharacteristic withValue(String value) { + setValue(value); + return this; + } + + public RoutineCharacteristic withSecurityClause(SecurityClause securityClause) { + setSecurityClause(securityClause); + return this; + } + + /** + * Appends this characteristic to a builder. + * + * @param builder the target + * @param valueRenderer appends each configuration parameter value + * @return the builder + */ + public StringBuilder appendTo(StringBuilder builder, Consumer valueRenderer) { + switch (kind) { + case CONFIGURATION_PARAMETER: + if (parameterChange == null) { + throw new IllegalStateException("Expected a configuration parameter change"); + } + return parameterChange.appendTo(builder, valueRenderer); + case SECURITY_DEFINER: + case SECURITY_INVOKER: + return builder + .append((securityClause == null ? SecurityClause.SECURITY + : securityClause).keywords) + .append(' ').append(kind.keywords); + default: + builder.append(kind.keywords); + return kind.hasValue() ? builder.append(' ').append(getValue()) : builder; + } + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index 91b272124..8aa4919f7 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -26,6 +26,8 @@ import net.sf.jsqlparser.parser.CCJSqlParserUtil; import net.sf.jsqlparser.statement.alter.Alter; import net.sf.jsqlparser.statement.alter.AlterCollation; +import net.sf.jsqlparser.statement.alter.AlterRoutine; +import net.sf.jsqlparser.statement.alter.AlterTrigger; import net.sf.jsqlparser.statement.alter.AlterDomain; import net.sf.jsqlparser.statement.alter.AlterExtension; import net.sf.jsqlparser.statement.alter.AlterForeignDataWrapper; @@ -1181,6 +1183,16 @@ public Void visit(AlterCollation statement, S context) { return schemaOnly(); } + @Override + public Void visit(AlterRoutine statement, S context) { + return schemaOnly(); + } + + @Override + public Void visit(AlterTrigger statement, S context) { + return schemaOnly(); + } + @Override public Void visit(AlterDatabase statement, S context) { return schemaOnly(); diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index bf0bf3e8b..3deb4a00e 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -23,6 +23,8 @@ import net.sf.jsqlparser.statement.notify.NotifyStatement; import net.sf.jsqlparser.statement.create.collation.CreateCollation; import net.sf.jsqlparser.statement.alter.AlterCollation; +import net.sf.jsqlparser.statement.alter.AlterRoutine; +import net.sf.jsqlparser.statement.alter.AlterTrigger; import net.sf.jsqlparser.statement.alter.AlterPolicy; import net.sf.jsqlparser.statement.drop.DropPolicy; import net.sf.jsqlparser.statement.create.statistics.CreateStatistics; @@ -875,4 +877,20 @@ default T visit(AlterCollation statement, S context) { default void visit(AlterCollation statement) { visit(statement, null); } + + default T visit(AlterRoutine statement, S context) { + return null; + } + + default void visit(AlterRoutine statement) { + visit(statement, null); + } + + default T visit(AlterTrigger statement, S context) { + return null; + } + + default void visit(AlterTrigger statement) { + visit(statement, null); + } } diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index 8a80763ca..aef820df8 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -11,6 +11,8 @@ import net.sf.jsqlparser.statement.alter.AlterRelation; import net.sf.jsqlparser.statement.alter.AlterPolicy; +import net.sf.jsqlparser.statement.alter.AlterRoutine; +import net.sf.jsqlparser.statement.alter.AlterTrigger; import net.sf.jsqlparser.statement.drop.DropPolicy; import net.sf.jsqlparser.statement.create.statistics.CreateStatistics; import net.sf.jsqlparser.statement.create.fdw.CreateForeignDataWrapper; @@ -1015,4 +1017,16 @@ public T visit(CreateCollation statement, S context) { return null; } + @Override + public T visit(AlterRoutine statement, S context) { + statement.visitExpressions(expression -> expression.accept(expressionVisitor, context)); + return null; + } + + @Override + public T visit(AlterTrigger statement, S context) { + fromItemVisitor.visitFromItem(statement.getTable(), context); + return null; + } + } diff --git a/src/main/java/net/sf/jsqlparser/statement/alter/AlterRoutine.java b/src/main/java/net/sf/jsqlparser/statement/alter/AlterRoutine.java new file mode 100644 index 000000000..66f1717cc --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/alter/AlterRoutine.java @@ -0,0 +1,244 @@ +/*- + * #%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.alter; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.statement.RoutineCharacteristic; +import net.sf.jsqlparser.statement.RoutineReference; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; + +/** + * PostgreSQL {@code ALTER FUNCTION}, {@code ALTER PROCEDURE} or {@code ALTER ROUTINE}, or MySQL + * {@code ALTER FUNCTION} or {@code ALTER PROCEDURE}. Both change characteristics through the shared + * {@link RoutineCharacteristic} model. PostgreSQL can instead rename the routine, change its owner + * or schema, or mark it as dependent on an extension. Names retain SQL identifier quoting. SQL + * Server's routine redefinitions remain + * {@link net.sf.jsqlparser.statement.CreateFunctionalStatement} nodes. + */ +public class AlterRoutine implements Statement { + + public enum RoutineType { + FUNCTION, PROCEDURE, ROUTINE + } + + public enum Action { + /** Characteristics, see {@link #getCharacteristics()} and {@link #isRestrict()}. */ + CHARACTERISTICS, + /** {@code RENAME TO}, see {@link #getNewName()}. */ + RENAME, + /** {@code OWNER TO}, see {@link #getOwner()}. */ + OWNER, + /** {@code SET SCHEMA}, see {@link #getSchemaName()}. */ + SET_SCHEMA, + /** + * {@code [NO] DEPENDS ON EXTENSION}, see {@link #getExtensionName()} and + * {@link #isNoDependency()}. + */ + DEPENDS_ON_EXTENSION + } + + private RoutineType routineType = RoutineType.FUNCTION; + private RoutineReference routine; + private Action action = Action.CHARACTERISTICS; + private final List characteristics = new ArrayList<>(); + private boolean restrict; + private String newName; + private String owner; + private String schemaName; + private String extensionName; + private boolean noDependency; + + public RoutineType getRoutineType() { + return routineType; + } + + public void setRoutineType(RoutineType routineType) { + this.routineType = routineType; + } + + /** + * The routine name and, for PostgreSQL, its optional argument types. + * + * @return the routine + */ + public RoutineReference getRoutine() { + return routine; + } + + public void setRoutine(RoutineReference routine) { + this.routine = routine; + } + + /** + * The rendered alteration. Payloads of other actions are retained for a later switch back. + * + * @return the action, {@link Action#CHARACTERISTICS} unless changed + */ + public Action getAction() { + return action; + } + + public void setAction(Action action) { + this.action = action; + } + + /** + * The characteristics of {@link Action#CHARACTERISTICS}, as a mutable list. + * + * @return the characteristics in source order + */ + public List getCharacteristics() { + return characteristics; + } + + public Optional getCharacteristic(RoutineCharacteristic.Kind kind) { + return characteristics.stream().filter(characteristic -> characteristic.getKind() == kind) + .findFirst(); + } + + /** + * Whether PostgreSQL's {@code RESTRICT}, which has no effect, follows the characteristics. + * + * @return {@code true} if {@code RESTRICT} is rendered + */ + public boolean isRestrict() { + return restrict; + } + + public void setRestrict(boolean restrict) { + this.restrict = restrict; + } + + public String getNewName() { + return newName; + } + + public void setNewName(String newName) { + this.newName = newName; + } + + /** + * The new owner, a role name or {@code CURRENT_ROLE}, {@code CURRENT_USER} or + * {@code SESSION_USER}. + * + * @return the owner + */ + public String getOwner() { + return owner; + } + + public void setOwner(String owner) { + this.owner = owner; + } + + public String getSchemaName() { + return schemaName; + } + + public void setSchemaName(String schemaName) { + this.schemaName = schemaName; + } + + public String getExtensionName() { + return extensionName; + } + + public void setExtensionName(String extensionName) { + this.extensionName = extensionName; + } + + /** + * Whether {@code NO DEPENDS ON EXTENSION} removes the dependency. + * + * @return {@code true} for {@code NO DEPENDS} + */ + public boolean isNoDependency() { + return noDependency; + } + + public void setNoDependency(boolean noDependency) { + this.noDependency = noDependency; + } + + public AlterRoutine withRoutineType(RoutineType routineType) { + setRoutineType(routineType); + return this; + } + + public AlterRoutine withRoutine(RoutineReference routine) { + setRoutine(routine); + return this; + } + + public AlterRoutine withAction(Action action) { + setAction(action); + return this; + } + + /** + * Visits the configuration parameter values of the characteristics. + * + * @param visitor receives each value + */ + public void visitExpressions(Consumer visitor) { + for (RoutineCharacteristic characteristic : characteristics) { + if (characteristic.getParameterChange() != null + && characteristic.getParameterChange().getValues() != null) { + characteristic.getParameterChange().getValues().forEach(visitor); + } + } + } + + public StringBuilder appendTo(StringBuilder builder) { + return appendTo(builder, builder::append); + } + + /** + * Appends this statement to a builder. + * + * @param builder the target + * @param valueRenderer appends each configuration parameter value + * @return the builder + */ + public StringBuilder appendTo(StringBuilder builder, Consumer valueRenderer) { + builder.append("ALTER ").append(routineType).append(' ').append(routine); + switch (action) { + case RENAME: + return builder.append(" RENAME TO ").append(newName); + case OWNER: + return builder.append(" OWNER TO ").append(owner); + case SET_SCHEMA: + return builder.append(" SET SCHEMA ").append(schemaName); + case DEPENDS_ON_EXTENSION: + return builder.append(noDependency ? " NO DEPENDS" : " DEPENDS") + .append(" ON EXTENSION ").append(extensionName); + default: + for (RoutineCharacteristic characteristic : characteristics) { + characteristic.appendTo(builder.append(' '), valueRenderer); + } + return restrict ? builder.append(" RESTRICT") : builder; + } + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/alter/AlterTrigger.java b/src/main/java/net/sf/jsqlparser/statement/alter/AlterTrigger.java new file mode 100644 index 000000000..de3be507e --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/alter/AlterTrigger.java @@ -0,0 +1,135 @@ +/*- + * #%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.alter; + +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; + +/** + * PostgreSQL {@code ALTER TRIGGER name ON table}, which renames the trigger or marks it as + * dependent on an extension. Names retain SQL identifier quoting. + */ +public class AlterTrigger implements Statement { + + public enum Action { + /** {@code RENAME TO}, see {@link #getNewName()}. */ + RENAME, + /** + * {@code [NO] DEPENDS ON EXTENSION}, see {@link #getExtensionName()} and + * {@link #isNoDependency()}. + */ + DEPENDS_ON_EXTENSION + } + + private String name; + private Table table; + private Action action = Action.RENAME; + private String newName; + private String extensionName; + private boolean noDependency; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + /** + * The table, view or foreign table that owns the trigger. + * + * @return the table + */ + public Table getTable() { + return table; + } + + public void setTable(Table table) { + this.table = table; + } + + /** + * The rendered alteration. Payloads of the other action are retained for a later switch back. + * + * @return the action, {@link Action#RENAME} unless changed + */ + public Action getAction() { + return action; + } + + public void setAction(Action action) { + this.action = action; + } + + public String getNewName() { + return newName; + } + + public void setNewName(String newName) { + this.newName = newName; + } + + public String getExtensionName() { + return extensionName; + } + + public void setExtensionName(String extensionName) { + this.extensionName = extensionName; + } + + /** + * Whether {@code NO DEPENDS ON EXTENSION} removes the dependency. + * + * @return {@code true} for {@code NO DEPENDS} + */ + public boolean isNoDependency() { + return noDependency; + } + + public void setNoDependency(boolean noDependency) { + this.noDependency = noDependency; + } + + public AlterTrigger withName(String name) { + setName(name); + return this; + } + + public AlterTrigger withTable(Table table) { + setTable(table); + return this; + } + + public AlterTrigger withAction(Action action) { + setAction(action); + return this; + } + + public StringBuilder appendTo(StringBuilder builder) { + builder.append("ALTER TRIGGER ").append(name).append(" ON ").append(table); + if (action == Action.DEPENDS_ON_EXTENSION) { + return builder.append(noDependency ? " NO DEPENDS" : " DEPENDS") + .append(" ON EXTENSION ").append(extensionName); + } + return builder.append(" RENAME TO ").append(newName); + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/create/trigger/CreateTrigger.java b/src/main/java/net/sf/jsqlparser/statement/create/trigger/CreateTrigger.java index 4c3b8f3f8..ce48fdf0d 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/trigger/CreateTrigger.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/trigger/CreateTrigger.java @@ -46,6 +46,7 @@ public enum ExecuteKeyword { private Table trigger; private Timing timing; private boolean orReplace; + private boolean ifNotExists; private boolean constraint; private Table referencedTable; private Orientation orientation; @@ -76,6 +77,19 @@ public void setOrReplace(boolean orReplace) { this.orReplace = orReplace; } + /** + * Whether MySQL's {@code IF NOT EXISTS} follows {@code TRIGGER}. + * + * @return {@code true} if {@code IF NOT EXISTS} is rendered + */ + public boolean isIfNotExists() { + return ifNotExists; + } + + public void setIfNotExists(boolean ifNotExists) { + this.ifNotExists = ifNotExists; + } + public boolean isConstraint() { return constraint; } @@ -207,6 +221,14 @@ public void setOtherTrigger(Table otherTrigger) { this.otherTrigger = otherTrigger; } + /** + * The MySQL trigger statement: a single statement, a {@link net.sf.jsqlparser.statement.Block} + * of statements, or an {@link net.sf.jsqlparser.statement.UnsupportedStatement} holding the + * tokens of a compound statement with procedural statements, such as {@code IF} or + * {@code DECLARE}. + * + * @return the body + */ public Statement getBody() { return body; } @@ -307,10 +329,17 @@ public String toString() { private String mysqlSql() { StringBuilder builder = new StringBuilder("CREATE "); + if (orReplace) { + builder.append("OR REPLACE "); + } if (definer != null) { builder.append("DEFINER = ").append(definer).append(" "); } - builder.append("TRIGGER ").append(trigger).append(" ").append(timing).append(" ") + builder.append("TRIGGER "); + if (ifNotExists) { + builder.append("IF NOT EXISTS "); + } + builder.append(trigger).append(" ").append(timing).append(" ") .append(getEvent()).append(" ON ").append(table).append(" FOR EACH ROW "); if (order != null) { builder.append(order).append(" ").append(otherTrigger).append(" "); diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index aeecdb89d..1045eae02 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -112,6 +112,8 @@ import net.sf.jsqlparser.statement.UseStatement; import net.sf.jsqlparser.statement.alter.Alter; import net.sf.jsqlparser.statement.alter.AlterCollation; +import net.sf.jsqlparser.statement.alter.AlterRoutine; +import net.sf.jsqlparser.statement.alter.AlterTrigger; import net.sf.jsqlparser.statement.alter.AlterDomain; import net.sf.jsqlparser.statement.alter.AlterExtension; import net.sf.jsqlparser.statement.alter.AlterForeignDataWrapper; @@ -3133,4 +3135,16 @@ public Void visit(CreateCollation statement, S context) { public Void visit(AlterCollation statement, S context) { return null; } + + @Override + public Void visit(AlterRoutine statement, S context) { + statement.visitExpressions(expression -> expression.accept(this, context)); + return null; + } + + @Override + public Void visit(AlterTrigger statement, S context) { + visit(statement.getTable(), context); + return null; + } } 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 c06ff88b5..04f07e0e6 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -23,6 +23,8 @@ import net.sf.jsqlparser.statement.notify.NotifyStatement; import net.sf.jsqlparser.statement.create.collation.CreateCollation; import net.sf.jsqlparser.statement.alter.AlterCollation; +import net.sf.jsqlparser.statement.alter.AlterRoutine; +import net.sf.jsqlparser.statement.alter.AlterTrigger; import net.sf.jsqlparser.statement.alter.AlterPolicy; import net.sf.jsqlparser.statement.drop.DropPolicy; import net.sf.jsqlparser.statement.create.statistics.CreateStatistics; @@ -943,4 +945,15 @@ public StringBuilder visit(CreateCollation statement, S context) { public StringBuilder visit(AlterCollation statement, S context) { return statement.appendTo(builder); } + + @Override + public StringBuilder visit(AlterRoutine statement, S context) { + return statement.appendTo(builder, + expression -> expression.accept(expressionDeParser, context)); + } + + @Override + public StringBuilder visit(AlterTrigger statement, S context) { + return statement.appendTo(builder); + } } diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java index b9bfba466..f8b709f2b 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java @@ -120,6 +120,10 @@ public enum MariaDbVersion implements Version { // https://mariadb.com/kb/en/alter-database/ Feature.createDatabase, Feature.alterDatabase, + // https://mariadb.com/kb/en/alter-function/ + // https://mariadb.com/kb/en/alter-procedure/ + Feature.alterFunction, Feature.alterProcedure, + // https://mariadb.com/kb/en/describe/ Feature.describe, // https://mariadb.com/kb/en/explain/ Feature.explain, // https://mariadb.com/kb/en/show/ diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java index 270fbbf35..a8bf66abb 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java @@ -97,6 +97,10 @@ public enum MySqlVersion implements Version { // https://dev.mysql.com/doc/refman/8.0/en/alter-database.html Feature.createDatabase, Feature.alterDatabase, + // https://dev.mysql.com/doc/refman/8.0/en/alter-function.html + // https://dev.mysql.com/doc/refman/8.0/en/alter-procedure.html + Feature.alterFunction, Feature.alterProcedure, + // https://dev.mysql.com/doc/refman/8.0/en/create-database.html Feature.createSchema, // https://dev.mysql.com/doc/refman/8.0/en/create-view.html Feature.createView, 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 4e91f10c8..991848350 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 @@ -124,6 +124,13 @@ public enum PostgresqlVersion implements Version { // https://www.postgresql.org/docs/current/sql-alterdatabase.html Feature.createDatabase, Feature.alterDatabase, + // https://www.postgresql.org/docs/current/sql-alterfunction.html + // https://www.postgresql.org/docs/current/sql-alterprocedure.html + // https://www.postgresql.org/docs/current/sql-alterroutine.html + // https://www.postgresql.org/docs/current/sql-altertrigger.html + Feature.alterFunction, Feature.alterProcedure, Feature.alterRoutine, + Feature.alterTrigger, + // https://www.postgresql.org/docs/16/sql-refreshmaterializedview.html Feature.refreshMaterializedView, Feature.refreshMaterializedWithNoDataView, 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 654331c98..0d98797d6 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 @@ -28,6 +28,8 @@ import net.sf.jsqlparser.statement.notify.NotifyStatement; import net.sf.jsqlparser.statement.create.collation.CreateCollation; import net.sf.jsqlparser.statement.alter.AlterCollation; +import net.sf.jsqlparser.statement.alter.AlterRoutine; +import net.sf.jsqlparser.statement.alter.AlterTrigger; import net.sf.jsqlparser.statement.alter.schema.AlterSchema; import net.sf.jsqlparser.statement.oracle.OracleBlock; import net.sf.jsqlparser.statement.oracle.OracleAssignment; @@ -1197,6 +1199,29 @@ public Void visit(AlterCollation statement, S context) { return null; } + @Override + public Void visit(AlterRoutine statement, S context) { + switch (statement.getRoutineType()) { + case PROCEDURE: + validateFeature(Feature.alterProcedure); + break; + case ROUTINE: + validateFeature(Feature.alterRoutine); + break; + default: + validateFeature(Feature.alterFunction); + } + statement.visitExpressions(this::validateOptionalExpression); + return null; + } + + @Override + public Void visit(AlterTrigger statement, S context) { + validateFeature(Feature.alterTrigger); + validateOptionalFromItem(statement.getTable()); + return null; + } + @Override public Void visit(AlterRelation statement, S context) { boolean index = statement.getObjectType() == AlterRelation.ObjectType.INDEX; diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index bcd2ee199..c7a6aecdf 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -319,6 +319,193 @@ public class CCJSqlParser extends AbstractJSqlParser { "ALLOW_CONNECTIONS", "IS_TEMPLATE", "OID").contains(name); } + /** Whether the words, matched without case, start at the offset. */ + private boolean isWordSequenceAhead(int offset, String... words) { + for (int i = 0; i < words.length; i++) { + Token token = getToken(offset + i); + if (token.kind == EOF || !words[i].equalsIgnoreCase(token.image)) { + return false; + } + } + return true; + } + + private boolean isSignedNumberAhead(int offset) { + Token token = getToken(offset); + if ("+".equals(token.image) || "-".equals(token.image)) { + token = getToken(offset + 1); + } + return token.kind == S_LONG || token.kind == S_DOUBLE; + } + + /** + * Whether ALTER FUNCTION, PROCEDURE or ROUTINE has a PostgreSQL or MySQL alteration after the + * routine name and optional signature. Other forms, such as SQL Server routine definitions, + * remain unsupported statements. + */ + private boolean isAlterRoutineAhead() { + Token token = getToken(1); + if (token.kind != K_FUNCTION && token.kind != K_PROCEDURE && !isKeywordAhead("ROUTINE")) { + return false; + } + int offset = 3; + while (".".equals(getToken(offset).image)) { + offset += 2; + } + if (getToken(offset).kind == OPENING_BRACKET) { + for (int depth = 0; ; offset++) { + int kind = getToken(offset).kind; + if (kind == EOF || kind == ST_SEMICOLON) { + return false; + } + depth += kind == OPENING_BRACKET ? 1 : kind == CLOSING_BRACKET ? -1 : 0; + if (depth == 0) { + offset++; + break; + } + } + } + Token next = getToken(offset + 1); + return getToken(offset).kind == K_RENAME && next.kind == K_TO + || isWordSequenceAhead(offset, "OWNER", "TO") + || getToken(offset).kind == K_SET && next.kind == K_SCHEMA + || isExtensionDependencyAhead(offset) + || isPostgreSqlRoutineCharacteristicAhead(offset) + || isMySqlRoutineCharacteristicAhead(offset); + } + + /** Whether PostgreSQL's [NO] DEPENDS ON EXTENSION starts at the offset. */ + private boolean isExtensionDependencyAhead(int offset) { + return isWordSequenceAhead(getToken(offset).kind == K_NO ? offset + 1 : offset, + "DEPENDS", "ON", "EXTENSION"); + } + + /** + * Whether a PostgreSQL characteristic of ALTER FUNCTION, PROCEDURE or ROUTINE starts at the + * offset. SET without TO, = or FROM after the parameter name is SET SCHEMA instead. + */ + private boolean isPostgreSqlRoutineCharacteristicAhead(int offset) { + Token token = getToken(offset); + switch (token.kind) { + case K_SET: + int nameEnd = offset + 2; + while (".".equals(getToken(nameEnd).image)) { + nameEnd += 2; + } + Token assignment = getToken(nameEnd); + return assignment.kind == K_TO || "=".equals(assignment.image) + || assignment.kind == K_FROM; + case K_RESET: + case K_VOLATILE: + case K_STRICT: + return true; + case K_ROWS: + return isSignedNumberAhead(offset + 1); + case K_NOT: + return isWordSequenceAhead(offset + 1, "LEAKPROOF"); + case K_RETURNS: + return isWordSequenceAhead(offset + 1, "NULL", "ON", "NULL", "INPUT"); + case K_PARALLEL: + return isWordSequenceAhead(offset + 1, "UNSAFE") + || isWordSequenceAhead(offset + 1, "RESTRICTED") + || isWordSequenceAhead(offset + 1, "SAFE"); + case K_SECURITY: + return isRoutineSecurityAhead(offset + 1); + case S_IDENTIFIER: + break; + default: + return false; + } + switch (token.image.toUpperCase(Locale.ROOT)) { + case "IMMUTABLE": + case "STABLE": + case "LEAKPROOF": + return true; + case "CALLED": + return isWordSequenceAhead(offset + 1, "ON", "NULL", "INPUT"); + case "EXTERNAL": + return getToken(offset + 1).kind == K_SECURITY && isRoutineSecurityAhead(offset + 2); + case "COST": + return isSignedNumberAhead(offset + 1); + case "SUPPORT": + int kind = getToken(offset + 1).kind; + return kind != EOF && kind != ST_SEMICOLON; + default: + return false; + } + } + + /** Whether a MySQL characteristic of ALTER FUNCTION or PROCEDURE starts at the offset. */ + private boolean isMySqlRoutineCharacteristicAhead(int offset) { + if (getToken(offset).kind == K_COMMENT) { + return getToken(offset + 1).kind == S_CHAR_LITERAL; + } + return isWordSequenceAhead(offset, "LANGUAGE", "SQL") + || isWordSequenceAhead(offset, "CONTAINS", "SQL") + || isWordSequenceAhead(offset, "NO", "SQL") + || isWordSequenceAhead(offset, "READS", "SQL", "DATA") + || isWordSequenceAhead(offset, "MODIFIES", "SQL", "DATA") + || isWordSequenceAhead(offset, "SQL", "SECURITY") && isRoutineSecurityAhead(offset + 2); + } + + private boolean isRoutineSecurityAhead(int offset) { + return isWordSequenceAhead(offset, "DEFINER") || isWordSequenceAhead(offset, "INVOKER"); + } + + /** + * Whether a MySQL compound statement starts here: BEGIN ... END or a flow control statement, + * where BEGIN, LOOP, REPEAT and WHILE may have a label. + */ + private boolean isMySqlCompoundStatementAhead() { + boolean labeled = getToken(1).kind == S_IDENTIFIER && ":".equals(getToken(2).image); + Token token = getToken(labeled ? 3 : 1); + if (token.kind == K_BEGIN || token.kind == K_LOOP + || isWordSequenceAhead(labeled ? 3 : 1, "REPEAT") + || isWordSequenceAhead(labeled ? 3 : 1, "WHILE")) { + return true; + } + return !labeled && (token.kind == K_IF || token.kind == K_CASE); + } + + /** + * Whether BEGIN ... END holds only SQL statements, each ending with a semicolon, and ends the + * enclosing statement. CASE ... END inside a statement is an expression. + */ + private boolean isStatementBlockAhead() { + if (getToken(1).kind != K_BEGIN) { + return false; + } + boolean statementStart = true; + boolean statements = false; + int caseDepth = 0; + for (int i = 2; ; i++) { + Token token = getToken(i); + if (token.kind == EOF) { + return false; + } else if (token.kind == ST_SEMICOLON) { + statementStart = true; + } else if (statementStart) { + if (token.kind == K_END) { + int next = getToken(i + 1).kind; + return statements && (next == ST_SEMICOLON || next == EOF); + } + switch (token.kind) { + case K_SELECT: case K_INSERT: case K_UPDATE: case K_DELETE: case K_REPLACE: + case K_SET: case K_CALL: case K_WITH: + break; + default: + return false; + } + statementStart = false; + statements = true; + } else if (token.kind == K_CASE) { + caseDepth++; + } else if (token.kind == K_END && caseDepth-- == 0) { + return false; + } + } + } + private static class ObjectNames { private final List names; @@ -4161,6 +4348,18 @@ DoStatement DoStatement(): } Block Block() #Block : { + Block block; +} +{ + block = BeginEndBlock() + [LOOKAHEAD(2) { block.setSemicolonAfterEnd(true); } ] + { + return block; + } +} + +/** BEGIN, statements and END; unlike Block(), it leaves a following semicolon to the caller. */ +Block BeginEndBlock(): { Statements stmts = new Statements(); List list = new ArrayList(); Statement stm; @@ -4192,7 +4391,7 @@ Block Block() #Block : { block.setStatements(stmts); } - [LOOKAHEAD(2) { block.setSemicolonAfterEnd(true); } ] + { return block; } @@ -15775,6 +15974,10 @@ TriggerDefiner MySqlTriggerDefiner(): { TriggerDefiner definer = new TriggerDefiner(); } { MySqlDefiner(definer) { return definer; } } +MySqlDefiner MySqlRoutineDefiner(): +{ MySqlDefiner definer = new MySqlDefiner(); } +{ MySqlDefiner(definer) { return definer; } } + /** MySQL fixes the prefix order: ALGORITHM, DEFINER, SQL SECURITY. */ MySqlViewOptions MySqlViewPrefix(): { @@ -15830,7 +16033,58 @@ CreateTrigger.Order MySqlTriggerOrder(): { return order; } } -CreateTrigger CreateTrigger(): +/** + * MySQL trigger statement. BEGIN ... END with SQL statements only remains a Block. Other compound + * statements, such as those with IF or DECLARE, keep their tokens like routine bodies and use the + * same boundary tracking. The semicolon after END is left to separate statements. + */ +Statement MySqlTriggerBody(): +{ + Statement body; + List tokens; +} +{ + ( + LOOKAHEAD({ isStatementBlockAhead() }) body=BeginEndBlock() + | + LOOKAHEAD({ isMySqlCompoundStatementAhead() }) tokens=captureCompoundStatement() + { body = new UnsupportedStatement(tokens); } + | + body=SingleStatement() + ) + { return body; } +} + +/** + * Captures a compound statement through its END and optional end label. Qualified names are + * joined, and a semicolon or label colon is attached to the preceding token. + */ +JAVACODE +List captureCompoundStatement() { + List tokens = new LinkedList(); + RoutineBodyBoundary boundary = RoutineBodyBoundary.forCompoundStatement(); + boolean afterDot = false; + Token tok; + while (true) { + tok = getToken(1); + if (boundary.endsAt(tok)) { + break; + } + int last = tokens.size() - 1; + if (last >= 0 && (afterDot || ".".equals(tok.image) || ":".equals(tok.image) + || tok.kind == ST_SEMICOLON)) { + tokens.set(last, tokens.get(last) + tok.image); + } else { + tokens.add(tok.image); + } + afterDot = ".".equals(tok.image); + boundary.accept(tok, getToken(2)); + getNextToken(); + } + return tokens; +} + +CreateTrigger CreateTrigger(boolean orReplace): { CreateTrigger createTrigger = new CreateTrigger(); TriggerDefiner definer = null; @@ -15845,7 +16099,14 @@ CreateTrigger CreateTrigger(): { [ LOOKAHEAD({ "DEFINER".equalsIgnoreCase(getToken(1).image) }) definer=MySqlTriggerDefiner() { createTrigger.setDefiner(definer); } ] - trigger=Table() { createTrigger.setTrigger(trigger); } + + [ LOOKAHEAD(2) { createTrigger.setIfNotExists(true); } ] + { + requireDdlSyntax(!orReplace || !createTrigger.isIfNotExists(), + "OR REPLACE and IF NOT EXISTS cannot be combined"); + createTrigger.setOrReplace(orReplace); + } + trigger=Table() { createTrigger.setTrigger(trigger); } timing=MySqlTriggerTiming() { createTrigger.setTiming(timing); } ( event= { createTrigger.setEvent(CreateTrigger.Event.INSERT); } | event= { createTrigger.setEvent(CreateTrigger.Event.UPDATE); } @@ -15857,7 +16118,7 @@ CreateTrigger CreateTrigger(): createTrigger.setOrder(order); createTrigger.setOtherTrigger(otherTrigger); } ] - ( body=SingleStatement() | body=Block() ) { createTrigger.setBody(body); } + body=MySqlTriggerBody() { createTrigger.setBody(body); } { return createTrigger; } } @@ -19757,6 +20018,11 @@ Statement Alter(): | LOOKAHEAD({ isKeywordAhead("STATISTICS") }) statement = AlterStatistics() | + // Other routine forms, such as SQL Server definitions, remain unsupported. + LOOKAHEAD({ isAlterRoutineAhead() }) statement = AlterRoutine() + | + LOOKAHEAD(AlterTrigger() ( | )) statement = AlterTrigger() + | LOOKAHEAD() statement = AlterPolicy() | LOOKAHEAD({ isKeywordAhead("ROLE") || isKeywordAhead("USER") || isKeywordAhead("GROUP") }) @@ -20617,7 +20883,10 @@ AlterRole AlterRole(): } } -/** PostgreSQL SET/RESET of a configuration parameter, shared by ALTER ROLE and ALTER DATABASE. */ +/** + * PostgreSQL SET/RESET of a configuration parameter, shared by ALTER ROLE, ALTER DATABASE and + * ALTER FUNCTION/PROCEDURE/ROUTINE. + */ ConfigurationParameterChange ConfigurationParameterChange(): { ConfigurationParameterChange change = new ConfigurationParameterChange(); @@ -20832,6 +21101,27 @@ TransitionRelation PostgreSqlTransitionRelation(): [ { result.setUseAs(true); } ] name=RelObjectName() { result.setName(name); return result; } } +/** PostgreSQL ALTER TRIGGER name ON table RENAME TO or [NO] DEPENDS ON EXTENSION. */ +AlterTrigger AlterTrigger(): +{ + AlterTrigger result = new AlterTrigger(); + String name; + Table table; +} +{ + name=RelObjectName() { result.setName(name); } + table=Table() { result.setTable(table); } + ( + name=RelObjectName() { result.setNewName(name); } + | + LOOKAHEAD({ isExtensionDependencyAhead(1) }) + [ { result.setNoDependency(true); } ] + ContextualKeyword("DEPENDS") ContextualKeyword("EXTENSION") name=RelObjectName() + { result.setAction(AlterTrigger.Action.DEPENDS_ON_EXTENSION); result.setExtensionName(name); } + ) + { return result; } +} + CreateTrigger PostgreSqlTrigger(boolean replace): { CreateTrigger result = new CreateTrigger(); String name; Table table; CreateTrigger.Timing timing; @@ -21147,14 +21437,15 @@ Statement Create(): | LOOKAHEAD(MySqlViewPrefix() ) statement = CreateView(isUsingOrReplace) | - LOOKAHEAD({ getToken(1).kind == K_TRIGGER - || "DEFINER".equalsIgnoreCase(getToken(1).image) }) - statement = CreateTrigger() + LOOKAHEAD([ MySqlTriggerDefiner() ] , + { getToken(1).kind == K_TRIGGER || isKeywordAhead("DEFINER") }) + statement = CreateTrigger(isUsingOrReplace) | LOOKAHEAD({ isCreateMacroAhead() }) statement = CreateMacro(isUsingOrReplace) | LOOKAHEAD({ isCreateFunctionMacroAhead() }) statement = CreateMacro(isUsingOrReplace) | + LOOKAHEAD([ MySqlRoutineDefiner() ] ( | )) statement = CreateFunctionStatement(isUsingOrReplace) | statement = CreateSchema() @@ -21193,8 +21484,10 @@ CreateFunctionalStatement CreateFunctionStatement(boolean isUsingOrReplace): CreateFunctionalStatement type = null; List tokens = new LinkedList(); String statementType = null; + MySqlDefiner definer = null; } { + [ definer=MySqlRoutineDefiner() ] ( { statementType = "FUNCTION"; } | @@ -21208,11 +21501,186 @@ CreateFunctionalStatement CreateFunctionStatement(boolean isUsingOrReplace): if(statementType.equals("PROCEDURE")) { type = new CreateProcedure(isUsingOrReplace, tokens); } - + type.setDefiner(definer); return type; } } +/** + * PostgreSQL ALTER FUNCTION, PROCEDURE or ROUTINE, or MySQL ALTER FUNCTION or PROCEDURE. One + * statement's characteristics belong to one dialect; MySQL's take no argument list. + */ +AlterRoutine AlterRoutine(): +{ + AlterRoutine result = new AlterRoutine(); + RoutineReference routine; + RoutineCharacteristic characteristic; + String name; +} +{ + ( + { result.setRoutineType(AlterRoutine.RoutineType.FUNCTION); } + | + { result.setRoutineType(AlterRoutine.RoutineType.PROCEDURE); } + | + ContextualKeyword("ROUTINE") { result.setRoutineType(AlterRoutine.RoutineType.ROUTINE); } + ) + routine=RoutineReference(false) { result.setRoutine(routine); } + ( + name=RelObjectName() + { result.setAction(AlterRoutine.Action.RENAME); result.setNewName(name); } + | + LOOKAHEAD({ isWordSequenceAhead(1, "OWNER", "TO") }) ContextualKeyword("OWNER") name=RelObjectName() + { result.setAction(AlterRoutine.Action.OWNER); result.setOwner(name); } + | + LOOKAHEAD({ getToken(1).kind == K_SET && getToken(2).kind == K_SCHEMA + && !isPostgreSqlRoutineCharacteristicAhead(1) }) + name=RelObjectName() + { result.setAction(AlterRoutine.Action.SET_SCHEMA); result.setSchemaName(name); } + | + LOOKAHEAD({ isExtensionDependencyAhead(1) }) + [ { result.setNoDependency(true); } ] + ContextualKeyword("DEPENDS") ContextualKeyword("EXTENSION") name=RelObjectName() + { result.setAction(AlterRoutine.Action.DEPENDS_ON_EXTENSION); result.setExtensionName(name); } + | + LOOKAHEAD({ isMySqlRoutineCharacteristicAhead(1) }) + ( + LOOKAHEAD({ isMySqlRoutineCharacteristicAhead(1) }) + characteristic=MySqlRoutineCharacteristic() { result.getCharacteristics().add(characteristic); } + )+ + { + requireDdlSyntax(result.getRoutineType() != AlterRoutine.RoutineType.ROUTINE + && routine.getArguments() == null, + "MySQL characteristics require ALTER FUNCTION or PROCEDURE without arguments"); + } + | + ( + LOOKAHEAD({ isPostgreSqlRoutineCharacteristicAhead(1) }) + characteristic=PostgreSqlRoutineCharacteristic() { result.getCharacteristics().add(characteristic); } + )+ + [ { result.setRestrict(true); } ] + ) + { return result; } +} + +RoutineCharacteristic PostgreSqlRoutineCharacteristic(): +{ + RoutineCharacteristic.Kind kind = RoutineCharacteristic.Kind.CONFIGURATION_PARAMETER; + RoutineCharacteristic.SecurityClause clause = null; + ConfigurationParameterChange change = null; + String value = null; +} +{ + ( + change=ConfigurationParameterChange() + | + LOOKAHEAD({ isKeywordAhead("IMMUTABLE") }) ContextualKeyword("IMMUTABLE") + { kind = RoutineCharacteristic.Kind.IMMUTABLE; } + | + LOOKAHEAD({ isKeywordAhead("STABLE") }) ContextualKeyword("STABLE") + { kind = RoutineCharacteristic.Kind.STABLE; } + | + { kind = RoutineCharacteristic.Kind.VOLATILE; } + | + LOOKAHEAD({ isKeywordAhead("CALLED") }) ContextualKeyword("CALLED") ContextualKeyword("INPUT") + { kind = RoutineCharacteristic.Kind.CALLED_ON_NULL_INPUT; } + | + ContextualKeyword("INPUT") + { kind = RoutineCharacteristic.Kind.RETURNS_NULL_ON_NULL_INPUT; } + | + { kind = RoutineCharacteristic.Kind.STRICT; } + | + LOOKAHEAD({ isKeywordAhead("LEAKPROOF") }) ContextualKeyword("LEAKPROOF") + { kind = RoutineCharacteristic.Kind.LEAKPROOF; } + | + ContextualKeyword("LEAKPROOF") { kind = RoutineCharacteristic.Kind.NOT_LEAKPROOF; } + | + LOOKAHEAD({ isKeywordAhead("EXTERNAL") }) ContextualKeyword("EXTERNAL") + kind=RoutineSecurity() { clause = RoutineCharacteristic.SecurityClause.EXTERNAL_SECURITY; } + | + kind=RoutineSecurity() { clause = RoutineCharacteristic.SecurityClause.SECURITY; } + | + + ( + LOOKAHEAD({ isKeywordAhead("UNSAFE") }) ContextualKeyword("UNSAFE") + { kind = RoutineCharacteristic.Kind.PARALLEL_UNSAFE; } + | + { kind = RoutineCharacteristic.Kind.PARALLEL_RESTRICTED; } + | + ContextualKeyword("SAFE") { kind = RoutineCharacteristic.Kind.PARALLEL_SAFE; } + ) + | + LOOKAHEAD({ isKeywordAhead("COST") }) ContextualKeyword("COST") value=PostgreSqlNumericValue() + { kind = RoutineCharacteristic.Kind.COST; } + | + value=PostgreSqlNumericValue() { kind = RoutineCharacteristic.Kind.ROWS; } + | + LOOKAHEAD({ isKeywordAhead("SUPPORT") }) ContextualKeyword("SUPPORT") value=AccessQualifiedName() + { kind = RoutineCharacteristic.Kind.SUPPORT; } + ) + { + return change != null ? new RoutineCharacteristic(change) + : new RoutineCharacteristic(kind, value).withSecurityClause(clause); + } +} + +RoutineCharacteristic MySqlRoutineCharacteristic(): +{ + RoutineCharacteristic.Kind kind; + RoutineCharacteristic.SecurityClause clause = null; + Token token; + StringValue comment = null; +} +{ + ( + token= + { kind = RoutineCharacteristic.Kind.COMMENT; comment = stringValue(token); } + | + LOOKAHEAD({ isKeywordAhead("LANGUAGE") }) ContextualKeyword("LANGUAGE") ContextualKeyword("SQL") + { kind = RoutineCharacteristic.Kind.LANGUAGE_SQL; } + | + LOOKAHEAD({ isKeywordAhead("CONTAINS") }) ContextualKeyword("CONTAINS") ContextualKeyword("SQL") + { kind = RoutineCharacteristic.Kind.CONTAINS_SQL; } + | + ContextualKeyword("SQL") { kind = RoutineCharacteristic.Kind.NO_SQL; } + | + LOOKAHEAD({ isKeywordAhead("READS") }) ContextualKeyword("READS") ContextualKeyword("SQL") + { kind = RoutineCharacteristic.Kind.READS_SQL_DATA; } + | + LOOKAHEAD({ isKeywordAhead("MODIFIES") }) ContextualKeyword("MODIFIES") ContextualKeyword("SQL") + { kind = RoutineCharacteristic.Kind.MODIFIES_SQL_DATA; } + | + ContextualKeyword("SQL") kind=RoutineSecurity() + { clause = RoutineCharacteristic.SecurityClause.SQL_SECURITY; } + ) + { + return new RoutineCharacteristic(kind).withComment(comment).withSecurityClause(clause); + } +} + +RoutineCharacteristic.Kind RoutineSecurity(): +{ RoutineCharacteristic.Kind kind; } +{ + ( + LOOKAHEAD({ isKeywordAhead("DEFINER") }) ContextualKeyword("DEFINER") + { kind = RoutineCharacteristic.Kind.SECURITY_DEFINER; } + | + ContextualKeyword("INVOKER") { kind = RoutineCharacteristic.Kind.SECURITY_INVOKER; } + ) + { return kind; } +} + +/** PostgreSQL NumericOnly: an optionally signed integer or decimal, as written. */ +String PostgreSqlNumericValue(): +{ + Token sign = null; + Token number; +} +{ + [ sign="+" | sign="-" ] ( number= | number= ) + { return (sign == null ? "" : sign.image) + number.image; } +} + CreateFunctionalStatement SqlServerRoutine(): { CreateFunctionalStatement result; diff --git a/src/site/sphinx/index.rst b/src/site/sphinx/index.rst index 5f3b0157b..36598eaa4 100644 --- a/src/site/sphinx/index.rst +++ b/src/site/sphinx/index.rst @@ -160,6 +160,8 @@ One grammar covers the SQL standard plus all major RDBMS. Missing syntax gets ad - ``ANALYZE`` Β· ``CHECK`` Β· ``OPTIMIZE`` Β· ``REPAIR TABLE`` * - **PostgreSQL RLS** - ``CREATE POLICY`` Β· ``ALTER TABLE … ENABLE``/``DISABLE``/``FORCE``/``NO FORCE ROW LEVEL SECURITY`` + * - **Routine changes** + - ``ALTER FUNCTION`` Β· ``ALTER PROCEDURE`` Β· ``ALTER ROUTINE`` Β· PostgreSQL ``ALTER TRIGGER`` * - **Salesforce SOQL** - ``INCLUDES`` Β· ``EXCLUDES`` diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index d887ffe86..54184d0c9 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -1922,6 +1922,58 @@ References: MySQL `CREATE DATABASE `_, `ALTER DATABASE `_. +Routine and trigger alterations +------------------------------- + +PostgreSQL ``ALTER FUNCTION``, ``ALTER PROCEDURE`` and ``ALTER ROUTINE`` and MySQL +``ALTER FUNCTION`` and ``ALTER PROCEDURE`` parse to ``AlterRoutine``. ``getRoutineType()`` +names the keyword and ``getRoutine()`` is a ``RoutineReference`` with the optional +PostgreSQL argument types. ``getAction()`` is ``CHARACTERISTICS``, ``RENAME``, ``OWNER``, +``SET_SCHEMA`` or ``DEPENDS_ON_EXTENSION`` (``isNoDependency()`` for ``NO DEPENDS``); +payloads of the other actions are kept. ``getCharacteristics()`` is a mutable list of +``RoutineCharacteristic`` in source order, shared by both dialects. Each ``Kind`` renders +its own keywords, such as ``IMMUTABLE``, ``CALLED_ON_NULL_INPUT``, ``PARALLEL_SAFE``, +``COST`` or MySQL's ``READS_SQL_DATA``. ``SECURITY_DEFINER`` and ``SECURITY_INVOKER`` keep +the ``[EXTERNAL] SECURITY`` or ``SQL SECURITY`` spelling in ``getSecurityClause()``. +``COST``, ``ROWS``, ``SUPPORT`` and ``COMMENT`` keep their value as SQL text in ``getValue()``. +``getComment()`` returns the ``COMMENT`` literal as a ``StringValue`` read with the dialect's +escaping, so ``getNotExcapedValue()`` is the text MySQL stores. ``SET`` and ``RESET`` reuse +``ConfigurationParameterChange``: + +.. code-block:: java + + AlterRoutine alter = (AlterRoutine) CCJSqlParserUtil.parse( + "ALTER FUNCTION f(int) IMMUTABLE SET search_path = public", + parser -> parser.withDialect(Dialect.POSTGRESQL)); + alter.getRoutine().getArguments(); // [int] + alter.getCharacteristics().get(0).getKind(); // IMMUTABLE + alter.getCharacteristics().get(1).getParameterChange().getParameter(); // search_path + +The characteristics of one statement belong to one dialect, and MySQL's take no argument +list. Other forms after ``ALTER FUNCTION`` or ``ALTER PROCEDURE``, such as Oracle's +``COMPILE`` or SQL Server routine definitions without the SQL Server dialect, remain an +``UnsupportedStatement``. PostgreSQL's ``ALTER TRIGGER name ON table RENAME TO`` and +``[NO] DEPENDS ON EXTENSION`` parse to ``AlterTrigger``; table visitors report its table. +Validators report ``alterFunction``, ``alterProcedure``, ``alterRoutine`` and +``alterTrigger``; PostgreSQL enables all of them, MySQL and MariaDB the first two. + +``CreateFunctionalStatement.getDefiner()`` returns the MySQL ``DEFINER`` of +``CREATE DEFINER = ... FUNCTION|PROCEDURE`` as the ``MySqlDefiner`` that views and +triggers use. MySQL ``CREATE TRIGGER`` accepts ``IF NOT EXISTS`` (``isIfNotExists()``) +and keeps MariaDB's ``OR REPLACE``. A ``BEGIN ... END`` trigger body with only SQL +statements remains a ``Block``; a compound body with procedural statements such as +``IF``, ``DECLARE`` or loops is an ``UnsupportedStatement`` with its tokens, captured with +the boundary of routine bodies. In both cases the semicolon after ``END`` separates the +next statement. + +References: PostgreSQL `ALTER FUNCTION `_, +`ALTER PROCEDURE `_, +`ALTER ROUTINE `_, +`ALTER TRIGGER `_; +MySQL `ALTER FUNCTION `_, +`ALTER PROCEDURE `_, +`CREATE TRIGGER `_. + MySQL table maintenance statements ---------------------------------- diff --git a/src/test/java/net/sf/jsqlparser/statement/StatementFeatureVisitorMetadataDdlTest.java b/src/test/java/net/sf/jsqlparser/statement/StatementFeatureVisitorMetadataDdlTest.java index 304d6da92..0e4f938e7 100644 --- a/src/test/java/net/sf/jsqlparser/statement/StatementFeatureVisitorMetadataDdlTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/StatementFeatureVisitorMetadataDdlTest.java @@ -17,9 +17,11 @@ import java.util.stream.Stream; import net.sf.jsqlparser.parser.CCJSqlParserUtil; import net.sf.jsqlparser.statement.alter.AlterCollation; +import net.sf.jsqlparser.statement.alter.AlterRoutine; import net.sf.jsqlparser.statement.alter.AlterStatistics; import net.sf.jsqlparser.statement.alter.AlterTablespaceMove; import net.sf.jsqlparser.statement.alter.AlterTextSearchConfiguration; +import net.sf.jsqlparser.statement.alter.AlterTrigger; import net.sf.jsqlparser.statement.alter.database.AlterDatabase; import net.sf.jsqlparser.statement.alter.schema.AlterSchema; import net.sf.jsqlparser.statement.create.textsearch.CreateTextSearchConfiguration; @@ -41,6 +43,10 @@ static Stream definitions() { AlterDatabase.class), Arguments.of("ALTER DATABASE d SET work_mem = '64MB'", AlterDatabase.class), Arguments.of("ALTER DATABASE d OWNER TO CURRENT_USER", AlterDatabase.class), + Arguments.of("ALTER FUNCTION f(int) OWNER TO u1", AlterRoutine.class), + Arguments.of("ALTER PROCEDURE p SET search_path = public", AlterRoutine.class), + Arguments.of("ALTER FUNCTION f READS SQL DATA", AlterRoutine.class), + Arguments.of("ALTER TRIGGER t ON tbl RENAME TO t2", AlterTrigger.class), Arguments.of("ALTER SCHEMA s RENAME TO s2", AlterSchema.class), Arguments.of("ALTER SCHEMA s OWNER TO CURRENT_USER", AlterSchema.class), Arguments.of("ALTER STATISTICS s RENAME TO s2", AlterStatistics.class), diff --git a/src/test/java/net/sf/jsqlparser/statement/alter/AlterRoutineTest.java b/src/test/java/net/sf/jsqlparser/statement/alter/AlterRoutineTest.java new file mode 100644 index 000000000..e0992bae1 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/alter/AlterRoutineTest.java @@ -0,0 +1,334 @@ +/*- + * #%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.alter; + +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static net.sf.jsqlparser.util.validation.ValidationTestAsserts.validateNoErrors; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.function.Consumer; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.expression.operators.relational.ExpressionList; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.statement.ConfigurationParameterChange; +import net.sf.jsqlparser.statement.RoutineCharacteristic; +import net.sf.jsqlparser.statement.RoutineCharacteristic.Kind; +import net.sf.jsqlparser.statement.RoutineCharacteristic.SecurityClause; +import net.sf.jsqlparser.statement.RoutineReference; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitorAdapter; +import net.sf.jsqlparser.statement.UnsupportedStatement; +import net.sf.jsqlparser.statement.create.function.CreateFunction; +import net.sf.jsqlparser.statement.create.procedure.CreateProcedure; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import net.sf.jsqlparser.util.validation.Validation; +import net.sf.jsqlparser.util.validation.feature.DatabaseType; +import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed; +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 AlterRoutineTest { + + @ParameterizedTest + @ValueSource(strings = {"ALTER FUNCTION f2(int) OWNER TO u1", + "ALTER FUNCTION public.f2(integer, text) OWNER TO CURRENT_ROLE", + "ALTER FUNCTION f2 OWNER TO SESSION_USER", "ALTER FUNCTION f2(int) RENAME TO g2", + "ALTER PROCEDURE p1() SET SCHEMA archive", + "ALTER ROUTINE \"My Func\"(IN a int, VARIADIC b text[]) RENAME TO \"Other\"", + "ALTER FUNCTION f2(int) DEPENDS ON EXTENSION ext", + "ALTER ROUTINE f2(int) NO DEPENDS ON EXTENSION ext", + "ALTER FUNCTION f2(int) SET search_path = public", + "ALTER FUNCTION f2(int) SET search_path TO app, public SET work_mem = '64MB'", + "ALTER FUNCTION f2(int) SET work_mem TO DEFAULT RESET search_path RESET ALL", + "ALTER PROCEDURE p1(int) SET search_path FROM CURRENT", + "ALTER PROCEDURE p1(int) SECURITY DEFINER", + "ALTER PROCEDURE p1 EXTERNAL SECURITY INVOKER", + "ALTER ROUTINE f2(int) IMMUTABLE", "ALTER FUNCTION f2(int) STABLE NOT LEAKPROOF", + "ALTER FUNCTION f2(int) VOLATILE LEAKPROOF STRICT RESTRICT", + "ALTER FUNCTION f2(int) CALLED ON NULL INPUT", + "ALTER FUNCTION f2(int) RETURNS NULL ON NULL INPUT", + "ALTER FUNCTION f2(int) PARALLEL SAFE PARALLEL RESTRICTED PARALLEL UNSAFE", + "ALTER FUNCTION f2(int) COST 100 ROWS 2.5 SUPPORT pg_catalog.my_support", + "ALTER FUNCTION f2(int) COST -1 ROWS +10"}) + void testPostgreSqlForms(String sql) throws JSQLParserException { + assertRoundTrip(sql, Dialect.POSTGRESQL); + assertRoundTrip(sql, null); + } + + @ParameterizedTest + @ValueSource(strings = {"ALTER PROCEDURE p1 COMMENT 'x' SQL SECURITY INVOKER", + "ALTER FUNCTION f1 READS SQL DATA", "ALTER FUNCTION db1.f1 LANGUAGE SQL CONTAINS SQL", + "ALTER PROCEDURE `p 1` NO SQL MODIFIES SQL DATA SQL SECURITY DEFINER", + "ALTER FUNCTION f1 COMMENT 'it''s'"}) + void testMySqlForms(String sql) throws JSQLParserException { + assertRoundTrip(sql, Dialect.MYSQL); + assertRoundTrip(sql, Dialect.MARIADB); + assertRoundTrip(sql, null); + } + + @Test + void testPostgreSqlActions() throws JSQLParserException { + AlterRoutine owner = parsePostgreSql("ALTER FUNCTION s.f2(IN a int, text) OWNER TO u1"); + assertEquals(AlterRoutine.RoutineType.FUNCTION, owner.getRoutineType()); + assertEquals(AlterRoutine.Action.OWNER, owner.getAction()); + assertEquals("u1", owner.getOwner()); + RoutineReference routine = owner.getRoutine(); + assertEquals("s.f2", routine.getName()); + assertEquals(2, routine.getArguments().size()); + assertEquals(RoutineReference.Argument.Mode.IN, routine.getArguments().get(0).getMode()); + assertEquals("a", routine.getArguments().get(0).getName()); + assertEquals("text", routine.getArguments().get(1).getDataType().toString()); + assertTrue(owner.getCharacteristics().isEmpty()); + + AlterRoutine rename = parsePostgreSql("ALTER ROUTINE f2 RENAME TO g2"); + assertEquals(AlterRoutine.RoutineType.ROUTINE, rename.getRoutineType()); + assertEquals(AlterRoutine.Action.RENAME, rename.getAction()); + assertEquals("g2", rename.getNewName()); + assertNull(rename.getRoutine().getArguments()); + + AlterRoutine schema = parsePostgreSql("ALTER PROCEDURE p1() SET SCHEMA archive"); + assertEquals(AlterRoutine.RoutineType.PROCEDURE, schema.getRoutineType()); + assertEquals(AlterRoutine.Action.SET_SCHEMA, schema.getAction()); + assertEquals("archive", schema.getSchemaName()); + assertTrue(schema.getRoutine().getArguments().isEmpty()); + + AlterRoutine dependency = + parsePostgreSql("ALTER FUNCTION f2(int) NO DEPENDS ON EXTENSION ext"); + assertEquals(AlterRoutine.Action.DEPENDS_ON_EXTENSION, dependency.getAction()); + assertEquals("ext", dependency.getExtensionName()); + assertTrue(dependency.isNoDependency()); + + // A configuration parameter named "schema" is a SET, not SET SCHEMA. + AlterRoutine parameter = parsePostgreSql("ALTER FUNCTION f2(int) SET schema = 'x'"); + assertEquals(AlterRoutine.Action.CHARACTERISTICS, parameter.getAction()); + assertEquals("schema", parameter.getCharacteristics().get(0).getParameterChange() + .getParameter()); + } + + @Test + void testPostgreSqlCharacteristics() throws JSQLParserException { + AlterRoutine statement = parsePostgreSql("ALTER FUNCTION f2(int) IMMUTABLE STRICT " + + "EXTERNAL SECURITY DEFINER PARALLEL SAFE COST 10 ROWS 5 SUPPORT s.sup " + + "SET search_path TO app, public RESET ALL RESTRICT"); + assertEquals(AlterRoutine.Action.CHARACTERISTICS, statement.getAction()); + assertTrue(statement.isRestrict()); + List characteristics = statement.getCharacteristics(); + assertEquals(List.of(Kind.IMMUTABLE, Kind.STRICT, Kind.SECURITY_DEFINER, + Kind.PARALLEL_SAFE, Kind.COST, Kind.ROWS, Kind.SUPPORT, + Kind.CONFIGURATION_PARAMETER, Kind.CONFIGURATION_PARAMETER), + characteristics.stream().map(RoutineCharacteristic::getKind) + .collect(java.util.stream.Collectors.toList())); + assertNull(characteristics.get(0).getSecurityClause()); + assertEquals(SecurityClause.EXTERNAL_SECURITY, + characteristics.get(2).getSecurityClause()); + assertEquals("10", statement.getCharacteristic(Kind.COST).orElseThrow().getValue()); + assertEquals("5", statement.getCharacteristic(Kind.ROWS).orElseThrow().getValue()); + assertEquals("s.sup", statement.getCharacteristic(Kind.SUPPORT).orElseThrow().getValue()); + + ConfigurationParameterChange set = characteristics.get(7).getParameterChange(); + assertEquals(ConfigurationParameterChange.Action.SET, set.getAction()); + assertEquals("search_path", set.getParameter()); + assertEquals(2, set.getValues().size()); + ConfigurationParameterChange reset = characteristics.get(8).getParameterChange(); + assertEquals(ConfigurationParameterChange.Action.RESET, reset.getAction()); + assertEquals("ALL", reset.getParameter()); + + AlterRoutine plainSecurity = parsePostgreSql("ALTER PROCEDURE p1(int) SECURITY INVOKER"); + RoutineCharacteristic security = plainSecurity.getCharacteristics().get(0); + assertEquals(Kind.SECURITY_INVOKER, security.getKind()); + assertEquals(SecurityClause.SECURITY, security.getSecurityClause()); + assertFalse(plainSecurity.isRestrict()); + } + + @Test + void testMySqlCharacteristics() throws JSQLParserException { + AlterRoutine statement = (AlterRoutine) CCJSqlParserUtil.parse( + "ALTER PROCEDURE db1.p1 COMMENT 'x' LANGUAGE SQL READS SQL DATA SQL SECURITY INVOKER", + parser -> parser.withDialect(Dialect.MYSQL)); + assertEquals(AlterRoutine.RoutineType.PROCEDURE, statement.getRoutineType()); + assertEquals("db1.p1", statement.getRoutine().getName()); + assertNull(statement.getRoutine().getArguments()); + List characteristics = statement.getCharacteristics(); + assertEquals(4, characteristics.size()); + assertEquals(Kind.COMMENT, characteristics.get(0).getKind()); + assertEquals("'x'", characteristics.get(0).getValue()); + assertEquals(Kind.LANGUAGE_SQL, characteristics.get(1).getKind()); + assertEquals(Kind.READS_SQL_DATA, characteristics.get(2).getKind()); + assertEquals(Kind.SECURITY_INVOKER, characteristics.get(3).getKind()); + assertEquals(SecurityClause.SQL_SECURITY, characteristics.get(3).getSecurityClause()); + assertFalse(statement.isRestrict()); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', quoteCharacter = '`', value = { + "'it\\'s'|it's", "'it''s'|it's", "\"dq\"|dq", "'a\\\\b'|a\\b", + "'line1\\nline2'|`line1\nline2`", "'tab\\there'|`tab\there`", + "'percent\\% underscore\\_'|percent\\% underscore\\_", + "'ν•œκΈ€ μ„€λͺ… πŸ˜€'|ν•œκΈ€ μ„€λͺ… πŸ˜€"}) + void testMySqlCommentKeepsLiteralAndStoredText(String literal, String stored) + throws JSQLParserException { + String sql = "ALTER PROCEDURE p1 COMMENT " + literal + " SQL SECURITY INVOKER"; + AlterRoutine statement = (AlterRoutine) assertSqlCanBeParsedAndDeparsed(sql, false, + parser -> parser.withDialect(Dialect.MYSQL)); + RoutineCharacteristic comment = statement.getCharacteristic(Kind.COMMENT).orElseThrow(); + assertEquals(literal, comment.getValue()); + assertEquals(literal, comment.getComment().toString()); + assertEquals(stored, comment.getComment().getNotExcapedValue()); + } + + @Test + void testCommentMutation() throws JSQLParserException { + AlterRoutine statement = (AlterRoutine) CCJSqlParserUtil.parse( + "ALTER FUNCTION f1 COMMENT 'it\\'s' NO SQL", + parser -> parser.withDialect(Dialect.MYSQL)); + RoutineCharacteristic comment = statement.getCharacteristics().get(0); + assertEquals(StringValue.Escaping.MYSQL_BACKSLASH, comment.getComment().getEscaping()); + + comment.getComment().setValue("changed"); + assertEquals("ALTER FUNCTION f1 COMMENT 'changed' NO SQL", statement.toString()); + comment.setValue("'x''y'"); + assertEquals("x'y", comment.getComment().getNotExcapedValue()); + comment.setComment(new StringValue("plain")); + assertEquals("'plain'", comment.getValue()); + + RoutineCharacteristic created = RoutineCharacteristic.comment(new StringValue("note")); + assertEquals(Kind.COMMENT, created.getKind()); + assertEquals("COMMENT 'note'", created.toString()); + assertEquals("COMMENT 'v'", new RoutineCharacteristic(Kind.COMMENT, "'v'").toString()); + assertNull(new RoutineCharacteristic(Kind.COST, "5").getComment()); + } + + @Test + void testMutationAndActionSwitch() throws JSQLParserException { + AlterRoutine statement = parsePostgreSql("ALTER FUNCTION f2(int) RENAME TO g2"); + statement.setAction(AlterRoutine.Action.OWNER); + statement.setOwner("CURRENT_USER"); + assertEquals("ALTER FUNCTION f2(int) OWNER TO CURRENT_USER", statement.toString()); + statement.setAction(AlterRoutine.Action.RENAME); + assertEquals("ALTER FUNCTION f2(int) RENAME TO g2", statement.toString()); + + statement.setAction(AlterRoutine.Action.CHARACTERISTICS); + statement.setRoutineType(AlterRoutine.RoutineType.ROUTINE); + statement.getCharacteristics().add(new RoutineCharacteristic(Kind.SECURITY_DEFINER)); + statement.getCharacteristics().add(new RoutineCharacteristic(Kind.COST, "5")); + statement.getCharacteristics().add(new RoutineCharacteristic( + new ConfigurationParameterChange(ConfigurationParameterChange.Action.SET, "a") + .withValues(new ExpressionList<>(new StringValue("b"))))); + statement.setRestrict(true); + assertEquals("ALTER ROUTINE f2(int) SECURITY DEFINER COST 5 SET a TO 'b' RESTRICT", + statement.toString()); + + RoutineCharacteristic security = statement.getCharacteristics().get(0); + security.setSecurityClause(SecurityClause.SQL_SECURITY); + security.setKind(Kind.SECURITY_INVOKER); + assertEquals("SQL SECURITY INVOKER", security.toString()); + + RoutineCharacteristic missing = new RoutineCharacteristic(Kind.CONFIGURATION_PARAMETER); + assertThrows(IllegalStateException.class, missing::toString); + } + + @Test + void testVisitorsAndDeparserUseExpressions() throws JSQLParserException { + AlterRoutine statement = + parsePostgreSql("ALTER FUNCTION f2(int) SET search_path TO app, public"); + assertEquals("context:f2", statement.accept(new StatementVisitorAdapter() { + @Override + public String visit(AlterRoutine routine, S context) { + return context + ":" + routine.getRoutine().getName(); + } + }, "context")); + assertTrue(new TablesNamesFinder().getTables((Statement) statement).isEmpty()); + + StringBuilder deparsed = new StringBuilder(); + new StatementDeParser(deparsed).visit(statement, null); + assertEquals("ALTER FUNCTION f2(int) SET search_path TO app, public", + deparsed.toString()); + } + + @ParameterizedTest + @ValueSource(strings = {"ALTER FUNCTION f", "ALTER PROCEDURE p1", + "ALTER FUNCTION f COMPILE", "ALTER FUNCTION f(int) NOT FENCED", + "ALTER FUNCTION f(int) EXTERNAL NAME 'lib'", "ALTER FUNCTION f(int) SET SECURE", + "ALTER FUNCTION IF EXISTS f(int) RENAME TO g", "ALTER PROCEDURE dbo.p AS SELECT 1", + "ALTER FUNCTION dbo.f(@a int) RETURNS int AS BEGIN RETURN 1 END", + "ALTER FUNCTION f(int) PARALLEL FAST", "ALTER FUNCTION f(int) SECURITY OWNER"}) + void testOtherFormsRemainUnsupported(String sql) throws JSQLParserException { + assertInstanceOf(UnsupportedStatement.class, CCJSqlParserUtil.parse(sql)); + assertInstanceOf(UnsupportedStatement.class, CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(Dialect.POSTGRESQL))); + } + + @ParameterizedTest + @ValueSource(strings = {"ALTER FUNCTION f(int) IMMUTABLE, STRICT", + "ALTER FUNCTION f(int) RENAME TO g RESTRICT", + "ALTER FUNCTION f(int) OWNER TO u IMMUTABLE", + "ALTER FUNCTION f COMMENT 'x' IMMUTABLE", "ALTER FUNCTION f IMMUTABLE COMMENT 'x'", + "ALTER FUNCTION f(int) COMMENT 'x'", "ALTER ROUTINE r COMMENT 'x'", + "ALTER FUNCTION f COMMENT 'x' RESTRICT"}) + void testRejectInvalidCombinations(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql)); + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(Dialect.MYSQL))); + } + + @Test + void testSqlServerRoutinesAreUnchanged() throws JSQLParserException { + Consumer sqlServer = parser -> parser.withDialect(Dialect.SQLSERVER); + assertInstanceOf(CreateFunction.class, CCJSqlParserUtil.parse( + "ALTER FUNCTION dbo.f(@a int) RETURNS int AS BEGIN RETURN @a END", sqlServer)); + assertInstanceOf(CreateProcedure.class, + CCJSqlParserUtil.parse("ALTER PROCEDURE dbo.p AS SELECT 1", sqlServer)); + } + + @Test + void testValidation() { + validateNoErrors("ALTER FUNCTION f2(int) OWNER TO u1", 1, DatabaseType.POSTGRESQL); + validateNoErrors("ALTER PROCEDURE p1(int) SET search_path = public", 1, + DatabaseType.POSTGRESQL); + validateNoErrors("ALTER ROUTINE f2(int) IMMUTABLE", 1, DatabaseType.POSTGRESQL); + validateNoErrors("ALTER FUNCTION f1 READS SQL DATA", 1, DatabaseType.MYSQL, + DatabaseType.MARIADB); + validateNoErrors("ALTER PROCEDURE p1 COMMENT 'x'", 1, DatabaseType.MYSQL, + DatabaseType.MARIADB); + assertFalse(new Validation(List.of(DatabaseType.MYSQL), "ALTER ROUTINE f2(int) STABLE") + .validate().isEmpty()); + assertFalse(new Validation(List.of(FeaturesAllowed.SELECT), + "ALTER FUNCTION f2(int) STABLE").validate().isEmpty()); + assertTrue(new Validation(List.of(new FeaturesAllowed().add(Feature.alterProcedure)), + "ALTER PROCEDURE p1(int) RESET ALL").validate().isEmpty()); + } + + /** Parses, deparses and reparses with the dialect, or without one if it is null. */ + private static void assertRoundTrip(String sql, Dialect dialect) throws JSQLParserException { + Consumer consumer = dialect == null ? parser -> { + } : parser -> parser.withDialect(dialect); + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, false, consumer); + assertInstanceOf(AlterRoutine.class, statement); + StringBuilder deparsed = new StringBuilder(); + statement.accept(new StatementDeParser(deparsed)); + assertEquals(sql, deparsed.toString()); + assertEquals(sql, CCJSqlParserUtil.parse(deparsed.toString(), consumer).toString()); + assertTrue(new TablesNamesFinder().getTables(statement).isEmpty()); + assertEquals(2, CCJSqlParserUtil.parseStatements(sql + ";\nSELECT 1", consumer).size()); + } + + private static AlterRoutine parsePostgreSql(String sql) throws JSQLParserException { + return (AlterRoutine) CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(Dialect.POSTGRESQL)); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/alter/AlterTriggerTest.java b/src/test/java/net/sf/jsqlparser/statement/alter/AlterTriggerTest.java new file mode 100644 index 000000000..cf33ab0f3 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/alter/AlterTriggerTest.java @@ -0,0 +1,109 @@ +/*- + * #%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.alter; + +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static net.sf.jsqlparser.util.validation.ValidationTestAsserts.validateNoErrors; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.List; +import java.util.function.Consumer; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitorAdapter; +import net.sf.jsqlparser.statement.UnsupportedStatement; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import net.sf.jsqlparser.util.validation.Validation; +import net.sf.jsqlparser.util.validation.feature.DatabaseType; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class AlterTriggerTest { + + @ParameterizedTest + @ValueSource(strings = {"ALTER TRIGGER trg ON users RENAME TO trg_new", + "ALTER TRIGGER \"Trg\" ON app.\"Users\" RENAME TO \"Trg 2\"", + "ALTER TRIGGER trg ON users DEPENDS ON EXTENSION ext", + "ALTER TRIGGER trg ON users NO DEPENDS ON EXTENSION ext"}) + void testRoundTrip(String sql) throws JSQLParserException { + for (Consumer consumer : List.>of(parser -> { + }, parser -> parser.withDialect(Dialect.POSTGRESQL))) { + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, false, consumer); + assertInstanceOf(AlterTrigger.class, statement); + StringBuilder deparsed = new StringBuilder(); + statement.accept(new StatementDeParser(deparsed)); + assertEquals(sql, deparsed.toString()); + assertEquals(sql, CCJSqlParserUtil.parse(deparsed.toString(), consumer).toString()); + assertEquals(2, + CCJSqlParserUtil.parseStatements(sql + ";\nSELECT 1", consumer).size()); + } + } + + @Test + void testActionsAndTable() throws JSQLParserException { + AlterTrigger rename = parse("ALTER TRIGGER trg ON app.users RENAME TO trg_new"); + assertEquals("trg", rename.getName()); + assertEquals("app.users", rename.getTable().getFullyQualifiedName()); + assertEquals(AlterTrigger.Action.RENAME, rename.getAction()); + assertEquals("trg_new", rename.getNewName()); + assertEquals(List.of("app.users"), + List.copyOf(new TablesNamesFinder().getTables((Statement) rename))); + + AlterTrigger dependency = parse("ALTER TRIGGER trg ON users NO DEPENDS ON EXTENSION ext"); + assertEquals(AlterTrigger.Action.DEPENDS_ON_EXTENSION, dependency.getAction()); + assertEquals("ext", dependency.getExtensionName()); + assertTrue(dependency.isNoDependency()); + + dependency.setNoDependency(false); + dependency.setTable(new Table("orders")); + assertEquals("ALTER TRIGGER trg ON orders DEPENDS ON EXTENSION ext", + dependency.toString()); + dependency.setAction(AlterTrigger.Action.RENAME); + dependency.setNewName("trg2"); + assertEquals("ALTER TRIGGER trg ON orders RENAME TO trg2", dependency.toString()); + + assertEquals("context:trg", rename.accept(new StatementVisitorAdapter() { + @Override + public String visit(AlterTrigger trigger, S context) { + return context + ":" + trigger.getName(); + } + }, "context")); + } + + @ParameterizedTest + @ValueSource(strings = {"ALTER TRIGGER trg ENABLE", "ALTER TRIGGER trg COMPILE", + "ALTER TRIGGER trg ON t AFTER INSERT AS SELECT 1", + "ALTER TRIGGER trg ON t RENAME TO t2 CASCADE", + "ALTER TRIGGER trg ON t DEPENDS ON SCHEMA s"}) + void testOtherFormsRemainUnsupported(String sql) throws JSQLParserException { + assertInstanceOf(UnsupportedStatement.class, CCJSqlParserUtil.parse(sql)); + assertInstanceOf(UnsupportedStatement.class, CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(Dialect.POSTGRESQL))); + } + + @Test + void testValidation() { + validateNoErrors("ALTER TRIGGER trg ON users RENAME TO trg_new", 1, + DatabaseType.POSTGRESQL); + assertFalse(new Validation(List.of(DatabaseType.MYSQL), + "ALTER TRIGGER trg ON users RENAME TO trg_new").validate().isEmpty()); + } + + private static AlterTrigger parse(String sql) throws JSQLParserException { + return (AlterTrigger) CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(Dialect.POSTGRESQL)); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/create/CreateFunctionalStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/create/CreateFunctionalStatementTest.java index 66880938c..d2059b6ba 100644 --- a/src/test/java/net/sf/jsqlparser/statement/create/CreateFunctionalStatementTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/create/CreateFunctionalStatementTest.java @@ -12,11 +12,21 @@ import static net.sf.jsqlparser.test.TestUtils.assertDeparse; import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import java.util.Arrays; 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.statement.MySqlDefiner; +import net.sf.jsqlparser.statement.CreateFunctionalStatement; import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.create.view.CreateView; +import net.sf.jsqlparser.statement.create.trigger.CreateTrigger; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import net.sf.jsqlparser.statement.create.function.CreateFunction; import net.sf.jsqlparser.statement.create.procedure.CreateProcedure; import org.junit.jupiter.api.Test; @@ -89,6 +99,65 @@ public void createOrReplaceFunctionMinimal() throws JSQLParserException { assertDeparse(func, statement); } + @ParameterizedTest + @ValueSource(strings = { + "CREATE DEFINER = CURRENT_USER FUNCTION f2 (s CHAR(20)) RETURNS CHAR(50) " + + "CHARSET utf8mb4 NO SQL RETURN CONCAT('Hello, ', s, '!')", + "CREATE DEFINER = CURRENT_USER PROCEDURE p3 () SELECT 1", + "CREATE DEFINER = `root`@`%` FUNCTION f3 () RETURNS INT RETURN 1", + "CREATE DEFINER = 'admin'@'localhost' PROCEDURE p (IN a INT) BEGIN SELECT a; END", + "CREATE OR REPLACE DEFINER = CURRENT_USER() PROCEDURE p () SELECT 1"}) + public void createRoutineWithMySqlDefiner(String sql) throws JSQLParserException { + for (Dialect dialect : new Dialect[] {Dialect.MYSQL, Dialect.MARIADB, null}) { + CreateFunctionalStatement statement = + (CreateFunctionalStatement) assertSqlCanBeParsedAndDeparsed( + sql, true, dialect == null ? parser -> { + } : parser -> parser.withDialect(dialect)); + assertThat(statement.getDefiner()).isNotNull(); + StringBuilder deparsed = new StringBuilder(); + statement.accept(new StatementDeParser(deparsed)); + assertThat(deparsed.toString()).isEqualTo(statement.toString()); + assertThat(CCJSqlParserUtil.parseStatements(sql + ";\nSELECT 1").getStatements()) + .hasSize(2); + } + } + + @Test + public void mySqlDefinerAccount() throws JSQLParserException { + CreateFunction function = (CreateFunction) CCJSqlParserUtil.parse( + "CREATE DEFINER='root'@'%' FUNCTION f3 () RETURNS INT RETURN 1"); + MySqlDefiner definer = function.getDefiner(); + assertThat(definer.getUser().getValue()).isEqualTo("root"); + assertThat(definer.getHost().getValue()).isEqualTo("%"); + assertThat(function.getFunctionDeclarationParts()).startsWith("f3", "(", ")"); + + CreateProcedure procedure = (CreateProcedure) CCJSqlParserUtil + .parse("CREATE DEFINER = CURRENT_USER PROCEDURE p3 () SELECT 1"); + assertThat(procedure.getDefiner().getUser().getValue()).isEqualTo("CURRENT_USER"); + assertThat(procedure.getDefiner().getHost()).isNull(); + + procedure.setDefiner(new MySqlDefiner().withUser(new StringValue("app")) + .withHost(new StringValue("%"))); + assertThat(procedure.toString()) + .isEqualTo("CREATE DEFINER = 'app'@'%' PROCEDURE p3 ( ) SELECT 1"); + procedure.setDefiner(null); + assertThat(procedure.toString()).isEqualTo("CREATE PROCEDURE p3 ( ) SELECT 1"); + } + + @Test + public void definerDispatchKeepsTriggersAndViews() throws JSQLParserException { + assertThat(CCJSqlParserUtil.parse("CREATE DEFINER = CURRENT_USER TRIGGER t " + + "BEFORE INSERT ON t1 FOR EACH ROW SET NEW.a = 1")) + .isInstanceOf(CreateTrigger.class); + assertThat(CCJSqlParserUtil.parse("CREATE DEFINER = CURRENT_USER VIEW v AS SELECT 1")) + .isInstanceOf(CreateView.class); + assertThat(CCJSqlParserUtil.parse("CREATE FUNCTION f() RETURNS int AS 'SELECT 1'")) + .isInstanceOf(CreateFunction.class) + .extracting(statement -> ((CreateFunction) statement).getDefiner()).isNull(); + assertThatThrownBy(() -> CCJSqlParserUtil.parse("CREATE DEFINER = x EVENT e " + + "ON SCHEDULE EVERY 1 DAY DO SELECT 1")).isInstanceOf(JSQLParserException.class); + } + @Test public void createFunctionWithPositionalParametersAcrossStatementsIssue2322() throws JSQLParserException { diff --git a/src/test/java/net/sf/jsqlparser/statement/create/trigger/CreateTriggerTest.java b/src/test/java/net/sf/jsqlparser/statement/create/trigger/CreateTriggerTest.java index f199bc293..ce5e89676 100644 --- a/src/test/java/net/sf/jsqlparser/statement/create/trigger/CreateTriggerTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/create/trigger/CreateTriggerTest.java @@ -11,13 +11,32 @@ import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; 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 static org.junit.jupiter.api.Assertions.assertTrue; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Block; import net.sf.jsqlparser.statement.SetStatement; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.UnsupportedStatement; +import net.sf.jsqlparser.statement.create.table.CreateTable; import net.sf.jsqlparser.statement.insert.Insert; +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 CreateTriggerTest { @@ -71,4 +90,121 @@ void parsesTriggerOrdering() throws JSQLParserException { assertEquals(CreateTrigger.Order.FOLLOWS, trigger.getOrder()); assertEquals("normalize_name", trigger.getOtherTrigger().getFullyQualifiedName()); } + + @ParameterizedTest + @ValueSource(strings = { + "CREATE DEFINER = `root`@`localhost` TRIGGER IF NOT EXISTS trg2 AFTER UPDATE ON t1 " + + "FOR EACH ROW FOLLOWS trg INSERT INTO audit (id) VALUES (NEW.id)", + "CREATE TRIGGER IF NOT EXISTS trg2 AFTER UPDATE ON t1 FOR EACH ROW " + + "INSERT INTO audit (id) VALUES (NEW.id)", + "CREATE OR REPLACE TRIGGER trg BEFORE INSERT ON t1 FOR EACH ROW SET NEW.a = 1", + "CREATE OR REPLACE DEFINER = CURRENT_USER TRIGGER trg BEFORE INSERT ON t1 " + + "FOR EACH ROW SET NEW.a = 1", + "CREATE TRIGGER trg3 BEFORE UPDATE ON t1 FOR EACH ROW " + + "BEGIN IF NEW.a < 0 THEN SET NEW.a = 0; END IF; END", + "CREATE TRIGGER trg BEFORE INSERT ON t1 FOR EACH ROW lbl: BEGIN " + + "DECLARE x INT DEFAULT 0; WHILE x < 3 DO SET x = x + 1; END WHILE; " + + "LEAVE lbl; END lbl", + "CREATE TRIGGER trg BEFORE INSERT ON t1 FOR EACH ROW " + + "IF NEW.a IS NULL THEN SET NEW.a = 'a.b'; END IF", + "CREATE TRIGGER trg BEFORE INSERT ON t1 FOR EACH ROW CASE WHEN NEW.a > 0 THEN " + + "SET NEW.b = 1; ELSE SET NEW.b = CASE NEW.c WHEN 1 THEN 2 END; END CASE", + "CREATE TRIGGER trg BEFORE INSERT ON t1 FOR EACH ROW BEGIN END"}) + void parsesMySqlTriggerForms(String sql) throws JSQLParserException { + for (Consumer consumer : List.>of( + parser -> parser.withDialect(Dialect.MYSQL), + parser -> parser.withDialect(Dialect.MARIADB), parser -> { + })) { + CreateTrigger trigger = assertInstanceOf(CreateTrigger.class, + assertSqlCanBeParsedAndDeparsed(sql, false, consumer)); + StringBuilder deparsed = new StringBuilder(); + trigger.accept(new StatementDeParser(deparsed)); + assertEquals(sql, deparsed.toString()); + assertEquals(sql, CCJSqlParserUtil.parse(deparsed.toString(), consumer).toString()); + Statements script = CCJSqlParserUtil.parseStatements(sql + ";\nSELECT 1", consumer); + assertEquals(2, script.size()); + assertEquals(sql, script.get(0).toString()); + } + } + + @Test + void parsesIfNotExistsAndOrReplace() throws JSQLParserException { + CreateTrigger trigger = (CreateTrigger) CCJSqlParserUtil.parse( + "CREATE DEFINER = CURRENT_USER TRIGGER IF NOT EXISTS trg AFTER UPDATE ON t1 " + + "FOR EACH ROW INSERT INTO audit (id) VALUES (NEW.id)"); + assertTrue(trigger.isIfNotExists()); + assertFalse(trigger.isOrReplace()); + assertEquals("trg", trigger.getTrigger().getFullyQualifiedName()); + assertEquals("CURRENT_USER", trigger.getDefiner().getUser().getValue()); + + trigger.setIfNotExists(false); + trigger.setOrReplace(true); + assertEquals("CREATE OR REPLACE DEFINER = CURRENT_USER TRIGGER trg AFTER UPDATE ON t1 " + + "FOR EACH ROW INSERT INTO audit (id) VALUES (NEW.id)", trigger.toString()); + + CreateTrigger replaced = (CreateTrigger) CCJSqlParserUtil.parse( + "CREATE OR REPLACE TRIGGER trg BEFORE INSERT ON t1 FOR EACH ROW SET NEW.a = 1", + parser -> parser.withDialect(Dialect.MARIADB)); + assertTrue(replaced.isOrReplace()); + assertFalse(replaced.isIfNotExists()); + + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse( + "CREATE OR REPLACE TRIGGER IF NOT EXISTS trg BEFORE INSERT ON t1 " + + "FOR EACH ROW SET NEW.a = 1")); + } + + @Test + void keepsStatementBlocksStructured() throws JSQLParserException { + String sql = "CREATE TRIGGER trg AFTER INSERT ON t1 FOR EACH ROW BEGIN " + + "INSERT INTO audit (id) VALUES (NEW.id); " + + "UPDATE counters SET n = CASE WHEN NEW.a > 0 THEN n + 1 ELSE n END; " + + "DELETE FROM pending WHERE id = NEW.id; END;"; + CreateTrigger trigger = (CreateTrigger) CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(Dialect.MYSQL)); + Block body = assertInstanceOf(Block.class, trigger.getBody()); + assertEquals(3, body.getStatements().size()); + assertInstanceOf(Insert.class, body.getStatements().get(0)); + // The semicolon after END ends the CREATE TRIGGER statement. + assertFalse(body.hasSemicolonAfterEnd()); + assertEquals(Set.of("t1", "audit", "counters", "pending"), + new HashSet<>(new TablesNamesFinder().getTables((Statement) trigger))); + } + + @Test + void keepsProceduralBodiesAsTokens() throws JSQLParserException { + CreateTrigger trigger = (CreateTrigger) CCJSqlParserUtil.parse( + "CREATE TRIGGER trg3 BEFORE UPDATE ON t1 FOR EACH ROW " + + "BEGIN IF NEW.a < 0 THEN SET NEW.a = 0; END IF; END", + parser -> parser.withDialect(Dialect.MYSQL)); + UnsupportedStatement body = assertInstanceOf(UnsupportedStatement.class, trigger.getBody()); + assertEquals("BEGIN IF NEW.a < 0 THEN SET NEW.a = 0; END IF; END", body.toString()); + assertEquals(Set.of("t1"), + new HashSet<>(new TablesNamesFinder().getTables((Statement) trigger))); + } + + @Test + void splitsScriptsAfterCompoundBodies() throws JSQLParserException { + String script = "CREATE TRIGGER trg BEFORE INSERT ON t1 FOR EACH ROW " + + "BEGIN SET NEW.a = 1; SET NEW.b = 2; END;\n" + + "CREATE TABLE b (y INT);\n" + + "CREATE TRIGGER trg3 BEFORE UPDATE ON t1 FOR EACH ROW " + + "BEGIN IF NEW.a < 0 THEN SET NEW.a = 0; END IF; END;\n" + + "CREATE TABLE c (z INT);"; + Statements statements = CCJSqlParserUtil.parseStatements(script, + parser -> parser.withDialect(Dialect.MYSQL)); + assertEquals(4, statements.size()); + assertInstanceOf(Block.class, ((CreateTrigger) statements.get(0)).getBody()); + assertInstanceOf(CreateTable.class, statements.get(1)); + assertInstanceOf(UnsupportedStatement.class, ((CreateTrigger) statements.get(2)).getBody()); + assertInstanceOf(CreateTable.class, statements.get(3)); + } + + @ParameterizedTest + @ValueSource(strings = { + "CREATE TRIGGER trg BEFORE INSERT ON t1 FOR EACH ROW BEGIN IF NEW.a < 0 THEN SET NEW.a = 0;", + "CREATE TRIGGER trg BEFORE INSERT ON t1 FOR EACH ROW BEGIN SET NEW.a = 1"}) + void rejectsUnterminatedCompoundBodies(String sql) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(Dialect.MYSQL))); + } } From 4c442d1d93a23873033a2d91f6db983c94cefb94 Mon Sep 17 00:00:00 2001 From: minleejae Date: Wed, 14 Oct 2026 18:23:39 +0900 Subject: [PATCH 3/3] feat: model MySQL CREATE FUNCTION and PROCEDURE characteristics With the MySQL or MariaDB dialect, parse the characteristics between the routine header and its body (COMMENT, LANGUAGE SQL, [NOT] DETERMINISTIC, the SQL data access and SQL SECURITY) into RoutineCharacteristic items with the production that ALTER FUNCTION/PROCEDURE uses. The declaration tokens are still captured and render the statement unchanged; getCharacteristics() is a read-only view of them, and the COMMENT literal is a StringValue. CHARACTER SET in a return type no longer starts the routine body. Signed-off-by: minleejae --- .../parser/RoutineBodyBoundary.java | 14 +- .../statement/CreateFunctionalStatement.java | 39 +++- .../statement/RoutineCharacteristic.java | 13 +- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 37 +++- src/site/sphinx/usage.rst | 10 +- .../MySqlRoutineCharacteristicsTest.java | 168 ++++++++++++++++++ 6 files changed, 269 insertions(+), 12 deletions(-) create mode 100644 src/test/java/net/sf/jsqlparser/statement/create/MySqlRoutineCharacteristicsTest.java diff --git a/src/main/java/net/sf/jsqlparser/parser/RoutineBodyBoundary.java b/src/main/java/net/sf/jsqlparser/parser/RoutineBodyBoundary.java index a8d3d8825..c5ef37b5d 100644 --- a/src/main/java/net/sf/jsqlparser/parser/RoutineBodyBoundary.java +++ b/src/main/java/net/sf/jsqlparser/parser/RoutineBodyBoundary.java @@ -60,6 +60,15 @@ boolean endsAt(Token token) throws ParseException { && !declarations && (hasParameters || quotedBody || completed); } + /** + * Whether the routine header, including a return type, is complete and the body has not + * started, which is where MySQL routine characteristics appear. + */ + boolean isBeforeBody() { + return hasParameters && parentheses == 0 && !bodyStarted && !returnTypeName + && !declarations && !quotedBody && !completed; + } + boolean startsQuotedBody(Token token, Token next) { return parentheses == 0 && !compoundBody && !quotedBody && token.kind == K_AS && next.kind == S_CHAR_LITERAL @@ -228,7 +237,10 @@ private void acceptStatementToken(Token token, Token next) { } if (!bodyStarted) { // SET may also be a PostgreSQL header option; AS remains recognizable above. - if (!isSimpleStatementStart(kind)) { + // CHARACTER SET and CHAR SET belong to a MySQL return type. + boolean characterSet = + kind == K_SET && (previousKind == K_CHARACTER || previousKind == K_CHAR); + if (!isSimpleStatementStart(kind) || characterSet) { return; } bodyStarted = true; diff --git a/src/main/java/net/sf/jsqlparser/statement/CreateFunctionalStatement.java b/src/main/java/net/sf/jsqlparser/statement/CreateFunctionalStatement.java index c34037ada..d3f75a127 100644 --- a/src/main/java/net/sf/jsqlparser/statement/CreateFunctionalStatement.java +++ b/src/main/java/net/sf/jsqlparser/statement/CreateFunctionalStatement.java @@ -34,6 +34,7 @@ public enum Operation { private MySqlDefiner definer; private FunctionReturnType returnType; private List routineBodyParts; + private List characteristics = Collections.emptyList(); public Operation getOperation() { return operation; @@ -99,8 +100,38 @@ public List getFunctionDeclarationParts() { return functionDeclarationParts; } + /** + * Replaces the declaration parts. The {@link #getCharacteristics() characteristics} described + * the replaced parts, so they are cleared. + * + * @param functionDeclarationParts the new parts + */ public void setFunctionDeclarationParts(List functionDeclarationParts) { + setFunctionDeclarationParts(functionDeclarationParts, Collections.emptyList()); + } + + /** + * Replaces the declaration parts together with the characteristics written in them. + * + * @param functionDeclarationParts the new parts, which keep rendering the statement + * @param characteristics the characteristics among the parts, in source order + */ + public void setFunctionDeclarationParts(List functionDeclarationParts, + List characteristics) { this.functionDeclarationParts = functionDeclarationParts; + this.characteristics = List.copyOf(characteristics); + } + + /** + * The MySQL characteristics of {@code CREATE FUNCTION|PROCEDURE}, such as {@code COMMENT}, + * {@code DETERMINISTIC} or {@code SQL SECURITY}, parsed with a MySQL or MariaDB dialect. They + * describe tokens of {@link #getFunctionDeclarationParts()}, which still render the statement, + * so the list is read-only. + * + * @return the characteristics in source order, empty if none were parsed + */ + public List getCharacteristics() { + return characteristics; } /** @@ -177,19 +208,23 @@ public CreateFunctionalStatement withFunctionDeclarationParts( return this; } + /** Appends declaration parts and keeps the {@link #getCharacteristics() characteristics}. */ public CreateFunctionalStatement addFunctionDeclarationParts( String... functionDeclarationParts) { List collection = Optional.ofNullable(getFunctionDeclarationParts()).orElseGet(ArrayList::new); Collections.addAll(collection, functionDeclarationParts); - return this.withFunctionDeclarationParts(collection); + setFunctionDeclarationParts(collection, characteristics); + return this; } + /** Appends declaration parts and keeps the {@link #getCharacteristics() characteristics}. */ public CreateFunctionalStatement addFunctionDeclarationParts( Collection functionDeclarationParts) { List collection = Optional.ofNullable(getFunctionDeclarationParts()).orElseGet(ArrayList::new); collection.addAll(functionDeclarationParts); - return this.withFunctionDeclarationParts(collection); + setFunctionDeclarationParts(collection, characteristics); + return this; } } diff --git a/src/main/java/net/sf/jsqlparser/statement/RoutineCharacteristic.java b/src/main/java/net/sf/jsqlparser/statement/RoutineCharacteristic.java index 93df5a7aa..90be0d3c6 100644 --- a/src/main/java/net/sf/jsqlparser/statement/RoutineCharacteristic.java +++ b/src/main/java/net/sf/jsqlparser/statement/RoutineCharacteristic.java @@ -15,15 +15,16 @@ import net.sf.jsqlparser.expression.StringValue; /** - * A characteristic set by {@code ALTER FUNCTION}, {@code ALTER PROCEDURE} or {@code ALTER ROUTINE}. - * PostgreSQL and MySQL share the model, and each {@link Kind} renders its own SQL: + * A characteristic set by {@code ALTER FUNCTION}, {@code ALTER PROCEDURE} or {@code ALTER ROUTINE}, + * or declared by MySQL's {@code CREATE FUNCTION|PROCEDURE}. PostgreSQL and MySQL share the model, + * and each {@link Kind} renders its own SQL: * *
    *
  • PostgreSQL: the volatility, the null input behavior, {@code [NOT] LEAKPROOF}, * {@code [EXTERNAL] SECURITY}, {@code PARALLEL}, {@code COST}, {@code ROWS}, {@code SUPPORT} and * the {@code SET} or {@code RESET} of a configuration parameter;
  • - *
  • MySQL: {@code COMMENT}, {@code LANGUAGE SQL}, the SQL data access and - * {@code SQL SECURITY}.
  • + *
  • MySQL: {@code COMMENT}, {@code LANGUAGE SQL}, {@code [NOT] DETERMINISTIC} (CREATE only), the + * SQL data access and {@code SQL SECURITY}.
  • *
* * The security kinds are shared; {@link #getSecurityClause()} keeps the spelling. Values keep their @@ -70,6 +71,10 @@ public enum Kind { COMMENT("COMMENT"), /** MySQL {@code LANGUAGE SQL}. */ LANGUAGE_SQL("LANGUAGE SQL"), + /** MySQL {@code DETERMINISTIC}, only in {@code CREATE FUNCTION|PROCEDURE}. */ + DETERMINISTIC("DETERMINISTIC"), + /** MySQL {@code NOT DETERMINISTIC}, only in {@code CREATE FUNCTION|PROCEDURE}. */ + NOT_DETERMINISTIC("NOT DETERMINISTIC"), /** MySQL {@code CONTAINS SQL}. */ CONTAINS_SQL("CONTAINS SQL"), /** MySQL {@code NO SQL}. */ diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index c7a6aecdf..9314b88f3 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -435,6 +435,12 @@ public class CCJSqlParser extends AbstractJSqlParser { } } + /** Whether a MySQL characteristic of CREATE FUNCTION or PROCEDURE starts here. */ + private boolean isMySqlCreateRoutineCharacteristicAhead() { + return isMySqlRoutineCharacteristicAhead(1) || isWordSequenceAhead(1, "DETERMINISTIC") + || isWordSequenceAhead(1, "NOT", "DETERMINISTIC"); + } + /** Whether a MySQL characteristic of ALTER FUNCTION or PROCEDURE starts at the offset. */ private boolean isMySqlRoutineCharacteristicAhead(int offset) { if (getToken(offset).kind == K_COMMENT) { @@ -21479,12 +21485,14 @@ Statement Create(): } } +/** With a MySQL dialect, the characteristics are also parsed; the tokens still render the statement. */ CreateFunctionalStatement CreateFunctionStatement(boolean isUsingOrReplace): { CreateFunctionalStatement type = null; List tokens = new LinkedList(); String statementType = null; MySqlDefiner definer = null; + List characteristics = new ArrayList(); } { [ definer=MySqlRoutineDefiner() ] @@ -21493,7 +21501,7 @@ CreateFunctionalStatement CreateFunctionStatement(boolean isUsingOrReplace): | { statementType = "PROCEDURE"; } ) - tokens=captureFunctionBody() + tokens=captureFunctionBody(isMySqlDialect() ? characteristics : null) { if(statementType.equals("FUNCTION")) { type = new CreateFunction(isUsingOrReplace, tokens); @@ -21501,6 +21509,7 @@ CreateFunctionalStatement CreateFunctionStatement(boolean isUsingOrReplace): if(statementType.equals("PROCEDURE")) { type = new CreateProcedure(isUsingOrReplace, tokens); } + type.setFunctionDeclarationParts(tokens, characteristics); type.setDefiner(definer); return type; } @@ -21546,7 +21555,7 @@ AlterRoutine AlterRoutine(): LOOKAHEAD({ isMySqlRoutineCharacteristicAhead(1) }) ( LOOKAHEAD({ isMySqlRoutineCharacteristicAhead(1) }) - characteristic=MySqlRoutineCharacteristic() { result.getCharacteristics().add(characteristic); } + characteristic=MySqlRoutineCharacteristic(false) { result.getCharacteristics().add(characteristic); } )+ { requireDdlSyntax(result.getRoutineType() != AlterRoutine.RoutineType.ROUTINE @@ -21624,7 +21633,8 @@ RoutineCharacteristic PostgreSqlRoutineCharacteristic(): } } -RoutineCharacteristic MySqlRoutineCharacteristic(): +/** MySQL routine characteristic; only CREATE accepts [NOT] DETERMINISTIC. */ +RoutineCharacteristic MySqlRoutineCharacteristic(boolean create): { RoutineCharacteristic.Kind kind; RoutineCharacteristic.SecurityClause clause = null; @@ -21644,6 +21654,12 @@ RoutineCharacteristic MySqlRoutineCharacteristic(): | ContextualKeyword("SQL") { kind = RoutineCharacteristic.Kind.NO_SQL; } | + LOOKAHEAD({ create && isWordSequenceAhead(1, "DETERMINISTIC") }) ContextualKeyword("DETERMINISTIC") + { kind = RoutineCharacteristic.Kind.DETERMINISTIC; } + | + LOOKAHEAD({ create && isWordSequenceAhead(1, "NOT", "DETERMINISTIC") }) + ContextualKeyword("DETERMINISTIC") { kind = RoutineCharacteristic.Kind.NOT_DETERMINISTIC; } + | LOOKAHEAD({ isKeywordAhead("READS") }) ContextualKeyword("READS") ContextualKeyword("SQL") { kind = RoutineCharacteristic.Kind.READS_SQL_DATA; } | @@ -22444,16 +22460,29 @@ List captureRest() { * PostgreSQL AS string bodies can use ordinary, escape or dollar quotes. * The boundary tracker distinguishes simple statements, declaration sections, * compound bodies, and expression CASE from procedural END qualifiers. + * With a list, MySQL characteristics between the header and the body are also + * parsed into it; their tokens are captured as well. */ JAVACODE -List captureFunctionBody() { +List captureFunctionBody(List characteristics) { List tokens = new LinkedList(); Token tok; RoutineBodyBoundary boundary = new RoutineBodyBoundary(); while(true) { tok = getToken(1); int l = tokens.size(); + if (characteristics != null && boundary.isBeforeBody() && isMySqlCreateRoutineCharacteristicAhead()) { + characteristics.add(MySqlRoutineCharacteristic(true)); + for (Token consumed = tok; ; consumed = consumed.next) { + tokens.add(consumed.image); + boundary.accept(consumed, consumed == token ? getToken(1) : consumed.next); + if (consumed == token) { + break; + } + } + continue; + } if (boundary.endsAt(tok)) { if (tok.kind == ST_SEMICOLON) { tokens.add(tok.image); diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 54184d0c9..c02c8a3f5 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -1959,7 +1959,15 @@ Validators report ``alterFunction``, ``alterProcedure``, ``alterRoutine`` and ``CreateFunctionalStatement.getDefiner()`` returns the MySQL ``DEFINER`` of ``CREATE DEFINER = ... FUNCTION|PROCEDURE`` as the ``MySqlDefiner`` that views and -triggers use. MySQL ``CREATE TRIGGER`` accepts ``IF NOT EXISTS`` (``isIfNotExists()``) +triggers use. With ``Dialect.MYSQL`` or ``Dialect.MARIADB``, +``CreateFunctionalStatement.getCharacteristics()`` lists the characteristics between the +routine header and its body, such as ``COMMENT``, ``DETERMINISTIC``, ``READS SQL DATA`` or +``SQL SECURITY INVOKER``, with the same ``RoutineCharacteristic`` kinds; ``DETERMINISTIC`` +and ``NOT_DETERMINISTIC`` occur only here. ``getFunctionDeclarationParts()`` still holds +every token and renders the statement unchanged, so the list is read-only. Replacing the +tokens with ``setFunctionDeclarationParts(parts)`` clears it, while +``setFunctionDeclarationParts(parts, characteristics)`` sets both, and appending tokens +keeps it. Without a MySQL dialect the list stays empty. MySQL ``CREATE TRIGGER`` accepts ``IF NOT EXISTS`` (``isIfNotExists()``) and keeps MariaDB's ``OR REPLACE``. A ``BEGIN ... END`` trigger body with only SQL statements remains a ``Block``; a compound body with procedural statements such as ``IF``, ``DECLARE`` or loops is an ``UnsupportedStatement`` with its tokens, captured with diff --git a/src/test/java/net/sf/jsqlparser/statement/create/MySqlRoutineCharacteristicsTest.java b/src/test/java/net/sf/jsqlparser/statement/create/MySqlRoutineCharacteristicsTest.java new file mode 100644 index 000000000..5593ec183 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/MySqlRoutineCharacteristicsTest.java @@ -0,0 +1,168 @@ +/*- + * #%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.*; + +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.CreateFunctionalStatement; +import net.sf.jsqlparser.statement.RoutineCharacteristic; +import net.sf.jsqlparser.statement.RoutineCharacteristic.Kind; +import net.sf.jsqlparser.statement.RoutineCharacteristic.SecurityClause; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.UnsupportedStatement; +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 MySqlRoutineCharacteristicsTest { + + @ParameterizedTest + @CsvSource(delimiter = '|', quoteCharacter = '`', value = { + "CREATE PROCEDURE cp1() COMMENT 'proc comment' SELECT 1|COMMENT", + "CREATE FUNCTION cf1() RETURNS INT DETERMINISTIC COMMENT 'fn' RETURN 1" + + "|DETERMINISTIC COMMENT", + "CREATE PROCEDURE gp13() COMMENT 'c' LANGUAGE SQL SQL SECURITY INVOKER SELECT 1" + + "|COMMENT LANGUAGE_SQL SECURITY_INVOKER", + "CREATE FUNCTION gf14() RETURNS INT DETERMINISTIC RETURN 1|DETERMINISTIC", + "CREATE FUNCTION f1 (a INT) RETURNS INT DETERMINISTIC RETURN a + 1|DETERMINISTIC", + "CREATE DEFINER=CURRENT_USER FUNCTION f2 (s CHAR(20)) RETURNS CHAR(50) " + + "CHARSET utf8mb4 NO SQL RETURN CONCAT('Hello, ', s, '!')|NO_SQL", + "CREATE FUNCTION f() RETURNS VARCHAR(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin " + + "READS SQL DATA RETURN 'a'|READS_SQL_DATA", + "CREATE FUNCTION f() RETURNS SET('a', 'b') CHAR SET latin1 MODIFIES SQL DATA " + + "RETURN 'a'|MODIFIES_SQL_DATA", + "CREATE PROCEDURE p (IN a INT) NOT DETERMINISTIC CONTAINS SQL SQL SECURITY DEFINER " + + "BEGIN SELECT a; END|NOT_DETERMINISTIC CONTAINS_SQL SECURITY_DEFINER", + "CREATE FUNCTION f() RETURNS INT not deterministic comment 'x' return 1" + + "|NOT_DETERMINISTIC COMMENT", + "CREATE PROCEDURE p1 (IN a INT, OUT b INT) BEGIN SELECT a INTO b; END|``", + "CREATE DEFINER=CURRENT_USER PROCEDURE p3 () SELECT 1|``", + "CREATE FUNCTION f() RETURNS INT RETURN (SELECT deterministic FROM t)|``", + "CREATE PROCEDURE p() READS SQL DATA BEGIN DECLARE deterministic INT; END" + + "|READS_SQL_DATA"}) + void modelsCharacteristicsWithoutChangingTokens(String sql, String kinds) + throws JSQLParserException { + // Without a dialect, the declaration is only captured as tokens. + CreateFunctionalStatement plain = + (CreateFunctionalStatement) CCJSqlParserUtil.parse(sql); + assertTrue(plain.getCharacteristics().isEmpty()); + + for (Dialect dialect : new Dialect[] {Dialect.MYSQL, Dialect.MARIADB}) { + Consumer consumer = parser -> parser.withDialect(dialect); + CreateFunctionalStatement statement = + (CreateFunctionalStatement) CCJSqlParserUtil.parse(sql, consumer); + assertEquals(kinds.isEmpty() ? List.of() : List.of(kinds.split(" ")), + statement.getCharacteristics().stream() + .map(characteristic -> characteristic.getKind().name()) + .collect(Collectors.toList())); + + assertEquals(plain.getFunctionDeclarationParts(), + statement.getFunctionDeclarationParts()); + assertEquals(plain.toString(), statement.toString()); + StringBuilder deparsed = new StringBuilder(); + statement.accept(new StatementDeParser(deparsed)); + assertEquals(plain.toString(), deparsed.toString()); + assertEquals(statement.toString(), + CCJSqlParserUtil.parse(deparsed.toString(), consumer).toString()); + + Statements script = CCJSqlParserUtil.parseStatements(sql + ";\nSELECT 1", consumer); + assertEquals(2, script.size()); + assertEquals(statement.getCharacteristics().size(), + ((CreateFunctionalStatement) script.get(0)).getCharacteristics().size()); + } + } + + @Test + void exposesCharacteristicValues() throws JSQLParserException { + CreateFunctionalStatement procedure = parse("CREATE PROCEDURE gp13() COMMENT 'c' " + + "LANGUAGE SQL SQL SECURITY INVOKER SELECT 1"); + List characteristics = procedure.getCharacteristics(); + assertEquals("c", characteristics.get(0).getComment().getNotExcapedValue()); + assertEquals("'c'", characteristics.get(0).getValue()); + assertEquals(SecurityClause.SQL_SECURITY, characteristics.get(2).getSecurityClause()); + assertEquals("SQL SECURITY INVOKER", characteristics.get(2).toString()); + assertThrows(UnsupportedOperationException.class, + () -> characteristics.add(new RoutineCharacteristic(Kind.NO_SQL))); + + CreateFunctionalStatement function = + parse("CREATE FUNCTION f() RETURNS INT not deterministic comment 'x' return 1"); + // The tokens keep the spelling; the characteristics render canonical keywords. + assertEquals("CREATE FUNCTION f ( ) RETURNS INT not deterministic comment 'x' return 1", + function.toString()); + assertEquals("NOT DETERMINISTIC", function.getCharacteristics().get(0).toString()); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', quoteCharacter = '`', value = {"'it\\'s'|it's", "'it''s'|it's", + "\"dq\"|dq", "'a\\\\b'|a\\b", "'ν•œκΈ€ μ„€λͺ… πŸ˜€'|ν•œκΈ€ μ„€λͺ… πŸ˜€"}) + void decodesComments(String literal, String stored) throws JSQLParserException { + CreateFunctionalStatement statement = + parse("CREATE PROCEDURE p() COMMENT " + literal + " SELECT 1"); + RoutineCharacteristic comment = statement.getCharacteristics().get(0); + assertEquals(Kind.COMMENT, comment.getKind()); + assertEquals(literal, comment.getValue()); + assertEquals(stored, comment.getComment().getNotExcapedValue()); + assertEquals("CREATE PROCEDURE p ( ) COMMENT " + literal + " SELECT 1", + statement.toString()); + } + + @Test + void replacingTheTokensClearsTheCharacteristics() throws JSQLParserException { + CreateFunctionalStatement statement = + parse("CREATE FUNCTION gf14() RETURNS INT DETERMINISTIC RETURN 1"); + assertEquals(1, statement.getCharacteristics().size()); + + statement.addFunctionDeclarationParts("+", "1"); + assertEquals(1, statement.getCharacteristics().size()); + assertEquals("CREATE FUNCTION gf14 ( ) RETURNS INT DETERMINISTIC RETURN 1 + 1", + statement.toString()); + + statement.setFunctionDeclarationParts(List.of("g", "(", ")", "SELECT", "1")); + assertTrue(statement.getCharacteristics().isEmpty()); + assertEquals("CREATE FUNCTION g ( ) SELECT 1", statement.toString()); + + statement.setFunctionDeclarationParts(List.of("g", "(", ")", "NO", "SQL", "SELECT", "1"), + List.of(new RoutineCharacteristic(Kind.NO_SQL))); + assertEquals(Kind.NO_SQL, statement.getCharacteristics().get(0).getKind()); + assertEquals("CREATE FUNCTION g ( ) NO SQL SELECT 1", statement.toString()); + } + + @Test + void otherDialectsKeepOnlyTokens() throws JSQLParserException { + String postgres = "CREATE FUNCTION add(a integer, b integer) RETURNS integer " + + "LANGUAGE sql IMMUTABLE STRICT AS $$ SELECT a + b $$"; + assertTrue(((CreateFunctionalStatement) CCJSqlParserUtil.parse(postgres, + parser -> parser.withDialect(Dialect.POSTGRESQL))).getCharacteristics().isEmpty()); + assertTrue(((CreateFunctionalStatement) CCJSqlParserUtil.parse(postgres)) + .getCharacteristics().isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = {"ALTER FUNCTION f1 DETERMINISTIC", + "ALTER PROCEDURE p1 NOT DETERMINISTIC"}) + void alterKeepsRejectingDeterministic(String sql) throws JSQLParserException { + assertInstanceOf(UnsupportedStatement.class, + CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(Dialect.MYSQL))); + } + + private static CreateFunctionalStatement parse(String sql) throws JSQLParserException { + return (CreateFunctionalStatement) CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(Dialect.MYSQL)); + } +}