diff --git a/README.md b/README.md index a2f3449967..e51edafa08 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,7 @@ and missing syntax gets added on demand — [open an issue](https://github.com/J | **PostgreSQL maintenance** | `VACUUM` · `ANALYZE` · `CLUSTER` · `CHECKPOINT` | | **MySQL locks** | `LOCK TABLES` · `UNLOCK TABLES` · `LOCK INSTANCE FOR BACKUP` · `UNLOCK INSTANCE` | | **MySQL resource groups** | `CREATE`/`ALTER`/`DROP RESOURCE GROUP` · `SET RESOURCE GROUP` | +| **MySQL loadable functions** | `CREATE [AGGREGATE] FUNCTION … RETURNS … SONAME` | | **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 server administration** | `FLUSH` · `KILL` · `RESET PERSIST` · `HANDLER … OPEN`/`READ`/`CLOSE` | diff --git a/src/main/java/net/sf/jsqlparser/statement/CreateFunctionalStatement.java b/src/main/java/net/sf/jsqlparser/statement/CreateFunctionalStatement.java index b1978dc4a6..35a4718dd7 100644 --- a/src/main/java/net/sf/jsqlparser/statement/CreateFunctionalStatement.java +++ b/src/main/java/net/sf/jsqlparser/statement/CreateFunctionalStatement.java @@ -142,6 +142,14 @@ public String getKind() { return kind; } + /** + * @return the keywords that name the kind in the statement, which may carry a modifier of + * {@link #getKind()} + */ + protected String getKindKeywords() { + return kind; + } + public void setOrReplace(boolean orReplace) { this.orReplace = orReplace; } @@ -156,7 +164,7 @@ public String formatDeclaration() { private StringBuilder appendDeclarationTo(StringBuilder builder, Consumer printer) { - appendTokens(builder, functionDeclarationParts); + appendTokens(builder, getFunctionDeclarationParts()); if (returnType != null) { builder.append(' '); returnType.appendTo(builder, printer); @@ -188,7 +196,7 @@ public StringBuilder appendTo(StringBuilder builder, Consumer prin if (definer != null) { builder.append("DEFINER = ").append(definer).append(' '); } - builder.append(kind).append(' '); + builder.append(getKindKeywords()).append(' '); return appendDeclarationTo(builder, printer); } diff --git a/src/main/java/net/sf/jsqlparser/statement/alter/AlterExpressionTableOption.java b/src/main/java/net/sf/jsqlparser/statement/alter/AlterExpressionTableOption.java index e717cf3bea..0de2de7c8c 100644 --- a/src/main/java/net/sf/jsqlparser/statement/alter/AlterExpressionTableOption.java +++ b/src/main/java/net/sf/jsqlparser/statement/alter/AlterExpressionTableOption.java @@ -11,6 +11,7 @@ import java.util.Collections; import java.util.List; +import java.util.Objects; import net.sf.jsqlparser.statement.create.table.TableOption; import net.sf.jsqlparser.statement.create.table.ConstraintKind; @@ -18,11 +19,13 @@ /** * Internal subclass for table-level option operations within ALTER TABLE. Handles ENGINE, * ALGORITHM, LOCK, KEY_BLOCK_SIZE, COMMENT, ENCRYPTION, AUTO_INCREMENT (SET_TABLE_OPTION), - * DISCARD/IMPORT TABLESPACE, DISABLE/ENABLE KEYS. + * DISCARD/IMPORT TABLESPACE, DISABLE/ENABLE KEYS, and MySQL's {@code WITH | WITHOUT VALIDATION} + * modifier (VALIDATION). */ public class AlterExpressionTableOption extends AlterExpression { private TableOption structuredTableOption; + private AlterExpressionPartition.ExchangeValidationMode validationMode; public TableOption getStructuredTableOption() { return structuredTableOption; @@ -36,6 +39,28 @@ public void setStructuredTableOption(TableOption option) { super.setUseEqual(false); } + /** + * @return the mode of MySQL's {@code WITH | WITHOUT VALIDATION} modifier, which applies to the + * whole statement, or {@code null} for any other action. The mode written after + * {@code EXCHANGE PARTITION ... WITH TABLE} belongs to that action instead. + */ + public AlterExpressionPartition.ExchangeValidationMode getValidationMode() { + return getOperation() == AlterOperation.VALIDATION ? validationMode : null; + } + + /** Makes this action the {@code WITH | WITHOUT VALIDATION} modifier. */ + public void setValidationMode(AlterExpressionPartition.ExchangeValidationMode validationMode) { + this.validationMode = Objects.requireNonNull(validationMode, "validationMode"); + structuredTableOption = null; + setOperation(AlterOperation.VALIDATION); + } + + public AlterExpressionTableOption withValidationMode( + AlterExpressionPartition.ExchangeValidationMode validationMode) { + setValidationMode(validationMode); + return this; + } + @Override public List getColumnIdentityActions() { return Collections.emptyList(); @@ -164,6 +189,9 @@ protected void appendBody(StringBuilder b) { return; } switch (getOperation()) { + case VALIDATION: + b.append(validationMode).append(" VALIDATION"); + break; case SET_TABLE_OPTION: b.append(getTableOption()); break; diff --git a/src/main/java/net/sf/jsqlparser/statement/alter/AlterOperation.java b/src/main/java/net/sf/jsqlparser/statement/alter/AlterOperation.java index 7d0fb9431f..6bc532db42 100644 --- a/src/main/java/net/sf/jsqlparser/statement/alter/AlterOperation.java +++ b/src/main/java/net/sf/jsqlparser/statement/alter/AlterOperation.java @@ -12,7 +12,7 @@ import java.util.Locale; public enum AlterOperation { - ADD, ALTER, DROP, DROP_PRIMARY_KEY, DROP_UNIQUE, DROP_FOREIGN_KEY, DROP_CHECK, MODIFY, CHANGE, CONVERT, COLLATE, ALGORITHM, RENAME, RENAME_TABLE, RENAME_INDEX, RENAME_KEY, RENAME_CONSTRAINT, COMMENT, COMMENT_WITH_EQUAL_SIGN, UNSPECIFIC, ADD_PARTITION, DROP_PARTITION, ATTACH_PARTITION, DETACH_PARTITION, DISCARD_PARTITION, IMPORT_PARTITION, TRUNCATE_PARTITION, COALESCE_PARTITION, REORGANIZE_PARTITION, EXCHANGE_PARTITION, ANALYZE_PARTITION, CHECK_PARTITION, OPTIMIZE_PARTITION, REBUILD_PARTITION, REPAIR_PARTITION, REMOVE_PARTITIONING, PARTITION_BY, SET_TABLE_OPTION, ENGINE, FORCE, KEY_BLOCK_SIZE, LOCK, DISCARD_TABLESPACE, IMPORT_TABLESPACE, DISABLE_KEYS, ENABLE_KEYS, ENABLE_ROW_LEVEL_SECURITY, DISABLE_ROW_LEVEL_SECURITY, FORCE_ROW_LEVEL_SECURITY, NO_FORCE_ROW_LEVEL_SECURITY, ALTER_PRIMARY_KEY, ALTER_RELATION, ORDER_BY, FOREIGN_OPTIONS; + ADD, ALTER, DROP, DROP_PRIMARY_KEY, DROP_UNIQUE, DROP_FOREIGN_KEY, DROP_CHECK, MODIFY, CHANGE, CONVERT, COLLATE, ALGORITHM, RENAME, RENAME_TABLE, RENAME_INDEX, RENAME_KEY, RENAME_CONSTRAINT, COMMENT, COMMENT_WITH_EQUAL_SIGN, UNSPECIFIC, ADD_PARTITION, DROP_PARTITION, ATTACH_PARTITION, DETACH_PARTITION, DISCARD_PARTITION, IMPORT_PARTITION, TRUNCATE_PARTITION, COALESCE_PARTITION, REORGANIZE_PARTITION, EXCHANGE_PARTITION, ANALYZE_PARTITION, CHECK_PARTITION, OPTIMIZE_PARTITION, REBUILD_PARTITION, REPAIR_PARTITION, REMOVE_PARTITIONING, PARTITION_BY, SET_TABLE_OPTION, ENGINE, FORCE, KEY_BLOCK_SIZE, LOCK, DISCARD_TABLESPACE, IMPORT_TABLESPACE, DISABLE_KEYS, ENABLE_KEYS, ENABLE_ROW_LEVEL_SECURITY, DISABLE_ROW_LEVEL_SECURITY, FORCE_ROW_LEVEL_SECURITY, NO_FORCE_ROW_LEVEL_SECURITY, ALTER_PRIMARY_KEY, ALTER_RELATION, ORDER_BY, FOREIGN_OPTIONS, VALIDATION; public static AlterOperation from(String operation) { return Enum.valueOf(AlterOperation.class, operation.toUpperCase(Locale.ROOT)); diff --git a/src/main/java/net/sf/jsqlparser/statement/create/function/CreateFunction.java b/src/main/java/net/sf/jsqlparser/statement/create/function/CreateFunction.java index ac20c0e3a9..421bc84eda 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/function/CreateFunction.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/function/CreateFunction.java @@ -10,15 +10,22 @@ package net.sf.jsqlparser.statement.create.function; import java.util.Collection; +import java.util.Collections; import java.util.List; import net.sf.jsqlparser.statement.CreateFunctionalStatement; +import net.sf.jsqlparser.statement.RoutineCharacteristic; /** - * A {@code CREATE PROCEDURE} statement + * A {@code CREATE FUNCTION} statement. A MySQL loadable function + * ({@code CREATE [AGGREGATE] FUNCTION name RETURNS type SONAME 'library'}) is held as a + * {@link LoadableFunction}, which then is the source of the declaration parts. */ public class CreateFunction extends CreateFunctionalStatement { + private boolean aggregate; + private LoadableFunction loadableFunction; + public CreateFunction() { super("FUNCTION"); } @@ -31,6 +38,71 @@ public CreateFunction(boolean orReplace, List functionDeclarationParts) super(orReplace, "FUNCTION", functionDeclarationParts); } + /** + * @return whether the function is declared with {@code AGGREGATE}, as a MySQL loadable + * aggregate function is + */ + public boolean isAggregate() { + return aggregate; + } + + public void setAggregate(boolean aggregate) { + this.aggregate = aggregate; + } + + public CreateFunction withAggregate(boolean aggregate) { + setAggregate(aggregate); + return this; + } + + /** + * @return the definition of a MySQL loadable function, or {@code null} for any other function + */ + public LoadableFunction getLoadableFunction() { + return loadableFunction; + } + + /** + * Makes this statement a MySQL loadable function. The definition replaces the declaration parts + * and the characteristics; {@link #getFunctionDeclarationParts()} is derived from it. + * + * @param loadableFunction the definition, or {@code null} to remove it + */ + public void setLoadableFunction(LoadableFunction loadableFunction) { + if (loadableFunction != null) { + super.setFunctionDeclarationParts(null, Collections.emptyList()); + } + this.loadableFunction = loadableFunction; + } + + public CreateFunction withLoadableFunction(LoadableFunction loadableFunction) { + setLoadableFunction(loadableFunction); + return this; + } + + @Override + protected String getKindKeywords() { + return aggregate ? "AGGREGATE " + getKind() : getKind(); + } + + /** + * @return the declaration parts; for a loadable function, a snapshot of + * {@link #getLoadableFunction()} + */ + @Override + public List getFunctionDeclarationParts() { + return loadableFunction != null ? loadableFunction.toDeclarationParts() + : super.getFunctionDeclarationParts(); + } + + /** Replaces the declaration parts, which also removes a loadable function definition. */ + @Override + public void setFunctionDeclarationParts(List functionDeclarationParts, + List characteristics) { + loadableFunction = null; + super.setFunctionDeclarationParts(functionDeclarationParts, characteristics); + } + @Override public CreateFunction withFunctionDeclarationParts(List functionDeclarationParts) { return (CreateFunction) super.withFunctionDeclarationParts(functionDeclarationParts); diff --git a/src/main/java/net/sf/jsqlparser/statement/create/function/LoadableFunction.java b/src/main/java/net/sf/jsqlparser/statement/create/function/LoadableFunction.java new file mode 100644 index 0000000000..a93846d2e8 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/function/LoadableFunction.java @@ -0,0 +1,150 @@ +/*- + * #%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.function; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; + +import net.sf.jsqlparser.expression.StringValue; + +/** + * The definition of a MySQL loadable function: + * {@code [IF NOT EXISTS] name RETURNS {STRING | INTEGER | REAL | DECIMAL} SONAME 'library'}. + */ +public class LoadableFunction implements Serializable { + + /** The result type of a loadable function. */ + public enum ReturnType { + STRING, INTEGER, REAL, DECIMAL; + + /** + * @param keyword a type keyword as MySQL spells it, including the synonyms {@code INT}, + * {@code INT4} and {@code DEC} + * @return the result type, or {@code null} if the keyword names none + */ + public static ReturnType from(String keyword) { + switch (keyword.toUpperCase(Locale.ROOT)) { + case "STRING": + return STRING; + case "INT": + case "INT4": + case "INTEGER": + return INTEGER; + case "REAL": + return REAL; + case "DEC": + case "DECIMAL": + return DECIMAL; + default: + return null; + } + } + } + + private boolean ifNotExists; + private String name; + private String returnTypeKeyword; + private StringValue library; + + public LoadableFunction() { + // filled by the setters + } + + public LoadableFunction(String name, ReturnType returnType, String library) { + setName(name); + setReturnType(returnType); + setLibrary(new StringValue(library)); + } + + public boolean isIfNotExists() { + return ifNotExists; + } + + public void setIfNotExists(boolean ifNotExists) { + this.ifNotExists = ifNotExists; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public ReturnType getReturnType() { + return returnTypeKeyword == null ? null : ReturnType.from(returnTypeKeyword); + } + + public void setReturnType(ReturnType returnType) { + returnTypeKeyword = Objects.requireNonNull(returnType, "returnType").name(); + } + + /** + * @return the result type as written, which may be one of the synonyms {@code INT}, + * {@code INT4} and {@code DEC} + */ + public String getReturnTypeKeyword() { + return returnTypeKeyword; + } + + /** + * @param keyword a keyword that {@link ReturnType#from(String)} resolves + */ + public void setReturnTypeKeyword(String keyword) { + if (ReturnType.from(keyword) == null) { + throw new IllegalArgumentException( + "A loadable function returns STRING, INTEGER, REAL or DECIMAL, not " + keyword); + } + returnTypeKeyword = keyword; + } + + /** + * @return the shared library file named by {@code SONAME} + */ + public StringValue getLibrary() { + return library; + } + + public void setLibrary(StringValue library) { + this.library = library; + } + + public LoadableFunction withIfNotExists(boolean ifNotExists) { + setIfNotExists(ifNotExists); + return this; + } + + /** + * @return the definition as the declaration parts of the CREATE FUNCTION statement + */ + public List toDeclarationParts() { + List parts = new ArrayList<>(); + if (ifNotExists) { + parts.add("IF"); + parts.add("NOT"); + parts.add("EXISTS"); + } + parts.add(name); + parts.add("RETURNS"); + parts.add(returnTypeKeyword); + parts.add("SONAME"); + parts.add(String.valueOf(library)); + return parts; + } + + @Override + public String toString() { + return String.join(" ", toDeclarationParts()); + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 98f1cb3f8a..a9f19cc028 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -169,6 +169,7 @@ import net.sf.jsqlparser.statement.create.extension.CreateExtension; import net.sf.jsqlparser.statement.create.extension.CreateExtensionRepository; import net.sf.jsqlparser.statement.create.fdw.CreateForeignDataWrapper; +import net.sf.jsqlparser.statement.create.function.CreateFunction; import net.sf.jsqlparser.statement.create.index.CreateIndex; import net.sf.jsqlparser.statement.create.macro.CreateMacro; import net.sf.jsqlparser.statement.create.policy.CreatePolicy; @@ -2633,7 +2634,11 @@ public void visit(AlterSequence alterSequence) { @Override public Void visit(CreateFunctionalStatement createFunctionalStatement, S context) { - throwUnsupported(createFunctionalStatement); + // a MySQL loadable function names a shared library and no table + if (!(createFunctionalStatement instanceof CreateFunction) + || ((CreateFunction) createFunctionalStatement).getLoadableFunction() == null) { + throwUnsupported(createFunctionalStatement); + } return null; } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index f21f6617b8..7910408332 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -4141,6 +4141,68 @@ public class CCJSqlParser extends AbstractJSqlParser { return new UnsupportedStatement("CREATE", declarations); } + /** + * Whether CREATE is followed by a MySQL loadable function: + * [AGGREGATE] FUNCTION [IF NOT EXISTS] name RETURNS type SONAME 'library'. With the MySQL + * dialect, AGGREGATE or a function name directly followed by RETURNS selects it, since a stored + * function has a parameter list there. With MariaDB, which also has stored aggregate functions, + * or without a dialect, the whole statement must have that shape. + */ + private boolean isLoadableFunctionAhead() { + String dialect = getAsString(Feature.dialect); + if (dialect != null && !isMySqlDialect()) { return false; } + boolean mysql = Dialect.MYSQL.name().equals(dialect); + int i = 1; + if (getToken(i).kind == K_AGGREGATE) { + if (mysql) { return true; } + i++; + } + if (getToken(i++).kind != K_FUNCTION) { return false; } + if (getToken(i).kind == K_IF && getToken(i + 1).kind == K_NOT && getToken(i + 2).kind == K_EXISTS) { i += 3; } + i++; + if (mysql) { + while (".".equals(getToken(i).image)) { i += 2; } + return getToken(i).kind == K_RETURNS; + } + int end = getToken(i + 4).kind; + return getToken(i).kind == K_RETURNS && LoadableFunction.ReturnType.from(getToken(i + 1).image) != null + && "SONAME".equalsIgnoreCase(getToken(i + 2).image) + && (getToken(i + 3).kind == S_CHAR_LITERAL || getToken(i + 3).kind == S_QUOTED_IDENTIFIER) + && (end == EOF || end == ST_SEMICOLON); + } + + /** + * Whether MySQL's WITH | WITHOUT VALIDATION modifier of ALTER TABLE starts here. With the MySQL + * dialect, WITH or WITHOUT selects it, as no other action starts with them. With MariaDB or + * without a dialect, it must be the whole item of the action list. + */ + private boolean isAlterValidationModifierAhead() { + int kind = getToken(1).kind; + if (kind != K_WITH && kind != K_WITHOUT) { return false; } + String dialect = getAsString(Feature.dialect); + if (Dialect.MYSQL.name().equals(dialect)) { return true; } + if (dialect != null && !isMySqlDialect() || getToken(2).kind != K_VALIDATION) { return false; } + Token next = getToken(3); + return next.kind == EOF || next.kind == ST_SEMICOLON || ",".equals(next.image) + || next.kind == K_PARTITION && getToken(4).kind == K_BY + || next.kind == K_REMOVE && getToken(4).kind == K_PARTITIONING; + } + + /** + * Whether a WITH | WITHOUT VALIDATION modifier follows, after a comma, a command that MySQL + * accepts only at the end of the action list: a partition management command, DISCARD or + * IMPORT TABLESPACE, PARTITION BY or REMOVE PARTITIONING. Checked with the MySQL dialect. + */ + private boolean isMisplacedAlterValidationModifier(AlterExpression previous, AlterExpression action) { + if (action.getOperation() != AlterOperation.VALIDATION + || !Dialect.MYSQL.name().equals(getAsString(Feature.dialect))) { + return false; + } + AlterOperation operation = previous.getOperation(); + return operation == AlterOperation.DISCARD_TABLESPACE || operation == AlterOperation.IMPORT_TABLESPACE + || operation.name().endsWith("_PARTITION") || Alter.isTrailingPartitionOption(previous); + } + /** * Whether a CREATE statement names none of TABLE, INDEX, VIEW and POLICY before it ends. Every * remaining CREATE form needs one of them, and CREATE TABLE and CREATE INDEX accept any leading @@ -25643,6 +25705,7 @@ AlterExpression AlterExpressionPartitionOp(): List partitionDefinition = null; Table exchangeTable = null; TablePartitioning partitioning = null; + AlterExpressionPartition.ExchangeValidationMode validationMode; } { ( @@ -25677,17 +25740,7 @@ AlterExpression AlterExpressionPartitionOp(): | LOOKAHEAD(2) partitions=PartitionNamesList() exchangeTable=Table() - [ - LOOKAHEAD(2) ( - { - alterExp.setExchangeValidationMode(AlterExpressionPartition.ExchangeValidationMode.WITH); - } - | - { - alterExp.setExchangeValidationMode(AlterExpressionPartition.ExchangeValidationMode.WITHOUT); - } - ) - ] + [ LOOKAHEAD(2) validationMode=AlterValidationMode() { alterExp.setExchangeValidationMode(validationMode); } ] { alterExp.setOperation(AlterOperation.EXCHANGE_PARTITION); alterExp.setPartitions(partitions); @@ -26077,12 +26130,23 @@ AlterConstraintAttributes AlterConstraintAttributes(): } } +/** WITH VALIDATION or WITHOUT VALIDATION: after EXCHANGE PARTITION, or MySQL's ALTER TABLE modifier. */ +AlterExpressionPartition.ExchangeValidationMode AlterValidationMode(): +{ AlterExpressionPartition.ExchangeValidationMode mode; } +{ + ( { mode = AlterExpressionPartition.ExchangeValidationMode.WITH; } + | { mode = AlterExpressionPartition.ExchangeValidationMode.WITHOUT; } ) + + { return mode; } +} + AlterExpression AlterExpression(): { AlterExpression alterExp = null; Token tk; Token tk2 = null; String sk3 = null; + AlterExpressionPartition.ExchangeValidationMode validationMode; // for captureRest() List tokens = new LinkedList(); @@ -26094,6 +26158,9 @@ AlterExpression AlterExpression(): && "OPTIONS".equalsIgnoreCase(getToken(getToken(2).kind == K_COLUMN ? 4 : 3).image) }) alterExp=AlterForeignDataOptions() | + LOOKAHEAD({ isAlterValidationModifierAhead() }) validationMode=AlterValidationMode() + { alterExp = new AlterExpressionTableOption().withValidationMode(validationMode); } + | LOOKAHEAD( ) alterExp=MySqlAlterOrderBy() | LOOKAHEAD({ isMySqlTableOptionAhead() }) @@ -26729,6 +26796,7 @@ Alter AlterTable(boolean foreign): Alter alter = new Alter(); Table table; AlterExpression alterExp; + AlterExpression previous; boolean usingIfExists = false; } { @@ -26748,7 +26816,12 @@ Alter AlterTable(boolean foreign): alterExp=AlterExpression() { alter.addAlterExpression(alterExp); } ( - "," alterExp=AlterExpression() { alter.addAlterExpression(alterExp); } + "," { previous = alterExp; } alterExp=AlterExpression() + { + requireDdlSyntax(!isMisplacedAlterValidationModifier(previous, alterExp), + "WITH or WITHOUT VALIDATION cannot follow a command that ends the action list"); + alter.addAlterExpression(alterExp); + } | LOOKAHEAD({ isMySqlTableOptionAhead() }) { requireDdlSyntax(isMySqlTableOptionAction(alterExp), "Expected a comma before table options"); } alterExp=AlterSharedTableOption() { alter.addAlterExpression(alterExp); } @@ -28424,6 +28497,8 @@ Statement Create(): | LOOKAHEAD({ isCreateFunctionMacroAhead() }) statement = CreateMacro(isUsingOrReplace) | + LOOKAHEAD({ isLoadableFunctionAhead() }) statement = CreateLoadableFunction(isUsingOrReplace) + | LOOKAHEAD([ MySqlRoutineDefiner() ] ( | )) statement = CreateFunctionStatement(isUsingOrReplace) | @@ -28459,6 +28534,49 @@ Statement Create(): } } +/** + * MySQL CREATE [AGGREGATE] FUNCTION [IF NOT EXISTS] name RETURNS type SONAME 'library', selected by + * isLoadableFunctionAhead(). SONAME is not a token. MySQL has no OR REPLACE; MariaDB has. + */ +CreateFunction CreateLoadableFunction(boolean isUsingOrReplace): +{ + CreateFunction function = new CreateFunction(); + LoadableFunction definition = new LoadableFunction(); + String name; + Token token; +} +{ + { + requireDdlSyntax(!isUsingOrReplace || !Dialect.MYSQL.name().equals(getAsString(Feature.dialect)), + "A MySQL loadable function does not support OR REPLACE"); + function.setOrReplace(isUsingOrReplace); + } + [ { function.setAggregate(true); } ] + + [ LOOKAHEAD(2) { definition.setIfNotExists(true); } ] + name=RelObjectName() { definition.setName(name); } + + ( token= | token= | token= ) + { + requireDdlSyntax(LoadableFunction.ReturnType.from(token.image) != null, + "A loadable function returns STRING, INTEGER, REAL or DECIMAL"); + definition.setReturnTypeKeyword(token.image); + } + ContextualKeyword("SONAME") + ( token= | token= ) + { + // a quoted text without prefix; double quotes are an identifier token in some configurations + requireDdlSyntax(token.image.startsWith("'") || token.image.startsWith("\""), + "SONAME expects a string literal"); + definition.setLibrary(token.kind == S_CHAR_LITERAL ? stringValue(token) + : linkToLastToken(new StringValue(token.image))); + } + { + function.setLoadableFunction(definition); + return function; + } +} + /** With a MySQL dialect, the characteristics are also parsed; the tokens still render the statement. */ CreateFunctionalStatement CreateFunctionStatement(boolean isUsingOrReplace): { diff --git a/src/site/sphinx/index.rst b/src/site/sphinx/index.rst index 5eca7088c7..082d6250aa 100644 --- a/src/site/sphinx/index.rst +++ b/src/site/sphinx/index.rst @@ -168,6 +168,8 @@ One grammar covers the SQL standard plus all major RDBMS. Missing syntax gets ad - ``LOCK TABLES`` · ``UNLOCK TABLES`` · ``LOCK INSTANCE FOR BACKUP`` · ``UNLOCK INSTANCE`` * - **MySQL resource groups** - ``CREATE``/``ALTER``/``DROP RESOURCE GROUP`` · ``SET RESOURCE GROUP`` + * - **MySQL loadable functions** + - ``CREATE [AGGREGATE] FUNCTION … RETURNS … SONAME`` * - **MySQL servers, plugins and components** - ``CREATE SERVER`` · ``ALTER SERVER`` with ``OPTIONS (… PORT 3306)`` · ``INSTALL``/``UNINSTALL PLUGIN`` · ``INSTALL``/``UNINSTALL COMPONENT`` * - **PostgreSQL session statements** diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index aba2e626ae..b4d01f236d 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -3406,6 +3406,59 @@ generic ``Drop`` reading. The validator checks ``createResourceGroup``, ``alterResourceGroup`` and ``setResourceGroup`` (MySQL); feature analysis reports ``SET RESOURCE GROUP`` as a session change and the others as schema changes. +MySQL loadable functions +======================== + +MySQL ``CREATE [AGGREGATE] FUNCTION [IF NOT EXISTS] name RETURNS {STRING | INTEGER | REAL | +DECIMAL} SONAME 'library'`` produces a ``CreateFunction`` whose ``getLoadableFunction()`` +holds the name, the ``ReturnType``, the library as a ``StringValue`` and ``isIfNotExists()``; +``isAggregate()`` tells the two forms apart. The synonyms ``INT``, ``INT4`` and ``DEC`` keep +their spelling in ``getReturnTypeKeyword()``. ``getFunctionDeclarationParts()`` is derived from +the definition, and setting declaration parts removes it. + +.. code-block:: java + + CreateFunction function = (CreateFunction) CCJSqlParserUtil.parse( + "CREATE AGGREGATE FUNCTION IF NOT EXISTS f RETURNS REAL SONAME 'udf.so'", + parser -> parser.withDialect(Dialect.MYSQL)); + LoadableFunction definition = function.getLoadableFunction(); + // true, f, REAL, udf.so + String parts = function.isAggregate() + ", " + definition.getName() + ", " + + definition.getReturnType() + ", " + definition.getLibrary().getValue(); + +With the MySQL dialect the statement follows MySQL's grammar: ``AGGREGATE``, or a function name +directly followed by ``RETURNS``, must continue as a loadable function, without ``OR REPLACE``. +With the MariaDB dialect or without one, a statement of exactly this shape gets the definition +(``OR REPLACE`` included) and anything else keeps its previous reading. Other dialects are +unchanged: ``CREATE AGGREGATE FUNCTION`` stays an ``UnsupportedStatement`` there. +``TablesNamesFinder`` finds no tables in a loadable function. + +MySQL ALTER TABLE validation modifier +===================================== + +MySQL ``ALTER TABLE ... {WITH | WITHOUT} VALIDATION`` is an ``AlterExpressionTableOption`` +with the operation ``VALIDATION`` and ``getValidationMode()``, like the ``ALGORITHM`` and +``LOCK`` modifiers it may be mixed with in the action list. The mode written directly after +``EXCHANGE PARTITION ... WITH TABLE t`` still belongs to that action +(``AlterExpressionPartition.getExchangeValidationMode()``). + +.. code-block:: java + + Alter alter = (Alter) CCJSqlParserUtil.parse( + "ALTER TABLE t1 WITHOUT VALIDATION, EXCHANGE PARTITION p0 WITH TABLE t2", + parser -> parser.withDialect(Dialect.MYSQL)); + AlterExpressionTableOption modifier = + (AlterExpressionTableOption) alter.getAlterExpressions().get(0); + // VALIDATION, WITHOUT, EXCHANGE_PARTITION + String parts = modifier.getOperation() + ", " + modifier.getValidationMode() + ", " + + alter.getAlterExpressions().get(1).getOperation(); + +With the MySQL dialect, ``WITH`` or ``WITHOUT`` at the start of an action must be this +modifier, and it is rejected after a comma that follows a command MySQL accepts only at the +end of the list (a partition management command, ``DISCARD`` or ``IMPORT TABLESPACE``, +``PARTITION BY``, ``REMOVE PARTITIONING``). With the MariaDB dialect or without one, the +modifier is read where it is a whole item of the list; other dialects keep the raw-text action. + MySQL SHOW statements ===================== diff --git a/src/test/java/net/sf/jsqlparser/statement/alter/MySqlAlterValidationModifierTest.java b/src/test/java/net/sf/jsqlparser/statement/alter/MySqlAlterValidationModifierTest.java new file mode 100644 index 0000000000..a3f021617c --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/alter/MySqlAlterValidationModifierTest.java @@ -0,0 +1,360 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.alter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import net.sf.jsqlparser.JSQLParserException; +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.statement.Statement; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.StatementVisitorAdapter; +import net.sf.jsqlparser.statement.alter.AlterExpressionPartition.ExchangeValidationMode; +import net.sf.jsqlparser.statement.create.table.TableOption; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import net.sf.jsqlparser.util.validation.Validation; +import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed; +import net.sf.jsqlparser.util.validation.feature.MySqlVersion; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** MySQL's WITH | WITHOUT VALIDATION modifier in the ALTER TABLE action list. */ +class MySqlAlterValidationModifierTest { + /** The modifier is read with these dialects or without one. */ + private static final Dialect[] MYSQL = {Dialect.MYSQL, Dialect.MARIADB, null}; + private static final Dialect[] OTHERS = + {Dialect.POSTGRESQL, Dialect.ORACLE, Dialect.SQLSERVER, Dialect.H2}; + + private static Statement parse(String sql, Dialect dialect) throws JSQLParserException { + return dialect == null ? CCJSqlParserUtil.parse(sql) + : CCJSqlParserUtil.parse(sql, p -> p.withDialect(dialect)); + } + + /** Parses, prints through toString and the deparser, and reparses. */ + private static Alter roundTrip(String sql, Dialect dialect, AlterOperation... operations) + throws Exception { + Alter alter = assertInstanceOf(Alter.class, parse(sql, dialect), sql); + assertEquals(Arrays.asList(operations), operations(alter), dialect + ": " + sql); + assertEquals(sql, alter.toString()); + Node node = alter.getASTNode(); + assertNotNull(node, sql); + assertEquals(sql, sql.substring(node.jjtGetFirstToken().absoluteBegin - 1, + node.jjtGetLastToken().absoluteEnd - 1)); + StringBuilder output = new StringBuilder(); + alter.accept(new StatementDeParser(output), null); + assertEquals(sql, output.toString()); + Alter reparsed = assertInstanceOf(Alter.class, parse(alter.toString(), dialect)); + assertEquals(operations(alter), operations(reparsed)); + assertEquals(sql, reparsed.toString()); + return alter; + } + + private static List operations(Alter alter) { + List operations = new ArrayList<>(); + for (AlterExpression action : alter.getAlterExpressions()) { + operations.add(action.getOperation()); + } + return operations; + } + + private static ExchangeValidationMode mode(Alter alter, int index) { + AlterExpressionTableOption action = assertInstanceOf(AlterExpressionTableOption.class, + alter.getAlterExpressions().get(index)); + assertNull(action.getOptionalSpecifier()); + assertNull(action.getStructuredTableOption()); + return action.getValidationMode(); + } + + @Test + void modifierBeforeExchangePartitionIsItsOwnAction() throws Exception { + for (Dialect dialect : MYSQL) { + Alter alter = roundTrip( + "ALTER TABLE t1 WITHOUT VALIDATION, EXCHANGE PARTITION p0 WITH TABLE t2", + dialect, AlterOperation.VALIDATION, AlterOperation.EXCHANGE_PARTITION); + assertEquals(ExchangeValidationMode.WITHOUT, mode(alter, 0)); + AlterExpressionPartition exchange = assertInstanceOf(AlterExpressionPartition.class, + alter.getAlterExpressions().get(1)); + assertEquals(Arrays.asList("p0"), exchange.getPartitions()); + assertEquals("t2", exchange.getExchangeTable().getName()); + assertNull(exchange.getExchangeValidationMode()); + assertEquals(Arrays.asList("t1", "t2"), + new TablesNamesFinder<>().getTableList((Statement) alter)); + + // the mode after WITH TABLE still belongs to the exchange + alter = roundTrip("ALTER TABLE t1 WITH VALIDATION, EXCHANGE PARTITION p0 WITH TABLE t2" + + " WITHOUT VALIDATION", dialect, AlterOperation.VALIDATION, + AlterOperation.EXCHANGE_PARTITION); + assertEquals(ExchangeValidationMode.WITH, mode(alter, 0)); + assertEquals(ExchangeValidationMode.WITHOUT, + ((AlterExpressionPartition) alter.getAlterExpressions().get(1)) + .getExchangeValidationMode()); + + alter = roundTrip("ALTER TABLE t1 EXCHANGE PARTITION p0 WITH TABLE t2 WITH VALIDATION", + dialect, AlterOperation.EXCHANGE_PARTITION); + assertEquals(ExchangeValidationMode.WITH, + ((AlterExpressionPartition) alter.getAlterExpressions().get(0)) + .getExchangeValidationMode()); + + roundTrip("ALTER TABLE t1 WITHOUT VALIDATION, WITH VALIDATION," + + " EXCHANGE PARTITION p0 WITH TABLE t2 WITH VALIDATION", dialect, + AlterOperation.VALIDATION, AlterOperation.VALIDATION, + AlterOperation.EXCHANGE_PARTITION); + roundTrip("ALTER TABLE t1 ALGORITHM = INPLACE, WITHOUT VALIDATION, LOCK = NONE," + + " EXCHANGE PARTITION p0 WITH TABLE t2", dialect, AlterOperation.ALGORITHM, + AlterOperation.VALIDATION, AlterOperation.LOCK, + AlterOperation.EXCHANGE_PARTITION); + } + } + + @Test + void modifierAloneAndAmongModifiers() throws Exception { + for (Dialect dialect : MYSQL) { + assertEquals(ExchangeValidationMode.WITH, + mode(roundTrip("ALTER TABLE t1 WITH VALIDATION", dialect, + AlterOperation.VALIDATION), 0)); + assertEquals(ExchangeValidationMode.WITHOUT, + mode(roundTrip("ALTER TABLE t1 WITHOUT VALIDATION", dialect, + AlterOperation.VALIDATION), 0)); + Alter alter = roundTrip("ALTER TABLE t1 WITH VALIDATION, WITHOUT VALIDATION", dialect, + AlterOperation.VALIDATION, AlterOperation.VALIDATION); + assertEquals(ExchangeValidationMode.WITHOUT, mode(alter, 1)); + roundTrip("ALTER TABLE t1 WITH VALIDATION, ALGORITHM = COPY, LOCK = SHARED", dialect, + AlterOperation.VALIDATION, AlterOperation.ALGORITHM, AlterOperation.LOCK); + roundTrip("ALTER TABLE t1 ALGORITHM = COPY, WITH VALIDATION", dialect, + AlterOperation.ALGORITHM, AlterOperation.VALIDATION); + } + Alter alter = assertInstanceOf(Alter.class, + parse("alter table t1 with validation, add column c int", Dialect.MYSQL)); + assertEquals("ALTER TABLE t1 WITH VALIDATION, ADD COLUMN c int", alter.toString()); + } + + @Test + void modifierBetweenAlterListItems() throws Exception { + for (Dialect dialect : MYSQL) { + Alter alter = roundTrip("ALTER TABLE t1 WITH VALIDATION, ADD COLUMN c INT", dialect, + AlterOperation.VALIDATION, AlterOperation.ADD); + assertEquals(ExchangeValidationMode.WITH, mode(alter, 0)); + assertEquals("c", + alter.getAlterExpressions().get(1).getColDataTypeList().get(0).getColumnName()); + alter = roundTrip("ALTER TABLE t1 ADD COLUMN c INT, WITHOUT VALIDATION", dialect, + AlterOperation.ADD, AlterOperation.VALIDATION); + assertEquals(ExchangeValidationMode.WITHOUT, mode(alter, 1)); + roundTrip("ALTER TABLE t1 WITH VALIDATION, ADD COLUMN c INT, WITHOUT VALIDATION," + + " ADD COLUMN d INT, WITH VALIDATION", dialect, AlterOperation.VALIDATION, + AlterOperation.ADD, AlterOperation.VALIDATION, AlterOperation.ADD, + AlterOperation.VALIDATION); + roundTrip("ALTER TABLE t1 ADD COLUMN c INT, ALGORITHM = COPY, WITH VALIDATION," + + " LOCK = SHARED", dialect, AlterOperation.ADD, AlterOperation.ALGORITHM, + AlterOperation.VALIDATION, AlterOperation.LOCK); + roundTrip("ALTER TABLE t1 ADD COLUMN g INT AS (a + 1) STORED, WITHOUT VALIDATION", + dialect, AlterOperation.ADD, AlterOperation.VALIDATION); + roundTrip("ALTER TABLE t1 MODIFY COLUMN a BIGINT, WITH VALIDATION", dialect, + AlterOperation.MODIFY, AlterOperation.VALIDATION); + roundTrip("ALTER TABLE t1 ENGINE = InnoDB, WITH VALIDATION", dialect, + AlterOperation.ENGINE, AlterOperation.VALIDATION); + roundTrip("ALTER TABLE t1 WITH VALIDATION, ENGINE = InnoDB", dialect, + AlterOperation.VALIDATION, AlterOperation.ENGINE); + roundTrip("ALTER TABLE t1 WITH VALIDATION, FORCE", dialect, AlterOperation.VALIDATION, + AlterOperation.FORCE); + roundTrip("ALTER TABLE t1 RENAME TO t4, WITH VALIDATION", dialect, + AlterOperation.RENAME_TABLE, AlterOperation.VALIDATION); + roundTrip("ALTER TABLE t1 ADD INDEX i1 (a), WITHOUT VALIDATION", dialect, + AlterOperation.ADD, AlterOperation.VALIDATION); + roundTrip("ALTER TABLE t1 DROP COLUMN a, WITH VALIDATION", dialect, + AlterOperation.DROP, AlterOperation.VALIDATION); + roundTrip("ALTER TABLE t1 CONVERT TO CHARACTER SET utf8mb4, WITH VALIDATION", dialect, + AlterOperation.CONVERT, AlterOperation.VALIDATION); + } + } + + @Test + void modifierBeforePartitionOptionsAndStandaloneCommands() throws Exception { + for (Dialect dialect : MYSQL) { + roundTrip("ALTER TABLE t1 WITH VALIDATION PARTITION BY HASH (id) PARTITIONS 2", + dialect, AlterOperation.VALIDATION, AlterOperation.PARTITION_BY); + roundTrip("ALTER TABLE t1 ADD COLUMN c INT, WITH VALIDATION" + + " PARTITION BY HASH (id) PARTITIONS 2", dialect, AlterOperation.ADD, + AlterOperation.VALIDATION, AlterOperation.PARTITION_BY); + roundTrip("ALTER TABLE t1 WITHOUT VALIDATION REMOVE PARTITIONING", dialect, + AlterOperation.VALIDATION, AlterOperation.REMOVE_PARTITIONING); + roundTrip("ALTER TABLE t1 WITH VALIDATION, TRUNCATE PARTITION p0", dialect, + AlterOperation.VALIDATION, AlterOperation.TRUNCATE_PARTITION); + roundTrip("ALTER TABLE t1 WITH VALIDATION, ANALYZE PARTITION p0", dialect, + AlterOperation.VALIDATION, AlterOperation.ANALYZE_PARTITION); + roundTrip("ALTER TABLE t1 WITHOUT VALIDATION, DROP PARTITION p1", dialect, + AlterOperation.VALIDATION, AlterOperation.DROP_PARTITION); + roundTrip("ALTER TABLE t1 WITH VALIDATION," + + " ADD PARTITION (PARTITION p2 VALUES LESS THAN (200))", dialect, + AlterOperation.VALIDATION, AlterOperation.ADD_PARTITION); + roundTrip("ALTER TABLE t1 WITH VALIDATION, REORGANIZE PARTITION p1 INTO" + + " (PARTITION p1 VALUES LESS THAN (200)," + + " PARTITION p2 VALUES LESS THAN MAXVALUE)", dialect, + AlterOperation.VALIDATION, AlterOperation.REORGANIZE_PARTITION); + roundTrip("ALTER TABLE t1 WITH VALIDATION, DISCARD TABLESPACE", dialect, + AlterOperation.VALIDATION, AlterOperation.DISCARD_TABLESPACE); + roundTrip("ALTER TABLE t1 WITHOUT VALIDATION, IMPORT TABLESPACE", dialect, + AlterOperation.VALIDATION, AlterOperation.IMPORT_TABLESPACE); + } + } + + @ParameterizedTest + @ValueSource(strings = { + "ALTER TABLE t1 EXCHANGE PARTITION p0 WITH TABLE t2, WITHOUT VALIDATION", + "ALTER TABLE t1 EXCHANGE PARTITION p0 WITH TABLE t2 WITHOUT VALIDATION, WITH VALIDATION", + "ALTER TABLE t1 WITH VALIDATION, EXCHANGE PARTITION p0 WITH TABLE t2," + + " WITHOUT VALIDATION", + "ALTER TABLE t1 EXCHANGE PARTITION p0 WITH TABLE t2 WITHOUT VALIDATION WITH VALIDATION", + "ALTER TABLE t1 EXCHANGE PARTITION p0 WITH TABLE t2 VALIDATION", + "ALTER TABLE t1 WITHOUT VALIDATION EXCHANGE PARTITION p0 WITH TABLE t2", + "ALTER TABLE t1 TRUNCATE PARTITION p0, WITH VALIDATION", + "ALTER TABLE t1 DROP PARTITION p1, WITH VALIDATION", + "ALTER TABLE t1 COALESCE PARTITION 1, WITH VALIDATION", + "ALTER TABLE t1 ADD PARTITION (PARTITION p2 VALUES LESS THAN (200)), WITH VALIDATION", + "ALTER TABLE t1 REORGANIZE PARTITION p1 INTO (PARTITION p1 VALUES LESS THAN (200)," + + " PARTITION p2 VALUES LESS THAN MAXVALUE), WITH VALIDATION", + "ALTER TABLE t1 DISCARD TABLESPACE, WITH VALIDATION", + "ALTER TABLE t1 IMPORT TABLESPACE, WITHOUT VALIDATION", + "ALTER TABLE t1 PARTITION BY HASH (id) PARTITIONS 2, WITH VALIDATION", + "ALTER TABLE t1 PARTITION BY HASH (id) PARTITIONS 2 WITH VALIDATION", + "ALTER TABLE t1 REMOVE PARTITIONING, WITH VALIDATION", + "ALTER TABLE t1 REMOVE PARTITIONING WITH VALIDATION", + "ALTER TABLE t1 WITH VALIDATION WITHOUT VALIDATION", + "ALTER TABLE t1 WITH VALIDATION ADD COLUMN c INT", + "ALTER TABLE t1 ADD COLUMN c INT WITHOUT VALIDATION", + "ALTER TABLE t1 ADD COLUMN c INT, WITH VALIDATION,", + "ALTER TABLE t1 WITH VALIDATION,", + "ALTER TABLE t1 WITH", + "ALTER TABLE t1 WITHOUT", + "ALTER TABLE t1 WITH NO VALIDATION", + "ALTER TABLE t1 WITH VALIDATION = 1", + "ALTER TABLE t1 ORDER BY a, WITH VALIDATION"}) + void mySqlRejectsWhatTheServerRejects(String sql) { + assertThrows(JSQLParserException.class, () -> parse(sql, Dialect.MYSQL), sql); + } + + @Test + void mariaDbAndNoDialectKeepWhatTheyAccepted() throws Exception { + for (Dialect dialect : new Dialect[] {Dialect.MARIADB, null}) { + // accepted before, now with a typed modifier + Alter alter = roundTrip( + "ALTER TABLE t1 EXCHANGE PARTITION p0 WITH TABLE t2, WITHOUT VALIDATION", + dialect, AlterOperation.EXCHANGE_PARTITION, AlterOperation.VALIDATION); + assertEquals(ExchangeValidationMode.WITHOUT, mode(alter, 1)); + // not the whole item of the list: raw text as before + for (String sql : new String[] {"ALTER TABLE t1 WITH VALIDATION foo", + "ALTER TABLE t1 WITH NOCHECK ADD CONSTRAINT c CHECK (a > 1)", + "ALTER TABLE t1 WITH"}) { + alter = assertInstanceOf(Alter.class, parse(sql, dialect)); + assertEquals(Arrays.asList(AlterOperation.UNSPECIFIC), operations(alter), sql); + } + } + } + + @Test + void otherDialectsKeepTheirReading() throws Exception { + for (Dialect dialect : OTHERS) { + Alter alter = assertInstanceOf(Alter.class, parse( + "ALTER TABLE t1 WITHOUT VALIDATION, EXCHANGE PARTITION p0 WITH TABLE t2", + dialect)); + assertEquals(Arrays.asList(AlterOperation.UNSPECIFIC), operations(alter)); + assertEquals("WITHOUT VALIDATION , EXCHANGE PARTITION p0 WITH TABLE t2", + alter.getAlterExpressions().get(0).getOptionalSpecifier()); + alter = assertInstanceOf(Alter.class, parse("ALTER TABLE t1 WITH VALIDATION", dialect)); + assertEquals(Arrays.asList(AlterOperation.UNSPECIFIC), operations(alter)); + alter = assertInstanceOf(Alter.class, parse( + "ALTER TABLE t1 EXCHANGE PARTITION p0 WITH TABLE t2 WITHOUT VALIDATION", + dialect)); + assertEquals(Arrays.asList(AlterOperation.EXCHANGE_PARTITION), operations(alter)); + assertEquals(ExchangeValidationMode.WITHOUT, + ((AlterExpressionPartition) alter.getAlterExpressions().get(0)) + .getExchangeValidationMode()); + } + } + + @Test + void statementBoundaries() throws Exception { + for (Dialect dialect : MYSQL) { + String script = + "ALTER TABLE t1 WITHOUT VALIDATION, EXCHANGE PARTITION p0 WITH TABLE t2;" + + " ALTER TABLE t1 ADD COLUMN c INT, WITH VALIDATION; SELECT 1;" + + " ALTER TABLE t1 WITH VALIDATION"; + Statements statements = dialect == null ? CCJSqlParserUtil.parseStatements(script) + : CCJSqlParserUtil.parseStatements(script, p -> p.withDialect(dialect)); + assertEquals(4, statements.size()); + assertEquals( + Arrays.asList(AlterOperation.VALIDATION, AlterOperation.EXCHANGE_PARTITION), + operations((Alter) statements.get(0))); + assertEquals(Arrays.asList(AlterOperation.ADD, AlterOperation.VALIDATION), + operations((Alter) statements.get(1))); + assertEquals("SELECT 1", statements.get(2).toString()); + assertEquals(Arrays.asList(AlterOperation.VALIDATION), + operations((Alter) statements.get(3))); + assertEquals(Arrays.asList(AlterOperation.VALIDATION), operations( + (Alter) parse("ALTER TABLE t1 WITH VALIDATION;", dialect))); + } + } + + @Test + void modelBuildsAndReplacesTheModifier() { + AlterExpressionTableOption action = + new AlterExpressionTableOption().withValidationMode(ExchangeValidationMode.WITHOUT); + assertEquals(AlterOperation.VALIDATION, action.getOperation()); + assertEquals("WITHOUT VALIDATION", action.toString()); + action.setValidationMode(ExchangeValidationMode.WITH); + assertEquals("WITH VALIDATION", action.toString()); + assertThrows(NullPointerException.class, () -> action.setValidationMode(null)); + assertEquals(AlterOperation.VALIDATION, AlterOperation.from("validation")); + + Alter alter = new Alter(); + alter.setTable(new net.sf.jsqlparser.schema.Table("t1")); + alter.addAlterExpression(action); + assertEquals("ALTER TABLE t1 WITH VALIDATION", alter.toString()); + + // another table option replaces the modifier, and the modifier replaces it + action.setStructuredTableOption( + new TableOption(TableOption.Kind.ENGINE, "ENGINE", "InnoDB", true)); + assertNull(action.getValidationMode()); + assertEquals(AlterOperation.ENGINE, action.getOperation()); + action.setValidationMode(ExchangeValidationMode.WITHOUT); + assertNull(action.getStructuredTableOption()); + assertEquals("WITHOUT VALIDATION", action.toString()); + assertNull(new AlterExpressionTableOption().getValidationMode()); + } + + @Test + void visitorsAndValidation() throws Exception { + String sql = "ALTER TABLE t1 WITHOUT VALIDATION, EXCHANGE PARTITION p0 WITH TABLE t2"; + List visited = new ArrayList<>(); + parse(sql, Dialect.MYSQL).accept(new StatementVisitorAdapter() { + @Override + public Void visit(Alter alter, S context) { + visited.add(alter.getTable().getName()); + return null; + } + }, null); + assertEquals(Arrays.asList("t1"), visited); + assertTrue(Validation.validate(Arrays.asList(MySqlVersion.V8_0), sql).isEmpty()); + assertTrue(Validation.validate(Arrays.asList(new FeaturesAllowed(Feature.alterTable)), sql) + .isEmpty()); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlAggregateDdlTest.java b/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlAggregateDdlTest.java index 798eb11023..42328e9821 100644 --- a/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlAggregateDdlTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/create/PostgreSqlAggregateDdlTest.java @@ -28,6 +28,7 @@ import net.sf.jsqlparser.statement.UnsupportedStatement; import net.sf.jsqlparser.statement.alter.AlterAggregate; import net.sf.jsqlparser.statement.create.aggregate.CreateAggregate; +import net.sf.jsqlparser.statement.create.function.CreateFunction; import net.sf.jsqlparser.util.TablesNamesFinder; import net.sf.jsqlparser.util.deparser.StatementDeParser; import net.sf.jsqlparser.util.validation.ValidationTestAsserts; @@ -264,9 +265,11 @@ void testEmptyAggregateSignatureIsRejected(String sql) { @Test void testOtherDialectsKeepTheirAggregates() throws Exception { String mysql = "CREATE AGGREGATE FUNCTION f RETURNS STRING SONAME 'x.so'"; - assertThat(CCJSqlParserUtil.parse(mysql)).isInstanceOf(UnsupportedStatement.class); - assertThat(CCJSqlParserUtil.parse(mysql, parser -> parser.withDialect(Dialect.MYSQL))) - .isInstanceOf(UnsupportedStatement.class); + for (Statement statement : new Statement[] {CCJSqlParserUtil.parse(mysql), + CCJSqlParserUtil.parse(mysql, parser -> parser.withDialect(Dialect.MYSQL))}) { + assertThat(statement).isInstanceOf(CreateFunction.class).hasToString(mysql); + assertThat(((CreateFunction) statement).getLoadableFunction()).isNotNull(); + } String sqlServer = "CREATE AGGREGATE dbo.Concatenate (@input nvarchar(200)) " + "RETURNS nvarchar(max) EXTERNAL NAME StringUtilities.Concatenate"; assertThat(CCJSqlParserUtil.parse(sqlServer)).isInstanceOf(UnsupportedStatement.class); diff --git a/src/test/java/net/sf/jsqlparser/statement/create/function/MySqlLoadableFunctionTest.java b/src/test/java/net/sf/jsqlparser/statement/create/function/MySqlLoadableFunctionTest.java new file mode 100644 index 0000000000..7b7ef9263f --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/function/MySqlLoadableFunctionTest.java @@ -0,0 +1,362 @@ +/*- + * #%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.function; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import net.sf.jsqlparser.JSQLParserException; +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.statement.CreateFunctionalStatement; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitorAdapter; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.UnsupportedStatement; +import net.sf.jsqlparser.statement.create.function.LoadableFunction.ReturnType; +import net.sf.jsqlparser.statement.drop.Drop; +import net.sf.jsqlparser.util.TablesNamesFinder; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import net.sf.jsqlparser.util.validation.Validation; +import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +/** MySQL CREATE [AGGREGATE] FUNCTION [IF NOT EXISTS] name RETURNS type SONAME 'library'. */ +class MySqlLoadableFunctionTest { + /** The loadable function is read with these dialects or without one. */ + private static final Dialect[] MYSQL = {Dialect.MYSQL, Dialect.MARIADB, null}; + private static final Dialect[] OTHERS = + {Dialect.POSTGRESQL, Dialect.ORACLE, Dialect.SQLSERVER, Dialect.H2}; + + 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, prints through toString and the deparser, reparses and finds no tables. */ + private static CreateFunction roundTrip(String sql, Dialect dialect) throws Exception { + CreateFunction function = assertInstanceOf(CreateFunction.class, parse(sql, dialect), sql); + assertNotNull(function.getLoadableFunction(), sql); + assertEquals(sql, function.toString()); + assertSpans(sql, sql, function); + StringBuilder output = new StringBuilder(); + function.accept(new StatementDeParser(output), null); + assertEquals(sql, output.toString()); + CreateFunction reparsed = assertInstanceOf(CreateFunction.class, parse(sql, dialect)); + assertEquals(function.isAggregate(), reparsed.isAggregate()); + assertEquals(sql, reparsed.toString()); + assertTrue(new TablesNamesFinder<>().getTables((Statement) function).isEmpty()); + return function; + } + + private static void assertRejected(String sql, Dialect... dialects) { + for (Dialect dialect : dialects) { + assertThrows(JSQLParserException.class, () -> parse(sql, dialect), + dialect + ": " + sql); + } + } + + @Test + void aggregateFunctionIsACreateFunction() throws Exception { + for (Dialect dialect : MYSQL) { + String sql = "CREATE AGGREGATE FUNCTION f RETURNS INTEGER SONAME 'udf.so'"; + CreateFunction function = roundTrip(sql, dialect); + assertTrue(function.isAggregate()); + assertEquals("FUNCTION", function.getKind()); + LoadableFunction definition = function.getLoadableFunction(); + assertFalse(definition.isIfNotExists()); + assertEquals("f", definition.getName()); + assertEquals(ReturnType.INTEGER, definition.getReturnType()); + assertEquals("INTEGER", definition.getReturnTypeKeyword()); + assertEquals("udf.so", definition.getLibrary().getValue()); + assertSpans("'udf.so'", sql, definition.getLibrary()); + assertEquals(Arrays.asList("f", "RETURNS", "INTEGER", "SONAME", "'udf.so'"), + function.getFunctionDeclarationParts()); + assertEquals("f RETURNS INTEGER SONAME 'udf.so'", function.formatDeclaration()); + assertTrue(function.getCharacteristics().isEmpty()); + assertNull(function.getDefiner()); + + definition = roundTrip( + "CREATE AGGREGATE FUNCTION IF NOT EXISTS f RETURNS REAL SONAME 'udf.so'", + dialect).getLoadableFunction(); + assertTrue(definition.isIfNotExists()); + assertEquals(ReturnType.REAL, definition.getReturnType()); + } + } + + @Test + void plainLoadableFunctionGetsTheSameDefinition() throws Exception { + for (Dialect dialect : MYSQL) { + CreateFunction function = roundTrip( + "CREATE FUNCTION IF NOT EXISTS f RETURNS STRING SONAME 'udf.so'", dialect); + assertFalse(function.isAggregate()); + assertTrue(function.getLoadableFunction().isIfNotExists()); + assertEquals(ReturnType.STRING, function.getLoadableFunction().getReturnType()); + assertEquals(ReturnType.DECIMAL, + roundTrip("CREATE FUNCTION f RETURNS DECIMAL SONAME 'udf.so'", dialect) + .getLoadableFunction().getReturnType()); + } + } + + @Test + void returnTypeSynonymsKeepTheirSpelling() throws Exception { + for (Dialect dialect : MYSQL) { + for (String keyword : new String[] {"INT", "INT4", "INTEGER", "integer"}) { + LoadableFunction definition = roundTrip( + "CREATE AGGREGATE FUNCTION f RETURNS " + keyword + " SONAME 'udf.so'", + dialect).getLoadableFunction(); + assertEquals(ReturnType.INTEGER, definition.getReturnType()); + assertEquals(keyword, definition.getReturnTypeKeyword()); + } + assertEquals(ReturnType.DECIMAL, + roundTrip("CREATE FUNCTION f RETURNS DEC SONAME 'udf.so'", dialect) + .getLoadableFunction().getReturnType()); + assertEquals(ReturnType.STRING, + roundTrip("CREATE AGGREGATE FUNCTION f RETURNS STRING SONAME 'udf.so'", dialect) + .getLoadableFunction().getReturnType()); + } + } + + @Test + void namesAndLibrarySpellings() throws Exception { + for (Dialect dialect : MYSQL) { + assertEquals("`my f`", + roundTrip("CREATE AGGREGATE FUNCTION `my f` RETURNS INTEGER SONAME 'udf.so'", + dialect).getLoadableFunction().getName()); + assertEquals("aggregate", + roundTrip("CREATE AGGREGATE FUNCTION aggregate RETURNS INTEGER SONAME 'udf.so'", + dialect).getLoadableFunction().getName()); + assertEquals("soname", + roundTrip("CREATE FUNCTION soname RETURNS REAL SONAME 'udf.so'", dialect) + .getLoadableFunction().getName()); + StringValue library = + roundTrip("CREATE AGGREGATE FUNCTION f RETURNS INTEGER SONAME \"udf.so\"", + dialect).getLoadableFunction().getLibrary(); + assertEquals("udf.so", library.getValue()); + assertEquals("\"", library.getQuoteStr()); + } + Statement statement = parse("create aggregate function f returns integer soname 'udf.so'", + Dialect.MYSQL); + assertEquals("CREATE AGGREGATE FUNCTION f RETURNS integer SONAME 'udf.so'", + statement.toString()); + } + + @ParameterizedTest + @ValueSource(strings = { + "CREATE AGGREGATE FUNCTION f RETURNS DOUBLE SONAME 'udf.so'", + "CREATE AGGREGATE FUNCTION f RETURNS FLOAT SONAME 'udf.so'", + "CREATE AGGREGATE FUNCTION f RETURNS VARCHAR(10) SONAME 'udf.so'", + "CREATE AGGREGATE FUNCTION f RETURNS NUMERIC SONAME 'udf.so'", + "CREATE AGGREGATE FUNCTION f RETURNS FIXED SONAME 'udf.so'", + "CREATE AGGREGATE FUNCTION f RETURNS BIGINT SONAME 'udf.so'", + "CREATE AGGREGATE FUNCTION f RETURNS INTEGER(10) SONAME 'udf.so'", + "CREATE AGGREGATE FUNCTION f RETURNS DECIMAL(10,2) SONAME 'udf.so'", + "CREATE AGGREGATE FUNCTION d.f RETURNS INTEGER SONAME 'udf.so'", + "CREATE AGGREGATE FUNCTION f RETURNS INTEGER SONAME udf", + "CREATE AGGREGATE FUNCTION f RETURNS INTEGER SONAME N'udf.so'", + "CREATE AGGREGATE FUNCTION f RETURNS INTEGER SONAME _utf8mb4'udf.so'", + "CREATE AGGREGATE FUNCTION f RETURNS INTEGER SONAME 'udf' '.so'", + "CREATE AGGREGATE FUNCTION f RETURNS INTEGER SONAME 0x7564662e736f", + "CREATE AGGREGATE FUNCTION f RETURNS INTEGER SONAME", + "CREATE AGGREGATE FUNCTION f RETURNS INTEGER", + "CREATE AGGREGATE FUNCTION f SONAME 'udf.so'", + "CREATE AGGREGATE FUNCTION f() RETURNS INTEGER SONAME 'udf.so'", + "CREATE AGGREGATE FUNCTION f (a INT) RETURNS INTEGER RETURN 1", + "CREATE AGGREGATE FUNCTION IF EXISTS f RETURNS INTEGER SONAME 'udf.so'", + "CREATE AGGREGATE FUNCTION f RETURNS INTEGER SONAME 'udf.so' COMMENT 'x'", + "CREATE AGGREGATE FUNCTION f RETURNS INTEGER SONAME 'udf.so', 'b.so'", + "CREATE OR REPLACE AGGREGATE FUNCTION f RETURNS INTEGER SONAME 'udf.so'", + "CREATE AGGREGATE DEFINER = root@localhost FUNCTION f RETURNS INTEGER SONAME 'udf.so'", + "CREATE AGGREGATE PROCEDURE f RETURNS INTEGER SONAME 'udf.so'", + "CREATE AGGREGATE AGGREGATE FUNCTION f RETURNS INTEGER SONAME 'udf.so'", + "CREATE AGGREGATE f RETURNS INTEGER SONAME 'udf.so'", + "CREATE FUNCTION f RETURNS DOUBLE SONAME 'udf.so'", + "CREATE FUNCTION f RETURNS VARCHAR(10) SONAME 'udf.so'", + "CREATE FUNCTION d.f RETURNS INTEGER SONAME 'udf.so'", + "CREATE FUNCTION f RETURNS INTEGER SONAME udf", + "CREATE FUNCTION f RETURNS INTEGER SONAME 'udf.so' COMMENT 'x'", + "CREATE FUNCTION f RETURNS INTEGER", + "CREATE OR REPLACE FUNCTION f RETURNS INTEGER SONAME 'udf.so'", + "CREATE FUNCTION IF NOT EXISTS f RETURNS FLOAT SONAME 'udf.so'"}) + void mySqlRejectsWhatTheServerRejects(String sql) { + assertRejected(sql, Dialect.MYSQL); + } + + @Test + void mariaDbKeepsOrReplaceAndItsOtherAggregateForms() throws Exception { + CreateFunction function = roundTrip( + "CREATE OR REPLACE AGGREGATE FUNCTION IF NOT EXISTS f RETURNS DEC SONAME 'udf.so'", + Dialect.MARIADB); + assertTrue(function.isAggregate()); + function = roundTrip("CREATE OR REPLACE FUNCTION f RETURNS INT SONAME 'udf.so'", null); + assertFalse(function.isAggregate()); + // not a loadable function: today's reading + for (Dialect dialect : new Dialect[] {Dialect.MARIADB, null}) { + assertInstanceOf(UnsupportedStatement.class, + parse("CREATE AGGREGATE FUNCTION f(x INT) RETURNS INT RETURN x", dialect)); + assertInstanceOf(UnsupportedStatement.class, + parse("CREATE AGGREGATE FUNCTION f RETURNS DOUBLE SONAME 'udf.so'", dialect)); + // The PostgreSQL aggregate parser rejects a definition without its signature. + assertRejected("CREATE AGGREGATE f RETURNS INTEGER SONAME 'udf.so'", dialect); + CreateFunction tokens = assertInstanceOf(CreateFunction.class, + parse("CREATE FUNCTION f RETURNS DOUBLE SONAME 'udf.so'", dialect)); + assertNull(tokens.getLoadableFunction()); + assertEquals("CREATE FUNCTION f RETURNS DOUBLE SONAME 'udf.so'", tokens.toString()); + } + } + + @Test + void otherDialectsKeepTheirReading() throws Exception { + for (Dialect dialect : OTHERS) { + String aggregate = "CREATE AGGREGATE FUNCTION f RETURNS INTEGER SONAME 'udf.so'"; + if (dialect == Dialect.POSTGRESQL) { + assertRejected(aggregate, dialect); + } else { + Statement statement = parse(aggregate, dialect); + assertInstanceOf(UnsupportedStatement.class, statement, String.valueOf(dialect)); + assertEquals(aggregate, statement.toString()); + } + String plain = "CREATE FUNCTION f RETURNS STRING SONAME 'udf.so'"; + if (dialect == Dialect.SQLSERVER) { + // a SQL Server function needs its parameter parentheses + assertRejected(plain, dialect); + continue; + } + CreateFunction function = assertInstanceOf(CreateFunction.class, parse(plain, dialect)); + assertNull(function.getLoadableFunction()); + assertFalse(function.isAggregate()); + assertEquals(plain, function.toString()); + assertThrows(UnsupportedOperationException.class, + () -> new TablesNamesFinder<>().getTables((Statement) function)); + } + } + + @Test + void storedFunctionsAreNotLoadableFunctions() throws Exception { + for (Dialect dialect : MYSQL) { + String sql = "CREATE FUNCTION f ( a INT ) RETURNS INT DETERMINISTIC RETURN a + 1"; + CreateFunction function = assertInstanceOf(CreateFunction.class, parse(sql, dialect)); + assertNull(function.getLoadableFunction()); + assertFalse(function.isAggregate()); + assertEquals(sql, function.toString()); + function = assertInstanceOf(CreateFunction.class, parse( + "CREATE DEFINER = root@localhost FUNCTION d.f () RETURNS INT RETURN 1", + dialect)); + assertNull(function.getLoadableFunction()); + assertNotNull(function.getDefiner()); + } + } + + @Test + void statementBoundaries() throws Exception { + for (Dialect dialect : MYSQL) { + String script = "CREATE AGGREGATE FUNCTION f RETURNS INTEGER SONAME 'udf.so';" + + " CREATE FUNCTION g RETURNS STRING SONAME 'udf.so';" + + " DROP FUNCTION IF EXISTS f; SELECT f(a) FROM t"; + Statements statements = dialect == null ? CCJSqlParserUtil.parseStatements(script) + : CCJSqlParserUtil.parseStatements(script, p -> p.withDialect(dialect)); + assertEquals(4, statements.size()); + assertTrue(((CreateFunction) statements.get(0)).isAggregate()); + assertEquals("g", + ((CreateFunction) statements.get(1)).getLoadableFunction().getName()); + assertInstanceOf(Drop.class, statements.get(2)); + assertEquals("CREATE AGGREGATE FUNCTION f RETURNS INTEGER SONAME 'udf.so'", + statements.get(0).toString()); + assertEquals("CREATE AGGREGATE FUNCTION f RETURNS INTEGER SONAME 'udf.so'", + parse("CREATE AGGREGATE FUNCTION f RETURNS INTEGER SONAME 'udf.so';", dialect) + .toString()); + } + } + + @Test + void modelBuildsAndReplacesTheDefinition() { + CreateFunction function = new CreateFunction().withAggregate(true) + .withLoadableFunction(new LoadableFunction("f", ReturnType.DECIMAL, "'udf.so'") + .withIfNotExists(true)); + assertEquals("CREATE AGGREGATE FUNCTION IF NOT EXISTS f RETURNS DECIMAL SONAME 'udf.so'", + function.toString()); + assertEquals("IF NOT EXISTS f RETURNS DECIMAL SONAME 'udf.so'", + function.getLoadableFunction().toString()); + + LoadableFunction definition = function.getLoadableFunction(); + definition.setReturnTypeKeyword("int"); + assertEquals(ReturnType.INTEGER, definition.getReturnType()); + definition.setReturnType(ReturnType.STRING); + assertEquals("STRING", definition.getReturnTypeKeyword()); + definition.setLibrary(new StringValue("'other.so'")); + definition.setName("g"); + definition.setIfNotExists(false); + assertEquals("CREATE AGGREGATE FUNCTION g RETURNS STRING SONAME 'other.so'", + function.toString()); + assertThrows(IllegalArgumentException.class, + () -> definition.setReturnTypeKeyword("DOUBLE")); + assertThrows(NullPointerException.class, () -> definition.setReturnType(null)); + assertNull(new LoadableFunction().getReturnType()); + assertNull(ReturnType.from("FLOAT")); + + // declaration parts are a snapshot; setting parts replaces the definition + List parts = new ArrayList<>(function.getFunctionDeclarationParts()); + function.getFunctionDeclarationParts().clear(); + assertEquals(parts, function.getFunctionDeclarationParts()); + function.addFunctionDeclarationParts("COMMENT", "'x'"); + assertNull(function.getLoadableFunction()); + assertEquals("CREATE AGGREGATE FUNCTION g RETURNS STRING SONAME 'other.so' COMMENT 'x'", + function.toString()); + function.setAggregate(false); + function.setLoadableFunction(definition); + assertEquals("CREATE FUNCTION g RETURNS STRING SONAME 'other.so'", function.toString()); + function.setLoadableFunction(null); + assertNull(function.getFunctionDeclarationParts()); + function.setFunctionDeclarationParts(Arrays.asList("h", "()")); + assertEquals("CREATE FUNCTION h ()", function.toString()); + } + + @Test + void visitorsAndValidation() throws Exception { + String sql = "CREATE AGGREGATE FUNCTION f RETURNS INTEGER SONAME 'udf.so'"; + Statement statement = parse(sql, Dialect.MYSQL); + List visited = new ArrayList<>(); + statement.accept(new StatementVisitorAdapter() { + @Override + public Void visit(CreateFunctionalStatement createFunctionalStatement, S context) { + visited.add(createFunctionalStatement.getKind()); + return null; + } + }, null); + assertEquals(Arrays.asList("FUNCTION"), visited); + assertTrue(Validation.validate( + Arrays.asList(new FeaturesAllowed(Feature.functionalStatement, + Feature.createFunction)), + sql).isEmpty()); + assertFalse(Validation.validate(Arrays.asList(new FeaturesAllowed(Feature.select)), sql) + .isEmpty()); + } +}