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..c5ef37b5d 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()) { @@ -50,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 @@ -218,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/parser/feature/Feature.java b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java index 0d3d080fb..50ebf078e 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 * @@ -711,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 new file mode 100644 index 000000000..913cace5e --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/ConfigurationParameterChange.java @@ -0,0 +1,175 @@ +/*- + * #%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: {@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 }
+ * 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/CreateFunctionalStatement.java b/src/main/java/net/sf/jsqlparser/statement/CreateFunctionalStatement.java index 0845742e9..d3f75a127 100644 --- a/src/main/java/net/sf/jsqlparser/statement/CreateFunctionalStatement.java +++ b/src/main/java/net/sf/jsqlparser/statement/CreateFunctionalStatement.java @@ -31,8 +31,10 @@ public enum Operation { } private Operation operation = Operation.CREATE; + private MySqlDefiner definer; private FunctionReturnType returnType; private List routineBodyParts; + private List characteristics = Collections.emptyList(); public Operation getOperation() { return operation; @@ -42,6 +44,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; } @@ -85,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; } /** @@ -139,6 +184,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); } @@ -160,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 new file mode 100644 index 000000000..90be0d3c6 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/RoutineCharacteristic.java @@ -0,0 +1,268 @@ +/*- + * #%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}, + * 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}, {@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 + * 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 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}. */ + 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/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/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/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/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 2490baa3e..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; @@ -838,7 +840,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 @@ -942,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 de84d3f82..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 @@ -116,6 +116,14 @@ 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/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 6aabf1fe0..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 @@ -93,6 +93,14 @@ 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/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 023a8c0e1..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 @@ -120,6 +120,17 @@ 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/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 1de8380a7..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; @@ -85,6 +87,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 +443,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; @@ -1186,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 2210dbe9d..9314b88f3 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -269,9 +269,247 @@ 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); + } + + /** 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 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) { + 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 { @@ -4116,6 +4354,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; @@ -4147,7 +4397,7 @@ Block Block() #Block : { block.setStatements(stmts); } - [LOOKAHEAD(2) { block.setSemicolonAfterEnd(true); } ] + { return block; } @@ -15730,6 +15980,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(): { @@ -15785,7 +16039,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; @@ -15800,7 +16105,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); } @@ -15812,7 +16124,7 @@ CreateTrigger CreateTrigger(): createTrigger.setOrder(order); createTrigger.setOtherTrigger(otherTrigger); } ] - ( body=SingleStatement() | body=Block() ) { createTrigger.setBody(body); } + body=MySqlTriggerBody() { createTrigger.setBody(body); } { return createTrigger; } } @@ -15888,6 +16200,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 +16225,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 +16376,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 +16388,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; @@ -19584,6 +20024,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") }) @@ -19591,10 +20036,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 +20860,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 +20873,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 +20889,39 @@ AlterRole AlterRole(): } } +/** + * PostgreSQL SET/RESET of a configuration parameter, shared by ALTER ROLE, ALTER DATABASE and + * ALTER FUNCTION/PROCEDURE/ROUTINE. + */ +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; } } @@ -20641,6 +21107,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; @@ -20956,14 +21443,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() @@ -20997,19 +21485,23 @@ 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() ] ( { statementType = "FUNCTION"; } | { statementType = "PROCEDURE"; } ) - tokens=captureFunctionBody() + tokens=captureFunctionBody(isMySqlDialect() ? characteristics : null) { if(statementType.equals("FUNCTION")) { type = new CreateFunction(isUsingOrReplace, tokens); @@ -21017,11 +21509,194 @@ CreateFunctionalStatement CreateFunctionStatement(boolean isUsingOrReplace): if(statementType.equals("PROCEDURE")) { type = new CreateProcedure(isUsingOrReplace, tokens); } - + type.setFunctionDeclarationParts(tokens, characteristics); + 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(false) { 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); + } +} + +/** MySQL routine characteristic; only CREATE accepts [NOT] DETERMINISTIC. */ +RoutineCharacteristic MySqlRoutineCharacteristic(boolean create): +{ + 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({ 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; } + | + 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; @@ -21785,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/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 901608d62..c02c8a3f5 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -1868,6 +1868,120 @@ 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 `_. + +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. 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 +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/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..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; @@ -39,6 +41,12 @@ 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 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/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/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/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)); + } +} 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"); + } } 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))); + } }