From 4b1c8a466d7e7308b7a8a7883e759d360320f7e7 Mon Sep 17 00:00:00 2001 From: minleejae Date: Fri, 13 Nov 2026 10:51:46 +0900 Subject: [PATCH] feat: parse MySQL loadable aggregate functions and the ALTER TABLE validation modifier CREATE [AGGREGATE] FUNCTION [IF NOT EXISTS] name RETURNS type SONAME 'library' is a CreateFunction with a LoadableFunction definition; the AGGREGATE form was an UnsupportedStatement. WITH | WITHOUT VALIDATION is a typed ALTER TABLE action (AlterOperation.VALIDATION) wherever MySQL accepts the modifier in the action list, instead of raw text that swallowed the following actions. With the MySQL dialect both follow the server grammar. MariaDB and no dialect read them only where the statement has exactly that shape; other dialects are unchanged. Signed-off-by: minleejae --- README.md | 1 + .../statement/CreateFunctionalStatement.java | 12 +- .../alter/AlterExpressionTableOption.java | 30 +- .../statement/alter/AlterOperation.java | 2 +- .../create/function/CreateFunction.java | 74 +++- .../create/function/LoadableFunction.java | 150 ++++++++ .../sf/jsqlparser/util/TablesNamesFinder.java | 7 +- .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 142 ++++++- src/site/sphinx/index.rst | 2 + src/site/sphinx/usage.rst | 53 +++ .../MySqlAlterValidationModifierTest.java | 360 ++++++++++++++++++ .../function/MySqlLoadableFunctionTest.java | 358 +++++++++++++++++ 12 files changed, 1173 insertions(+), 18 deletions(-) create mode 100644 src/main/java/net/sf/jsqlparser/statement/create/function/LoadableFunction.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/alter/MySqlAlterValidationModifierTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/create/function/MySqlLoadableFunctionTest.java diff --git a/README.md b/README.md index 9eda48d5b0..e455ff641e 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` | | **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` | | **MySQL account management** | `CREATE`/`ALTER`/`RENAME USER` · `CREATE ROLE` · `SET ROLE` · `SET DEFAULT ROLE` · `SET PASSWORD` | 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 b9fc69a086..fdf02627e2 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -159,6 +159,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; @@ -2611,7 +2612,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 f2ad5ec524..1c9718c7e3 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -3571,6 +3571,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 @@ -24107,6 +24169,7 @@ AlterExpression AlterExpressionPartitionOp(): List partitionDefinition = null; Table exchangeTable = null; TablePartitioning partitioning = null; + AlterExpressionPartition.ExchangeValidationMode validationMode; } { ( @@ -24141,17 +24204,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); @@ -24541,12 +24594,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(); @@ -24558,6 +24622,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() }) @@ -25187,6 +25254,7 @@ Alter AlterTable(boolean foreign): Alter alter = new Alter(); Table table; AlterExpression alterExp; + AlterExpression previous; boolean usingIfExists = false; } { @@ -25206,7 +25274,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); } @@ -26873,6 +26946,8 @@ Statement Create(): | LOOKAHEAD({ isCreateFunctionMacroAhead() }) statement = CreateMacro(isUsingOrReplace) | + LOOKAHEAD({ isLoadableFunctionAhead() }) statement = CreateLoadableFunction(isUsingOrReplace) + | LOOKAHEAD([ MySqlRoutineDefiner() ] ( | )) statement = CreateFunctionStatement(isUsingOrReplace) | @@ -26908,6 +26983,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 b03b08dbb6..2c2835a54c 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`` * - **PostgreSQL session statements** - ``DISCARD`` · ``LISTEN`` · ``UNLISTEN`` · ``SHOW``/``RESET TIME ZONE``/``TRANSACTION ISOLATION LEVEL``/``SESSION AUTHORIZATION`` · ``COPY`` options from before 9.0 * - **MySQL server administration** diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index b6de3a5d75..031a0666e0 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -3156,6 +3156,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/function/MySqlLoadableFunctionTest.java b/src/test/java/net/sf/jsqlparser/statement/create/function/MySqlLoadableFunctionTest.java new file mode 100644 index 0000000000..5f04f58709 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/function/MySqlLoadableFunctionTest.java @@ -0,0 +1,358 @@ +/*- + * #%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)); + assertInstanceOf(UnsupportedStatement.class, + parse("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'"; + 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()); + } +}