diff --git a/README.md b/README.md index 0fdeacbe3..af0976936 100644 --- a/README.md +++ b/README.md @@ -151,6 +151,7 @@ and missing syntax gets added on demand — [open an issue](https://github.com/J | **MySQL table maintenance** | `ANALYZE` · `CHECK` · `CHECKSUM` · `OPTIMIZE` · `REPAIR TABLE` | | **PostgreSQL maintenance** | `VACUUM` · `ANALYZE` · `CLUSTER` · `CHECKPOINT` | | **MySQL locks** | `LOCK TABLES` · `UNLOCK TABLES` · `LOCK INSTANCE FOR BACKUP` · `UNLOCK INSTANCE` | +| **MySQL servers, plugins and components** | `CREATE SERVER` · `ALTER SERVER` with `OPTIONS (… PORT 3306)` · `INSTALL`/`UNINSTALL PLUGIN` · `INSTALL`/`UNINSTALL COMPONENT` | | **PostgreSQL session statements** | `DISCARD` · `LISTEN` · `UNLISTEN` · `SHOW`/`RESET TIME ZONE`/`TRANSACTION ISOLATION LEVEL`/`SESSION AUTHORIZATION` · `COPY` options from before 9.0 | | **MySQL account management** | `CREATE`/`ALTER`/`RENAME USER` · `CREATE ROLE` · `SET ROLE` · `SET DEFAULT ROLE` · `SET PASSWORD` | | **Tablespaces** | MySQL and PostgreSQL `CREATE TABLESPACE` · `ALTER TABLESPACE` | 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 7382b49e2..139bd825e 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -22,6 +22,7 @@ import net.sf.jsqlparser.statement.DeclareStatement; import net.sf.jsqlparser.statement.DescribeStatement; import net.sf.jsqlparser.statement.ExplainStatement; +import net.sf.jsqlparser.statement.PluginStatement; import net.sf.jsqlparser.statement.PrepareTransactionStatement; import net.sf.jsqlparser.statement.ResetStatement; import net.sf.jsqlparser.statement.SetConstraintsStatement; @@ -778,6 +779,18 @@ public enum Feature { * @see XaStatement */ xa, + /** + * MySQL and MariaDB INSTALL PLUGIN and UNINSTALL PLUGIN + * + * @see PluginStatement + */ + installPlugin, + /** + * MySQL INSTALL COMPONENT and UNINSTALL COMPONENT + * + * @see PluginStatement + */ + installComponent, /** * SQL "LOCK TABLE" statement is allowed * diff --git a/src/main/java/net/sf/jsqlparser/statement/ComponentVariable.java b/src/main/java/net/sf/jsqlparser/statement/ComponentVariable.java new file mode 100644 index 000000000..4c50128e4 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/ComponentVariable.java @@ -0,0 +1,112 @@ +/*- + * #%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.Objects; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; + +/** + * One assignment of the {@code SET} clause of MySQL's {@code INSTALL COMPONENT}: + * {@code [GLOBAL | PERSIST] variable {= | :=} value}. The value is an expression, or the word + * {@code ON}, which is read as a column. + * + * @see PluginStatement + */ +public class ComponentVariable implements Serializable { + private SetScope scope = SetScope.NONE; + private String name; + private SetStatement.AssignmentOperator operator = SetStatement.AssignmentOperator.EQUALS; + private Expression value; + + public ComponentVariable(String name, Expression value) { + this.name = Objects.requireNonNull(name, "name"); + this.value = Objects.requireNonNull(value, "value"); + } + + /** {@link SetScope#GLOBAL}, {@link SetScope#PERSIST}, or {@link SetScope#NONE} if not given. */ + public SetScope getScope() { + return scope; + } + + /** + * Sets the scope keyword; null means none. + * + * @throws IllegalArgumentException for a scope other than GLOBAL, PERSIST or NONE + */ + public void setScope(SetScope scope) { + SetScope value = scope == null ? SetScope.NONE : scope; + if (value != SetScope.NONE && value != SetScope.GLOBAL && value != SetScope.PERSIST) { + throw new IllegalArgumentException("INSTALL COMPONENT SET does not accept " + value); + } + this.scope = value; + } + + public ComponentVariable withScope(SetScope scope) { + setScope(scope); + return this; + } + + /** The variable name as written: {@code variable} or {@code component.variable}. */ + public String getName() { + return name; + } + + public void setName(String name) { + this.name = Objects.requireNonNull(name, "name"); + } + + /** {@code =} or {@code :=}. */ + public SetStatement.AssignmentOperator getOperator() { + return operator; + } + + /** + * Sets the assignment operator. + * + * @throws IllegalArgumentException for an operator other than EQUALS or COLON_EQUALS + */ + public void setOperator(SetStatement.AssignmentOperator operator) { + if (operator != SetStatement.AssignmentOperator.EQUALS + && operator != SetStatement.AssignmentOperator.COLON_EQUALS) { + throw new IllegalArgumentException("INSTALL COMPONENT SET assigns with = or :="); + } + this.operator = operator; + } + + public ComponentVariable withOperator(SetStatement.AssignmentOperator operator) { + setOperator(operator); + return this; + } + + public Expression getValue() { + return value; + } + + public void setValue(Expression value) { + this.value = Objects.requireNonNull(value, "value"); + } + + public StringBuilder appendTo(StringBuilder builder, Consumer expressionPrinter) { + if (scope != SetScope.NONE) { + builder.append(scope.getKeyword()).append(' '); + } + builder.append(name).append(' ').append(operator.getToken()).append(' '); + expressionPrinter.accept(value); + 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/PluginStatement.java b/src/main/java/net/sf/jsqlparser/statement/PluginStatement.java new file mode 100644 index 000000000..cef012f4f --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/PluginStatement.java @@ -0,0 +1,174 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.function.Consumer; +import net.sf.jsqlparser.expression.Expression; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; + +/** + * The MySQL statements that load and unload server plugins and components: + * {@code INSTALL PLUGIN name SONAME 'library'}, {@code UNINSTALL PLUGIN name}, + * {@code INSTALL COMPONENT 'urn'[, ...] [SET [GLOBAL | PERSIST] variable = value[, ...]]} and + * {@code UNINSTALL COMPONENT 'urn'[, ...]}. DuckDB's {@code INSTALL extension} is an + * {@link ExtensionStatement}. + * + * @see MySQL Plugin and + * Component Statements + */ +public class PluginStatement extends ASTNodeAccessImpl implements Statement { + /** The statement's first word. */ + public enum Action { + INSTALL, UNINSTALL + } + + /** What the statement loads or unloads. */ + public enum ObjectType { + PLUGIN, COMPONENT + } + + private Action action; + private ObjectType objectType; + private String pluginName; + private StringValue library; + private List components = new ArrayList<>(); + private List variables = new ArrayList<>(); + + public PluginStatement(Action action, ObjectType objectType) { + this.action = Objects.requireNonNull(action, "action"); + this.objectType = Objects.requireNonNull(objectType, "objectType"); + } + + public Action getAction() { + return action; + } + + public void setAction(Action action) { + this.action = Objects.requireNonNull(action, "action"); + } + + public ObjectType getObjectType() { + return objectType; + } + + public void setObjectType(ObjectType objectType) { + this.objectType = Objects.requireNonNull(objectType, "objectType"); + } + + /** The plugin name as written, or null for a component statement. */ + public String getPluginName() { + return pluginName; + } + + public void setPluginName(String pluginName) { + this.pluginName = pluginName; + } + + public PluginStatement withPluginName(String pluginName) { + setPluginName(pluginName); + return this; + } + + /** The plugin library file after SONAME; only INSTALL PLUGIN names one. */ + public StringValue getLibrary() { + return library; + } + + public void setLibrary(StringValue library) { + this.library = library; + } + + public PluginStatement withLibrary(StringValue library) { + setLibrary(library); + return this; + } + + /** The component URNs, in the order written; empty for a plugin statement. */ + public List getComponents() { + return components; + } + + public void setComponents(List components) { + this.components = Objects.requireNonNull(components, "components"); + } + + public PluginStatement addComponents(StringValue... components) { + for (StringValue component : components) { + this.components.add(component); + } + return this; + } + + /** The assignments after SET; only INSTALL COMPONENT has them. */ + public List getVariables() { + return variables; + } + + public void setVariables(List variables) { + this.variables = Objects.requireNonNull(variables, "variables"); + } + + public PluginStatement addVariables(ComponentVariable... variables) { + for (ComponentVariable variable : variables) { + this.variables.add(variable); + } + return this; + } + + /** Passes the literals and the assigned values to {@code visitor}. */ + public void visitExpressions(Consumer visitor) { + if (objectType == ObjectType.PLUGIN) { + if (library != null) { + visitor.accept(library); + } + return; + } + components.forEach(visitor); + for (ComponentVariable variable : variables) { + visitor.accept(variable.getValue()); + } + } + + public StringBuilder appendTo(StringBuilder builder, Consumer expressionPrinter) { + builder.append(action).append(' ').append(objectType); + if (objectType == ObjectType.PLUGIN) { + builder.append(' ').append(pluginName); + if (library != null) { + builder.append(" SONAME "); + expressionPrinter.accept(library); + } + return builder; + } + for (int i = 0; i < components.size(); i++) { + builder.append(i == 0 ? " " : ", "); + expressionPrinter.accept(components.get(i)); + } + for (int i = 0; i < variables.size(); i++) { + builder.append(i == 0 ? " SET " : ", "); + variables.get(i).appendTo(builder, expressionPrinter); + } + return builder; + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + return appendTo(builder, builder::append).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index be1c4cf27..e2ac79aba 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -604,6 +604,14 @@ public Void visit(ExtensionStatement extensionStatement, S context) { return null; } + @Override + public Void visit(PluginStatement statement, S context) { + // loads or unloads server code, like DuckDB's INSTALL; no data or schema changes + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SESSION); + return null; + } + @Override public Void visit(AttachStatement attachStatement, S context) { analysis.claimTopLevel(); diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index 2711d3a76..e14626683 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -197,6 +197,14 @@ default void visit(XaStatement statement) { visit(statement, null); } + default T visit(PluginStatement statement, S context) { + return null; + } + + default void visit(PluginStatement statement) { + visit(statement, null); + } + T visit(Commit commit, S context); default void visit(Commit commit) { diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index 2d43aff58..313de4d1c 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -262,6 +262,11 @@ public T visit(XaStatement statement, S context) { return null; } + @Override + public T visit(PluginStatement statement, S context) { + return null; + } + @Override public T visit(Commit commit, S context) { diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/ForeignDataOption.java b/src/main/java/net/sf/jsqlparser/statement/create/table/ForeignDataOption.java index 9bc11c1a1..704a4277d 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/ForeignDataOption.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/ForeignDataOption.java @@ -15,7 +15,11 @@ import net.sf.jsqlparser.expression.Expression; import net.sf.jsqlparser.expression.StringValue; -/** A foreign table or column option; absent action means the implicit ADD form. */ +/** + * A foreign table, column or server option; absent action means the implicit ADD form. The value is + * a string constant; MySQL's CREATE SERVER and ALTER SERVER also take a number, as in + * {@code PORT 3306}. + */ public class ForeignDataOption implements Serializable { public enum Action { ADD, SET, DROP @@ -23,7 +27,7 @@ public enum Action { private Action action; private String name; - private StringValue value; + private Expression value; public ForeignDataOption(String name, StringValue value) { this.name = name; @@ -49,14 +53,31 @@ public void setName(String name) { this.name = name; } + /** The string value, or null if the option has none or has a numeric value. */ public StringValue getValue() { - return value; + return value instanceof StringValue ? (StringValue) value : null; } public void setValue(StringValue value) { this.value = value; } + /** + * The value as written: a {@link StringValue}, or a numeric literal of a MySQL server option. + */ + public Expression getValueExpression() { + return value; + } + + public void setValueExpression(Expression value) { + this.value = value; + } + + public ForeignDataOption withValueExpression(Expression value) { + setValueExpression(value); + return this; + } + public void appendTo(StringBuilder builder, Consumer expressionPrinter) { if (action != null) { builder.append(action).append(' '); diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index f65ddf14f..89ee1cbba 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -93,6 +93,7 @@ import net.sf.jsqlparser.statement.OutputClause; import net.sf.jsqlparser.statement.PragmaStatement; import net.sf.jsqlparser.statement.PrepareStatement; +import net.sf.jsqlparser.statement.PluginStatement; import net.sf.jsqlparser.statement.PrepareTransactionStatement; import net.sf.jsqlparser.statement.PurgeObjectType; import net.sf.jsqlparser.statement.PurgeStatement; @@ -2272,6 +2273,12 @@ public Void visit(XaStatement statement, S context) { return null; } + @Override + public Void visit(PluginStatement statement, S context) { + statement.visitExpressions(expression -> expression.accept(this, context)); + return null; + } + @Override public Void visit(Commit commit, S 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 41defbd12..1874055e2 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -154,6 +154,7 @@ import net.sf.jsqlparser.statement.ConnectStatement; import net.sf.jsqlparser.statement.DisconnectStatement; import net.sf.jsqlparser.statement.PrepareStatement; +import net.sf.jsqlparser.statement.PluginStatement; import net.sf.jsqlparser.statement.PrepareTransactionStatement; import net.sf.jsqlparser.statement.DeallocateStatement; import net.sf.jsqlparser.statement.CopyStatement; @@ -503,6 +504,12 @@ public StringBuilder visit(XaStatement statement, S context) { return statement.appendTo(builder); } + @Override + public StringBuilder visit(PluginStatement statement, S context) { + return statement.appendTo(builder, + expression -> expression.accept(expressionDeParser, context)); + } + @Override public StringBuilder visit(Commit commit, S context) { return commit.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 3b70763cf..9ba3f513a 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 @@ -155,7 +155,9 @@ public enum MariaDbVersion implements Version { Feature.commit, // https://mariadb.com/kb/en/rollback/ Feature.rollback, // https://mariadb.com/kb/en/savepoint/ Feature.savepoint, // https://mariadb.com/kb/en/xa-transactions/ - Feature.xa, // https://mariadb.com/kb/en/optimizer-hints/ + Feature.xa, // https://mariadb.com/kb/en/create-server/ + Feature.createServer, Feature.alterServer, // https://mariadb.com/kb/en/install-plugin/ + Feature.installPlugin, // https://mariadb.com/kb/en/optimizer-hints/ Feature.mySqlHintStraightJoin, Feature.mysqlSelectModifiers, Feature.mysqlCalcFoundRows, Feature.mysqlSqlCacheFlag)), 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 0b0eaf25e..49403ae09 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 @@ -154,7 +154,9 @@ public enum MySqlVersion implements Version { Feature.transactionRelease, // https://dev.mysql.com/doc/refman/8.4/en/set-transaction.html Feature.setTransaction, Feature.setTransactionGlobal, Feature.setTransactionPersist, // https://dev.mysql.com/doc/refman/8.4/en/xa-statements.html - Feature.xa, // + Feature.xa, // https://dev.mysql.com/doc/refman/8.4/en/create-server.html + Feature.createServer, Feature.alterServer, // https://dev.mysql.com/doc/refman/8.4/en/component-statements.html + Feature.installPlugin, Feature.installComponent, // Feature.mySqlHintStraightJoin, Feature.mysqlSqlCacheFlag, Feature.mysqlSelectModifiers, Feature.mysqlCalcFoundRows)); 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 a69059598..60c734e8e 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 @@ -162,6 +162,7 @@ import net.sf.jsqlparser.statement.ConnectStatement; import net.sf.jsqlparser.statement.DisconnectStatement; import net.sf.jsqlparser.statement.PrepareStatement; +import net.sf.jsqlparser.statement.PluginStatement; import net.sf.jsqlparser.statement.PrepareTransactionStatement; import net.sf.jsqlparser.statement.DeallocateStatement; import net.sf.jsqlparser.statement.CopyStatement; @@ -459,6 +460,15 @@ public Void visit(XaStatement statement, S context) { return null; } + @Override + public Void visit(PluginStatement statement, S context) { + validateFeature(statement.getObjectType() == PluginStatement.ObjectType.COMPONENT + ? Feature.installComponent + : Feature.installPlugin); + statement.visitExpressions(this::validateOptionalExpression); + return null; + } + @Override public Void visit(Commit commit, S context) { validateFeature(Feature.commit); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index e4fcc2e76..479f285f6 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -191,7 +191,8 @@ public class CCJSqlParser extends AbstractJSqlParser { || "REASSIGN".equalsIgnoreCase(next.image) || "REVOKE".equalsIgnoreCase(next.image) || "RELEASE".equalsIgnoreCase(next.image) - || isAbortTransactionAhead() || isXaStatementAhead(); + || isAbortTransactionAhead() || isXaStatementAhead() + || isPluginStatementAhead(); } } @@ -3497,6 +3498,113 @@ public class CCJSqlParser extends AbstractJSqlParser { return Dialect.MYSQL.name().equals(dialect) || Dialect.MARIADB.name().equals(dialect); } + /** + * Whether a MySQL INSTALL or UNINSTALL PLUGIN or COMPONENT statement starts here; read with the + * MySQL and MariaDB dialects or none. UNINSTALL is not a token. With those dialects every + * INSTALL is this statement and FORCE INSTALL is an error. Without a dialect, INSTALL plugin + * and INSTALL component stay DuckDB extension names unless SONAME or a component URN follows. + */ + private boolean isPluginStatementAhead() { + boolean install = getToken(1).kind == K_INSTALL; + boolean plugin = isWordAt(2, "PLUGIN"); + boolean object = plugin || isWordAt(2, "COMPONENT"); + if (getAsString(Feature.dialect) != null) { + return isMySqlDialect() && (install || object && isWordAt(1, "UNINSTALL") + || getToken(1).kind == K_FORCE && getToken(2).kind == K_INSTALL); + } + if (!object) { + return false; + } + return install + ? (plugin ? isWordAt(4, "SONAME") : getToken(3).kind == S_CHAR_LITERAL) + : isWordAt(1, "UNINSTALL"); + } + + /** Whether the literal is MySQL's TEXT_STRING_sys: a quoted string without a prefix. */ + private static boolean isMySqlTextString(StringValue value) { + return value.getPrefix() == null && value.getQuoteStr() != null + && !value.getQuoteStr().startsWith("$"); + } + + /** The string after SONAME or a component URN. */ + private StringValue mySqlTextString(Token token) throws ParseException { + StringValue value = stringValue(token); + requireDdlSyntax(isMySqlTextString(value), "Expected a string literal but found " + token.image); + return value; + } + + /** + * Whether the name is one MySQL does not read as a variable of INSTALL COMPONENT ... SET: an + * unquoted scope keyword on its own, or GLOBAL, LOCAL or SESSION as the qualifier, quoted or + * not. + */ + private static boolean isComponentVariableScopeWord(String name, boolean qualifier) { + switch ((qualifier ? MultiPartName.unquote(name) : name).toUpperCase(Locale.ROOT)) { + case "GLOBAL": case "LOCAL": case "SESSION": + return true; + case "PERSIST": case "PERSIST_ONLY": + return !qualifier; + default: + return false; + } + } + + /** + * Checks the value of INSTALL COMPONENT ... SET, an expression that starts at {@code first}: + * MySQL takes neither the words DEFAULT and ALL nor a subquery, which SELECT, or TABLE, VALUES + * or WITH after a parenthesis, starts. + */ + private void requireComponentVariableValue(Token first) throws ParseException { + Token last = getToken(0); + requireDdlSyntax(first.kind != K_DEFAULT && first.kind != K_ALL + || first != last && first.next.kind == OPENING_BRACKET, + "Expected a value but found " + first.image); + boolean afterParenthesis = false; + for (Token token = first; token != last.next; token = token.next) { + int kind = token.kind; + requireDdlSyntax(kind != K_SELECT + && !(afterParenthesis && (kind == K_TABLE || kind == K_VALUES || kind == K_WITH)), + "INSTALL COMPONENT ... SET does not accept a subquery"); + afterParenthesis = kind == OPENING_BRACKET; + } + } + + /** Whether CREATE SERVER and ALTER SERVER take MySQL's quoted names and numeric option values. */ + private boolean isMySqlServerSyntaxAllowed() { + return getAsString(Feature.dialect) == null || isMySqlDialect(); + } + + /** + * Checks the options of CREATE SERVER and ALTER SERVER. A numeric value is MySQL's, read with + * the MySQL and MariaDB dialects or none. With the MySQL dialect the options are exactly its + * server_option: USER, HOST, DATABASE, OWNER, PASSWORD or SOCKET with a string, or PORT with a + * number. + */ + private void requireServerOptions(List options) throws ParseException { + boolean mySql = Dialect.MYSQL.name().equals(getAsString(Feature.dialect)); + for (ForeignDataOption option : options) { + boolean numeric = option.getValueExpression() != null && option.getValue() == null; + requireDdlSyntax(!numeric || isMySqlServerSyntaxAllowed(), "Option values are string constants"); + if (mySql) { + requireDdlSyntax(option.getAction() == null, "MySQL server options do not allow ADD, SET or DROP"); + requireDdlSyntax(isMySqlServerOption(option, numeric), + "Unknown MySQL server option or value: " + option); + } + } + } + + private static boolean isMySqlServerOption(ForeignDataOption option, boolean numeric) { + switch (option.getName().toUpperCase(Locale.ROOT)) { + case "USER": case "HOST": case "DATABASE": case "OWNER": case "PASSWORD": case "SOCKET": + return !numeric && isMySqlTextString(option.getValue()); + case "PORT": + return numeric && (!(option.getValueExpression() instanceof HexValue) + || isXaHexPart(option.getValueExpression().toString())); + default: + return false; + } + } + /** * Whether ALTER TABLE DROP names a MySQL FOREIGN KEY or CHECK constraint. The default * configuration accepts both like MySQL; MariaDB has no DROP CHECK. A parenthesized FOREIGN KEY @@ -5734,6 +5842,8 @@ Statement SingleStatement() : | LOOKAHEAD({ getToken(1).kind == K_PRAGMA }) stm = PragmaStatement() | + LOOKAHEAD({ isPluginStatementAhead() }) stm = PluginStatement() + | LOOKAHEAD({ getToken(1).kind == K_INSTALL || (getToken(1).kind == K_LOAD && getToken(2).kind != K_DATA) || (getToken(1).kind == K_FORCE && getToken(2).kind == K_INSTALL) }) @@ -7781,6 +7891,85 @@ DetachStatement DetachStatement() #DetachStatement: { return detachStatement; } } +/** + * MySQL INSTALL PLUGIN name SONAME 'library', UNINSTALL PLUGIN name, + * INSTALL COMPONENT 'urn'[, ...] [SET [GLOBAL | PERSIST] variable = value[, ...]] and + * UNINSTALL COMPONENT 'urn'[, ...]. isPluginStatementAhead() routes here. + */ +PluginStatement PluginStatement(): +{ + PluginStatement statement; + PluginStatement.Action action; + String name; + Token token; + ComponentVariable variable; +} +{ + ( + { action = PluginStatement.Action.INSTALL; } + | + // The action keeps the JAVACODE call from being the lookahead's first unit. + LOOKAHEAD({ isWordAt(1, "UNINSTALL") }) { action = PluginStatement.Action.UNINSTALL; } + consumeTransactionWord() + ) + ( + LOOKAHEAD({ isKeywordAhead("PLUGIN") }) ContextualKeyword("PLUGIN") + { statement = new PluginStatement(action, PluginStatement.ObjectType.PLUGIN); } + name=RelObjectName() { statement.setPluginName(name); } + [ LOOKAHEAD({ isKeywordAhead("SONAME") }) ContextualKeyword("SONAME") token= + { statement.setLibrary(mySqlTextString(token)); } ] + { + requireDdlSyntax((action == PluginStatement.Action.INSTALL) == (statement.getLibrary() != null), + "INSTALL PLUGIN names a library after SONAME, UNINSTALL PLUGIN does not"); + } + | + ContextualKeyword("COMPONENT") + { + statement = new PluginStatement(action, PluginStatement.ObjectType.COMPONENT); + requireDdlSyntax(!Dialect.MARIADB.name().equals(getAsString(Feature.dialect)), + "MariaDB has no components"); + } + token= { statement.addComponents(mySqlTextString(token)); } + ( "," token= { statement.addComponents(mySqlTextString(token)); } )* + [ + LOOKAHEAD(1) { requireDdlSyntax(action == PluginStatement.Action.INSTALL, "UNINSTALL COMPONENT does not accept SET"); } + variable=ComponentVariable() { statement.addVariables(variable); } + ( "," variable=ComponentVariable() { statement.addVariables(variable); } )* + ] + ) + { return statement; } +} + +/** One assignment of INSTALL COMPONENT ... SET: [GLOBAL | PERSIST] name[.name] {= | :=} {expr | ON}. */ +ComponentVariable ComponentVariable(): +{ + SetScope scope = SetScope.NONE; + String name; + String member = null; + SetStatement.AssignmentOperator operator; + Expression value; + Token first; +} +{ + // persist.variable is a name + [ LOOKAHEAD({ (getToken(1).kind == K_GLOBAL || isKeywordAhead("PERSIST")) && !".".equals(getToken(2).image) }) + ( { scope = SetScope.GLOBAL; } | ContextualKeyword("PERSIST") { scope = SetScope.PERSIST; } ) ] + ( + LOOKAHEAD(1) "." member=RelObjectName() { name = "DEFAULT"; } + | + name=RelObjectName() [ "." member=RelObjectName() ] + { requireDdlSyntax(!isComponentVariableScopeWord(name, member != null), "Expected a variable name but found " + name); } + ) + ( "=" { operator = SetStatement.AssignmentOperator.EQUALS; } + | ":=" { operator = SetStatement.AssignmentOperator.COLON_EQUALS; } ) + ( LOOKAHEAD({ getToken(1).kind == K_ON }) { value = linkToLastToken(new Column("ON")); } + | { first = getToken(1); } value=Expression() { requireComponentVariableValue(first); } ) + { + return new ComponentVariable(member == null ? name : name + "." + member, value) + .withScope(scope).withOperator(operator); + } +} + /** * DuckDB's [FORCE] INSTALL extension [FROM repository] and LOAD extension. */ @@ -19705,6 +19894,26 @@ ForeignTableOptions ForeignTableOptions(): } List ForeignDataOptions(boolean alter): +{ List options; } +{ + options=ForeignDataOptionList(alter) + { + for (ForeignDataOption item : options) { + requireDdlSyntax(item.getValueExpression() == null || item.getValue() != null, + "Option values are string constants"); + } + return options; + } +} + +/** OPTIONS of CREATE SERVER and ALTER SERVER, where MySQL also takes a number, as in PORT 3306. */ +List ServerOptions(boolean alter): +{ List options; } +{ + options=ForeignDataOptionList(alter) { requireServerOptions(options); return options; } +} + +List ForeignDataOptionList(boolean alter): { List options = new ArrayList(); ForeignDataOption option; @@ -19725,6 +19934,7 @@ ForeignDataOption ForeignDataOption(): ForeignDataOption.Action action = null; String name; Token value; + Expression number; ForeignDataOption option; } { @@ -19734,12 +19944,24 @@ ForeignDataOption ForeignDataOption(): | [ LOOKAHEAD(1) ( { action = ForeignDataOption.Action.ADD; } | { action = ForeignDataOption.Action.SET; } ) ] - name=RelObjectName() value= - { option = new ForeignDataOption(name, stringValue(value)); option.setAction(action); } + name=RelObjectName() + ( value= { option = new ForeignDataOption(name, stringValue(value)); } + | number=ServerOptionNumber() { option = new ForeignDataOption(name, null).withValueExpression(number); } ) + { option.setAction(action); } ) { return option; } } +/** MySQL's ulong_num, the value of the PORT server option; only ServerOptions() keeps it. */ +Expression ServerOptionNumber(): +{ Token token; Expression number; } +{ + ( token= { number = new LongValue(token.image); } + | token= { number = new DoubleValue(token.image); } + | token= { number = new HexValue(token.image); } ) + { linkAST(number, token, token); return number; } +} + TableElement CreateTableElement(boolean typed): { TableElement element; } { @@ -27132,12 +27354,32 @@ CreateServer CreateServer(): { ContextualKeyword("SERVER") [ LOOKAHEAD(2) { statement.setIfNotExists(true); } ] - name=RelObjectName() { statement.setName(name); } + name=ServerObjectName() { statement.setName(name); } [ type= { statement.setType(stringValue(type)); } ] [ version=ForeignServerVersion() { statement.setVersion(version); } ] - ContextualKeyword("WRAPPER") name=RelObjectName() { statement.setForeignDataWrapper(name); } - [ LOOKAHEAD({ isKeywordAhead("OPTIONS") }) options=ForeignDataOptions(false) { statement.setOptions(options); } ] - { return statement; } + ContextualKeyword("WRAPPER") name=ServerObjectName() { statement.setForeignDataWrapper(name); } + [ LOOKAHEAD({ isKeywordAhead("OPTIONS") }) options=ServerOptions(false) { statement.setOptions(options); } ] + { + // MySQL: CREATE SERVER name FOREIGN DATA WRAPPER wrapper OPTIONS (option[, ...]) + requireDdlSyntax(!Dialect.MYSQL.name().equals(getAsString(Feature.dialect)) + || !statement.isIfNotExists() && statement.getType() == null && statement.getVersion() == null + && statement.getOptions() != null, + "MySQL CREATE SERVER takes a name, FOREIGN DATA WRAPPER and OPTIONS"); + return statement; + } +} + +/** A server or wrapper name; MySQL's ident_or_text also takes a string literal, kept as written. */ +String ServerObjectName(): +{ String name; Token token; } +{ + ( name=RelObjectName() + | token= { + requireDdlSyntax(isMySqlServerSyntaxAllowed() && isMySqlTextString(new StringValue(token.image)), + "Expected a name but found " + token.image); + name = token.image; + } ) + { return name; } } Expression ForeignServerVersion(): @@ -27154,18 +27396,24 @@ AlterServer AlterServer(): Expression version; List options; } { - ContextualKeyword("SERVER") name=RelObjectName() { statement.setName(name); } + ContextualKeyword("SERVER") name=ServerObjectName() { statement.setName(name); } ( name=RelObjectName() { statement.setAction(ForeignObjectAlterAction.RENAME); statement.setNewName(name); } | LOOKAHEAD({ isKeywordAhead("OWNER") }) ContextualKeyword("OWNER") name=RelObjectName() { statement.setAction(ForeignObjectAlterAction.OWNER); statement.setOwner(name); } | [ version=ForeignServerVersion() { statement.setVersion(version); } ] - [ LOOKAHEAD({ isKeywordAhead("OPTIONS") }) options=ForeignDataOptions(true) { statement.setOptions(options); } ] + [ LOOKAHEAD({ isKeywordAhead("OPTIONS") }) options=ServerOptions(true) { statement.setOptions(options); } ] { requireDdlSyntax(statement.getVersion() != null || statement.getOptions() != null, "ALTER SERVER requires VERSION or OPTIONS"); } ) - { return statement; } + { + // MySQL: ALTER SERVER name OPTIONS (option[, ...]) + requireDdlSyntax(!Dialect.MYSQL.name().equals(getAsString(Feature.dialect)) + || statement.getAction() == ForeignObjectAlterAction.OPTIONS && statement.getVersion() == null, + "MySQL ALTER SERVER takes OPTIONS"); + return statement; + } } CreateUserMapping CreateUserMapping(): diff --git a/src/site/sphinx/index.rst b/src/site/sphinx/index.rst index 0097242f8..e506804e2 100644 --- a/src/site/sphinx/index.rst +++ b/src/site/sphinx/index.rst @@ -166,6 +166,8 @@ One grammar covers the SQL standard plus all major RDBMS. Missing syntax gets ad - ``VACUUM`` · ``ANALYZE`` · ``CLUSTER`` · ``CHECKPOINT`` * - **MySQL locks** - ``LOCK TABLES`` · ``UNLOCK TABLES`` · ``LOCK INSTANCE FOR BACKUP`` · ``UNLOCK INSTANCE`` + * - **MySQL servers, plugins and components** + - ``CREATE SERVER`` · ``ALTER SERVER`` with ``OPTIONS (… PORT 3306)`` · ``INSTALL``/``UNINSTALL PLUGIN`` · ``INSTALL``/``UNINSTALL COMPONENT`` * - **PostgreSQL session statements** - ``DISCARD`` · ``LISTEN`` · ``UNLISTEN`` · ``SHOW``/``RESET TIME ZONE``/``TRANSACTION ISOLATION LEVEL``/``SESSION AUTHORIZATION`` · ``COPY`` options from before 9.0 * - **MySQL account management** diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 9d8943000..02610ace9 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -2633,6 +2633,63 @@ Table visitors report the locked tables but not their aliases. Validators report three, MariaDB the first two, and PostgreSQL and Oracle ``lock``. Feature analysis reports ``MODIFIES_TRANSACTION``. +MySQL servers, plugins and components +------------------------------------- + +MySQL `CREATE SERVER `_ and +`ALTER SERVER `_ parse to the +``CreateServer`` and ``AlterServer`` of the PostgreSQL statements. A ``ForeignDataOption`` +keeps its value in ``getValueExpression()``: a ``StringValue``, or for MySQL's ``PORT`` the +number as written (``LongValue``, ``DoubleValue`` or ``HexValue``). ``getValue()`` still +returns the string value and is null for a number. A server or wrapper name written as a +string literal, as in ``FOREIGN DATA WRAPPER 'mysql'``, is kept with its quotes. + +.. code-block:: java + + CreateServer server = (CreateServer) CCJSqlParserUtil.parse( + "CREATE SERVER s FOREIGN DATA WRAPPER mysql OPTIONS (HOST 'h', PORT 3306)"); + ForeignDataOption port = server.getOptions().get(1); + port.getValueExpression(); // LongValue 3306 + port.getValue(); // null + +Numeric values and quoted names are read with the MySQL and MariaDB dialects and without a +dialect, and only in these two statements; PostgreSQL and the other option lists keep +requiring string constants. ``Dialect.MYSQL`` follows the server grammar: the options are +``USER``, ``HOST``, ``DATABASE``, ``OWNER``, ``PASSWORD`` and ``SOCKET`` with a string and +``PORT`` with a number, ``OPTIONS`` is required, and the PostgreSQL clauses (``IF NOT +EXISTS``, ``TYPE``, ``VERSION``, ``ADD``/``SET``/``DROP`` options, ``RENAME TO``, +``OWNER TO``) are rejected. ``DROP SERVER`` stays a ``Drop``. + +`INSTALL PLUGIN `_, +`UNINSTALL PLUGIN `_, +`INSTALL COMPONENT `_ and +`UNINSTALL COMPONENT `_ +parse to one ``PluginStatement`` with an ``Action`` (``INSTALL``, ``UNINSTALL``) and an +``ObjectType`` (``PLUGIN``, ``COMPONENT``). A plugin statement has ``getPluginName()`` and, +for ``INSTALL``, the ``getLibrary()`` after ``SONAME``. A component statement lists its URNs +in ``getComponents()``, and ``INSTALL COMPONENT ... SET`` its assignments in +``getVariables()``: each ``ComponentVariable`` has a ``SetScope`` (``NONE``, ``GLOBAL`` or +``PERSIST``), the name as written, the operator (``=`` or ``:=``) and the value expression; +the word ``ON`` is read as a column. As in MySQL, a value is not ``DEFAULT`` or a subquery. + +.. code-block:: java + + PluginStatement install = (PluginStatement) CCJSqlParserUtil.parse( + "INSTALL COMPONENT 'file://component1' SET GLOBAL component1.var1 = 12 + 3", + parser -> parser.withDialect(Dialect.MYSQL)); + ComponentVariable variable = install.getVariables().get(0); + // GLOBAL, component1.var1, 12 + 3 + String parts = variable.getScope() + ", " + variable.getName() + ", " + variable.getValue(); + +The statements are read with the MySQL and MariaDB dialects and without a dialect; MariaDB +has no components. With the MySQL and MariaDB dialects every ``INSTALL`` is this statement. +Without a dialect DuckDB's ``INSTALL extension`` keeps producing an ``ExtensionStatement``, +also for extensions named ``plugin`` or ``component``: ``SONAME`` after the plugin name or a +component URN selects the MySQL statement. ``UNINSTALL``, ``PLUGIN``, ``COMPONENT`` and +``SONAME`` remain ordinary identifiers. The validator checks ``installPlugin`` (MySQL and +MariaDB) and ``installComponent`` (MySQL), and ``createServer`` and ``alterServer`` are now +part of the MySQL and MariaDB capabilities. Feature analysis reports ``MODIFIES_SESSION``. + PostgreSQL session statements and COPY options ---------------------------------------------- diff --git a/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java b/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java index f65d16d82..6cc50ac11 100644 --- a/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java +++ b/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java @@ -136,6 +136,11 @@ public void testStatementsTheCorpusLacks() throws JSQLParserException { "PREPARE TRANSACTION 'x'", "COMMIT PREPARED 'x'", "ROLLBACK PREPARED 'x'", "ROLLBACK WORK FORCE '1.2.3'", "XA START X'0a', B'01', 1 JOIN", "XA END 'x' SUSPEND FOR MIGRATE", "XA RECOVER CONVERT XID", + "INSTALL PLUGIN p SONAME 'p.so'", "UNINSTALL PLUGIN p", + "INSTALL COMPONENT 'file://c', 'file://d' SET GLOBAL c.v = 1 + 2, w = ON", + "UNINSTALL COMPONENT 'file://c'", + "CREATE SERVER 's' FOREIGN DATA WRAPPER mysql OPTIONS (HOST 'h', PORT 3306)", + "ALTER SERVER s OPTIONS (PORT 0x10, SOCKET '/tmp/x')", "CREATE SCHEMA s CREATE TABLE t (a INT) CREATE VIEW v AS SELECT 1", "CREATE TABLE t AS EXECUTE p", "ALTER DEFAULT PRIVILEGES GRANT SELECT ON TABLES TO r", diff --git a/src/test/java/net/sf/jsqlparser/statement/PluginStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/PluginStatementTest.java new file mode 100644 index 000000000..ab00776d4 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/PluginStatementTest.java @@ -0,0 +1,472 @@ +/*- + * #%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 net.sf.jsqlparser.statement.TransactionStatementTest.assertSpans; +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Arrays; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.expression.operators.arithmetic.Addition; +import net.sf.jsqlparser.parser.ASTNodeAccess; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.parser.feature.FeatureConfiguration; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.statement.select.PlainSelect; +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.ValidationCapability; +import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed; +import net.sf.jsqlparser.util.validation.feature.MariaDbVersion; +import net.sf.jsqlparser.util.validation.feature.MySqlVersion; +import net.sf.jsqlparser.util.validation.feature.PostgresqlVersion; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; + +/** MySQL INSTALL and UNINSTALL PLUGIN and COMPONENT. */ +class PluginStatementTest { + private static final Dialect[] MYSQL_FAMILY = {Dialect.MYSQL, Dialect.MARIADB, null}; + private static final Dialect[] MYSQL_OR_NONE = {Dialect.MYSQL, null}; + + private static Statement parse(String sql, Dialect dialect) throws JSQLParserException { + return dialect == null ? CCJSqlParserUtil.parse(sql) + : CCJSqlParserUtil.parse(sql, p -> p.withDialect(dialect)); + } + + /** + * Parses {@code sql}, which must print as written, and checks the statement span, the deparser, + * the reparse, the feature analysis and the table finder. + */ + private static PluginStatement roundTrip(String sql, Dialect dialect) throws Exception { + PluginStatement statement = assertInstanceOf(PluginStatement.class, parse(sql, dialect)); + assertEquals(sql, statement.toString()); + assertSpans(sql, sql, statement); + StringBuilder output = new StringBuilder(); + statement.accept(new StatementDeParser(output), null); + assertEquals(sql, output.toString()); + assertEquals(sql, parse(output.toString(), dialect).toString()); + assertEquals(Set.of(StmtFeature.MODIFIES_SESSION), statement.getFeatures().getCertain()); + assertTrue(statement.getFeatures().getUncertain().isEmpty()); + assertTrue(new TablesNamesFinder<>().getTables((Statement) statement).isEmpty()); + return statement; + } + + // ---- plugins ------------------------------------------------------------------------------- + + @Test + void installPluginNamesThePluginAndItsLibrary() throws Exception { + String sql = "INSTALL PLUGIN myplugin SONAME 'somelib.so'"; + for (Dialect dialect : MYSQL_FAMILY) { + PluginStatement statement = roundTrip(sql, dialect); + assertEquals(PluginStatement.Action.INSTALL, statement.getAction()); + assertEquals(PluginStatement.ObjectType.PLUGIN, statement.getObjectType()); + assertEquals("myplugin", statement.getPluginName()); + assertEquals("somelib.so", statement.getLibrary().getValue()); + assertSpans("'somelib.so'", sql, statement.getLibrary()); + assertTrue(statement.getComponents().isEmpty()); + assertTrue(statement.getVariables().isEmpty()); + } + } + + @Test + void uninstallPluginNamesThePlugin() throws Exception { + for (Dialect dialect : MYSQL_FAMILY) { + PluginStatement statement = roundTrip("UNINSTALL PLUGIN myplugin", dialect); + assertEquals(PluginStatement.Action.UNINSTALL, statement.getAction()); + assertEquals(PluginStatement.ObjectType.PLUGIN, statement.getObjectType()); + assertEquals("myplugin", statement.getPluginName()); + assertNull(statement.getLibrary()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"INSTALL PLUGIN `my plugin` SONAME 'somelib.so'", + "INSTALL PLUGIN rpl_semi_sync_source SONAME \"nosuch_semisync_source.so\"", + "INSTALL PLUGIN plugin SONAME ''", "INSTALL PLUGIN soname SONAME 'x'", + "UNINSTALL PLUGIN `my plugin`", "UNINSTALL PLUGIN plugin", + "UNINSTALL PLUGIN component"}) + void pluginNamesAndLibrariesAreKeptAsWritten(String sql) throws Exception { + roundTrip(sql, Dialect.MYSQL); + PluginStatement lower = + (PluginStatement) parse(sql.toLowerCase(java.util.Locale.ROOT), Dialect.MYSQL); + assertEquals(sql.toLowerCase(java.util.Locale.ROOT), + lower.toString().toLowerCase(java.util.Locale.ROOT)); + } + + // ---- components ---------------------------------------------------------------------------- + + @Test + void installComponentListsUrnsAndVariables() throws Exception { + String sql = "INSTALL COMPONENT 'file://component1', 'file://component2'" + + " SET GLOBAL component1.var1 = 12 + 3, PERSIST component1.var3 = 'x'"; + for (Dialect dialect : MYSQL_OR_NONE) { + PluginStatement statement = roundTrip(sql, dialect); + assertEquals(PluginStatement.Action.INSTALL, statement.getAction()); + assertEquals(PluginStatement.ObjectType.COMPONENT, statement.getObjectType()); + assertNull(statement.getPluginName()); + assertNull(statement.getLibrary()); + assertEquals(Arrays.asList("file://component1", "file://component2"), + statement.getComponents().stream().map(StringValue::getValue) + .collect(Collectors.toList())); + assertSpans("'file://component2'", sql, statement.getComponents().get(1)); + assertEquals(2, statement.getVariables().size()); + ComponentVariable first = statement.getVariables().get(0); + assertEquals(SetScope.GLOBAL, first.getScope()); + assertEquals("component1.var1", first.getName()); + assertEquals(SetStatement.AssignmentOperator.EQUALS, first.getOperator()); + assertSpans("12 + 3", sql, assertInstanceOf(Addition.class, first.getValue())); + ComponentVariable second = statement.getVariables().get(1); + assertEquals(SetScope.PERSIST, second.getScope()); + assertEquals("component1.var3", second.getName()); + assertEquals("x", assertInstanceOf(StringValue.class, second.getValue()).getValue()); + assertEquals("PERSIST component1.var3 = 'x'", second.toString()); + } + } + + @Test + void uninstallComponentListsUrns() throws Exception { + for (Dialect dialect : MYSQL_OR_NONE) { + PluginStatement statement = + roundTrip("UNINSTALL COMPONENT 'file://component1'", dialect); + assertEquals(PluginStatement.Action.UNINSTALL, statement.getAction()); + assertEquals(PluginStatement.ObjectType.COMPONENT, statement.getObjectType()); + assertEquals(1, statement.getComponents().size()); + assertTrue(statement.getVariables().isEmpty()); + } + assertEquals(2, roundTrip("UNINSTALL COMPONENT 'file://c', 'file://d'", Dialect.MYSQL) + .getComponents().size()); + } + + @ParameterizedTest + @ValueSource(strings = {"INSTALL COMPONENT 'file://component_validate_password'", + "INSTALL COMPONENT 'file://c' SET v = 1", "INSTALL COMPONENT 'file://c' SET c.v := 1", + "INSTALL COMPONENT 'file://c' SET GLOBAL c.v = ON", + "INSTALL COMPONENT 'file://c' SET PERSIST v = OFF, w = 1", + "INSTALL COMPONENT 'file://c' SET DEFAULT.v = 1", + "INSTALL COMPONENT 'file://c' SET GLOBAL DEFAULT.v = 1", + "INSTALL COMPONENT 'file://c' SET persist.v = 1, PERSIST persist.w = 2, persist_only.x = 3", + "INSTALL COMPONENT 'file://c' SET `global` = 1, c.global = 2, c.`default` = 3", + "INSTALL COMPONENT 'file://c' SET v = (1 + 2) * 3, w = CONCAT('a', 'b'), x = NOW(), y = @a + 1, z = @@global.max_connections", + "INSTALL COMPONENT 'file://c' SET v = other, w = a.b, x = NULL, y = true, z = -1.5e3", + "INSTALL COMPONENT 'file://c' SET v = a AND b OR NOT c, w = CASE WHEN 1 THEN 2 ELSE 3 END, x = VALUES(a), y = DEFAULT(a)", + "INSTALL COMPONENT 'file://c', 'file://d' SET GLOBAL v = 1, GLOBAL w = 2, x = 3", + "INSTALL COMPONENT \"file://c\"", "INSTALL COMPONENT ''"}) + void componentFormsOfTheServerGrammarRoundTrip(String sql) throws Exception { + roundTrip(sql, Dialect.MYSQL); + } + + @Test + void variablesKeepScopeNameOperatorAndValue() throws Exception { + String sql = "INSTALL COMPONENT 'file://c' SET c.v := 1, DEFAULT.w = ON, persist.x = 2," + + " PERSIST y = OFF"; + List variables = roundTrip(sql, Dialect.MYSQL).getVariables(); + assertEquals(SetScope.NONE, variables.get(0).getScope()); + assertEquals("c.v", variables.get(0).getName()); + assertEquals(SetStatement.AssignmentOperator.COLON_EQUALS, variables.get(0).getOperator()); + assertEquals(1L, assertInstanceOf(LongValue.class, variables.get(0).getValue()).getValue()); + assertEquals("DEFAULT.w", variables.get(1).getName()); + Column on = assertInstanceOf(Column.class, variables.get(1).getValue()); + assertEquals("ON", on.getColumnName()); + assertSpans("ON", sql, on); + assertEquals(SetScope.NONE, variables.get(2).getScope()); + assertEquals("persist.x", variables.get(2).getName()); + assertEquals(SetScope.PERSIST, variables.get(3).getScope()); + assertEquals("y", variables.get(3).getName()); + } + + // ---- rejected ------------------------------------------------------------------------------ + + @ParameterizedTest + @ValueSource(strings = {"INSTALL PLUGIN myplugin", "INSTALL PLUGIN SONAME 'somelib.so'", + "INSTALL PLUGIN myplugin SONAME somelib", + "INSTALL PLUGIN myplugin SONAME N'somelib.so'", + "INSTALL PLUGIN 'myplugin' SONAME 'somelib.so'", + "INSTALL PLUGIN a.b SONAME 'somelib.so'", + "INSTALL PLUGIN IF NOT EXISTS myplugin SONAME 'somelib.so'", + "INSTALL PLUGIN select SONAME 'x'", "INSTALL SONAME 'somelib.so'", "INSTALL myplugin", + "INSTALL myplugin FROM community", "FORCE INSTALL myplugin", "INSTALL", "UNINSTALL", + "UNINSTALL PLUGIN", "UNINSTALL PLUGIN 'myplugin'", + "UNINSTALL PLUGIN myplugin SONAME 'somelib.so'", "UNINSTALL PLUGIN IF EXISTS myplugin", + "UNINSTALL PLUGIN a, b", "UNINSTALL SONAME 'somelib.so'", "UNINSTALL myplugin", + "INSTALL COMPONENT", "INSTALL COMPONENT component1", "INSTALL COMPONENT 'file://c',", + "INSTALL COMPONENT N'file://c'", "INSTALL COMPONENT X'41'", + "INSTALL COMPONENT 'file://c' 'file://d'", "INSTALL COMPONENT 'file://c' SET", + "INSTALL COMPONENT 'file://c' SET @@GLOBAL.validate_password.length = 12", + "INSTALL COMPONENT 'file://c' SET @v = 1", + "INSTALL COMPONENT 'file://c' SET SESSION v = 1", + "INSTALL COMPONENT 'file://c' SET LOCAL v = 1", + "INSTALL COMPONENT 'file://c' SET PERSIST_ONLY v = 1", + "INSTALL COMPONENT 'file://c' SET global.v = 1", + "INSTALL COMPONENT 'file://c' SET session.v = 1", + "INSTALL COMPONENT 'file://c' SET local.v = 1", + "INSTALL COMPONENT 'file://c' SET `session`.v = 1", + "INSTALL COMPONENT 'file://c' SET GLOBAL global.v = 1", + "INSTALL COMPONENT 'file://c' SET GLOBAL = 1", + "INSTALL COMPONENT 'file://c' SET persist = 1", + "INSTALL COMPONENT 'file://c' SET persist_only = 1", + "INSTALL COMPONENT 'file://c' SET v", + "INSTALL COMPONENT 'file://c' SET v =", "INSTALL COMPONENT 'file://c' SET a.b.c = 1", + "INSTALL COMPONENT 'file://c' SET DEFAULT = 1", + "INSTALL COMPONENT 'file://c' SET v TO 1", + "INSTALL COMPONENT 'file://c' SET GLOBAL PERSIST v = 1", + "INSTALL COMPONENT 'file://c' SET v = DEFAULT", + "INSTALL COMPONENT 'file://c' SET v = ALL", + "INSTALL COMPONENT 'file://c' SET v = ON + 1", + "INSTALL COMPONENT 'file://c' SET v = (SELECT 1)", + "INSTALL COMPONENT 'file://c' SET v = 1 + (SELECT 1)", + "INSTALL COMPONENT 'file://c' SET v = EXISTS (SELECT 1)", + "INSTALL COMPONENT 'file://c' SET v = 1 IN (SELECT 1)", + "INSTALL COMPONENT 'file://c' SET v = (TABLE t)", + "INSTALL COMPONENT 'file://c' SET v = (VALUES ROW(1))", + "INSTALL COMPONENT 'file://c' SET v = 1,", + "INSTALL COMPONENT 'file://c' SET v = 1 w = 2", + "UNINSTALL COMPONENT", "UNINSTALL COMPONENT c", + "UNINSTALL COMPONENT 'file://c' SET v = 1"}) + void mySqlRejectsWhatTheServerRejects(String sql) { + assertThrows(JSQLParserException.class, () -> parse(sql, Dialect.MYSQL), sql); + } + + @ParameterizedTest + @ValueSource(strings = {"INSTALL COMPONENT 'file://c'", "UNINSTALL COMPONENT 'file://c'", + "INSTALL COMPONENT 'file://c' SET v = 1"}) + void mariaDbHasNoComponents(String sql) { + assertThrows(JSQLParserException.class, () -> parse(sql, Dialect.MARIADB), sql); + } + + @ParameterizedTest + @ValueSource(strings = {"INSTALL PLUGIN myplugin SONAME 'somelib.so'", + "UNINSTALL PLUGIN myplugin", "INSTALL COMPONENT 'file://c'", + "UNINSTALL COMPONENT 'file://c'"}) + void otherDialectsKeepRejectingTheStatements(String sql) { + for (Dialect dialect : new Dialect[] {Dialect.POSTGRESQL, Dialect.ORACLE, + Dialect.SQLSERVER}) { + assertThrows(JSQLParserException.class, () -> parse(sql, dialect), sql); + } + } + + @ParameterizedTest + @ValueSource(strings = {"UNINSTALL myplugin", "INSTALL PLUGIN myplugin SONAME", + "INSTALL PLUGIN myplugin SONAME x", "INSTALL COMPONENT 'file://c' SET v = (SELECT 1)", + "UNINSTALL PLUGIN myplugin SONAME 'x'", "UNINSTALL COMPONENT c"}) + void noDialectRejectsMalformedStatements(String sql) { + assertThrows(JSQLParserException.class, () -> parse(sql, null), sql); + } + + // ---- other readings ------------------------------------------------------------------------ + + @ParameterizedTest + @ValueSource(strings = {"INSTALL plugin", "INSTALL plugin FROM community", "INSTALL component", + "INSTALL component FROM 'http://example.org'", "FORCE INSTALL plugin", + "INSTALL spatial"}) + void duckDbExtensionsKeepTheirReading(String sql) throws Exception { + for (Dialect dialect : new Dialect[] {null, Dialect.ORACLE, Dialect.POSTGRESQL}) { + ExtensionStatement statement = + assertInstanceOf(ExtensionStatement.class, parse(sql, dialect)); + assertEquals(ExtensionStatement.Operation.INSTALL, statement.getOperation()); + assertEquals(sql, statement.toString()); + } + } + + @Test + void statementWordsRemainIdentifiers() throws Exception { + String sql = "SELECT uninstall, plugin, component, soname FROM install"; + for (Dialect dialect : MYSQL_FAMILY) { + PlainSelect select = assertInstanceOf(PlainSelect.class, parse(sql, dialect)); + assertEquals(sql, select.toString()); + assertEquals(Set.of("install"), + new TablesNamesFinder<>().getTables((Statement) select)); + } + assertEquals("CREATE TABLE uninstall (plugin INT, component INT)", + parse("CREATE TABLE uninstall (plugin INT, component INT)", Dialect.MYSQL) + .toString()); + } + + // ---- statement boundaries ------------------------------------------------------------------ + + private static Stream newStatementStarts() { + return Stream.of( + Arguments.of(Dialect.MYSQL, "INSTALL PLUGIN myplugin SONAME 'somelib.so'"), + Arguments.of(Dialect.MARIADB, "UNINSTALL PLUGIN myplugin"), + Arguments.of(null, "UNINSTALL PLUGIN myplugin"), + Arguments.of(Dialect.MYSQL, "INSTALL COMPONENT 'file://c' SET GLOBAL c.v = 1"), + Arguments.of(Dialect.MYSQL, "UNINSTALL COMPONENT 'file://c', 'file://d'"), + Arguments.of(null, "UNINSTALL COMPONENT 'file://c'")); + } + + @ParameterizedTest + @MethodSource("newStatementStarts") + void newStatementStartsEndThePreviousStatement(Dialect dialect, String sql) throws Exception { + // COMMIT on its own line is closed by the next statement start rather than a semicolon + String script = "SELECT 1;\nCOMMIT\n" + sql + "\n;SAVEPOINT sp"; + for (boolean opaque : new boolean[] {false, true}) { + Statements statements = CCJSqlParserUtil.parseStatements(script, p -> { + if (dialect != null) { + p.withDialect(dialect); + } + p.withUnsupportedStatements(opaque); + }); + assertEquals(4, statements.size(), script); + assertEquals("COMMIT", statements.get(1).toString()); + assertInstanceOf(PluginStatement.class, statements.get(2)); + assertEquals(sql, statements.get(2).toString()); + assertSpans(sql, script, (ASTNodeAccess) statements.get(2)); + assertInstanceOf(SavepointStatement.class, statements.get(3)); + } + } + + @Test + void scriptsKeepEachStatement() throws Exception { + String script = "INSTALL COMPONENT 'file://c' SET c.v = 1; SET GLOBAL x = 2;" + + " INSTALL PLUGIN p SONAME 'p.so'; SELECT * FROM t; UNINSTALL PLUGIN p;" + + " UNINSTALL COMPONENT 'file://c'"; + Statements statements = + CCJSqlParserUtil.parseStatements(script, p -> p.withDialect(Dialect.MYSQL)); + assertEquals(6, statements.size()); + assertEquals(1, ((PluginStatement) statements.get(0)).getVariables().size()); + assertInstanceOf(SetStatement.class, statements.get(1)); + assertEquals(Set.of("t"), new TablesNamesFinder<>().getTables(statements.get(3))); + assertEquals(PluginStatement.Action.UNINSTALL, + ((PluginStatement) statements.get(5)).getAction()); + assertEquals(statements.toString(), CCJSqlParserUtil + .parseStatements(statements.toString(), p -> p.withDialect(Dialect.MYSQL)) + .toString()); + } + + // ---- visitors and validation --------------------------------------------------------------- + + @Test + void visitorDispatchPassesContextAndResult() throws Exception { + StatementVisitorAdapter visitor = new StatementVisitorAdapter<>() { + @Override + public String visit(PluginStatement statement, S context) { + return context + ":" + statement.getAction() + " " + statement.getObjectType(); + } + }; + Statement statement = parse("UNINSTALL PLUGIN p", null); + assertEquals("ctx:UNINSTALL PLUGIN", statement.accept(visitor, "ctx")); + assertNull(statement.accept(new StatementVisitorAdapter<>(), null)); + StringBuilder seen = new StringBuilder(); + new StatementVisitorAdapter() { + @Override + public Void visit(PluginStatement plugin, S context) { + seen.append(plugin.getPluginName()); + return null; + } + }.visit((PluginStatement) statement); + assertEquals("p", seen.toString()); + } + + @Test + void expressionsReachTheVisitors() throws Exception { + PluginStatement statement = (PluginStatement) parse( + "INSTALL COMPONENT 'file://c', 'file://d' SET v = a + 1, w = 'x'", Dialect.MYSQL); + StringBuilder seen = new StringBuilder(); + statement.visitExpressions(expression -> seen.append(expression).append('|')); + assertEquals("'file://c'|'file://d'|a + 1|'x'|", seen.toString()); + seen.setLength(0); + ((PluginStatement) parse("INSTALL PLUGIN p SONAME 'p.so'", null)) + .visitExpressions(expression -> seen.append(expression)); + assertEquals("'p.so'", seen.toString()); + ((PluginStatement) parse("UNINSTALL PLUGIN p", null)) + .visitExpressions(expression -> fail("no expression")); + } + + private static boolean isValid(Dialect dialect, String sql, + ValidationCapability... capabilities) { + FeatureConfiguration configuration = new FeatureConfiguration(); + if (dialect != null) { + configuration.setValue(Feature.dialect, dialect.name()); + } + return new Validation(configuration, Arrays.asList(capabilities), sql).validate() + .isEmpty(); + } + + @Test + void validationKnowsWhichServersTakeWhichForm() { + String plugin = "INSTALL PLUGIN p SONAME 'p.so'; UNINSTALL PLUGIN p"; + String component = "INSTALL COMPONENT 'file://c' SET v = 1; UNINSTALL COMPONENT 'file://c'"; + assertTrue(isValid(Dialect.MYSQL, plugin, MySqlVersion.V8_0)); + assertTrue(isValid(Dialect.MYSQL, component, MySqlVersion.V8_0)); + assertTrue(isValid(Dialect.MARIADB, plugin, MariaDbVersion.V10_5_4)); + assertFalse(isValid(null, "UNINSTALL COMPONENT 'file://c'", MariaDbVersion.V10_5_4)); + assertFalse(isValid(null, "UNINSTALL PLUGIN p", PostgresqlVersion.V14)); + assertTrue(isValid(null, plugin, new FeaturesAllowed(Feature.installPlugin))); + assertFalse(isValid(null, component, new FeaturesAllowed(Feature.installPlugin))); + assertTrue(isValid(null, component, new FeaturesAllowed(Feature.installComponent))); + assertFalse(isValid(null, "INSTALL PLUGIN p SONAME 'p.so'", + new FeaturesAllowed(Feature.installExtension))); + } + + // ---- the model ----------------------------------------------------------------------------- + + @Test + void statementsCanBeBuilt() { + PluginStatement install = new PluginStatement(PluginStatement.Action.INSTALL, + PluginStatement.ObjectType.PLUGIN).withPluginName("p") + .withLibrary(new StringValue("'p.so'")); + assertEquals("INSTALL PLUGIN p SONAME 'p.so'", install.toString()); + install.setAction(PluginStatement.Action.UNINSTALL); + install.setLibrary(null); + assertEquals("UNINSTALL PLUGIN p", install.toString()); + + PluginStatement component = new PluginStatement(PluginStatement.Action.INSTALL, + PluginStatement.ObjectType.PLUGIN); + component.setObjectType(PluginStatement.ObjectType.COMPONENT); + component.addComponents(new StringValue("'file://c'"), new StringValue("'file://d'")) + .addVariables( + new ComponentVariable("c.v", new LongValue(1)).withScope(SetScope.PERSIST), + new ComponentVariable("w", new Column("ON")) + .withOperator(SetStatement.AssignmentOperator.COLON_EQUALS)); + assertEquals("INSTALL COMPONENT 'file://c', 'file://d' SET PERSIST c.v = 1, w := ON", + component.toString()); + component.setComponents(new java.util.ArrayList<>(component.getComponents().subList(0, 1))); + component.setVariables(new java.util.ArrayList<>()); + assertEquals("INSTALL COMPONENT 'file://c'", component.toString()); + assertThrows(NullPointerException.class, () -> component.setAction(null)); + assertThrows(NullPointerException.class, () -> component.setObjectType(null)); + assertThrows(NullPointerException.class, () -> component.setComponents(null)); + assertThrows(NullPointerException.class, () -> component.setVariables(null)); + } + + @Test + void componentVariableKeepsItsContract() { + ComponentVariable variable = new ComponentVariable("v", new LongValue(1)); + assertEquals(SetScope.NONE, variable.getScope()); + assertEquals("v = 1", variable.toString()); + variable.setScope(null); + assertEquals(SetScope.NONE, variable.getScope()); + variable.setScope(SetScope.GLOBAL); + variable.setName("c.v"); + variable.setValue(new StringValue("'x'")); + variable.setOperator(SetStatement.AssignmentOperator.COLON_EQUALS); + assertEquals("GLOBAL c.v := 'x'", variable.toString()); + assertThrows(IllegalArgumentException.class, () -> variable.setScope(SetScope.SESSION)); + assertThrows(IllegalArgumentException.class, + () -> variable.setScope(SetScope.PERSIST_ONLY)); + assertThrows(IllegalArgumentException.class, + () -> variable.setOperator(SetStatement.AssignmentOperator.TO)); + assertThrows(IllegalArgumentException.class, () -> variable.setOperator(null)); + assertThrows(NullPointerException.class, () -> variable.setName(null)); + assertThrows(NullPointerException.class, () -> variable.setValue(null)); + assertThrows(NullPointerException.class, () -> new ComponentVariable(null, null)); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/create/MySqlServerTest.java b/src/test/java/net/sf/jsqlparser/statement/create/MySqlServerTest.java new file mode 100644 index 000000000..ad5f7f121 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/MySqlServerTest.java @@ -0,0 +1,326 @@ +/*- + * #%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.Arrays; +import java.util.List; +import java.util.Set; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.DoubleValue; +import net.sf.jsqlparser.expression.HexValue; +import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.ASTNodeAccess; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.Node; +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.parser.feature.FeatureConfiguration; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.StmtFeature; +import net.sf.jsqlparser.statement.alter.AlterServer; +import net.sf.jsqlparser.statement.alter.ForeignObjectAlterAction; +import net.sf.jsqlparser.statement.create.server.CreateServer; +import net.sf.jsqlparser.statement.create.table.ForeignDataOption; +import net.sf.jsqlparser.statement.drop.Drop; +import net.sf.jsqlparser.statement.foreign.ForeignDataStatement; +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.ValidationCapability; +import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed; +import net.sf.jsqlparser.util.validation.feature.MariaDbVersion; +import net.sf.jsqlparser.util.validation.feature.MySqlVersion; +import net.sf.jsqlparser.util.validation.feature.PostgresqlVersion; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** MySQL CREATE SERVER and ALTER SERVER: numeric option values and quoted names. */ +class MySqlServerTest { + private static final Dialect[] MYSQL_FAMILY = {Dialect.MYSQL, Dialect.MARIADB, null}; + + private static Statement parse(String sql, Dialect dialect) throws JSQLParserException { + return dialect == null ? CCJSqlParserUtil.parse(sql) + : CCJSqlParserUtil.parse(sql, p -> p.withDialect(dialect)); + } + + private static void assertSpans(String expected, String source, ASTNodeAccess access) { + Node node = access.getASTNode(); + assertNotNull(node, expected); + assertEquals(expected, source.substring(node.jjtGetFirstToken().absoluteBegin - 1, + node.jjtGetLastToken().absoluteEnd - 1)); + } + + /** + * Parses {@code sql}, which must print as written, and checks the statement span, the deparser, + * the reparse, the feature analysis and the table finder. + */ + private static ForeignDataStatement roundTrip(String sql, Dialect dialect) throws Exception { + ForeignDataStatement statement = + assertInstanceOf(ForeignDataStatement.class, parse(sql, dialect)); + assertEquals(sql, statement.toString()); + assertSpans(sql, sql, statement); + StringBuilder output = new StringBuilder(); + statement.accept(new StatementDeParser(output), null); + assertEquals(sql, output.toString()); + assertEquals(sql, parse(output.toString(), dialect).toString()); + assertEquals(Set.of(StmtFeature.MODIFIES_SCHEMA), statement.getFeatures().getCertain()); + assertTrue(new TablesNamesFinder<>().getTables((Statement) statement).isEmpty()); + return statement; + } + + @Test + void createServerTakesANumericPort() throws Exception { + String sql = "CREATE SERVER s FOREIGN DATA WRAPPER mysql" + + " OPTIONS (USER 'u', HOST 'h', DATABASE 'd', PORT 3306)"; + for (Dialect dialect : MYSQL_FAMILY) { + CreateServer server = (CreateServer) roundTrip(sql, dialect); + assertEquals("s", server.getName()); + assertEquals("mysql", server.getForeignDataWrapper()); + List options = server.getOptions(); + assertEquals(4, options.size()); + assertEquals("USER", options.get(0).getName()); + assertEquals("u", options.get(0).getValue().getValue()); + assertSame(options.get(0).getValue(), options.get(0).getValueExpression()); + ForeignDataOption port = options.get(3); + assertEquals("PORT", port.getName()); + assertNull(port.getAction()); + assertNull(port.getValue()); + LongValue number = assertInstanceOf(LongValue.class, port.getValueExpression()); + assertEquals(3306L, number.getValue()); + assertSpans("3306", sql, number); + assertEquals("PORT 3306", port.toString()); + } + } + + @Test + void alterServerTakesANumericPort() throws Exception { + String sql = "ALTER SERVER s OPTIONS (USER 'u2', PORT 3307)"; + for (Dialect dialect : MYSQL_FAMILY) { + AlterServer server = (AlterServer) roundTrip(sql, dialect); + assertEquals("s", server.getName()); + assertEquals(ForeignObjectAlterAction.OPTIONS, server.getAction()); + assertEquals(3307L, assertInstanceOf(LongValue.class, + server.getOptions().get(1).getValueExpression()).getValue()); + } + } + + @ParameterizedTest + @ValueSource(strings = { + "CREATE SERVER s2 FOREIGN DATA WRAPPER mysql OPTIONS (HOST 'h', DATABASE 'db', USER 'u', PASSWORD 'p', PORT 3306, SOCKET '/tmp/mysql.sock', OWNER 'o')", + "CREATE SERVER `s-1` FOREIGN DATA WRAPPER 'mysql' OPTIONS (USER 'u')", + "CREATE SERVER 's3' FOREIGN DATA WRAPPER mysql OPTIONS (USER 'u')", + "CREATE SERVER s4 FOREIGN DATA WRAPPER `mysql` OPTIONS (user 'u', port 1)", + "CREATE SERVER s5 FOREIGN DATA WRAPPER mysql OPTIONS (USER 'a', USER 'b')", + "CREATE SERVER s6 FOREIGN DATA WRAPPER mysql OPTIONS (PORT 3306.5)", + "CREATE SERVER s7 FOREIGN DATA WRAPPER mysql OPTIONS (PORT 1e3)", + "CREATE SERVER s8 FOREIGN DATA WRAPPER mysql OPTIONS (PORT 0x10)", + "CREATE SERVER s9 FOREIGN DATA WRAPPER mysql OPTIONS (PORT X'10')", + "CREATE SERVER s10 FOREIGN DATA WRAPPER mysql OPTIONS (PORT 99999999999999999999)", + "CREATE SERVER s11 FOREIGN DATA WRAPPER mysql OPTIONS (USER \"u\")", + "CREATE SERVER s12 FOREIGN DATA WRAPPER \"mysql\" OPTIONS (PORT .5)", + "CREATE SERVER \"s13\" FOREIGN DATA WRAPPER mysql OPTIONS (USER 'u')", + "CREATE SERVER '' FOREIGN DATA WRAPPER mysql OPTIONS (USER 'u')", + "ALTER SERVER s OPTIONS (HOST '10.0.0.2', PORT 3307)", + "ALTER SERVER 's3' OPTIONS (SOCKET '/tmp/x')"}) + void mySqlServerFormsRoundTrip(String sql) throws Exception { + roundTrip(sql, Dialect.MYSQL); + } + + @Test + void numericValuesKeepTheirLiteralKind() throws Exception { + String sql = "CREATE SERVER s FOREIGN DATA WRAPPER mysql" + + " OPTIONS (PORT 3306.5, PORT 0x10, PORT X'10')"; + List options = roundTrip(sql, Dialect.MYSQL).getOptions(); + assertSpans("3306.5", sql, + assertInstanceOf(DoubleValue.class, options.get(0).getValueExpression())); + assertSpans("0x10", sql, + assertInstanceOf(HexValue.class, options.get(1).getValueExpression())); + assertSpans("X'10'", sql, + assertInstanceOf(HexValue.class, options.get(2).getValueExpression())); + StringBuilder seen = new StringBuilder(); + ForeignDataOption.visitExpressions(options, expression -> seen.append(expression)); + assertEquals("3306.50x10X'10'", seen.toString()); + } + + @Test + void quotedNamesAreKeptAsWritten() throws Exception { + for (Dialect dialect : MYSQL_FAMILY) { + CreateServer server = (CreateServer) roundTrip( + "CREATE SERVER 's' FOREIGN DATA WRAPPER 'mysql' OPTIONS (PORT 3306)", dialect); + assertEquals("'s'", server.getName()); + assertEquals("'mysql'", server.getForeignDataWrapper()); + assertEquals("'s'", ((AlterServer) roundTrip("ALTER SERVER 's' OPTIONS (PORT 1)", + dialect)).getName()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"CREATE SERVER r1 FOREIGN DATA WRAPPER mysql", + "CREATE SERVER r2 FOREIGN DATA WRAPPER mysql OPTIONS ()", + "CREATE SERVER r3 FOREIGN DATA WRAPPER mysql OPTIONS (PORT '3306')", + "CREATE SERVER r4 FOREIGN DATA WRAPPER mysql OPTIONS (PORT -1)", + "CREATE SERVER r5 FOREIGN DATA WRAPPER mysql OPTIONS (USER 1)", + "CREATE SERVER r6 FOREIGN DATA WRAPPER mysql OPTIONS (USER u)", + "CREATE SERVER r7 FOREIGN DATA WRAPPER mysql OPTIONS (USER N'u')", + "CREATE SERVER r8 FOREIGN DATA WRAPPER mysql OPTIONS (USER _utf8mb4'u')", + "CREATE SERVER r9 FOREIGN DATA WRAPPER mysql OPTIONS (dbname 'd')", + "CREATE SERVER r10 FOREIGN DATA WRAPPER mysql OPTIONS (`USER` 'u')", + "CREATE SERVER r11 FOREIGN DATA WRAPPER mysql OPTIONS (ADD USER 'u')", + "CREATE SERVER IF NOT EXISTS r12 FOREIGN DATA WRAPPER mysql OPTIONS (USER 'u')", + "CREATE SERVER r13 TYPE 't' FOREIGN DATA WRAPPER mysql OPTIONS (USER 'u')", + "CREATE SERVER r14 VERSION '1' FOREIGN DATA WRAPPER mysql OPTIONS (USER 'u')", + "CREATE SERVER r15 FOREIGN DATA WRAPPER mysql OPTIONS (USER 'u',)", + "CREATE SERVER r16 FOREIGN DATA WRAPPER mysql OPTIONS (USER 'u' PORT 1)", + "CREATE SERVER db.r17 FOREIGN DATA WRAPPER mysql OPTIONS (USER 'u')", + "CREATE OR REPLACE SERVER r18 FOREIGN DATA WRAPPER mysql OPTIONS (USER 'u')", + "CREATE SERVER r19 FOREIGN DATA WRAPPER N'mysql' OPTIONS (USER 'u')", + "CREATE SERVER r20 FOREIGN DATA WRAPPER mysql OPTIONS (PORT 0X10)", + "CREATE SERVER r21 FOREIGN DATA WRAPPER mysql OPTIONS (PORT x'1')", + "CREATE SERVER r22 FOREIGN DATA WRAPPER mysql OPTIONS (PORT 1_0)", + "CREATE SERVER r23 FOREIGN DATA WRAPPER mysql OPTIONS (PORT +1)", + "CREATE SERVER r24 FOREIGN DATA WRAPPER mysql OPTIONS (USER 'u') COMMENT 'c'", + "ALTER SERVER s", "ALTER SERVER s OPTIONS ()", "ALTER SERVER s OPTIONS (SET USER 'u')", + "ALTER SERVER s OPTIONS (DROP USER)", "ALTER SERVER s OPTIONS (PORT '1')", + "ALTER SERVER s VERSION '2'", "ALTER SERVER s VERSION '2' OPTIONS (USER 'u')", + "ALTER SERVER s RENAME TO t", "ALTER SERVER s OWNER TO o"}) + void mySqlRejectsWhatTheServerRejects(String sql) { + assertThrows(JSQLParserException.class, () -> parse(sql, Dialect.MYSQL), sql); + } + + @ParameterizedTest + @ValueSource(strings = { + "CREATE SERVER IF NOT EXISTS s TYPE 't' VERSION '1' FOREIGN DATA WRAPPER w OPTIONS (host 'h', dbname 'd')", + "CREATE SERVER s FOREIGN DATA WRAPPER w", + "ALTER SERVER s VERSION '2' OPTIONS (SET host 'h', ADD port '5432', DROP dbname)", + "ALTER SERVER s RENAME TO t", "ALTER SERVER s OWNER TO o"}) + void postgreSqlFormsKeepTheirReading(String sql) throws Exception { + for (Dialect dialect : new Dialect[] {Dialect.POSTGRESQL, null}) { + ForeignDataStatement statement = roundTrip(sql, dialect); + if (statement.getOptions() != null) { + for (ForeignDataOption option : statement.getOptions()) { + assertSame(option.getValue(), option.getValueExpression()); + } + } + } + } + + @Test + void mariaDbAndNoDialectKeepTheGenericOptions() throws Exception { + roundTrip("CREATE SERVER IF NOT EXISTS 's' FOREIGN DATA WRAPPER mysql" + + " OPTIONS (USER 'u', PORT 3306)", Dialect.MARIADB); + roundTrip("ALTER SERVER s OPTIONS (PORT 3307)", Dialect.MARIADB); + AlterServer server = (AlterServer) roundTrip( + "ALTER SERVER s VERSION '2' OPTIONS (SET port 5432, ADD host 'h', DROP dbname)", + null); + assertEquals(ForeignDataOption.Action.SET, server.getOptions().get(0).getAction()); + assertEquals(5432L, assertInstanceOf(LongValue.class, + server.getOptions().get(0).getValueExpression()).getValue()); + } + + @ParameterizedTest + @ValueSource(strings = {"CREATE SERVER s FOREIGN DATA WRAPPER w OPTIONS (port 5432)", + "ALTER SERVER s OPTIONS (SET port 5432)", "CREATE SERVER 's' FOREIGN DATA WRAPPER w", + "CREATE SERVER s FOREIGN DATA WRAPPER 'w'", "ALTER SERVER 's' OPTIONS (SET host 'h')", + "CREATE USER MAPPING FOR u SERVER s OPTIONS (port 5432)"}) + void postgreSqlKeepsRejectingNumbersAndQuotedNames(String sql) { + assertThrows(JSQLParserException.class, () -> parse(sql, Dialect.POSTGRESQL), sql); + assertThrows(JSQLParserException.class, () -> parse(sql, Dialect.ORACLE), sql); + } + + @ParameterizedTest + @ValueSource(strings = {"CREATE USER MAPPING FOR u SERVER s OPTIONS (port 5432)", + "ALTER USER MAPPING FOR u SERVER s OPTIONS (SET port 5432)", + "CREATE FOREIGN TABLE t (a INT) SERVER s OPTIONS (port 5432)", + "ALTER FOREIGN TABLE t OPTIONS (ADD port 5432)", + "CREATE FOREIGN DATA WRAPPER w OPTIONS (port 5432)", + "CREATE SERVER s FOREIGN DATA WRAPPER w OPTIONS (port -1)", + "CREATE SERVER s FOREIGN DATA WRAPPER w OPTIONS (port 1 + 1)", + "CREATE SERVER s FOREIGN DATA WRAPPER w OPTIONS (port)", + "CREATE SERVER N's' FOREIGN DATA WRAPPER w"}) + void otherOptionListsKeepStringValues(String sql) { + for (Dialect dialect : MYSQL_FAMILY) { + assertThrows(JSQLParserException.class, () -> parse(sql, dialect), sql); + } + } + + @ParameterizedTest + @ValueSource(strings = {"DROP SERVER IF EXISTS s", "DROP SERVER s2", + "DROP SERVER IF EXISTS 's3'", "DROP SERVER `s-1`"}) + void dropServerKeepsTheGenericDrop(String sql) throws Exception { + for (Dialect dialect : MYSQL_FAMILY) { + Drop drop = assertInstanceOf(Drop.class, parse(sql, dialect)); + assertEquals("SERVER", drop.getType()); + assertEquals(sql, drop.toString()); + } + } + + @Test + void scriptsKeepEachStatement() throws Exception { + String script = "CREATE SERVER s FOREIGN DATA WRAPPER mysql OPTIONS (HOST 'h', PORT 3306);" + + " ALTER SERVER s OPTIONS (PORT 3307); SELECT * FROM t; DROP SERVER IF EXISTS s"; + for (boolean opaque : new boolean[] {false, true}) { + Statements statements = CCJSqlParserUtil.parseStatements(script, + p -> p.withDialect(Dialect.MYSQL).withUnsupportedStatements(opaque)); + assertEquals(4, statements.size()); + assertInstanceOf(CreateServer.class, statements.get(0)); + assertInstanceOf(AlterServer.class, statements.get(1)); + assertEquals(Set.of("t"), new TablesNamesFinder<>().getTables(statements.get(2))); + assertInstanceOf(Drop.class, statements.get(3)); + assertEquals(statements.toString(), CCJSqlParserUtil + .parseStatements(statements.toString(), p -> p.withDialect(Dialect.MYSQL)) + .toString()); + } + } + + private static boolean isValid(Dialect dialect, String sql, + ValidationCapability... capabilities) { + FeatureConfiguration configuration = new FeatureConfiguration(); + if (dialect != null) { + configuration.setValue(Feature.dialect, dialect.name()); + } + return new Validation(configuration, Arrays.asList(capabilities), sql).validate() + .isEmpty(); + } + + @Test + void validationKnowsTheServerStatements() { + String sql = "CREATE SERVER s FOREIGN DATA WRAPPER mysql OPTIONS (PORT 3306);" + + " ALTER SERVER s OPTIONS (PORT 3307)"; + assertTrue(isValid(Dialect.MYSQL, sql, MySqlVersion.V8_0)); + assertTrue(isValid(Dialect.MARIADB, sql, MariaDbVersion.V10_5_4)); + assertTrue(isValid(null, sql, PostgresqlVersion.V14)); + assertTrue(isValid(null, sql, + new FeaturesAllowed(Feature.createServer, Feature.alterServer))); + assertFalse(isValid(null, sql, new FeaturesAllowed(Feature.createServer))); + } + + @Test + void optionValueAccessorsShareOneValue() { + ForeignDataOption option = new ForeignDataOption("PORT", null); + assertNull(option.getValueExpression()); + assertEquals("PORT", option.toString()); + assertSame(option, option.withValueExpression(new LongValue(3306))); + assertNull(option.getValue()); + assertEquals("PORT 3306", option.toString()); + StringValue text = new StringValue("'3306'"); + option.setValue(text); + assertSame(text, option.getValue()); + assertSame(text, option.getValueExpression()); + option.setValueExpression(new LongValue(1)); + option.setAction(ForeignDataOption.Action.DROP); + assertNull(option.getValueExpression()); + assertEquals("DROP PORT", option.toString()); + } +} diff --git a/src/test/java/net/sf/jsqlparser/util/validation/validator/StatementValidatorTest.java b/src/test/java/net/sf/jsqlparser/util/validation/validator/StatementValidatorTest.java index a9967d6c5..982fe5788 100644 --- a/src/test/java/net/sf/jsqlparser/util/validation/validator/StatementValidatorTest.java +++ b/src/test/java/net/sf/jsqlparser/util/validation/validator/StatementValidatorTest.java @@ -52,6 +52,8 @@ static Stream statementsOutsideSelect() { Arguments.of("ROLLBACK", Feature.rollback), Arguments.of("PREPARE TRANSACTION 'x'", Feature.twoPhaseCommit), Arguments.of("XA RECOVER", Feature.xa), + Arguments.of("INSTALL PLUGIN p SONAME 'p.so'", Feature.installPlugin), + Arguments.of("UNINSTALL COMPONENT 'file://c'", Feature.installComponent), Arguments.of("LOCK TABLE t IN EXCLUSIVE MODE", Feature.lock), Arguments.of("ALTER SESSION SET CURRENT_SCHEMA = other", Feature.alterSession), Arguments.of("ALTER SYSTEM SET archive_command = 'cp %p /tmp/%f'",