From dfde61a1cf0e8460c4e4a25504add16adfed319f Mon Sep 17 00:00:00 2001 From: minleejae Date: Tue, 10 Nov 2026 15:56:47 +0900 Subject: [PATCH] feat: parse MySQL server option values and INSTALL/UNINSTALL PLUGIN and COMPONENT MySQL CREATE SERVER and ALTER SERVER failed on a numeric option value such as PORT 3306 and on a server or wrapper name written as a string, and INSTALL/UNINSTALL PLUGIN and COMPONENT did not parse. ForeignDataOption keeps its value as an expression: a string, or the number of a MySQL server option; getValue() derives the string from it. Numbers and quoted names are read in CREATE SERVER and ALTER SERVER with the MySQL and MariaDB dialects or none. The MySQL dialect follows the server grammar: USER, HOST, DATABASE, OWNER, PASSWORD and SOCKET take a string, PORT a number, and the PostgreSQL clauses are rejected. The plugin and component statements are one PluginStatement with an Action and an ObjectType; INSTALL COMPONENT ... SET assignments are ComponentVariable items with a SetScope. They are read with the MySQL and MariaDB dialects or none; MariaDB has no components. DuckDB INSTALL extension keeps its reading outside those dialects, and UNINSTALL, PLUGIN, COMPONENT and SONAME stay usable as names. Wire the statement into the deparser, visitors, table finder, feature analysis and validation, with new installPlugin and installComponent features. Signed-off-by: minleejae --- README.md | 1 + .../sf/jsqlparser/parser/feature/Feature.java | 13 + .../statement/ComponentVariable.java | 112 +++++ .../jsqlparser/statement/PluginStatement.java | 174 +++++++ .../statement/StatementFeatureVisitor.java | 8 + .../statement/StatementVisitor.java | 8 + .../statement/StatementVisitorAdapter.java | 5 + .../create/table/ForeignDataOption.java | 27 +- .../sf/jsqlparser/util/TablesNamesFinder.java | 7 + .../util/deparser/StatementDeParser.java | 7 + .../validation/feature/MariaDbVersion.java | 4 +- .../util/validation/feature/MySqlVersion.java | 4 +- .../validator/StatementValidator.java | 10 + .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 268 +++++++++- src/site/sphinx/index.rst | 2 + src/site/sphinx/usage.rst | 57 +++ .../parser/ASTNodeCoverageTest.java | 5 + .../statement/PluginStatementTest.java | 472 ++++++++++++++++++ .../statement/create/MySqlServerTest.java | 326 ++++++++++++ .../validator/StatementValidatorTest.java | 2 + 20 files changed, 1497 insertions(+), 15 deletions(-) create mode 100644 src/main/java/net/sf/jsqlparser/statement/ComponentVariable.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/PluginStatement.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/PluginStatementTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/create/MySqlServerTest.java diff --git a/README.md b/README.md index 0fdeacbe3a..af09769369 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 7382b49e24..139bd825e8 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 0000000000..4c50128e45 --- /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 0000000000..cef012f4f6 --- /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 be1c4cf274..e2ac79aba3 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 2711d3a764..e14626683e 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 2d43aff589..313de4d1c3 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 9bc11c1a1b..704a4277d9 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 f65ddf14f6..89ee1cbba2 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 41defbd120..1874055e21 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 3b70763cfe..9ba3f513a8 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 0b0eaf25e7..49403ae09a 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 a69059598b..60c734e8e9 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 e4fcc2e76e..479f285f6a 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 0097242f85..e506804e24 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 9d89430009..02610ace9e 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 f65d16d82e..6cc50ac116 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 0000000000..ab00776d4f --- /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 0000000000..ad5f7f1217 --- /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 a9967d6c55..982fe57884 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'",