diff --git a/src/main/java/net/sf/jsqlparser/statement/create/index/AlgorithmOrLockOption.java b/src/main/java/net/sf/jsqlparser/statement/create/index/AlgorithmOrLockOption.java new file mode 100644 index 000000000..64bc22aef --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/index/AlgorithmOrLockOption.java @@ -0,0 +1,85 @@ +/*- + * #%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.index; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * MySQL's {@code ALGORITHM [=] value} or {@code LOCK [=] value} clause of CREATE INDEX and DROP + * INDEX. The value keeps its SQL spelling, such as INPLACE, COPY, NONE or SHARED. + */ +public class AlgorithmOrLockOption implements Serializable { + + public enum Kind { + ALGORITHM, LOCK + } + + private Kind kind; + private String value; + private boolean useEquals; + + public AlgorithmOrLockOption(Kind kind, String value, boolean useEquals) { + this.kind = Objects.requireNonNull(kind, "kind"); + this.value = Objects.requireNonNull(value, "value"); + this.useEquals = useEquals; + } + + public static AlgorithmOrLockOption algorithm(String value) { + return new AlgorithmOrLockOption(Kind.ALGORITHM, value, true); + } + + public static AlgorithmOrLockOption lock(String value) { + return new AlgorithmOrLockOption(Kind.LOCK, value, true); + } + + public Kind getKind() { + return kind; + } + + public void setKind(Kind kind) { + this.kind = Objects.requireNonNull(kind, "kind"); + } + + public String getValue() { + return value; + } + + public void setValue(String value) { + this.value = Objects.requireNonNull(value, "value"); + } + + public boolean isUseEquals() { + return useEquals; + } + + public void setUseEquals(boolean useEquals) { + this.useEquals = useEquals; + } + + /** Returns an unmodifiable token snapshot, such as {@code [ALGORITHM, =, INPLACE]}. */ + public List getTokens() { + List tokens = new ArrayList<>(); + tokens.add(kind.name()); + if (useEquals) { + tokens.add("="); + } + tokens.add(value); + return Collections.unmodifiableList(tokens); + } + + @Override + public String toString() { + return String.join(" ", getTokens()); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/create/index/CreateIndex.java b/src/main/java/net/sf/jsqlparser/statement/create/index/CreateIndex.java index b2d277611..9e5739ae5 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/index/CreateIndex.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/index/CreateIndex.java @@ -23,7 +23,7 @@ public class CreateIndex implements Statement { private Table table; private Index index; private IndexOptions detachedOptions; - private List tailParameters; + private final List algorithmOrLockOptions = new ArrayList<>(); private boolean indexTypeBeforeOn = false; private boolean usingIfNotExists = false; private boolean concurrently; @@ -170,6 +170,7 @@ public void setIndex(Index index) { detachedOptions.setNullsDistinct(previousOptions.getNullsDistinct()); detachedOptions.setStorageParameters(previousOptions.getStorageParameters()); detachedOptions.setTableSpace(previousOptions.getTableSpace()); + detachedOptions.setSpecOptions(previousOptions.getSpecOptions()); } } else { if (previousOptions != null && previousOptions != index.getIndexOptions()) { @@ -185,6 +186,9 @@ public void setIndex(Index index) { if (index.getTableSpace() == null) { index.setTableSpace(previousOptions.getTableSpace()); } + if (index.getSpecOptions() == null) { + index.setSpecOptions(previousOptions.getSpecOptions()); + } } detachedOptions = null; } @@ -199,12 +203,56 @@ public void setTable(Table table) { this.table = table; } + /** + * Returns the mutable MySQL ALGORITHM and LOCK clauses in source order. They are rendered after + * the index options, as MySQL requires. + */ + public List getAlgorithmOrLockOptions() { + return algorithmOrLockOptions; + } + + /** Copies the list membership and retains the option nodes; null clears the clauses. */ + public void setAlgorithmOrLockOptions(List options) { + algorithmOrLockOptions.clear(); + if (options != null) { + algorithmOrLockOptions.addAll(options); + } + } + + public CreateIndex withAlgorithmOrLockOptions(List options) { + setAlgorithmOrLockOptions(options); + return this; + } + + public CreateIndex addAlgorithmOrLockOptions(AlgorithmOrLockOption... options) { + Collections.addAll(algorithmOrLockOptions, options); + return this; + } + + /** + * Returns an unmodifiable token snapshot of the trailing clauses: the index options after the + * key list ({@link Index#getSpecOptions()}), followed by {@link #getAlgorithmOrLockOptions()}. + * Edit those nodes instead of the snapshot. + */ public List getTailParameters() { - return tailParameters; + List tokens = new ArrayList<>(); + IndexOptions options = getOptions(); + if (options != null && options.getIndexSpec() != null) { + tokens.addAll(options.getIndexSpec()); + } + for (AlgorithmOrLockOption option : algorithmOrLockOptions) { + tokens.addAll(option.getTokens()); + } + return Collections.unmodifiableList(tokens); } + /** + * Replaces the index options after the key list and the ALGORITHM and LOCK clauses with one + * opaque option holding a copy of the tokens, without parsing them. Null clears them. + */ public void setTailParameters(List tailParameters) { - this.tailParameters = tailParameters; + algorithmOrLockOptions.clear(); + getOrCreateOptions().setIndexSpec(tailParameters); } @Override @@ -223,11 +271,14 @@ public StringBuilder appendTo(StringBuilder buffer, Consumer express appendIndexTarget(buffer); appendIndexColumns(buffer, expressionPrinter); appendPostgreSqlTail(buffer, expressionPrinter); - if (tailParameters != null) { - for (String param : tailParameters) { - buffer.append(" ").append(param); + if (index.getSpecOptions() != null) { + for (IndexSpecOption option : index.getSpecOptions()) { + buffer.append(" ").append(option); } } + for (AlgorithmOrLockOption option : algorithmOrLockOptions) { + buffer.append(" ").append(option); + } return buffer; } diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/Index.java b/src/main/java/net/sf/jsqlparser/statement/create/table/Index.java index f0b1a1dea..9c809447f 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/Index.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/Index.java @@ -183,6 +183,11 @@ public Index withIndexSpec(List value) { return this; } + public Index withSpecOptions(List value) { + setSpecOptions(value); + return this; + } + public Index withClustering(IndexOptions.Clustering value) { setClustering(value); return this; diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/IndexOptionSource.java b/src/main/java/net/sf/jsqlparser/statement/create/table/IndexOptionSource.java index 99037db13..4c016d32a 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/IndexOptionSource.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/IndexOptionSource.java @@ -33,6 +33,14 @@ default void setIndexSpec(List value) { getIndexOptions().setIndexSpec(value); } + default List getSpecOptions() { + return getIndexOptions().getSpecOptions(); + } + + default void setSpecOptions(List value) { + getIndexOptions().setSpecOptions(value); + } + default String getCommentText() { return getIndexOptions().getCommentText(); } diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/IndexOptions.java b/src/main/java/net/sf/jsqlparser/statement/create/table/IndexOptions.java index 2e6cc0788..f6050b4bc 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/IndexOptions.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/IndexOptions.java @@ -11,6 +11,9 @@ import java.io.Serializable; import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.function.Consumer; import net.sf.jsqlparser.expression.Expression; @@ -23,7 +26,7 @@ public enum Clustering { } private String using; - private List indexSpec; + private List specOptions; private String commentText; private Clustering clustering; private Boolean nullsDistinct; @@ -39,12 +42,60 @@ public void setUsing(String using) { this.using = using; } + /** + * Returns the mutable, ordered options written after the key list, or null when unset. An index + * type written before the key list is {@link #getUsing()} instead. + */ + public List getSpecOptions() { + return specOptions; + } + + /** Copies the list membership and retains the option nodes; null clears the options. */ + public void setSpecOptions(List specOptions) { + this.specOptions = specOptions == null ? null : new ArrayList<>(specOptions); + } + + public IndexOptions addSpecOptions(IndexSpecOption... options) { + return addSpecOptions(Arrays.asList(options)); + } + + public IndexOptions addSpecOptions(Collection options) { + if (specOptions == null) { + specOptions = new ArrayList<>(); + } + specOptions.addAll(options); + return this; + } + + /** + * Returns an unmodifiable token snapshot of {@link #getSpecOptions()}, such as + * {@code [KEY_BLOCK_SIZE, =, 8, COMMENT, 'text']}, or null when unset. Edit the option nodes + * instead of the snapshot. + */ public List getIndexSpec() { - return indexSpec; + if (specOptions == null) { + return null; + } + List tokens = new ArrayList<>(); + for (IndexSpecOption option : specOptions) { + tokens.addAll(option.getTokens()); + } + return Collections.unmodifiableList(tokens); } + /** + * Replaces all options after the key list with one opaque option holding a copy of the tokens, + * without parsing them. Null clears the options; an empty list stores no options. + */ public void setIndexSpec(List indexSpec) { - this.indexSpec = indexSpec; + if (indexSpec == null) { + specOptions = null; + return; + } + specOptions = new ArrayList<>(); + if (!indexSpec.isEmpty()) { + specOptions.add(IndexSpecOption.raw(indexSpec)); + } } public String getCommentText() { diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/IndexSpecOption.java b/src/main/java/net/sf/jsqlparser/statement/create/table/IndexSpecOption.java new file mode 100644 index 000000000..9ff6fa655 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/IndexSpecOption.java @@ -0,0 +1,200 @@ +/*- + * #%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.table; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import net.sf.jsqlparser.expression.StringValue; + +/** + * An option written after the key list of an index or key definition. MySQL's index options + * (KEY_BLOCK_SIZE, USING index type, WITH PARSER, COMMENT, VISIBLE/INVISIBLE, ENGINE_ATTRIBUTE and + * SECONDARY_ENGINE_ATTRIBUTE) are typed; other accepted tokens are retained as opaque + * {@link Kind#OTHER} options. The kind is fixed; replace the node to change it. + */ +public class IndexSpecOption implements Serializable { + + public enum Kind { + KEY_BLOCK_SIZE, INDEX_TYPE, PARSER, COMMENT, VISIBILITY, ENGINE_ATTRIBUTE, SECONDARY_ENGINE_ATTRIBUTE, OTHER + } + + private final Kind kind; + private String value; + private StringValue comment; + private boolean useEquals; + private boolean visible; + private List tokens; + + private IndexSpecOption(Kind kind) { + this.kind = kind; + } + + /** + * Creates a valued option. {@code value} keeps its SQL spelling: a number for KEY_BLOCK_SIZE, + * an index type or parser name, or a quoted string literal for COMMENT and the engine + * attributes. + */ + public static IndexSpecOption of(Kind kind, String value) { + return of(kind, value, false); + } + + /** Like {@link #of(Kind, String)}; {@code useEquals} requires a kind that accepts '='. */ + public static IndexSpecOption of(Kind kind, String value, boolean useEquals) { + IndexSpecOption option = new IndexSpecOption(Objects.requireNonNull(kind, "kind")); + option.setValue(value); + option.setUseEquals(useEquals); + return option; + } + + /** + * Creates a COMMENT option. The literal keeps its quotes and escaping, so + * {@link StringValue#getNotExcapedValue()} returns the stored comment text. + */ + public static IndexSpecOption comment(StringValue comment) { + IndexSpecOption option = new IndexSpecOption(Kind.COMMENT); + option.setComment(comment); + return option; + } + + public static IndexSpecOption visibility(boolean visible) { + IndexSpecOption option = new IndexSpecOption(Kind.VISIBILITY); + option.visible = visible; + return option; + } + + /** Creates an opaque option from a copy of {@code tokens}; the tokens are never parsed. */ + public static IndexSpecOption raw(List tokens) { + IndexSpecOption option = new IndexSpecOption(Kind.OTHER); + option.setTokens(tokens); + return option; + } + + public static IndexSpecOption raw(String... tokens) { + return raw(Arrays.asList(tokens)); + } + + public Kind getKind() { + return kind; + } + + /** + * The SQL spelling of the option value, or null for VISIBILITY and opaque options. For COMMENT, + * this is the literal's SQL text, such as {@code 'it\'s'}; use {@link #getComment()} for its + * decoded text. + */ + public String getValue() { + return kind == Kind.COMMENT ? comment.toString() : value; + } + + /** + * Replaces the value with its SQL spelling. A COMMENT value is read as a string literal and + * keeps the escaping of the literal it replaces. + */ + public void setValue(String value) { + if (kind == Kind.VISIBILITY || kind == Kind.OTHER) { + throw new IllegalStateException(kind + " options have no value"); + } + Objects.requireNonNull(value, "value"); + if (kind == Kind.COMMENT) { + setComment(new StringValue(value) + .withEscaping(comment == null ? null : comment.getEscaping())); + } else { + this.value = value; + } + } + + /** The literal of a COMMENT option, otherwise null. */ + public StringValue getComment() { + return comment; + } + + public void setComment(StringValue comment) { + if (kind != Kind.COMMENT) { + throw new IllegalStateException(kind + " options have no comment"); + } + this.comment = Objects.requireNonNull(comment, "comment"); + } + + public boolean isUseEquals() { + return useEquals; + } + + /** + * Selects the optional '=' of KEY_BLOCK_SIZE, ENGINE_ATTRIBUTE and SECONDARY_ENGINE_ATTRIBUTE. + */ + public void setUseEquals(boolean useEquals) { + if (useEquals && kind != Kind.KEY_BLOCK_SIZE && kind != Kind.ENGINE_ATTRIBUTE + && kind != Kind.SECONDARY_ENGINE_ATTRIBUTE) { + throw new IllegalStateException(kind + " options do not use '='"); + } + this.useEquals = useEquals; + } + + /** VISIBLE (true) or INVISIBLE (false) for a VISIBILITY option, otherwise null. */ + public Boolean getVisible() { + return kind == Kind.VISIBILITY ? visible : null; + } + + public void setVisible(boolean visible) { + if (kind != Kind.VISIBILITY) { + throw new IllegalStateException(kind + " options have no visibility"); + } + this.visible = visible; + } + + /** + * Returns an unmodifiable, detached token snapshot: the keywords, an optional '=' and the value + * of a typed option, or a copy of an opaque option's tokens. + */ + public List getTokens() { + if (kind == Kind.OTHER) { + return Collections.unmodifiableList(new ArrayList<>(tokens)); + } + List result = new ArrayList<>(Arrays.asList(keyword().split(" "))); + if (useEquals) { + result.add("="); + } + if (getValue() != null) { + result.add(getValue()); + } + return Collections.unmodifiableList(result); + } + + /** Replaces the tokens of an opaque option with a copy; typed options reject raw tokens. */ + public void setTokens(List tokens) { + if (kind != Kind.OTHER) { + throw new IllegalStateException( + "Typed " + kind + " options cannot be replaced with raw tokens"); + } + this.tokens = new ArrayList<>(Objects.requireNonNull(tokens, "tokens")); + } + + private String keyword() { + switch (kind) { + case INDEX_TYPE: + return "USING"; + case PARSER: + return "WITH PARSER"; + case VISIBILITY: + return visible ? "VISIBLE" : "INVISIBLE"; + default: + return kind.name(); + } + } + + @Override + public String toString() { + return String.join(" ", getTokens()); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/create/table/KeyConstraint.java b/src/main/java/net/sf/jsqlparser/statement/create/table/KeyConstraint.java index e48b01434..979fe4476 100644 --- a/src/main/java/net/sf/jsqlparser/statement/create/table/KeyConstraint.java +++ b/src/main/java/net/sf/jsqlparser/statement/create/table/KeyConstraint.java @@ -113,6 +113,11 @@ public KeyConstraint withIndexSpec(List value) { return this; } + public KeyConstraint withSpecOptions(List value) { + setSpecOptions(value); + return this; + } + public KeyConstraint withClustering(IndexOptions.Clustering value) { setClustering(value); return this; diff --git a/src/main/java/net/sf/jsqlparser/statement/drop/Drop.java b/src/main/java/net/sf/jsqlparser/statement/drop/Drop.java index d6dec72f0..57b0fdb4f 100644 --- a/src/main/java/net/sf/jsqlparser/statement/drop/Drop.java +++ b/src/main/java/net/sf/jsqlparser/statement/drop/Drop.java @@ -24,6 +24,7 @@ import net.sf.jsqlparser.statement.RoutineReference; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.StatementVisitor; +import net.sf.jsqlparser.statement.create.index.AlgorithmOrLockOption; import net.sf.jsqlparser.statement.create.user.UserAccount; import net.sf.jsqlparser.statement.select.PlainSelect; @@ -56,6 +57,8 @@ public boolean hasOwnerTable() { private List parameters; private Table table; private int tablePosition; + private final List algorithmOrLockOptions = new ArrayList<>(); + private Integer algorithmOrLockPosition; private CascadeBehavior behavior; private Map> typeToParameters = new HashMap<>(); private boolean ifExists = false; @@ -242,19 +245,16 @@ private static Table qualifiedTable(String name) { } /** - * Returns the legacy tokens after the targets. With a structured ON table or CASCADE/RESTRICT - * behavior, this is a snapshot that includes them; edit {@link #setTable} or - * {@link #setBehavior} instead. + * Returns the legacy tokens after the targets. With a structured ON table, ALGORITHM or LOCK + * clauses or CASCADE/RESTRICT behavior, this is a snapshot that includes them in source order; + * edit {@link #setTable}, {@link #getAlgorithmOrLockOptions()} or {@link #setBehavior} instead. */ public List getParameters() { - if (table == null && behavior == null) { + if (table == null && algorithmOrLockOptions.isEmpty() && behavior == null) { return parameters; } - List tokens = parameters == null ? new ArrayList<>() : new ArrayList<>(parameters); - if (table != null) { - tokens.add(tablePosition, "ON"); - tokens.add(tablePosition + 1, table.toString()); - } + List tokens = new ArrayList<>(); + appendParameters(tokens::add, table -> tokens.add(table.toString())); if (behavior != null) { tokens.add(behavior.name()); } @@ -299,13 +299,15 @@ public void visitTables(Consumer visitor) { } /** - * Replaces all legacy parameters and clears the structured ON table and CASCADE/RESTRICT - * behavior. + * Replaces all legacy parameters and clears the structured ON table, the ALGORITHM and LOCK + * clauses and the CASCADE/RESTRICT behavior. */ public void setParameters(List list) { parameters = list; table = null; tablePosition = 0; + algorithmOrLockOptions.clear(); + algorithmOrLockPosition = null; behavior = null; } @@ -490,7 +492,10 @@ public StringBuilder appendTo(StringBuilder builder, Consumer
tablePrinte if (engine != null) { builder.append(engineUseEquals ? " ENGINE = " : " ENGINE ").append(engine); } - appendParameters(builder, tablePrinter); + appendParameters(token -> builder.append(' ').append(token), table -> { + builder.append(' '); + tablePrinter.accept(table); + }); if (behavior != null) { builder.append(' ').append(behavior); } @@ -526,15 +531,21 @@ private void appendNames(StringBuilder builder, Consumer
tablePrinter) { } } - private void appendParameters(StringBuilder builder, Consumer
tablePrinter) { + /** Emits the parameter tokens with ON and the ALGORITHM/LOCK clauses at their positions. */ + private void appendParameters(Consumer tokens, Consumer
onTable) { int size = parameters == null ? 0 : parameters.size(); + int optionPosition = algorithmOrLockPosition == null ? size + : Math.min(algorithmOrLockPosition, size); for (int i = 0; i <= size; i++) { if (table != null && i == tablePosition) { - builder.append(" ON "); - tablePrinter.accept(table); + tokens.accept("ON"); + onTable.accept(table); + } + if (i == optionPosition) { + algorithmOrLockOptions.forEach(option -> option.getTokens().forEach(tokens)); } if (i < size) { - builder.append(' ').append(parameters.get(i)); + tokens.accept(parameters.get(i)); } } } @@ -596,4 +607,46 @@ public Drop addParameters(Collection parameters) { this.parameters.addAll(parameters); return this; } + + /** + * Returns the mutable MySQL ALGORITHM and LOCK clauses of DROP INDEX in source order. They + * follow the parameter tokens unless positioned with + * {@link #setAlgorithmOrLockOptions(List, int)}. + */ + public List getAlgorithmOrLockOptions() { + return algorithmOrLockOptions; + } + + /** Copies the list membership and places the clauses after all parameter tokens. */ + public void setAlgorithmOrLockOptions(List options) { + setAlgorithmOrLockOptions(options, -1); + } + + /** + * Copies the list membership and places the clauses before the parameter token at + * {@code parameterPosition}; -1 places them after all parameter tokens. + */ + public void setAlgorithmOrLockOptions(List options, + int parameterPosition) { + int size = parameters == null ? 0 : parameters.size(); + if (parameterPosition < -1 || parameterPosition > size) { + throw new IllegalArgumentException( + "Invalid ALGORITHM/LOCK position: " + parameterPosition); + } + algorithmOrLockOptions.clear(); + if (options != null) { + algorithmOrLockOptions.addAll(options); + } + algorithmOrLockPosition = parameterPosition < 0 ? null : parameterPosition; + } + + public Drop withAlgorithmOrLockOptions(List options) { + setAlgorithmOrLockOptions(options); + return this; + } + + public Drop addAlgorithmOrLockOptions(AlgorithmOrLockOption... options) { + Collections.addAll(algorithmOrLockOptions, options); + return this; + } } diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index f49b3ddba..6cd97c7d9 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -2556,13 +2556,13 @@ public class CCJSqlParser extends AbstractJSqlParser { // Preserve generic ALTER's historical placement of USING and COMMENT options. if (!Dialect.MYSQL.name().equals(getAsString(Feature.dialect)) && !Dialect.POSTGRESQL.name().equals(getAsString(Feature.dialect))) { - for (String option : new ArrayList(key.getIndexSpec())) { - if (option.toUpperCase(Locale.ROOT).startsWith("USING ")) { - alterExp.addParameters("USING", option.substring("USING ".length())); - key.getIndexSpec().remove(option); - } else if (option.toUpperCase(Locale.ROOT).startsWith("COMMENT ")) { - key.setCommentText(option.substring("COMMENT ".length())); - key.getIndexSpec().remove(option); + for (IndexSpecOption option : new ArrayList(key.getSpecOptions())) { + if (option.getKind() == IndexSpecOption.Kind.INDEX_TYPE) { + alterExp.addParameters("USING", option.getValue()); + key.getSpecOptions().remove(option); + } else if (option.getKind() == IndexSpecOption.Kind.COMMENT) { + key.setCommentText(option.getValue()); + key.getSpecOptions().remove(option); } } } @@ -15264,8 +15264,6 @@ CreateIndex CreateIndex(): Expression where = null; String indexType; Index index; - List parameter = new ArrayList(); - List tailParameters = new ArrayList(); ObjectNames name; } { @@ -15310,45 +15308,54 @@ CreateIndex CreateIndex(): { createIndex.setTableSpace(tableSpace); } ] [ LOOKAHEAD(2) where=Expression() { createIndex.setWhere(where); } ] ( LOOKAHEAD(2, { getToken(1).kind != K_LOCK || getToken(2).kind != K_TABLE }) - parameter=CreateIndexTailOption() { tailParameters.addAll(parameter); } )* + CreateIndexTailOption(createIndex, index) )* { index.setColumns(colNames); createIndex.setIndex(index); createIndex.setTable(table); - createIndex.setTailParameters(tailParameters); return createIndex; } } -List CreateIndexTailOption(): +/** + * Index options shared with table key definitions are kept on the index, ALGORITHM and LOCK on + * the statement. Other trailing tokens remain opaque index options. + */ +void CreateIndexTailOption(CreateIndex statement, Index index): { - List option; + AlgorithmOrLockOption algorithmOrLock; + IndexSpecOption option; + List parameter; } { ( LOOKAHEAD({ getToken(1).kind == K_ALGORITHM || getToken(1).kind == K_LOCK }) - option=IndexAlgorithmOrLockOption() + algorithmOrLock=IndexAlgorithmOrLockOption() + { statement.addAlgorithmOrLockOptions(algorithmOrLock); } | - option=CreateParameter() + LOOKAHEAD(IndexSpecOption(), { !(getToken(1).kind == K_USING && getToken(2).kind == K_INDEX) }) + option=IndexSpecOption() { index.getIndexOptions().addSpecOptions(option); } + | + parameter=CreateParameter() + { index.getIndexOptions().addSpecOptions(IndexSpecOption.raw(parameter)); } ) - { return option; } } // CREATE INDEX and DROP INDEX share MySQL's algorithm_option / lock_option syntax. -// Keep the legacy token list, including optional equals signs and the original order. -List IndexAlgorithmOrLockOption(): +AlgorithmOrLockOption IndexAlgorithmOrLockOption(): { - List option = new ArrayList(); - Token keyword; + AlgorithmOrLockOption.Kind kind; + boolean useEquals = false; Token value; } { - ( keyword= | keyword= ) { option.add(keyword.image); } - [ "=" { option.add("="); } ] + ( { kind = AlgorithmOrLockOption.Kind.ALGORITHM; } + | { kind = AlgorithmOrLockOption.Kind.LOCK; } ) + [ "=" { useEquals = true; } ] // COPY is one of MySQL's ALGORITHM values and also starts the COPY statement ( value= | value= | value= | value= | value= ) - { option.add(value.image); return option; } + { return new AlgorithmOrLockOption(kind, value.image, useEquals); } } List ColumnDefinitionParameter(): { @@ -16287,7 +16294,7 @@ TableElement TableIndexSpec(boolean createContext): String indexName = null; String using = null; List columns; - List indexOptions = new ArrayList(); + List indexOptions = new ArrayList(); TableElement index; Boolean nullsDistinct = null; IndexOptions.Clustering clustering = null; @@ -16296,15 +16303,19 @@ TableElement TableIndexSpec(boolean createContext): ( typeToken= keywordToken= clustering=SqlServerIndexClustering() - [ LOOKAHEAD({ clustering == null && getToken(1).kind != OPENING_BRACKET }) indexName=RelObjectName() ] + [ LOOKAHEAD({ clustering == null && getToken(1).kind != OPENING_BRACKET + && getToken(1).kind != K_USING }) indexName=RelObjectName() ] + // MySQL's index_type may also precede the key list: PRIMARY KEY USING BTREE (id) + [ LOOKAHEAD({ getToken(1).kind == K_USING && getToken(2).kind != K_INDEX + && getToken(2).kind != K_COLUMNS }) using=UsingIndexType() ] columns=IndexKeyColumns(true) TableIndexOptions(createContext, indexOptions) { index = new KeyConstraint() .withIndexName(indexName) .withType(typeToken.image + " " + keywordToken.image) - .withColumns(columns) - .withIndexSpec(indexOptions); + .withUsing(using).withColumns(columns) + .withSpecOptions(indexOptions); } | typeToken= @@ -16326,12 +16337,12 @@ TableElement TableIndexSpec(boolean createContext): .withIndexName(indexName) .withType(typeToken.image + (keywordToken != null ? " " + keywordToken.image : "")) - .withUsing(using).withColumns(columns).withIndexSpec(indexOptions); + .withUsing(using).withColumns(columns).withSpecOptions(indexOptions); } else { index = new KeyConstraint().withType(typeToken.image) .withIndexKeyword(keywordToken != null ? keywordToken.image : null) .withIndexName(indexName).withUsing(using).withColumns(columns) - .withIndexSpec(indexOptions); + .withSpecOptions(indexOptions); } } | @@ -16345,7 +16356,7 @@ TableElement TableIndexSpec(boolean createContext): index = new Index().withType(createContext ? typeToken.image : null) .withIndexKeyword(typeToken.image).withKind(Index.Kind.INDEX) .withName(indexName).withUsing(using).withColumns(columns) - .withIndexSpec(indexOptions); + .withSpecOptions(indexOptions); } | typeToken= @@ -16358,7 +16369,7 @@ TableElement TableIndexSpec(boolean createContext): index = new Index().withType(createContext ? typeToken.image : null) .withIndexKeyword(typeToken.image).withKind(Index.Kind.INDEX) .withName(indexName).withUsing(using).withColumns(columns) - .withIndexSpec(indexOptions); + .withSpecOptions(indexOptions); } | (typeToken= | typeToken=) @@ -16372,7 +16383,7 @@ TableElement TableIndexSpec(boolean createContext): ? " " + keywordToken.image : "")) .withIndexKeyword(!createContext && keywordToken != null ? keywordToken.image : null) - .withName(indexName).withColumns(columns).withIndexSpec(indexOptions); + .withName(indexName).withColumns(columns).withSpecOptions(indexOptions); } ) { ((IndexOptionSource) index).setNullsDistinct(nullsDistinct); @@ -16381,8 +16392,13 @@ TableElement TableIndexSpec(boolean createContext): { return index; } } -void TableIndexOptions(boolean createContext, List options): +/** + * Index options after the key list, shared by CREATE TABLE and ALTER TABLE key definitions. + * CREATE TABLE also retains other accepted tokens as opaque options. + */ +void TableIndexOptions(boolean createContext, List options): { + IndexSpecOption option; List parameter; } { @@ -16393,9 +16409,18 @@ void TableIndexOptions(boolean createContext, List options): && getToken(1).kind != K_DEFERRABLE && !isKeywordAhead("INITIALLY") && !(getToken(1).kind == K_NOT && (getToken(2).kind == K_DEFERRABLE || "VALID".equalsIgnoreCase(getToken(2).image))) }) - parameter=CreateParameter() { options.addAll(parameter); } )* + ( + LOOKAHEAD(IndexSpecOption()) option=IndexSpecOption() { options.add(option); } + | + parameter=CreateParameter() { options.add(IndexSpecOption.raw(parameter)); } + ) + )* | - LOOKAHEAD({ !createContext }) IndexOptionList(options) + LOOKAHEAD({ !createContext }) + ( + LOOKAHEAD(IndexSpecOption(), { !(getToken(1).kind == K_USING && getToken(2).kind == K_INDEX) }) + option=IndexSpecOption() { options.add(option); } + )* ) } @@ -18217,7 +18242,9 @@ Drop Drop(): Table indexTable = null; int indexTablePosition = 0; List dropArgs = new ArrayList(); - List indexOption; + AlgorithmOrLockOption indexOption; + List indexOptions = new ArrayList(); + int indexOptionPosition = 0; boolean useTemporary = false; boolean foreign = false; boolean eventTrigger = false; @@ -18296,16 +18323,19 @@ Drop Drop(): [ "=" { drop.setEngineUseEquals(true); } ] engine=RelObjectName() { drop.setEngine(engine); } ] ( + // MySQL DROP INDEX accepts a trailing algorithm_option / lock_option, e.g. + // "DROP INDEX i ON t ALGORITHM = INPLACE LOCK = NONE". LOCK also starts a LOCK TABLE + // statement, so it is only taken here when it cannot begin the next statement. LOOKAHEAD({ getToken(1).kind == K_ALGORITHM || (getToken(1).kind == K_LOCK && getToken(2).kind != K_TABLE) }) - indexOption=IndexAlgorithmOrLockOption() { dropArgs.addAll(indexOption); } + indexOption=IndexAlgorithmOrLockOption() { + if (indexOptions.isEmpty()) { + indexOptionPosition = dropArgs.size(); + } + indexOptions.add(indexOption); + } | - ( - tk= - // MySQL DROP INDEX accepts a trailing algorithm_option / lock_option, - // e.g. "DROP INDEX i ON t ALGORITHM = INPLACE LOCK = NONE". - | tk= | tk= | tk="=" - ) { dropArgs.add(tk.image); } + tk= { dropArgs.add(tk.image); } | ( name = DropObjectName() { @@ -18318,11 +18348,6 @@ Drop Drop(): } } ) - | - // The lock_option of DROP INDEX. LOCK also starts a LOCK TABLE statement, so it is only - // taken as a DROP argument when it cannot be the beginning of the next statement. - LOOKAHEAD({ getToken(1).kind == K_LOCK && getToken(2).kind != K_TABLE }) - tk= { dropArgs.add(tk.image); } )* [ { behavior = CascadeBehavior.CASCADE; } | { behavior = CascadeBehavior.RESTRICT; } ] { @@ -18332,6 +18357,9 @@ Drop Drop(): if (indexTable != null) { drop.setTable(indexTable, indexTablePosition); } + if (!indexOptions.isEmpty()) { + drop.setAlgorithmOrLockOptions(indexOptions, indexOptionPosition); + } drop.setBehavior(behavior); drop.setUsingTemporary(useTemporary); @@ -18647,15 +18675,6 @@ void IndexWithComment(IndexOptionSource index): } } -void IndexOptionList(List list) : -{} -{ - ( - LOOKAHEAD(2, { !(getToken(1).kind == K_USING && getToken(2).kind == K_INDEX) }) - IndexOption(list) - )* -} - String UsingIndexType() : { String sk = null; @@ -18667,48 +18686,35 @@ String UsingIndexType() : } } -void IndexOption(List list) : +/** MySQL's index_option, shared by CREATE INDEX and the key definitions of CREATE/ALTER TABLE. */ +IndexSpecOption IndexSpecOption(): { - Token tk1 = null; - Token tk2 = null; - String sk1 = null; - boolean useEqual = false; - TableOption attribute; + Token token; + String value; + TableOption scalar; + IndexSpecOption option; } { ( - LOOKAHEAD({ isMySqlEngineAttributeAhead() }) attribute=MySqlScalarTableOption() - { list.addAll(attribute.getTokens()); } - | - tk1= ["=" { useEqual = true; } ] tk2= - { - list.add("KEY_BLOCK_SIZE" + (useEqual ? " = " : "") + tk2.image); - } - | - tk1= tk2= - { - list.add("WITH PARSER " + tk2.image); - } - | - tk1= tk2= - { - list.add("COMMENT " + tk2.image); - } - | - tk1= - { - list.add("VISIBLE"); - } - | - tk1= - { - list.add("INVISIBLE"); - } - | - sk1 = UsingIndexType(){ - list.add("USING " + sk1); - } + // KEY_BLOCK_SIZE and the engine attributes share the scalar syntax of table options. + LOOKAHEAD({ getToken(1).kind == K_KEY_BLOCK_SIZE || isMySqlEngineAttributeAhead() }) + scalar=MySqlScalarTableOption() { + option = IndexSpecOption.of(IndexSpecOption.Kind.valueOf(scalar.getKind().name()), + scalar.getValue(), scalar.isUseEquals()); + } + | + value=UsingIndexType() { option = IndexSpecOption.of(IndexSpecOption.Kind.INDEX_TYPE, value); } + | + value=RelObjectName() + { option = IndexSpecOption.of(IndexSpecOption.Kind.PARSER, value); } + | + token= { option = IndexSpecOption.comment(stringValue(token)); } + | + { option = IndexSpecOption.visibility(true); } + | + { option = IndexSpecOption.visibility(false); } ) + { return option; } } TablePartitioning CreateTablePartitioning(): @@ -19201,6 +19207,7 @@ void AlterExpressionAddConstraint(AlterExpression alterExp): List constraints = null; CheckConstraint checkCs = null; IndexOptions.Clustering clustering = null; + String using = null; } { @@ -19230,12 +19237,14 @@ void AlterExpressionAddConstraint(AlterExpression alterExp): | ( tk= tk2= clustering=SqlServerIndexClustering() + [ LOOKAHEAD({ getToken(1).kind == K_USING && getToken(2).kind != K_INDEX }) + using=UsingIndexType() ] columnNames=ColumnsNamesList() { index = new KeyConstraint() .withName(sk3) .withType(tk.image + " " + tk2.image) - .withClustering(clustering) + .withClustering(clustering).withUsing(using) .withColumnsNames(columnNames); alterExp.setConstraint(index); } @@ -19260,12 +19269,14 @@ void AlterExpressionAddConstraint(AlterExpression alterExp): ( tk= (tk2= | tk2=)? clustering=SqlServerIndexClustering() + [ LOOKAHEAD({ getToken(1).kind == K_USING && getToken(2).kind != K_INDEX }) + using=UsingIndexType() ] columnNames=ColumnsNamesList() { index = new KeyConstraint() .withName(sk3) .withType(tk.image + (tk2!=null?" " + tk2.image:"")) - .withClustering(clustering) + .withClustering(clustering).withUsing(using) .withColumnsNames(columnNames); alterExp.setConstraint(index); } @@ -19479,7 +19490,7 @@ AlterExpression AlterExpressionAddAlterModify(): TableElement element; AlterExpression.ColumnDataType alterExpressionColumnDataType = null; AlterExpression.ColumnDropNotNull alterExpressionColumnDropNotNull = null; - List indexSpec = new ArrayList(); + List indexSpec = new ArrayList(); List partitionDefinition = null; } { @@ -19501,9 +19512,9 @@ AlterExpression AlterExpressionAddAlterModify(): | LOOKAHEAD({ alterExp.getOperation() == AlterOperation.ALTER && getToken(1).kind == K_INDEX }) - tk= sk3=RelObjectName() IndexOptionList(indexSpec) { + tk= sk3=RelObjectName() TableIndexOptions(false, indexSpec) { index = new Index().withIndexKeyword(tk.image).withName(sk3) - .withKind(Index.Kind.INDEX).withIndexSpec(indexSpec); + .withKind(Index.Kind.INDEX).withSpecOptions(indexSpec); alterExp.setIndex(index); } | diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index fac9650c5..2f7fc5f2f 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -293,10 +293,11 @@ The legacy accessors remain and are derived from these properties: the argument declarations of the last routine, where the legacy single-signature form kept them. The map now also covers PROCEDURE, ROUTINE and AGGREGATE. -* ``getParameters()`` is a snapshot that includes ON with the owner table and - the CASCADE or RESTRICT keyword. ``setParameters()`` replaces the clause with - raw tokens and clears both; ``addParameters()`` keeps them. Raw tokens are not - parsed: ``addParameters("CASCADE")`` leaves ``getBehavior()`` null. +* ``getParameters()`` is a snapshot that includes ON with the owner table, the + MySQL ALGORITHM and LOCK clauses and the CASCADE or RESTRICT keyword, in + source order. ``setParameters()`` replaces the clause with raw tokens and + clears all three; ``addParameters()`` keeps them. Raw tokens are not parsed: + ``addParameters("CASCADE")`` leaves ``getBehavior()`` null. * ``setName()``, ``setNames()``, ``addNames()`` and ``setTypeToParameters()`` first convert routine and account targets to the legacy names and single signature, so ``setName()`` on ``DROP FUNCTION f(int)`` still renders @@ -328,7 +329,8 @@ setters do not parse SQL or infer a table. Table discovery and statement visitors traverse DROP TABLE/VIEW targets and explicit index owners, without reporting catalog-only object names as tables. The statement deparser delegates real tables to its configured select deparser. -MySQL ALGORITHM/LOCK tokens retain their order and optional equals signs. +MySQL ALGORITHM and LOCK clauses retain their order and optional equals signs as +typed nodes; see :ref:`Share MySQL index options`. MySQL named constraint drops ~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -468,6 +470,73 @@ Use ``TableOption.onCommit`` and ``TableOption.accessMethod`` to build them. .setOnCommitAction(TableOption.OnCommitAction.PRESERVE_ROWS); // CREATE TEMP TABLE t (id INT) USING heap ON COMMIT PRESERVE ROWS +Share MySQL index options +------------------------- + +CREATE INDEX, the index and key definitions of CREATE TABLE, and ALTER TABLE +``ADD`` or ``ALTER INDEX`` parse MySQL index options with one grammar rule into +one model. ``IndexOptions.getSpecOptions()``, also available on ``Index`` and +``KeyConstraint``, returns the options written after the key list as mutable, +ordered ``IndexSpecOption`` nodes. ``getKind()`` selects the payload: + +* ``KEY_BLOCK_SIZE``, ``ENGINE_ATTRIBUTE`` and ``SECONDARY_ENGINE_ATTRIBUTE``: + ``getValue()`` and the optional ``=`` reported by ``isUseEquals()`` +* ``INDEX_TYPE`` (``USING BTREE``) and ``PARSER`` (``WITH PARSER ngram``): + ``getValue()`` +* ``COMMENT``: ``getComment()`` returns the literal as a ``StringValue`` that + records how the dialect escapes it, so ``getNotExcapedValue()`` returns the + stored text, for example ``it's`` for ``'it''s'`` or, with MySQL backslash + escapes, ``'it\'s'``. ``getValue()`` remains the literal's SQL text. +* ``VISIBILITY``: ``getVisible()`` for VISIBLE or INVISIBLE +* ``OTHER``: opaque tokens that CREATE TABLE and CREATE INDEX accept for other + dialects + +Values keep their SQL spelling, including the quotes of string literals. An +index type written before the key list, as in ``KEY k USING BTREE (a)``, +``PRIMARY KEY USING BTREE (id)`` or ``CREATE INDEX k USING HASH ON t (a)``, +remains ``getUsing()``; ``CreateIndex.isIndexTypeBeforeOn()`` still distinguishes +the two CREATE INDEX positions. After the key list it is an ``INDEX_TYPE`` option +at its source position. PostgreSQL's INCLUDE, NULLS DISTINCT, storage parameters +and tablespace keep their dedicated properties. + +CREATE INDEX keeps these options on ``getIndex()``. Its ALGORITHM and LOCK +clauses are ``AlgorithmOrLockOption`` nodes in +``CreateIndex.getAlgorithmOrLockOptions()``; DROP INDEX exposes the same type +through ``Drop.getAlgorithmOrLockOptions()``. CREATE INDEX renders them after the +index options, as MySQL requires. + +.. code-block:: java + + CreateIndex create = (CreateIndex) CCJSqlParserUtil.parse( + "CREATE UNIQUE INDEX uk ON t (a) USING BTREE COMMENT 'x' ALGORITHM = INPLACE", + parser -> parser.withDialect(Dialect.MYSQL)); + List options = create.getIndex().getSpecOptions(); + options.get(0).setValue("HASH"); // INDEX_TYPE + options.add(IndexSpecOption.visibility(false)); + create.getAlgorithmOrLockOptions().get(0).setValue("COPY"); + // CREATE UNIQUE INDEX uk ON t (a) USING HASH COMMENT 'x' INVISIBLE ALGORITHM = COPY + +The kind of an option is fixed. Setters for a payload that the kind does not have, +and ``setTokens`` on a typed option, throw ``IllegalStateException``; replace the +node to change its kind. ``IndexSpecOption.raw`` copies its tokens and never +parses them. + +The legacy accessors are derived from these nodes. ``getIndexSpec()`` returns an +unmodifiable token snapshot of ``getSpecOptions()``, or null when the options are +unset. ``CreateIndex.getTailParameters()`` returns the tokens of the index options +followed by ALGORITHM and LOCK, and ``Drop.getParameters()`` includes ALGORITHM +and LOCK at their source position. ``setIndexSpec`` and +``CreateIndex.setTailParameters`` replace these clauses with one opaque option; +``Drop.setParameters`` also clears ALGORITHM and LOCK. + +Migration: edits of the ``getIndexSpec()`` and ``getTailParameters()`` snapshots +now throw ``UnsupportedOperationException``; edit the nodes or call the setters. +ALTER TABLE key definitions report the same tokens as CREATE TABLE, for example +``KEY_BLOCK_SIZE``, ``=`` and ``8`` instead of the single entry +``KEY_BLOCK_SIZE = 8``. For a constructed statement without trailing clauses, +``getTailParameters()`` returns an empty list instead of null. Typed option +keywords are rendered in upper case. + Inspect PostgreSQL schema statements ------------------------------------ diff --git a/src/test/java/net/sf/jsqlparser/statement/alter/AlterTest.java b/src/test/java/net/sf/jsqlparser/statement/alter/AlterTest.java index 04ee07179..55baedf75 100644 --- a/src/test/java/net/sf/jsqlparser/statement/alter/AlterTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/alter/AlterTest.java @@ -40,6 +40,7 @@ import net.sf.jsqlparser.statement.create.table.CheckConstraint; import net.sf.jsqlparser.statement.create.table.ForeignKeyConstraint; import net.sf.jsqlparser.statement.create.table.Index; +import net.sf.jsqlparser.statement.create.table.IndexSpecOption; import net.sf.jsqlparser.statement.create.table.KeyElement; import net.sf.jsqlparser.statement.create.table.KeyConstraint; import net.sf.jsqlparser.statement.create.table.PartitionDefinition; @@ -2072,13 +2073,14 @@ public void testAlterTableAddFullTextIndexWithOptions() throws JSQLParserExcepti assertEquals("col1", columnNames.get(0)); assertEquals("col2", columnNames.get(1)); - List indexSpec = index.getIndexSpec(); - assertNotNull(indexSpec); - assertEquals(4, indexSpec.size()); - assertEquals("KEY_BLOCK_SIZE = 8", indexSpec.get(0)); - assertEquals("WITH PARSER ngram", indexSpec.get(1)); - assertEquals("COMMENT 'fulltext'", indexSpec.get(2)); - assertEquals("INVISIBLE", indexSpec.get(3)); + List options = index.getSpecOptions(); + assertEquals(4, options.size()); + assertEquals("KEY_BLOCK_SIZE = 8", options.get(0).toString()); + assertEquals("WITH PARSER ngram", options.get(1).toString()); + assertEquals("COMMENT 'fulltext'", options.get(2).toString()); + assertEquals("INVISIBLE", options.get(3).toString()); + assertEquals(List.of("KEY_BLOCK_SIZE", "=", "8", "WITH", "PARSER", "ngram", "COMMENT", + "'fulltext'", "INVISIBLE"), index.getIndexSpec()); assertSqlCanBeParsedAndDeparsed(sql); } @@ -2134,13 +2136,14 @@ public void testAlterTableAddIndexWithOptions() throws JSQLParserException { assertEquals("idx_lastname", index.getName()); assertEquals("last_name", index.getColumnsNames().get(0)); - List indexSpec = index.getIndexSpec(); - assertNotNull(indexSpec); - assertEquals(4, indexSpec.size()); - assertEquals("USING BTREE", indexSpec.get(0)); - assertEquals("KEY_BLOCK_SIZE = 16", indexSpec.get(1)); - assertEquals("COMMENT 'Performance tuning'", indexSpec.get(2)); - assertEquals("VISIBLE", indexSpec.get(3)); + List options = index.getSpecOptions(); + assertEquals(4, options.size()); + assertEquals("USING BTREE", options.get(0).toString()); + assertEquals("KEY_BLOCK_SIZE = 16", options.get(1).toString()); + assertEquals("COMMENT 'Performance tuning'", options.get(2).toString()); + assertEquals("VISIBLE", options.get(3).toString()); + assertEquals(List.of("USING", "BTREE", "KEY_BLOCK_SIZE", "=", "16", "COMMENT", + "'Performance tuning'", "VISIBLE"), index.getIndexSpec()); assertSqlCanBeParsedAndDeparsed(sql); } diff --git a/src/test/java/net/sf/jsqlparser/statement/create/MySqlIndexSpecOptionTest.java b/src/test/java/net/sf/jsqlparser/statement/create/MySqlIndexSpecOptionTest.java new file mode 100644 index 000000000..4ea4f93b1 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/create/MySqlIndexSpecOptionTest.java @@ -0,0 +1,421 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement.create; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.function.Consumer; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.CascadeBehavior; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.alter.Alter; +import net.sf.jsqlparser.statement.alter.AlterExpression; +import net.sf.jsqlparser.statement.create.index.AlgorithmOrLockOption; +import net.sf.jsqlparser.statement.create.index.CreateIndex; +import net.sf.jsqlparser.statement.create.table.CreateTable; +import net.sf.jsqlparser.statement.create.table.Index; +import net.sf.jsqlparser.statement.create.table.IndexOptionSource; +import net.sf.jsqlparser.statement.create.table.IndexSpecOption; +import net.sf.jsqlparser.statement.create.table.IndexSpecOption.Kind; +import net.sf.jsqlparser.statement.create.table.KeyConstraint; +import net.sf.jsqlparser.statement.drop.Drop; +import net.sf.jsqlparser.util.deparser.StatementDeParser; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class MySqlIndexSpecOptionTest { + private static final String OPTIONS = "KEY_BLOCK_SIZE = 8 USING HASH WITH PARSER ngram " + + "COMMENT 'c' INVISIBLE ENGINE_ATTRIBUTE '{}' SECONDARY_ENGINE_ATTRIBUTE = '{}'"; + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void createIndexKeepsIndexOptionsOnItsIndexAndAlgorithmLockOnTheStatement(boolean mysql) + throws Exception { + String sql = "CREATE UNIQUE INDEX uk ON t1 (a DESC, b (10)) USING BTREE COMMENT 'x' " + + "ALGORITHM = INPLACE LOCK = NONE"; + CreateIndex create = (CreateIndex) roundTrip(sql, mysql); + Index index = create.getIndex(); + + assertNull(index.getUsing()); + assertOptions(index, "INDEX_TYPE BTREE", "COMMENT 'x'"); + assertEquals(2, create.getAlgorithmOrLockOptions().size()); + assertAlgorithmOrLock(create.getAlgorithmOrLockOptions().get(0), + AlgorithmOrLockOption.Kind.ALGORITHM, "INPLACE"); + assertAlgorithmOrLock(create.getAlgorithmOrLockOptions().get(1), + AlgorithmOrLockOption.Kind.LOCK, "NONE"); + assertEquals(List.of("USING", "BTREE", "COMMENT", "'x'"), index.getIndexSpec()); + assertEquals(List.of("USING", "BTREE", "COMMENT", "'x'", "ALGORITHM", "=", "INPLACE", + "LOCK", "=", "NONE"), create.getTailParameters()); + } + + @ParameterizedTest + @CsvSource(delimiter = '|', value = { + "CREATE FULLTEXT INDEX ft ON t1 (body) WITH PARSER ngram|PARSER ngram", + "CREATE INDEX i ON t1 (a) INVISIBLE|VISIBILITY false", + "CREATE INDEX i ON t1 (a) VISIBLE|VISIBILITY true", + "CREATE INDEX i ON db1.t1 (a, b) KEY_BLOCK_SIZE = 8|KEY_BLOCK_SIZE 8 =", + "CREATE INDEX i ON t1 (a) KEY_BLOCK_SIZE 8 ENGINE_ATTRIBUTE '{}'" + + "|KEY_BLOCK_SIZE 8,ENGINE_ATTRIBUTE '{}'"}) + void createIndexOptionsAreTyped(String sql, String expected) throws Exception { + for (boolean mysql : List.of(true, false)) { + CreateIndex create = (CreateIndex) roundTrip(sql, mysql); + assertOptions(create.getIndex(), expected.split(",")); + assertTrue(create.getAlgorithmOrLockOptions().isEmpty()); + } + } + + @Test + void indexTypeKeepsItsPositionRelativeToTheKeyList() throws Exception { + CreateIndex beforeOn = (CreateIndex) roundTrip("CREATE INDEX i USING HASH ON t1 (a)", true); + assertEquals("HASH", beforeOn.getIndex().getUsing()); + assertTrue(beforeOn.isIndexTypeBeforeOn()); + assertNull(beforeOn.getIndex().getSpecOptions()); + + CreateTable table = (CreateTable) roundTrip("CREATE TABLE t20 (a INT, b VARCHAR (100), " + + "KEY idx_a USING BTREE (a) KEY_BLOCK_SIZE = 4 COMMENT 'idx', " + + "UNIQUE INDEX uk_b (b (20)) USING HASH)", true); + Index key = table.getIndexes().get(0); + assertEquals("BTREE", key.getUsing()); + assertOptions(key, "KEY_BLOCK_SIZE 4 =", "COMMENT 'idx'"); + assertEquals(List.of("KEY_BLOCK_SIZE", "=", "4", "COMMENT", "'idx'"), key.getIndexSpec()); + KeyConstraint unique = (KeyConstraint) table.getTableConstraints().get(0); + assertNull(unique.getUsing()); + assertOptions(unique, "INDEX_TYPE HASH"); + assertEquals(List.of("USING", "HASH"), unique.getIndexSpec()); + } + + @ParameterizedTest + @ValueSource(strings = {"ALTER TABLE t1 ADD CONSTRAINT pk_t1 PRIMARY KEY USING BTREE (id)", + "ALTER TABLE t1 ADD PRIMARY KEY USING BTREE (id)", + "ALTER TABLE t1 ADD CONSTRAINT uk UNIQUE KEY uk_idx USING BTREE (id)", + "CREATE TABLE t1 (id INT, PRIMARY KEY USING BTREE (id) COMMENT 'pk')", + "CREATE TABLE t1 (id INT, CONSTRAINT pk_t1 PRIMARY KEY USING HASH (id))"}) + void keysAcceptTheIndexTypeBeforeTheKeyList(String sql) throws Exception { + KeyConstraint key = keyConstraint(roundTrip(sql, true)); + assertEquals(sql.contains("HASH") ? "HASH" : "BTREE", key.getUsing()); + assertEquals(List.of("id"), key.getColumnsNames()); + assertFalse(key.getSpecOptions().stream() + .anyMatch(option -> option.getKind() == Kind.INDEX_TYPE)); + } + + @Test + void defaultModeAcceptsTheIndexTypeBeforeNamedConstraintKeys() throws Exception { + for (String sql : List.of( + "ALTER TABLE t1 ADD CONSTRAINT pk_t1 PRIMARY KEY USING BTREE (id)", + "ALTER TABLE t1 ADD CONSTRAINT uk UNIQUE KEY USING BTREE (id)")) { + Statement statement = roundTrip(sql, false); + KeyConstraint key = keyConstraint(statement); + assertEquals("BTREE", key.getUsing()); + key.setUsing("HASH"); + assertRendered(statement, sql.replace("BTREE", "HASH")); + } + } + + @ParameterizedTest + @ValueSource(strings = {"CREATE INDEX ix ON t (a) %s", + "CREATE TABLE t (a INT, KEY ix (a) %s)", + "CREATE TABLE t (a INT, UNIQUE KEY ix (a) %s)", + "ALTER TABLE t ADD INDEX ix (a) %s", "ALTER TABLE t ADD UNIQUE KEY ix (a) %s", + "ALTER TABLE t ADD CONSTRAINT pk PRIMARY KEY (a) %s"}) + void everyDefinitionSharesTheSameOptionModel(String template) throws Exception { + IndexOptionSource source = definition(roundTrip(String.format(template, OPTIONS), true)); + assertOptions(source, "KEY_BLOCK_SIZE 8 =", "INDEX_TYPE HASH", "PARSER ngram", + "COMMENT 'c'", "VISIBILITY false", "ENGINE_ATTRIBUTE '{}'", + "SECONDARY_ENGINE_ATTRIBUTE '{}' ="); + assertEquals(List.of("KEY_BLOCK_SIZE", "=", "8", "USING", "HASH", "WITH", "PARSER", + "ngram", "COMMENT", "'c'", "INVISIBLE", "ENGINE_ATTRIBUTE", "'{}'", + "SECONDARY_ENGINE_ATTRIBUTE", "=", "'{}'"), source.getIndexSpec()); + } + + @Test + void alterKeyBlockSizeWithoutEqualsKeepsItsSeparator() throws Exception { + // ALTER used to render this option as KEY_BLOCK_SIZE8 + Alter alter = (Alter) roundTrip("ALTER TABLE t ADD INDEX i (a) KEY_BLOCK_SIZE 8", true); + assertOptions(alter.getAlterExpressions().get(0).getIndex(), "KEY_BLOCK_SIZE 8"); + } + + @Test + void commentsAreStringValuesThatDecodeTheStoredText() throws Exception { + String[][] comments = {{"'it''s'", "it's"}, {"'it\\'s'", "it's"}, {"\"dq\"", "dq"}, + {"'a\\\\b'", "a\\b"}, {"'인덱스 설명'", "인덱스 설명"}}; + for (String template : List.of("CREATE INDEX ix ON t (a) COMMENT %s", + "CREATE TABLE t (a INT, KEY ix (a) COMMENT %s)", + "ALTER TABLE t ADD INDEX ix (a) COMMENT %s")) { + for (String[] comment : comments) { + String sql = String.format(template, comment[0]); + IndexSpecOption option = + definition(roundTrip(sql, true)).getSpecOptions().get(0); + assertEquals(Kind.COMMENT, option.getKind()); + assertEquals(comment[0], option.getValue(), sql); + assertEquals(comment[0], option.getComment().toString(), sql); + assertEquals(comment[1], option.getComment().getNotExcapedValue(), sql); + } + } + + Alter alter = (Alter) roundTrip("ALTER TABLE t ADD INDEX ix (a) COMMENT 'it\\'s'", true); + IndexSpecOption comment = alter.getAlterExpressions().get(0).getIndex() + .getSpecOptions().get(0); + assertEquals(StringValue.Escaping.MYSQL_BACKSLASH, comment.getComment().getEscaping()); + comment.setValue("'don\\'t'"); + assertEquals("don't", comment.getComment().getNotExcapedValue()); + comment.setComment(new StringValue("'changed'")); + assertRendered(alter, "ALTER TABLE t ADD INDEX ix (a) COMMENT 'changed'"); + assertEquals(List.of("COMMENT", "'changed'"), + alter.getAlterExpressions().get(0).getIndex().getIndexSpec()); + } + + @Test + void typedEditsReachBothRenderers() throws Exception { + Alter alter = (Alter) roundTrip( + "ALTER TABLE t1 ADD INDEX i3 (a) USING BTREE COMMENT 'c' INVISIBLE", true); + List options = alter.getAlterExpressions().get(0).getIndex() + .getSpecOptions(); + options.get(0).setValue("HASH"); + options.get(1).setValue("'changed'"); + options.get(2).setVisible(true); + options.add(0, IndexSpecOption.of(Kind.KEY_BLOCK_SIZE, "16", true)); + assertRendered(alter, "ALTER TABLE t1 ADD INDEX i3 (a) KEY_BLOCK_SIZE = 16 USING HASH " + + "COMMENT 'changed' VISIBLE"); + + CreateIndex create = (CreateIndex) roundTrip( + "CREATE INDEX i ON t (a) LOCK EXCLUSIVE ALGORITHM DEFAULT", true); + create.getAlgorithmOrLockOptions().get(0).setValue("SHARED"); + create.getAlgorithmOrLockOptions().get(1).setUseEquals(true); + create.getIndex().getIndexOptions().addSpecOptions(IndexSpecOption.of(Kind.COMMENT, "'x'")); + assertRendered(create, + "CREATE INDEX i ON t (a) COMMENT 'x' LOCK SHARED ALGORITHM = DEFAULT"); + assertEquals(List.of("COMMENT", "'x'", "LOCK", "SHARED", "ALGORITHM", "=", "DEFAULT"), + create.getTailParameters()); + } + + @Test + void legacyAccessorsAreSnapshotsAndSettersReplaceWithOpaqueOptions() throws Exception { + CreateIndex create = (CreateIndex) roundTrip( + "CREATE INDEX i ON t (a) parallel compress nologging", false); + assertEquals(List.of("parallel", "compress", "nologging"), create.getTailParameters()); + assertTrue(create.getIndex().getSpecOptions().stream() + .allMatch(option -> option.getKind() == Kind.OTHER)); + assertThrows(UnsupportedOperationException.class, + () -> create.getTailParameters().add("x")); + assertThrows(UnsupportedOperationException.class, + () -> create.getIndex().getIndexSpec().add("x")); + + create.addAlgorithmOrLockOptions(AlgorithmOrLockOption.lock("NONE")); + List tail = new ArrayList<>(List.of("KEY_BLOCK_SIZE", "=", "4")); + create.setTailParameters(tail); + tail.clear(); + assertTrue(create.getAlgorithmOrLockOptions().isEmpty()); + assertEquals(Kind.OTHER, create.getIndex().getSpecOptions().get(0).getKind()); + assertRendered(create, "CREATE INDEX i ON t (a) KEY_BLOCK_SIZE = 4"); + + Index index = new Index().withName("i").withColumnsNames(List.of("a")); + assertNull(index.getIndexSpec()); + index.setIndexSpec(List.of("USING", "BTREE")); + assertEquals(List.of("USING", "BTREE"), index.getIndexSpec()); + assertEquals("i (a) USING BTREE", index.toString()); + index.setIndexSpec(List.of()); + assertTrue(index.getSpecOptions().isEmpty()); + index.setIndexSpec(null); + assertNull(index.getSpecOptions()); + assertEquals(List.of(), new CreateIndex().getTailParameters()); + } + + @Test + void typedOptionsRejectPayloadsOfOtherKinds() { + IndexSpecOption comment = IndexSpecOption.comment(new StringValue("'c'")); + assertEquals("'c'", comment.getValue()); + assertThrows(IllegalStateException.class, + () -> IndexSpecOption.of(Kind.PARSER, "ngram").setComment(new StringValue("'x'"))); + assertNull(IndexSpecOption.of(Kind.PARSER, "ngram").getComment()); + assertThrows(NullPointerException.class, () -> comment.setComment(null)); + assertThrows(IllegalStateException.class, () -> comment.setTokens(List.of("x"))); + assertThrows(IllegalStateException.class, () -> comment.setUseEquals(true)); + assertThrows(IllegalStateException.class, () -> comment.setVisible(true)); + assertNull(comment.getVisible()); + IndexSpecOption visible = IndexSpecOption.visibility(true); + assertThrows(IllegalStateException.class, () -> visible.setValue("x")); + assertEquals(List.of("VISIBLE"), visible.getTokens()); + assertThrows(IllegalStateException.class, + () -> IndexSpecOption.of(Kind.OTHER, "x")); + assertThrows(NullPointerException.class, () -> IndexSpecOption.of(Kind.PARSER, null)); + + List tokens = new ArrayList<>(List.of("ON", "[PRIMARY]")); + IndexSpecOption raw = IndexSpecOption.raw(tokens); + tokens.set(1, "ignored"); + assertEquals("ON [PRIMARY]", raw.toString()); + assertThrows(UnsupportedOperationException.class, () -> raw.getTokens().add("x")); + assertThrows(IllegalStateException.class, () -> raw.setValue("x")); + raw.setTokens(List.of("ON", "fg")); + assertEquals(List.of("ON", "fg"), raw.getTokens()); + } + + @Test + void dropIndexAlgorithmAndLockAreTyped() throws Exception { + Drop drop = (Drop) roundTrip("DROP INDEX idx ON t1 ALGORITHM = INPLACE LOCK = NONE", true); + assertEquals("t1", drop.getTable().getName()); + assertAlgorithmOrLock(drop.getAlgorithmOrLockOptions().get(0), + AlgorithmOrLockOption.Kind.ALGORITHM, "INPLACE"); + assertAlgorithmOrLock(drop.getAlgorithmOrLockOptions().get(1), + AlgorithmOrLockOption.Kind.LOCK, "NONE"); + assertEquals(List.of("ON", "t1", "ALGORITHM", "=", "INPLACE", "LOCK", "=", "NONE"), + drop.getParameters()); + + drop.getAlgorithmOrLockOptions().remove(1); + drop.getAlgorithmOrLockOptions().get(0).setValue("COPY"); + drop.setBehavior(CascadeBehavior.RESTRICT); + assertRendered(drop, "DROP INDEX idx ON t1 ALGORITHM = COPY RESTRICT"); + assertEquals(List.of("ON", "t1", "ALGORITHM", "=", "COPY", "RESTRICT"), + drop.getParameters()); + + drop.addParameters("legacy"); + drop.setAlgorithmOrLockOptions(List.of(AlgorithmOrLockOption.lock("SHARED"))); + assertRendered(drop, "DROP INDEX idx ON t1 legacy LOCK = SHARED RESTRICT"); + drop.setAlgorithmOrLockOptions(List.of(AlgorithmOrLockOption.lock("SHARED")), 0); + assertEquals(List.of("ON", "t1", "LOCK", "=", "SHARED", "legacy", "RESTRICT"), + drop.getParameters()); + assertThrows(IllegalArgumentException.class, + () -> drop.setAlgorithmOrLockOptions(List.of(), 2)); + + drop.setParameters(List.of("CASCADE")); + assertTrue(drop.getAlgorithmOrLockOptions().isEmpty()); + assertNull(drop.getBehavior()); + assertNull(drop.getTable()); + assertEquals("DROP INDEX idx CASCADE", drop.toString()); + + Drop single = (Drop) roundTrip("DROP INDEX idx ON t1 ALGORITHM = INPLACE", false); + assertEquals(1, single.getAlgorithmOrLockOptions().size()); + } + + @Test + void replacingTheCreateIndexDefinitionKeepsOrInheritsItsOptions() throws Exception { + CreateIndex create = (CreateIndex) roundTrip( + "CREATE INDEX i ON t (a) KEY_BLOCK_SIZE = 8", true); + Index removed = create.getIndex(); + IndexSpecOption keyBlockSize = removed.getSpecOptions().get(0); + create.setIndex(null); + assertEquals(List.of("KEY_BLOCK_SIZE", "=", "8"), create.getTailParameters()); + + create.setIndex(new Index().withName("j").withColumnsNames(List.of("b"))); + assertSame(keyBlockSize, create.getIndex().getSpecOptions().get(0)); + assertNotSame(removed.getSpecOptions(), create.getIndex().getSpecOptions()); + assertRendered(create, "CREATE INDEX j ON t (b) KEY_BLOCK_SIZE = 8"); + + create.setIndex(new Index().withName("k").withColumnsNames(List.of("c")) + .withSpecOptions(List.of(IndexSpecOption.visibility(false)))); + assertRendered(create, "CREATE INDEX k ON t (c) INVISIBLE"); + } + + @Test + void postgreSqlIndexOptionsKeepTheirProperties() throws Exception { + String sql = "CREATE UNIQUE INDEX CONCURRENTLY ix ON ONLY t USING btree (id) " + + "INCLUDE (v) NULLS NOT DISTINCT WITH (fillfactor = 80) TABLESPACE fast " + + "WHERE active"; + CreateIndex create = (CreateIndex) CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(Dialect.POSTGRESQL)); + assertEquals(sql, create.toString()); + assertEquals("btree", create.getIndex().getUsing()); + assertEquals(List.of("v"), create.getIncludeColumns()); + assertEquals("fast", create.getTableSpace()); + assertNull(create.getIndex().getSpecOptions()); + assertTrue(create.getTailParameters().isEmpty()); + + Alter alter = (Alter) CCJSqlParserUtil.parse("ALTER TABLE t ADD CONSTRAINT uk UNIQUE (id) " + + "INCLUDE (v) WITH (fillfactor = 70) USING INDEX TABLESPACE fast", + parser -> parser.withDialect(Dialect.POSTGRESQL)); + KeyConstraint key = alter.getAlterExpressions().get(0).getKeyConstraint(); + assertEquals("fast", key.getTableSpace()); + assertEquals(1, key.getStorageParameters().size()); + assertTrue(key.getSpecOptions().isEmpty()); + } + + @ParameterizedTest + @ValueSource(strings = {"ALTER TABLE t ADD PRIMARY KEY USING (id)", + "ALTER TABLE t ADD PRIMARY KEY USING BTREE USING HASH (id)", + "ALTER TABLE t ADD INDEX i (a) KEY_BLOCK_SIZE", + "ALTER TABLE t ADD INDEX i (a) WITH PARSER", + "ALTER TABLE t ADD INDEX i (a) COMMENT", + "CREATE INDEX i ON t (a) LOCK = NONE ALGORITHM"}) + void rejectsIncompleteOptions(String sql) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(Dialect.MYSQL))); + } + + private static void assertOptions(IndexOptionSource source, String... expected) { + List actual = new ArrayList<>(); + for (IndexSpecOption option : source.getSpecOptions()) { + Object payload = option.getKind() == Kind.VISIBILITY ? option.getVisible() + : option.getValue(); + actual.add(option.getKind() + " " + payload + (option.isUseEquals() ? " =" : "")); + } + assertEquals(List.of(expected), actual); + } + + private static void assertAlgorithmOrLock(AlgorithmOrLockOption option, + AlgorithmOrLockOption.Kind kind, String value) { + assertEquals(kind, option.getKind()); + assertEquals(value, option.getValue()); + assertTrue(option.isUseEquals()); + } + + private static IndexOptionSource definition(Statement statement) { + if (statement instanceof CreateIndex) { + return ((CreateIndex) statement).getIndex(); + } + if (statement instanceof CreateTable) { + CreateTable table = (CreateTable) statement; + return (IndexOptionSource) (table.getIndexes().isEmpty() + ? table.getTableConstraints().get(0) + : table.getIndexes().get(0)); + } + AlterExpression action = ((Alter) statement).getAlterExpressions().get(0); + return (IndexOptionSource) (action.getConstraint() != null ? action.getConstraint() + : action.getIndex()); + } + + private static KeyConstraint keyConstraint(Statement statement) { + return (KeyConstraint) definition(statement); + } + + private static Statement roundTrip(String sql, boolean mysql) throws Exception { + Consumer dialect = mysql ? parser -> parser.withDialect(Dialect.MYSQL) + : parser -> { + }; + Statement statement = CCJSqlParserUtil.parse(sql, dialect); + assertEquals(sql, statement.toString()); + StringBuilder deparsed = new StringBuilder(); + statement.accept(new StatementDeParser(deparsed)); + assertEquals(sql, deparsed.toString()); + assertEquals(sql, CCJSqlParserUtil.parse(sql, dialect).toString()); + return statement; + } + + private static void assertRendered(Statement statement, String expected) throws Exception { + assertEquals(expected, statement.toString()); + StringBuilder deparsed = new StringBuilder(); + statement.accept(new StatementDeParser(deparsed)); + assertEquals(expected, deparsed.toString()); + assertEquals(expected, CCJSqlParserUtil.parse(expected, + parser -> parser.withDialect(Dialect.MYSQL)).toString()); + } +}