From 74ce2dfc678579e0b0e2cb5ab9447130ac1484ac Mon Sep 17 00:00:00 2001 From: minleejae Date: Wed, 14 Oct 2026 12:58:58 +0900 Subject: [PATCH] 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"); + } }