diff --git a/README.md b/README.md index d4b0e2848a..d8855f6771 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ and missing syntax gets added on demand — [open an issue](https://github.com/J | **Oracle vector types** | `VECTOR(*, *)` · `VECTOR(*, FLOAT32)` · `VECTOR(3, *)`, including dense and sparse storage formats | | **PostgreSQL ownership** | `DROP OWNED` · `REASSIGN OWNED` | | **MySQL table maintenance** | `ANALYZE` · `CHECK` · `CHECKSUM` · `OPTIMIZE` · `REPAIR TABLE` | +| **PostgreSQL maintenance** | `VACUUM` · `ANALYZE` · `CLUSTER` · `CHECKPOINT` | | **MySQL account management** | `CREATE`/`ALTER`/`RENAME USER` · `CREATE ROLE` · `SET ROLE` · `SET DEFAULT ROLE` · `SET PASSWORD` | | **Tablespaces** | MySQL and PostgreSQL `CREATE TABLESPACE` · `ALTER TABLESPACE` | | **PostgreSQL RLS** | `CREATE POLICY` · `ALTER TABLE … ENABLE`/`DISABLE`/`FORCE`/`NO FORCE ROW LEVEL SECURITY` | diff --git a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java index 3ce49efd6a..e8508a3e8a 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -32,6 +32,9 @@ import net.sf.jsqlparser.statement.alter.AlterEventTrigger; import net.sf.jsqlparser.statement.alter.sequence.AlterSequence; import net.sf.jsqlparser.statement.analyze.Analyze; +import net.sf.jsqlparser.statement.maintenance.CheckpointStatement; +import net.sf.jsqlparser.statement.maintenance.ClusterStatement; +import net.sf.jsqlparser.statement.maintenance.VacuumStatement; import net.sf.jsqlparser.statement.comment.Comment; import net.sf.jsqlparser.statement.create.database.CreateDatabase; import net.sf.jsqlparser.statement.create.function.CreateFunction; @@ -485,6 +488,27 @@ public enum Feature { */ tableMaintenance, + /** + * PostgreSQL "VACUUM" statement is allowed + * + * @see VacuumStatement + */ + vacuum, + + /** + * PostgreSQL "CLUSTER" statement is allowed + * + * @see ClusterStatement + */ + cluster, + + /** + * PostgreSQL "CHECKPOINT" statement is allowed + * + * @see CheckpointStatement + */ + checkpoint, + /** * SQL "TRUNCATE" statement is allowed * diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index 8f4e7aa8f7..4abb61e85a 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -97,6 +97,9 @@ import net.sf.jsqlparser.statement.insert.InsertBulk; import net.sf.jsqlparser.statement.load.LoadDataStatement; import net.sf.jsqlparser.statement.lock.LockStatement; +import net.sf.jsqlparser.statement.maintenance.CheckpointStatement; +import net.sf.jsqlparser.statement.maintenance.ClusterStatement; +import net.sf.jsqlparser.statement.maintenance.VacuumStatement; import net.sf.jsqlparser.statement.merge.Merge; import net.sf.jsqlparser.statement.oracle.OracleAssignment; import net.sf.jsqlparser.statement.oracle.OracleBlock; @@ -748,6 +751,30 @@ public Void visit(TableMaintenanceStatement statement, S context) { return null; } + @Override + public Void visit(VacuumStatement vacuum, S context) { + // reclaims storage and, with ANALYZE, rewrites statistics; FULL rewrites the tables + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SCHEMA, StmtFeature.READS_DATA); + return null; + } + + @Override + public Void visit(ClusterStatement cluster, S context) { + // rewrites the table in index order + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SCHEMA, StmtFeature.READS_DATA); + return null; + } + + @Override + public Void visit(CheckpointStatement checkpoint, S context) { + // a server operation like ALTER SYSTEM CHECKPOINT; no data or schema changes + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SESSION); + return null; + } + private Void schemaOnly() { analysis.claimTopLevel(); analysis.certain(StmtFeature.MODIFIES_SCHEMA); diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index c802107c0b..5693198da7 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -91,6 +91,9 @@ import net.sf.jsqlparser.statement.insert.InsertBulk; import net.sf.jsqlparser.statement.insert.ParenthesedInsert; import net.sf.jsqlparser.statement.lock.LockStatement; +import net.sf.jsqlparser.statement.maintenance.CheckpointStatement; +import net.sf.jsqlparser.statement.maintenance.ClusterStatement; +import net.sf.jsqlparser.statement.maintenance.VacuumStatement; import net.sf.jsqlparser.statement.merge.Merge; import net.sf.jsqlparser.statement.refresh.RefreshMaterializedViewStatement; import net.sf.jsqlparser.statement.reindex.Reindex; @@ -371,6 +374,30 @@ default void visit(TableMaintenanceStatement statement) { this.visit(statement, null); } + default T visit(VacuumStatement vacuum, S context) { + return null; + } + + default void visit(VacuumStatement vacuum) { + this.visit(vacuum, null); + } + + default T visit(ClusterStatement cluster, S context) { + return null; + } + + default void visit(ClusterStatement cluster) { + this.visit(cluster, null); + } + + default T visit(CheckpointStatement checkpoint, S context) { + return null; + } + + default void visit(CheckpointStatement checkpoint) { + this.visit(checkpoint, null); + } + default void visit(Execute execute) { this.visit(execute, null); } diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index 5367cc9a0c..adc28b2b56 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -94,6 +94,9 @@ import net.sf.jsqlparser.statement.insert.InsertConflictAction; import net.sf.jsqlparser.statement.insert.ParenthesedInsert; import net.sf.jsqlparser.statement.lock.LockStatement; +import net.sf.jsqlparser.statement.maintenance.CheckpointStatement; +import net.sf.jsqlparser.statement.maintenance.ClusterStatement; +import net.sf.jsqlparser.statement.maintenance.VacuumStatement; import net.sf.jsqlparser.statement.merge.Merge; import net.sf.jsqlparser.statement.merge.MergeOperationVisitor; import net.sf.jsqlparser.statement.merge.MergeOperationVisitorAdapter; @@ -708,6 +711,21 @@ public T visit(TableMaintenanceStatement statement, S context) { return null; } + @Override + public T visit(VacuumStatement vacuum, S context) { + return null; + } + + @Override + public T visit(ClusterStatement cluster, S context) { + return null; + } + + @Override + public T visit(CheckpointStatement checkpoint, S context) { + return null; + } + @Override public T visit(SavepointStatement savepointStatement, S context) { // @todo: do something usefully here diff --git a/src/main/java/net/sf/jsqlparser/statement/UtilityOption.java b/src/main/java/net/sf/jsqlparser/statement/UtilityOption.java new file mode 100644 index 0000000000..60c0c1fdb2 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/UtilityOption.java @@ -0,0 +1,318 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement; + +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; + +/** + * One element {@code name [value]} of a PostgreSQL utility option list, such as + * {@code VACUUM (VERBOSE, PARALLEL 2) t}. The same list follows {@code VACUUM}, {@code ANALYZE} and + * {@code CLUSTER}. + *

+ * The parser accepts every option that PostgreSQL's grammar accepts, as PostgreSQL does: names and + * values are checked when the server runs the statement. {@link #getKind(Class)} tells whether a + * statement knows the name. + *

+ * The name and the value keep their spelling. {@link #getValueType()} tells how the value is + * written, and {@link #getBooleanValue()}, {@link #getLongValue()} and {@link #getStringValue()} + * read it. + */ +public class UtilityOption extends ASTNodeAccessImpl { + + /** How the value of an option is written. */ + public enum ValueType { + /** {@code TRUE}, {@code FALSE}, {@code ON} or {@code OFF}. */ + BOOLEAN, + /** An integer or decimal number, optionally signed, such as {@code 2}. */ + NUMBER, + /** A string literal, such as {@code '256kB'}. */ + STRING, + /** Another word, such as {@code AUTO}, or a double-quoted identifier. */ + WORD + } + + private String name; + private ValueType valueType; + private String value; + + /** Creates an option without a value, such as {@code VERBOSE}. */ + public UtilityOption(String name) { + setName(name); + } + + /** + * Creates an option with a value written as SQL text: a string value includes its quotes. + */ + public UtilityOption(String name, ValueType valueType, String value) { + setName(name); + setValue(valueType, value); + } + + /** {@code name TRUE} or {@code name FALSE}. */ + public static UtilityOption of(String name, boolean value) { + return new UtilityOption(name, ValueType.BOOLEAN, value ? "TRUE" : "FALSE"); + } + + /** {@code name n}, such as {@code PARALLEL 2}. */ + public static UtilityOption of(String name, long value) { + return new UtilityOption(name, ValueType.NUMBER, Long.toString(value)); + } + + /** {@code name 'text'}, quoting the text. */ + public static UtilityOption ofString(String name, String text) { + return new UtilityOption(name, ValueType.STRING, + "'" + Objects.requireNonNull(text, "text").replace("'", "''") + "'"); + } + + /** {@code name word}, such as {@code INDEX_CLEANUP AUTO}. */ + public static UtilityOption ofWord(String name, String word) { + return new UtilityOption(name, isBooleanWord(word) ? ValueType.BOOLEAN : ValueType.WORD, + word); + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = Objects.requireNonNull(name, "name"); + } + + /** + * Whether this option has the given name, read as PostgreSQL reads it: an unquoted name ignores + * case, a double-quoted one keeps it, and the unquoted {@code ANALYSE} is the option + * {@code ANALYZE}. + */ + public boolean isNamed(String name) { + return name != null && canonicalName(this.name).equals(canonicalName(name)); + } + + /** + * The constant of a statement's option kinds that this option names, such as + * {@code getKind(VacuumStatement.OptionKind.class)}; null for a name that the statement does + * not know, which PostgreSQL rejects when it runs the statement. As in PostgreSQL, a + * double-quoted name matches only in lower case: {@code "verbose"} is {@code VERBOSE}, and + * {@code "VERBOSE"} is unknown. + */ + public > K getKind(Class kinds) { + return constantNamed(kinds, canonicalName(name)); + } + + public boolean hasValue() { + return valueType != null; + } + + /** The way the value is written, or null without a value. */ + public ValueType getValueType() { + return valueType; + } + + /** The value as written in SQL, with the quotes of a string; null without a value. */ + public String getValue() { + return value; + } + + /** Sets the value as written in SQL; both arguments are null to remove the value. */ + public void setValue(ValueType valueType, String value) { + if ((valueType == null) != (value == null)) { + throw new IllegalArgumentException("A value needs both its type and its text"); + } + this.valueType = valueType; + this.value = value; + } + + public void removeValue() { + setValue(null, null); + } + + /** + * The Boolean value as PostgreSQL reads it: {@code TRUE}/{@code ON} and {@code 1} are true, + * {@code FALSE}/{@code OFF} and {@code 0} are false, also as a string. Null without a value or + * for any other value. PostgreSQL enables a Boolean option written without a value. + */ + public Boolean getBooleanValue() { + if (valueType == null) { + return null; + } + if (valueType == ValueType.NUMBER) { + Long number = getLongValue(); + return number == null || number > 1 || number < 0 ? null : number == 1; + } + String text = getStringValue().toUpperCase(Locale.ROOT); + if ("TRUE".equals(text) || "ON".equals(text)) { + return Boolean.TRUE; + } + if ("FALSE".equals(text) || "OFF".equals(text)) { + return Boolean.FALSE; + } + return null; + } + + /** + * The constant of the enum that the value spells, ignoring case, such as {@code AUTO}; null + * without a value or for another value. + */ + public > E getEnumValue(Class type) { + return valueType == null ? null + : constantNamed(type, getStringValue().toLowerCase(Locale.ROOT)); + } + + /** The value of an integer number, or null for any other value. */ + public Long getLongValue() { + if (valueType != ValueType.NUMBER) { + return null; + } + try { + return Long.valueOf(value.startsWith("+") ? value.substring(1) : value); + } catch (NumberFormatException ex) { + return null; + } + } + + /** + * The text of the value: the characters of a string or a double-quoted identifier without its + * quotes and escapes, or the value as written otherwise. Null without a value. + */ + public String getStringValue() { + if (valueType != ValueType.STRING) { + return valueType == ValueType.WORD && isQuoted(value) ? unquoted(value) : value; + } + StringValue literal = new StringValue(value); + if ("E".equalsIgnoreCase(literal.getPrefix())) { + literal.setEscaping(StringValue.Escaping.POSTGRESQL_ESCAPE_STRING); + } + return literal.getNotExcapedValue(); + } + + public UtilityOption withName(String name) { + setName(name); + return this; + } + + public UtilityOption withValue(ValueType valueType, String value) { + setValue(valueType, value); + return this; + } + + @Override + public StringBuilder appendTo(StringBuilder builder) { + builder.append(name); + if (valueType != null) { + builder.append(' ').append(value); + } + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + /** + * Whether the word is one of the Boolean words {@code TRUE}, {@code FALSE}, {@code ON}, + * {@code OFF}. + */ + public static boolean isBooleanWord(String word) { + String text = Objects.requireNonNull(word, "word").toUpperCase(Locale.ROOT); + return "TRUE".equals(text) || "FALSE".equals(text) || "ON".equals(text) + || "OFF".equals(text); + } + + /** + * Appends the options of a statement after a space: in parentheses, or as bare keywords when + * {@code parenthesized} is false and the unparenthesized form of the statement can express + * them. Nothing is appended without options. + * + * @param keywords the options that the statement accepts without parentheses, in the order that + * it requires; each can appear once, without a value + */ + public static StringBuilder appendOptions(StringBuilder builder, List options, + boolean parenthesized, String... keywords) { + if (options.isEmpty()) { + return builder; + } + if (!parenthesized && !options.isEmpty() && isKeywordForm(options, keywords)) { + for (String keyword : keywords) { + for (UtilityOption option : options) { + if (option.isNamed(keyword)) { + builder.append(' ').append(option.getName()); + } + } + } + return builder; + } + builder.append(" ("); + for (int i = 0; i < options.size(); i++) { + if (i > 0) { + builder.append(", "); + } + options.get(i).appendTo(builder); + } + return builder.append(')'); + } + + /** + * Whether the unparenthesized form of a statement can express the options: each is one of the + * keywords, unquoted, without a value, and named once. True without options. + * + * @param keywords the options that the statement accepts without parentheses + */ + public static boolean isKeywordForm(List options, String... keywords) { + for (UtilityOption option : options) { + if (option.hasValue() || isQuoted(option.name)) { + return false; + } + int matches = 0; + for (String keyword : keywords) { + if (option.isNamed(keyword)) { + matches++; + } + } + if (matches == 0 || options.stream().filter(other -> other.isNamed(option.name)) + .count() > 1) { + return false; + } + } + return true; + } + + /** + * The name as PostgreSQL's grammar hands it on: a double-quoted name without its quotes, and an + * unquoted one in lower case, with {@code analyse} spelled {@code analyze}. + */ + private static String canonicalName(String name) { + if (isQuoted(name)) { + return unquoted(name); + } + String lower = name.toLowerCase(Locale.ROOT); + return "analyse".equals(lower) ? "analyze" : lower; + } + + private static boolean isQuoted(String text) { + return text.length() > 1 && text.charAt(0) == '"' && text.charAt(text.length() - 1) == '"'; + } + + private static String unquoted(String text) { + return text.substring(1, text.length() - 1).replace("\"\"", "\""); + } + + private static > E constantNamed(Class type, String name) { + for (E constant : type.getEnumConstants()) { + if (constant.name().toLowerCase(Locale.ROOT).equals(name)) { + return constant; + } + } + return null; + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/analyze/Analyze.java b/src/main/java/net/sf/jsqlparser/statement/analyze/Analyze.java index d8d0e01397..ebb4964157 100644 --- a/src/main/java/net/sf/jsqlparser/statement/analyze/Analyze.java +++ b/src/main/java/net/sf/jsqlparser/statement/analyze/Analyze.java @@ -13,6 +13,7 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; import net.sf.jsqlparser.expression.StringValue; import net.sf.jsqlparser.parser.ASTNodeAccessImpl; @@ -20,7 +21,26 @@ import net.sf.jsqlparser.schema.Table; import net.sf.jsqlparser.statement.Statement; import net.sf.jsqlparser.statement.StatementVisitor; - +import net.sf.jsqlparser.statement.UtilityOption; +import net.sf.jsqlparser.statement.maintenance.MaintenanceTarget; + +/** + * {@code ANALYZE} in its MySQL and PostgreSQL forms, which exclude each other. + *

    + *
  • MySQL: {@code ANALYZE [NO_WRITE_TO_BINLOG | LOCAL] TABLE t, ... [histogram]}, where the + * histogram clause is {@code UPDATE HISTOGRAM ON c, ... [WITH n BUCKETS] [{AUTO | MANUAL} UPDATE]}, + * {@code UPDATE HISTOGRAM ON c USING DATA 'json'} or {@code DROP HISTOGRAM ON c, ...}.
  • + *
  • PostgreSQL: {@code ANALYZE [(option [value], ...)] [target, ...]} and the unparenthesized + * {@code ANALYZE [VERBOSE] [target, ...]}, where a target can name columns. Without targets, every + * table of the database is analyzed. {@code ANALYZE VERBOSE t} holds the option {@code VERBOSE} + * like {@code ANALYZE (VERBOSE) t}, and {@link #isParenthesizedOptions()} tells them apart. The + * statement can also be spelled {@code ANALYSE}, which {@link #getCommand()} keeps.
  • + *
+ * The tables of both forms are kept as {@link MaintenanceTarget}s; {@link #getTables()} lists their + * tables. The modifier, the {@code TABLE} keyword and the histogram clause belong to the MySQL + * form, the options and the targets with {@code ONLY}, {@code *} or columns to the PostgreSQL form, + * and the parser accepts no statement that mixes them. + */ public class Analyze extends ASTNodeAccessImpl implements Statement { public enum Modifier { @@ -36,7 +56,22 @@ public enum HistogramUpdateMode { AUTO, MANUAL } - private final List tables = new ArrayList<>(); + /** The options that PostgreSQL 18 documents for ANALYZE. */ + public enum OptionKind { + VERBOSE, SKIP_LOCKED, BUFFER_USAGE_LIMIT + } + + /** The spelling of the statement; PostgreSQL also accepts {@code ANALYSE}. */ + public enum Command { + ANALYZE, ANALYSE + } + + private static final String[] KEYWORD_OPTIONS = {"VERBOSE"}; + + private final List targets = new ArrayList<>(); + private final List options = new ArrayList<>(); + private boolean parenthesizedOptions; + private Command command = Command.ANALYZE; private boolean tableKeyword; private Modifier modifier; private HistogramAction histogramAction; @@ -51,27 +86,106 @@ public T accept(StatementVisitor statementVisitor, S context) { } public Table getTable() { - return tables.isEmpty() ? null : tables.get(0); + return targets.isEmpty() ? null : targets.get(0).getTable(); } + /** Replaces the targets with the table; null removes every target. */ public void setTable(Table table) { - tables.clear(); + targets.clear(); if (table != null) { - tables.add(table); + targets.add(new MaintenanceTarget(table)); } } + /** A read-only snapshot of the tables of {@link #getTargets()}. */ public List
getTables() { - return Collections.unmodifiableList(tables); + return Collections.unmodifiableList(targets.stream().map(MaintenanceTarget::getTable) + .collect(Collectors.toList())); } + /** Replaces the targets with plain tables, without {@code ONLY} or columns. */ public void setTables(Collection tables) { - this.tables.clear(); + targets.clear(); if (tables != null) { - this.tables.addAll(tables); + tables.forEach(table -> targets.add(new MaintenanceTarget(table))); } } + /** The tables to analyze, with their PostgreSQL {@code ONLY} and column lists. */ + public List getTargets() { + return targets; + } + + public void setTargets(Collection targets) { + List replacement = + targets == null ? Collections.emptyList() : new ArrayList<>(targets); + this.targets.clear(); + this.targets.addAll(replacement); + } + + public Analyze addTargets(MaintenanceTarget... targets) { + Collections.addAll(this.targets, targets); + return this; + } + + public Analyze withTargets(Collection targets) { + setTargets(targets); + return this; + } + + /** The PostgreSQL options, such as {@code VERBOSE} or {@code SKIP_LOCKED}. */ + public List getOptions() { + return options; + } + + public void setOptions(Collection options) { + List replacement = + options == null ? Collections.emptyList() : new ArrayList<>(options); + this.options.clear(); + this.options.addAll(replacement); + } + + public Analyze addOptions(UtilityOption... options) { + Collections.addAll(this.options, options); + return this; + } + + public Analyze withOptions(Collection options) { + setOptions(options); + return this; + } + + /** + * Whether the options are written in parentheses. Options that the unparenthesized form cannot + * express are always rendered in parentheses. + */ + public boolean isParenthesizedOptions() { + return parenthesizedOptions; + } + + public void setParenthesizedOptions(boolean parenthesizedOptions) { + this.parenthesizedOptions = parenthesizedOptions; + } + + public Analyze withParenthesizedOptions(boolean parenthesizedOptions) { + setParenthesizedOptions(parenthesizedOptions); + return this; + } + + /** {@code ANALYZE}, or PostgreSQL's {@code ANALYSE}, which takes only the PostgreSQL form. */ + public Command getCommand() { + return command; + } + + public void setCommand(Command command) { + this.command = Objects.requireNonNull(command, "command"); + } + + public Analyze withCommand(Command command) { + setCommand(command); + return this; + } + public boolean isTableKeyword() { return tableKeyword; } @@ -134,16 +248,16 @@ public void setHistogramUpdateMode(HistogramUpdateMode histogramUpdateMode) { } @Override - public String toString() { - StringBuilder builder = new StringBuilder("ANALYZE"); + public StringBuilder appendTo(StringBuilder builder) { + builder.append(command); if (modifier != null) { builder.append(" ").append(modifier); } + UtilityOption.appendOptions(builder, options, parenthesizedOptions, KEYWORD_OPTIONS); if (tableKeyword) { builder.append(" TABLE"); } - builder.append(" ").append(tables.stream().map(Table::toString) - .collect(Collectors.joining(", "))); + MaintenanceTarget.appendTargets(builder, targets); if (histogramAction != null) { builder.append(" ").append(histogramAction).append(" HISTOGRAM ON ") .append(histogramColumns.stream().map(Column::toString) @@ -158,7 +272,12 @@ public String toString() { builder.append(" ").append(histogramUpdateMode).append(" UPDATE"); } } - return builder.toString(); + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); } public Analyze withTable(Table table) { @@ -171,8 +290,11 @@ public Analyze withTables(Collection tables) { return this; } + /** Adds plain tables, without {@code ONLY} or columns. */ public Analyze addTables(Table... tables) { - Collections.addAll(this.tables, tables); + for (Table table : tables) { + targets.add(new MaintenanceTarget(table)); + } return this; } diff --git a/src/main/java/net/sf/jsqlparser/statement/maintenance/CheckpointStatement.java b/src/main/java/net/sf/jsqlparser/statement/maintenance/CheckpointStatement.java new file mode 100644 index 0000000000..edec9138f6 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/maintenance/CheckpointStatement.java @@ -0,0 +1,33 @@ +/*- + * #%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.maintenance; + +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; + +/** PostgreSQL {@code CHECKPOINT}, which forces a write-ahead log checkpoint. */ +public class CheckpointStatement extends ASTNodeAccessImpl implements Statement { + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } + + @Override + public StringBuilder appendTo(StringBuilder builder) { + return builder.append("CHECKPOINT"); + } + + @Override + public String toString() { + return "CHECKPOINT"; + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/maintenance/ClusterStatement.java b/src/main/java/net/sf/jsqlparser/statement/maintenance/ClusterStatement.java new file mode 100644 index 0000000000..77d8fc70b4 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/maintenance/ClusterStatement.java @@ -0,0 +1,173 @@ +/*- + * #%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.maintenance; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; +import net.sf.jsqlparser.statement.UtilityOption; + +/** + * PostgreSQL {@code CLUSTER [(option [value], ...)] [table [USING index]]}, the unparenthesized + * {@code CLUSTER [VERBOSE] [table [USING index]]} and the pre-8.3 form + * {@code CLUSTER [VERBOSE] index ON table}. Without a table, every previously clustered table of + * the database is clustered again. + *

+ * {@code CLUSTER VERBOSE} holds the option {@code VERBOSE} like {@code CLUSTER (VERBOSE)}, and + * {@link #isParenthesizedOptions()} tells them apart. Both index forms keep the table and the + * index, and {@link #getIndexSyntax()} tells them apart. + */ +public class ClusterStatement extends ASTNodeAccessImpl implements Statement { + + /** The options that PostgreSQL 18 documents for CLUSTER. */ + public enum OptionKind { + VERBOSE + } + + /** How the index is named: {@code table USING index}, or the pre-8.3 {@code index ON table}. */ + public enum IndexSyntax { + USING, ON + } + + private static final String[] KEYWORD_OPTIONS = {"VERBOSE"}; + + private final List options = new ArrayList<>(); + private boolean parenthesizedOptions; + private Table table; + private String indexName; + private IndexSyntax indexSyntax = IndexSyntax.USING; + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } + + public List getOptions() { + return options; + } + + public void setOptions(Collection options) { + List replacement = + options == null ? Collections.emptyList() : new ArrayList<>(options); + this.options.clear(); + this.options.addAll(replacement); + } + + public ClusterStatement addOptions(UtilityOption... options) { + Collections.addAll(this.options, options); + return this; + } + + public ClusterStatement withOptions(Collection options) { + setOptions(options); + return this; + } + + /** + * Whether the options are written in parentheses. Options that the unparenthesized form cannot + * express are always rendered in parentheses. + */ + public boolean isParenthesizedOptions() { + return parenthesizedOptions; + } + + public void setParenthesizedOptions(boolean parenthesizedOptions) { + this.parenthesizedOptions = parenthesizedOptions; + } + + public ClusterStatement withParenthesizedOptions(boolean parenthesizedOptions) { + setParenthesizedOptions(parenthesizedOptions); + return this; + } + + /** The table to cluster, or null to cluster every previously clustered table. */ + public Table getTable() { + return table; + } + + /** Sets the table; without a table the index name is discarded too. */ + public void setTable(Table table) { + this.table = table; + if (table == null) { + indexName = null; + } + } + + public ClusterStatement withTable(Table table) { + setTable(table); + return this; + } + + /** The index of {@code USING index}, or null to use the index the table was clustered on. */ + public String getIndexName() { + return indexName; + } + + /** Sets the index; it requires a table. */ + public void setIndexName(String indexName) { + if (indexName != null && table == null) { + throw new IllegalStateException("CLUSTER ... USING requires a table"); + } + this.indexName = indexName; + } + + public ClusterStatement withIndexName(String indexName) { + setIndexName(indexName); + return this; + } + + /** + * {@code USING}, or {@code ON} for {@code CLUSTER [VERBOSE] index ON table}. The {@code ON} + * form is rendered only with a table, an index and options that it can express, which is at + * most an unparenthesized {@code VERBOSE}; otherwise the {@code USING} form is rendered. + */ + public IndexSyntax getIndexSyntax() { + return indexSyntax; + } + + public void setIndexSyntax(IndexSyntax indexSyntax) { + this.indexSyntax = Objects.requireNonNull(indexSyntax, "indexSyntax"); + } + + public ClusterStatement withIndexSyntax(IndexSyntax indexSyntax) { + setIndexSyntax(indexSyntax); + return this; + } + + @Override + public StringBuilder appendTo(StringBuilder builder) { + builder.append("CLUSTER"); + if (indexSyntax == IndexSyntax.ON && table != null && indexName != null + && !parenthesizedOptions && UtilityOption.isKeywordForm(options, KEYWORD_OPTIONS)) { + UtilityOption.appendOptions(builder, options, false, KEYWORD_OPTIONS); + return builder.append(' ').append(indexName).append(" ON ") + .append(table.getFullyQualifiedName()); + } + UtilityOption.appendOptions(builder, options, parenthesizedOptions, KEYWORD_OPTIONS); + if (table != null) { + builder.append(' ').append(table.getFullyQualifiedName()); + if (indexName != null) { + builder.append(" USING ").append(indexName); + } + } + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/maintenance/MaintenanceTarget.java b/src/main/java/net/sf/jsqlparser/statement/maintenance/MaintenanceTarget.java new file mode 100644 index 0000000000..0feb551126 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/maintenance/MaintenanceTarget.java @@ -0,0 +1,114 @@ +/*- + * #%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.maintenance; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.schema.TableReference; + +/** + * A table processed by PostgreSQL {@code VACUUM} or {@code ANALYZE}: + * {@code [ONLY] table [*] [(column, ...)]}. The relation reuses {@link TableReference}, so + * {@code ONLY} and {@code *} are described as in {@code LOCK} and {@code TRUNCATE}. + */ +public class MaintenanceTarget extends ASTNodeAccessImpl { + + private TableReference reference; + private final List columns = new ArrayList<>(); + + public MaintenanceTarget(Table table) { + this(new TableReference(table)); + } + + public MaintenanceTarget(TableReference reference) { + setReference(reference); + } + + public TableReference getReference() { + return reference; + } + + public void setReference(TableReference reference) { + this.reference = Objects.requireNonNull(reference, "reference"); + } + + public Table getTable() { + return reference.getTable(); + } + + /** Replaces the table and keeps {@code ONLY} or {@code *}. */ + public void setTable(Table table) { + reference.setTable(table); + } + + /** Whether the target is a plain table name, without {@code ONLY}, {@code *} or columns. */ + public boolean isPlainTable() { + return !reference.isOnly() && !reference.isIncludeDescendants() && columns.isEmpty(); + } + + /** The columns to analyze; empty for all columns. */ + public List getColumns() { + return columns; + } + + public void setColumns(Collection columns) { + List replacement = + columns == null ? Collections.emptyList() : new ArrayList<>(columns); + this.columns.clear(); + this.columns.addAll(replacement); + } + + public MaintenanceTarget addColumns(Column... columns) { + Collections.addAll(this.columns, columns); + return this; + } + + public MaintenanceTarget withColumns(Collection columns) { + setColumns(columns); + return this; + } + + @Override + public StringBuilder appendTo(StringBuilder builder) { + reference.appendTo(builder); + if (!columns.isEmpty()) { + builder.append(" ("); + for (int i = 0; i < columns.size(); i++) { + if (i > 0) { + builder.append(", "); + } + builder.append(columns.get(i).getFullyQualifiedName()); + } + builder.append(')'); + } + return builder; + } + + /** Appends the targets after a space, separated by commas; nothing without targets. */ + public static StringBuilder appendTargets(StringBuilder builder, + List targets) { + for (int i = 0; i < targets.size(); i++) { + builder.append(i == 0 ? " " : ", "); + targets.get(i).appendTo(builder); + } + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/maintenance/VacuumStatement.java b/src/main/java/net/sf/jsqlparser/statement/maintenance/VacuumStatement.java new file mode 100644 index 0000000000..b8f41e1d0d --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/maintenance/VacuumStatement.java @@ -0,0 +1,123 @@ +/*- + * #%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.maintenance; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; +import net.sf.jsqlparser.statement.UtilityOption; + +/** + * PostgreSQL {@code VACUUM [(option [value], ...)] [target, ...]} and the unparenthesized + * {@code VACUUM [FULL] [FREEZE] [VERBOSE] [ANALYZE] [target, ...]}. + *

+ * Both forms keep their options in {@link #getOptions()}: {@code VACUUM FULL t} holds the option + * {@code FULL} like {@code VACUUM (FULL) t}, and {@link #isParenthesizedOptions()} tells them + * apart. + */ +public class VacuumStatement extends ASTNodeAccessImpl implements Statement { + + /** + * The options that PostgreSQL 18 documents for VACUUM; see + * {@link UtilityOption#getKind(Class)}. + */ + public enum OptionKind { + FULL, FREEZE, VERBOSE, ANALYZE, DISABLE_PAGE_SKIPPING, SKIP_LOCKED, INDEX_CLEANUP, PROCESS_MAIN, PROCESS_TOAST, TRUNCATE, PARALLEL, SKIP_DATABASE_STATS, ONLY_DATABASE_STATS, BUFFER_USAGE_LIMIT + } + + /** The options of the unparenthesized form, in the order that PostgreSQL requires. */ + private static final String[] KEYWORD_OPTIONS = {"FULL", "FREEZE", "VERBOSE", "ANALYZE"}; + + private final List options = new ArrayList<>(); + private boolean parenthesizedOptions; + private final List targets = new ArrayList<>(); + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } + + public List getOptions() { + return options; + } + + public void setOptions(Collection options) { + replace(this.options, options); + } + + public VacuumStatement addOptions(UtilityOption... options) { + Collections.addAll(this.options, options); + return this; + } + + public VacuumStatement withOptions(Collection options) { + setOptions(options); + return this; + } + + /** + * Whether the options are written in parentheses. Options that the unparenthesized form cannot + * express are always rendered in parentheses. + */ + public boolean isParenthesizedOptions() { + return parenthesizedOptions; + } + + public void setParenthesizedOptions(boolean parenthesizedOptions) { + this.parenthesizedOptions = parenthesizedOptions; + } + + public VacuumStatement withParenthesizedOptions(boolean parenthesizedOptions) { + setParenthesizedOptions(parenthesizedOptions); + return this; + } + + /** The tables to vacuum; empty for every table of the database. */ + public List getTargets() { + return targets; + } + + public void setTargets(Collection targets) { + replace(this.targets, targets); + } + + public VacuumStatement addTargets(MaintenanceTarget... targets) { + Collections.addAll(this.targets, targets); + return this; + } + + public VacuumStatement withTargets(Collection targets) { + setTargets(targets); + return this; + } + + private static void replace(List list, Collection elements) { + List replacement = + elements == null ? Collections.emptyList() : new ArrayList<>(elements); + list.clear(); + list.addAll(replacement); + } + + @Override + public StringBuilder appendTo(StringBuilder builder) { + builder.append("VACUUM"); + UtilityOption.appendOptions(builder, options, parenthesizedOptions, KEYWORD_OPTIONS); + return MaintenanceTarget.appendTargets(builder, targets); + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index 7befd9beb2..a578249716 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -195,6 +195,9 @@ import net.sf.jsqlparser.statement.insert.ParenthesedInsert; import net.sf.jsqlparser.statement.load.LoadDataStatement; import net.sf.jsqlparser.statement.lock.LockStatement; +import net.sf.jsqlparser.statement.maintenance.CheckpointStatement; +import net.sf.jsqlparser.statement.maintenance.ClusterStatement; +import net.sf.jsqlparser.statement.maintenance.VacuumStatement; import net.sf.jsqlparser.statement.merge.Merge; import net.sf.jsqlparser.statement.merge.MergeDelete; import net.sf.jsqlparser.statement.merge.MergeInsert; @@ -1727,6 +1730,41 @@ public void visit(TableMaintenanceStatement statement) { StatementVisitor.super.visit(statement); } + @Override + public Void visit(VacuumStatement vacuum, S context) { + // the column lists name columns of these tables, not tables + vacuum.getTargets().forEach(target -> visit(target.getTable(), context)); + return null; + } + + @Override + public void visit(VacuumStatement vacuum) { + StatementVisitor.super.visit(vacuum); + } + + @Override + public Void visit(ClusterStatement cluster, S context) { + if (cluster.getTable() != null) { + visit(cluster.getTable(), context); + } + return null; + } + + @Override + public void visit(ClusterStatement cluster) { + StatementVisitor.super.visit(cluster); + } + + @Override + public Void visit(CheckpointStatement checkpoint, S context) { + return null; + } + + @Override + public void visit(CheckpointStatement checkpoint) { + StatementVisitor.super.visit(checkpoint); + } + @Override public Void visit(Drop drop, S context) { drop.visitTables(table -> visit(table, context)); diff --git a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java index 6a3e88c7cd..2fff9c94a2 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -122,6 +122,9 @@ import net.sf.jsqlparser.statement.insert.InsertBulk; import net.sf.jsqlparser.statement.insert.ParenthesedInsert; import net.sf.jsqlparser.statement.lock.LockStatement; +import net.sf.jsqlparser.statement.maintenance.CheckpointStatement; +import net.sf.jsqlparser.statement.maintenance.ClusterStatement; +import net.sf.jsqlparser.statement.maintenance.VacuumStatement; import net.sf.jsqlparser.statement.merge.Merge; import net.sf.jsqlparser.statement.refresh.RefreshMaterializedViewStatement; import net.sf.jsqlparser.statement.reindex.Reindex; @@ -377,9 +380,9 @@ public StringBuilder visit(Update update, S context) { return builder; } + @Override public StringBuilder visit(Analyze analyzer, S context) { - builder.append(analyzer); - return builder; + return analyzer.appendTo(builder); } @Override @@ -388,6 +391,21 @@ public StringBuilder visit(TableMaintenanceStatement statement, S context) { return builder; } + @Override + public StringBuilder visit(VacuumStatement vacuum, S context) { + return vacuum.appendTo(builder); + } + + @Override + public StringBuilder visit(ClusterStatement cluster, S context) { + return cluster.appendTo(builder); + } + + @Override + public StringBuilder visit(CheckpointStatement checkpoint, S context) { + return checkpoint.appendTo(builder); + } + @Override public StringBuilder visit(Alter alter, S context) { AlterDeParser alterDeParser = new AlterDeParser(builder, expressionDeParser); diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java index f4ca890270..14d59b54a2 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/MariaDbVersion.java @@ -81,7 +81,7 @@ public enum MariaDbVersion implements Version { Feature.truncate, // https://mariadb.com/kb/en/table-statements/ - Feature.tableMaintenance, + Feature.analyze, Feature.tableMaintenance, // https://mariadb.com/kb/en/account-management-sql-statements/ Feature.createRole, Feature.alterUser, Feature.renameUser, Feature.setRole, diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java index 17a1eae4d7..97f0879f1a 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/MySqlVersion.java @@ -77,7 +77,7 @@ public enum MySqlVersion implements Version { Feature.truncate, // https://dev.mysql.com/doc/refman/8.0/en/table-maintenance-statements.html - Feature.tableMaintenance, + Feature.analyze, Feature.tableMaintenance, // https://dev.mysql.com/doc/refman/8.0/en/account-management-statements.html Feature.createRole, Feature.alterUser, Feature.renameUser, Feature.setRole, diff --git a/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java b/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java index b0d8115d91..c9c08bb563 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/feature/PostgresqlVersion.java @@ -166,6 +166,12 @@ public enum PostgresqlVersion implements Version { Feature.deleteReturningExpressionList, // https://www.postgresql.org/docs/current/sql-truncate.html Feature.truncate, + // https://www.postgresql.org/docs/current/sql-analyze.html + Feature.analyze, // https://www.postgresql.org/docs/current/sql-vacuum.html + Feature.vacuum, // https://www.postgresql.org/docs/current/sql-cluster.html + Feature.cluster, // https://www.postgresql.org/docs/current/sql-checkpoint.html + Feature.checkpoint, + // https://www.postgresql.org/docs/current/sql-call.html Feature.execute, Feature.executeCall, diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/ClusterValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/ClusterValidator.java new file mode 100644 index 0000000000..de3e495a35 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/ClusterValidator.java @@ -0,0 +1,28 @@ +/*- + * #%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.util.validation.validator; + +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.statement.maintenance.ClusterStatement; +import net.sf.jsqlparser.util.validation.ValidationCapability; +import net.sf.jsqlparser.util.validation.metadata.NamedObject; + +public class ClusterValidator extends AbstractValidator { + @Override + public void validate(ClusterStatement cluster) { + for (ValidationCapability c : getCapabilities()) { + validateFeature(c, Feature.cluster); + if (cluster.getTable() != null) { + validateName(c, NamedObject.table, cluster.getTable().getFullyQualifiedName(), + true); + } + } + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java index e15bded789..ffe0b44d58 100644 --- a/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/StatementValidator.java @@ -124,6 +124,9 @@ import net.sf.jsqlparser.statement.insert.InsertBulk; import net.sf.jsqlparser.statement.insert.ParenthesedInsert; import net.sf.jsqlparser.statement.lock.LockStatement; +import net.sf.jsqlparser.statement.maintenance.CheckpointStatement; +import net.sf.jsqlparser.statement.maintenance.ClusterStatement; +import net.sf.jsqlparser.statement.maintenance.VacuumStatement; import net.sf.jsqlparser.statement.merge.Merge; import net.sf.jsqlparser.statement.refresh.RefreshMaterializedViewStatement; import net.sf.jsqlparser.statement.reindex.Reindex; @@ -639,6 +642,24 @@ public Void visit(TableMaintenanceStatement statement, S context) { return null; } + @Override + public Void visit(VacuumStatement vacuum, S context) { + getValidator(VacuumValidator.class).validate(vacuum); + return null; + } + + @Override + public Void visit(ClusterStatement cluster, S context) { + getValidator(ClusterValidator.class).validate(cluster); + return null; + } + + @Override + public Void visit(CheckpointStatement checkpoint, S context) { + validateFeature(Feature.checkpoint); + return null; + } + @Override public Void visit(SavepointStatement savepointStatement, S context) { // TODO: not yet implemented @@ -992,6 +1013,21 @@ public void visit(TableMaintenanceStatement statement) { visit(statement, null); } + @Override + public void visit(VacuumStatement vacuum) { + visit(vacuum, null); + } + + @Override + public void visit(ClusterStatement cluster) { + visit(cluster, null); + } + + @Override + public void visit(CheckpointStatement checkpoint) { + visit(checkpoint, null); + } + public void visit(SavepointStatement savepointStatement) { visit(savepointStatement, null); } diff --git a/src/main/java/net/sf/jsqlparser/util/validation/validator/VacuumValidator.java b/src/main/java/net/sf/jsqlparser/util/validation/validator/VacuumValidator.java new file mode 100644 index 0000000000..656ed348c4 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/util/validation/validator/VacuumValidator.java @@ -0,0 +1,26 @@ +/*- + * #%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.util.validation.validator; + +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.statement.maintenance.VacuumStatement; +import net.sf.jsqlparser.util.validation.ValidationCapability; +import net.sf.jsqlparser.util.validation.metadata.NamedObject; + +public class VacuumValidator extends AbstractValidator { + @Override + public void validate(VacuumStatement vacuum) { + for (ValidationCapability c : getCapabilities()) { + validateFeature(c, Feature.vacuum); + vacuum.getTargets().forEach(target -> validateName(c, NamedObject.table, + target.getTable().getFullyQualifiedName(), true)); + } + } +} diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 087c530b43..1c1a49e6ee 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -48,6 +48,7 @@ import net.sf.jsqlparser.expression.operators.relational.*; import net.sf.jsqlparser.schema.*; import net.sf.jsqlparser.statement.*; import net.sf.jsqlparser.statement.analyze.*; +import net.sf.jsqlparser.statement.maintenance.*; import net.sf.jsqlparser.statement.alter.*; import net.sf.jsqlparser.statement.create.fdw.*; import net.sf.jsqlparser.statement.create.server.CreateServer; @@ -171,6 +172,8 @@ public class CCJSqlParser extends AbstractJSqlParser { return "LABEL".equalsIgnoreCase(getToken(2).image); case K_REINDEX: return isReindexAhead(); + case K_VACUUM: case K_ANALYSE: case K_CLUSTER: case K_CHECKPOINT: + return isPostgreSqlMaintenanceAhead(); case K_IMPORT: case K_EXPORT: return Dialect.EXASOL.name().equals(getAsString(Feature.dialect)) || next.kind == K_EXPORT && getToken(2).kind == K_DATA; @@ -2540,6 +2543,124 @@ public class CCJSqlParser extends AbstractJSqlParser { || next == K_SYSTEM || "(".equals(getToken(2).image); } + /** The PostgreSQL forms of VACUUM, ANALYZE and CLUSTER: with that dialect or without one. */ + private boolean isPostgreSqlMaintenanceDialect() { + String dialect = getAsString(Feature.dialect); + return dialect == null || Dialect.POSTGRESQL.name().equals(dialect); + } + + /** + * PostgreSQL 18's reserved keywords. Its grammar takes none of them as the name or the word + * value of a utility option, except ANALYZE and ANALYSE as names. + */ + private static final Set POSTGRESQL_RESERVED_KEYWORDS = new HashSet(Arrays.asList( + "ALL", "ANALYSE", "ANALYZE", "AND", "ANY", "ARRAY", "AS", "ASC", "ASYMMETRIC", "BOTH", + "CASE", "CAST", "CHECK", "COLLATE", "COLUMN", "CONSTRAINT", "CREATE", "CURRENT_CATALOG", + "CURRENT_DATE", "CURRENT_ROLE", "CURRENT_TIME", "CURRENT_TIMESTAMP", "CURRENT_USER", + "DEFAULT", "DEFERRABLE", "DESC", "DISTINCT", "DO", "ELSE", "END", "EXCEPT", "FALSE", + "FETCH", "FOR", "FOREIGN", "FROM", "GRANT", "GROUP", "HAVING", "IN", "INITIALLY", + "INTERSECT", "INTO", "LATERAL", "LEADING", "LIMIT", "LOCALTIME", "LOCALTIMESTAMP", "NOT", + "NULL", "OFFSET", "ON", "ONLY", "OR", "ORDER", "PLACING", "PRIMARY", "REFERENCES", + "RETURNING", "SELECT", "SESSION_USER", "SOME", "SYMMETRIC", "SYSTEM_USER", "TABLE", "THEN", + "TO", "TRAILING", "TRUE", "UNION", "UNIQUE", "USER", "USING", "VARIADIC", "WHEN", "WHERE", + "WINDOW", "WITH")); + + /** Rejects an unquoted utility option name or word value that PostgreSQL reserves. */ + private void requireUtilityOptionWord(String word, boolean name) throws ParseException { + String upper = word.toUpperCase(Locale.ROOT); + requireDdlSyntax(!POSTGRESQL_RESERVED_KEYWORDS.contains(upper) + || name && ("ANALYZE".equals(upper) || "ANALYSE".equals(upper)), + "PostgreSQL reserves " + word + " in utility options"); + } + + /** Whether a PostgreSQL identifier in double quotes follows, not one in brackets or backticks. */ + private boolean isDoubleQuotedIdentifierAhead() { + Token token = getToken(1); + return token.kind == S_QUOTED_IDENTIFIER && token.image.startsWith("\""); + } + + /** + * Whether a PostgreSQL VACUUM, CLUSTER or CHECKPOINT statement, or ANALYZE spelled ANALYSE, + * starts here. The PostgreSQL dialect always reads these words as statements, and other + * dialects never do. Without a dialect the next tokens must take the PostgreSQL form, so that + * other products' variants such as SQLite's VACUUM INTO or SQL Server's CHECKPOINT 10 stay + * unsupported statements. + */ + private boolean isPostgreSqlMaintenanceAhead() { + int kind = getToken(1).kind; + if (kind != K_VACUUM && kind != K_ANALYSE && kind != K_CLUSTER && kind != K_CHECKPOINT + || !isPostgreSqlMaintenanceDialect()) { + return false; + } + Token next = getToken(2); + if (getAsString(Feature.dialect) != null || next.kind == EOF || next.kind == ST_SEMICOLON) { + return true; + } + return kind != K_CHECKPOINT && isPostgreSqlMaintenanceFormAhead(kind, next); + } + + /** Whether the token after VACUUM, ANALYSE or CLUSTER starts the PostgreSQL form. */ + private boolean isPostgreSqlMaintenanceFormAhead(int kind, Token next) { + if (next.kind == OPENING_BRACKET || next.kind == K_VERBOSE) { + return true; + } + boolean targets = kind != K_CLUSTER; + if (targets && next.kind == K_ONLY) { + return true; + } + if (kind == K_VACUUM && (next.kind == K_FULL || next.kind == K_ANALYZE + || next.kind == K_ANALYSE || "FREEZE".equalsIgnoreCase(next.image))) { + return true; + } + return isIdentifierishNameToken(next) && isMaintenanceTableEnd(getToken(3), targets); + } + + /** + * Whether the token can follow the first part of a VACUUM, ANALYZE or CLUSTER table name, or + * the index name of CLUSTER index ON table. + */ + private static boolean isMaintenanceTableEnd(Token token, boolean withColumns) { + if (token.kind == EOF || token.kind == ST_SEMICOLON || ".".equals(token.image)) { + return true; + } + return withColumns ? token.kind == OPENING_BRACKET || ",".equals(token.image) + || "*".equals(token.image) : token.kind == K_USING || token.kind == K_ON; + } + + /** + * Whether VERBOSE is the PostgreSQL option of ANALYZE. Without a dialect a table name must + * follow, so ANALYZE verbose still analyzes a table named verbose; other dialects keep that + * reading. + */ + private boolean isAnalyzeVerboseAhead() { + if (getToken(1).kind != K_VERBOSE || !isPostgreSqlMaintenanceDialect()) { + return false; + } + Token next = getToken(2); + return getAsString(Feature.dialect) != null || next.kind == K_ONLY + || isIdentifierishNameToken(next) && isMaintenanceTableEnd(getToken(3), true); + } + + /** + * MySQL's modifier, TABLE keyword and histogram clause, which carries the bucket count, the + * update mode and the JSON data, require tables and exclude the PostgreSQL options and target + * forms; other dialects require tables. + */ + private void requireAnalyzeForm(Analyze analyze) throws ParseException { + boolean mySqlForm = analyze.getModifier() != null || analyze.isTableKeyword() + || analyze.getHistogramAction() != null; + requireDdlSyntax(!mySqlForm || analyze.getCommand() == Analyze.Command.ANALYZE, + "MySQL ANALYZE clauses require the ANALYZE spelling"); + requireDdlSyntax(!analyze.getTargets().isEmpty() + || !mySqlForm && isPostgreSqlMaintenanceDialect(), "ANALYZE requires a table"); + boolean postgreSqlForm = !analyze.getOptions().isEmpty(); + for (MaintenanceTarget target : analyze.getTargets()) { + postgreSqlForm |= !target.isPlainTable(); + } + requireDdlSyntax(!mySqlForm || !postgreSqlForm, + "MySQL ANALYZE clauses cannot be combined with PostgreSQL options or targets"); + } + /** Whether the next tokens spell an option accepted by this table maintenance operation. */ private boolean isTableMaintenanceOptionAhead(TableMaintenanceStatement.Operation operation) { Token token = getToken(1); @@ -4038,6 +4159,7 @@ String NonReservedWordPart1() : | tk= | tk= | tk= + | tk= | tk= | tk= | tk= @@ -4396,6 +4518,7 @@ String NonReservedWordPart2() : | tk= | tk= | tk= + | tk= | tk= | tk= | tk= @@ -5011,8 +5134,12 @@ Statement SingleStatement() : LOOKAHEAD({ getToken(1).kind == K_REASSIGN && "OWNED".equalsIgnoreCase(getToken(2).image) }) stm = ReassignOwned() | + LOOKAHEAD({ getToken(1).kind == K_ANALYZE + || getToken(1).kind == K_ANALYSE && isPostgreSqlMaintenanceAhead() }) stm = Analyze() | + LOOKAHEAD({ isPostgreSqlMaintenanceAhead() }) stm = PostgreSqlMaintenance() + | LOOKAHEAD({ isTableMaintenanceAhead() }) stm = TableMaintenanceStatement() | LOOKAHEAD({ isReindexAhead() }) stm = Reindex() @@ -7187,21 +7314,16 @@ List PostgresqlExplainOptions(): { return options; } } +/** An element of the shared utility option list, kept in the EXPLAIN option model. */ ExplainStatement.Option PostgresqlExplainOption(): { - Token name; - Token value = null; + UtilityOption option; } { - ( name = | name = | name = - | name = | name = | name = - | name = ) - [ ( value = | value = | value = | value = - | value = | value = | value = | value = - | value = | value = | value = | value = ) ] + option = UtilityOption() { - return new ExplainStatement.Option(postgresqlExplainOptionType(name.image)) - .withValue(value == null ? null : value.image); + return new ExplainStatement.Option(postgresqlExplainOptionType(option.getName())) + .withValue(option.getValue()); } } @@ -19617,26 +19739,43 @@ TableMaintenanceStatement TableMaintenanceStatement(): } } +/** + * MySQL ANALYZE [NO_WRITE_TO_BINLOG | LOCAL] TABLE t, ... [histogram] and, with the PostgreSQL + * dialect or without a dialect, PostgreSQL {ANALYZE | ANALYSE} [(options) | VERBOSE] [target, ...]. + * SingleStatement chooses the ANALYSE spelling only by isPostgreSqlMaintenanceAhead(). + */ Analyze Analyze(): { Analyze analyze = new Analyze(); - Table table = null; + List options; + List targets; Column column = null; + Token token = null; Token modifier = null; Token histogramAction = null; Token bucketCount = null; Token data; } { - + ( | { analyze.setCommand(Analyze.Command.ANALYSE); } ) [ LOOKAHEAD(2) (modifier= | modifier=) { analyze.setModifier(Analyze.Modifier.valueOf(modifier.image.toUpperCase(Locale.ROOT))); } ] + [ + LOOKAHEAD({ getToken(1).kind == OPENING_BRACKET && isPostgreSqlMaintenanceDialect() }) + options=UtilityOptionList() + { + analyze.setOptions(options); + analyze.setParenthesizedOptions(true); + } + | + LOOKAHEAD({ isAnalyzeVerboseAhead() }) + token= { analyze.addOptions(linkToLastToken(new UtilityOption(token.image))); } + ] [ LOOKAHEAD(2) { analyze.setTableKeyword(true); } ] - table=Table() { analyze.addTables(table); } - ( "," table=Table() { analyze.addTables(table); } )* + [ LOOKAHEAD(1) targets=MaintenanceTargetList() { analyze.setTargets(targets); } ] [ LOOKAHEAD(2) (histogramAction= | histogramAction=) @@ -19670,12 +19809,244 @@ Analyze Analyze(): ] ) ] - { + requireAnalyzeForm(analyze); return analyze; } } +/** + * PostgreSQL VACUUM, CLUSTER and CHECKPOINT, chosen by isPostgreSqlMaintenanceAhead(). The ANALYSE + * spelling, which that check accepts too, is routed to Analyze() before. + */ +Statement PostgreSqlMaintenance(): +{ + Statement statement; +} +{ + ( + LOOKAHEAD({ getToken(1).kind == K_CHECKPOINT }) + { statement = new CheckpointStatement(); } + | + LOOKAHEAD({ getToken(1).kind == K_VACUUM }) statement=Vacuum() + | + statement=Cluster() + ) + { return statement; } +} + +/** + * PostgreSQL VACUUM [(options)] [target, ...] and VACUUM [FULL] [FREEZE] [VERBOSE] [ANALYZE] + * [target, ...]. As in PostgreSQL, the four words are always options here, never table names. + */ +VacuumStatement Vacuum(): +{ + VacuumStatement statement = new VacuumStatement(); + List options; + List targets; + Token token; +} +{ + + ( + LOOKAHEAD({ getToken(1).kind == OPENING_BRACKET }) + options=UtilityOptionList() + { + statement.setOptions(options); + statement.setParenthesizedOptions(true); + } + | + [ + // FULL can name a table elsewhere, but directly after VACUUM it is the option + LOOKAHEAD({ getToken(1).kind == K_FULL }) + token= { statement.addOptions(linkToLastToken(new UtilityOption(token.image))); } + ] + [ + LOOKAHEAD({ isKeywordAhead("FREEZE") }) + token= { statement.addOptions(linkToLastToken(new UtilityOption(token.image))); } + ] + [ + LOOKAHEAD({ getToken(1).kind == K_VERBOSE }) + token= { statement.addOptions(linkToLastToken(new UtilityOption(token.image))); } + ] + [ + LOOKAHEAD({ getToken(1).kind == K_ANALYZE || getToken(1).kind == K_ANALYSE }) + ( token= | token= ) + { statement.addOptions(linkToLastToken(new UtilityOption(token.image))); } + ] + ) + [ LOOKAHEAD(1) targets=MaintenanceTargetList() { statement.setTargets(targets); } ] + { return statement; } +} + +/** + * PostgreSQL CLUSTER (options) [table [USING index]], CLUSTER [VERBOSE] [table [USING index]] and + * the pre-8.3 form CLUSTER [VERBOSE] index ON table, which takes no option list. + */ +ClusterStatement Cluster(): +{ + ClusterStatement statement = new ClusterStatement(); + List options; + Table table; + String index; + Token token; +} +{ + + ( + LOOKAHEAD({ getToken(1).kind == OPENING_BRACKET }) + options=UtilityOptionList() + { + statement.setOptions(options); + statement.setParenthesizedOptions(true); + } + [ LOOKAHEAD(1) ClusterTable(statement) ] + | + [ + LOOKAHEAD({ getToken(1).kind == K_VERBOSE }) + token= { statement.addOptions(linkToLastToken(new UtilityOption(token.image))); } + ] + [ + LOOKAHEAD({ getToken(2).kind == K_ON && isIdentifierishNameToken(getToken(1)) }) + index=RelObjectName() table=Table() + { + statement.setTable(table); + statement.setIndexName(index); + statement.setIndexSyntax(ClusterStatement.IndexSyntax.ON); + } + | + LOOKAHEAD(1) ClusterTable(statement) + ] + ) + { return statement; } +} + +/** The table [USING index] of CLUSTER. */ +void ClusterTable(ClusterStatement statement): +{ + Table table; + String index; +} +{ + table=Table() { statement.setTable(table); } + [ index=RelObjectName() { statement.setIndexName(index); } ] +} + +/** The tables of PostgreSQL VACUUM and ANALYZE, separated by commas. */ +List MaintenanceTargetList(): +{ + List targets = new ArrayList(); + MaintenanceTarget target; +} +{ + target=MaintenanceTarget() { targets.add(target); } + ( "," target=MaintenanceTarget() { targets.add(target); } )* + { return targets; } +} + +/** + * [ONLY] table [*] [(column, ...)], reusing the relation target of LOCK and TRUNCATE. Only the + * PostgreSQL dialect and no dialect accept more than a table name. + */ +MaintenanceTarget MaintenanceTarget(): +{ + Token first = getToken(1); + TableReference reference; + MaintenanceTarget target; + String column; +} +{ + reference=RelationTarget() { target = new MaintenanceTarget(reference); } + [ + LOOKAHEAD({ getToken(1).kind == OPENING_BRACKET && isPostgreSqlMaintenanceDialect() }) + "(" column=RelObjectName() { target.addColumns(linkToLastToken(new Column(column))); } + ( "," column=RelObjectName() { target.addColumns(linkToLastToken(new Column(column))); } )* + ")" + ] + { + requireDdlSyntax(target.isPlainTable() || isPostgreSqlMaintenanceDialect(), + "ONLY, * and column lists require the PostgreSQL dialect"); + return linked(target, first); + } +} + +/** + * PostgreSQL utility_option_list, shared by VACUUM, ANALYZE and CLUSTER: (name [value], ...). + * As in PostgreSQL's grammar any name and value is accepted; the server checks them when it runs + * the statement. EXPLAIN reads its options through UtilityOption() and maps them to its own model. + */ +List UtilityOptionList(): +{ + List options = new ArrayList(); + UtilityOption option; +} +{ + "(" option=UtilityOption() { options.add(option); } + ( "," option=UtilityOption() { options.add(option); } )* + ")" + { return options; } +} + +UtilityOption UtilityOption(): +{ + Token first = getToken(1); + Token token; + String name; + UtilityOption option; +} +{ + ( + LOOKAHEAD({ isDoubleQuotedIdentifierAhead() }) token= { name = token.image; } + | + ( + token= { name = token.image; } + | token= { name = token.image; } + | name=NonReservedWord() + ) + { requireUtilityOptionWord(name, true); } + ) + { option = new UtilityOption(name); } + [ UtilityOptionArgument(option) ] + { return linked(option, first); } +} + +/** + * The value of a utility option, as PostgreSQL's utility_option_arg: a Boolean word, a signed + * number, a string, or another word or double-quoted identifier. Names and words are identifiers + * and JSqlParser's non-reserved words, which PostgreSQL does not reserve. + */ +void UtilityOptionArgument(UtilityOption option): +{ + Token token; + Token sign = null; + String word; +} +{ + ( + ( token= | token= | token= ) + { option.setValue(UtilityOption.ValueType.BOOLEAN, token.image); } + | + [ sign="+" | sign="-" ] ( token= | token= ) + { + option.setValue(UtilityOption.ValueType.NUMBER, + sign == null ? token.image : sign.image + token.image); + } + | + token= { option.setValue(UtilityOption.ValueType.STRING, token.image); } + | + LOOKAHEAD({ isDoubleQuotedIdentifierAhead() }) + token= { option.setValue(UtilityOption.ValueType.WORD, token.image); } + | + ( token= { word = token.image; } | token= { word = token.image; } + | word=NonReservedWord() ) + { + requireUtilityOptionWord(word, false); + option.setValue(UtilityOption.isBooleanWord(word) ? UtilityOption.ValueType.BOOLEAN + : UtilityOption.ValueType.WORD, word); + } + ) +} + /** Parses PostgreSQL REINDEX; only DATABASE and SYSTEM may omit the name. */ Reindex Reindex(): { @@ -20230,6 +20601,8 @@ List CreateParameter(): | tk= | tk= | tk= | tk= | tk= | tk = | tk = | tk= | tk= | tk= | tk= | tk= | tk= | tk= + // BigQuery CLUSTER BY; CLUSTER became a token for the PostgreSQL statement + | tk= // MySQL index_option / algorithm_option / lock_option keywords, e.g. the trailing // "KEY_BLOCK_SIZE = 8 ALGORITHM = INPLACE LOCK = NONE" of CREATE INDEX, the // "WITH PARSER" option, and the FULLTEXT / SPATIAL index types of CREATE INDEX. @@ -22531,7 +22904,7 @@ RelationAlterAction PostgreSqlRelationAction(): } { ( - LOOKAHEAD({ isKeywordAhead("CLUSTER") }) ContextualKeyword("CLUSTER") name=RelObjectName() + LOOKAHEAD({ isKeywordAhead("CLUSTER") }) name=RelObjectName() { action.setKind(RelationAlterAction.Kind.CLUSTER_ON); action.setValue(name); } | name=AccessQualifiedName() @@ -22575,7 +22948,7 @@ RelationAlterAction PostgreSqlRelationAction(): ( - ( LOOKAHEAD({ isKeywordAhead("CLUSTER") }) ContextualKeyword("CLUSTER") + ( LOOKAHEAD({ isKeywordAhead("CLUSTER") }) { action.setKind(RelationAlterAction.Kind.SET_WITHOUT_CLUSTER); } | ContextualKeyword("OIDS") { action.setKind(RelationAlterAction.Kind.SET_WITHOUT_OIDS); } ) | { action.setKind(RelationAlterAction.Kind.SET_UNLOGGED); } diff --git a/src/site/sphinx/index.rst b/src/site/sphinx/index.rst index a5a6969f6a..ee0a400af7 100644 --- a/src/site/sphinx/index.rst +++ b/src/site/sphinx/index.rst @@ -160,6 +160,8 @@ One grammar covers the SQL standard plus all major RDBMS. Missing syntax gets ad - ``DROP OWNED`` · ``REASSIGN OWNED`` * - **MySQL table maintenance** - ``ANALYZE`` · ``CHECK`` · ``CHECKSUM`` · ``OPTIMIZE`` · ``REPAIR TABLE`` + * - **PostgreSQL maintenance** + - ``VACUUM`` · ``ANALYZE`` · ``CLUSTER`` · ``CHECKPOINT`` * - **MySQL account management** - ``CREATE``/``ALTER``/``RENAME USER`` · ``CREATE ROLE`` · ``SET ROLE`` · ``SET DEFAULT ROLE`` · ``SET PASSWORD`` * - **Tablespaces** diff --git a/src/site/sphinx/usage.rst b/src/site/sphinx/usage.rst index 250dda5b6f..e5c42bcf30 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -2493,6 +2493,71 @@ report ``MODIFIES_SCHEMA``, and REPAIR may report ``MODIFIES_DATA``. ``StringValue``). ``USING DATA`` excludes the other two options, and ``DROP HISTOGRAM`` takes none of them. +PostgreSQL maintenance statements +--------------------------------- + +With ``Dialect.POSTGRESQL`` and without a dialect, PostgreSQL's maintenance statements parse +to typed statements: + +* ``VACUUM [(option [value], ...)] [target, ...]`` and + ``VACUUM [FULL] [FREEZE] [VERBOSE] [ANALYZE] [target, ...]`` parse to ``VacuumStatement``. +* ``ANALYZE [(option [value], ...)] [target, ...]`` and ``ANALYZE [VERBOSE] [target, ...]`` + extend ``Analyze``, which keeps the MySQL ``ANALYZE TABLE`` form and its histogram clause. A + statement takes one form: options, or targets with ``ONLY``, ``*`` or columns, cannot be + combined with ``NO_WRITE_TO_BINLOG``, ``LOCAL``, ``TABLE`` or ``UPDATE``/``DROP HISTOGRAM``. + The PostgreSQL form can be spelled ``ANALYSE``, which ``getCommand()`` keeps. +* ``CLUSTER [(option [value], ...)] [table [USING index]]``, + ``CLUSTER [VERBOSE] [table [USING index]]`` and the pre-8.3 ``CLUSTER [VERBOSE] index ON table`` + parse to ``ClusterStatement``; ``getIndexSyntax()`` tells ``USING`` and ``ON`` apart. +* ``CHECKPOINT`` parses to ``CheckpointStatement``. + +The three option lists share ``UtilityOption``: ``getName()`` keeps the name as written, and +``getValueType()`` tells whether the value is a Boolean word, a number, a string or another word. +``getBooleanValue()``, ``getLongValue()``, ``getStringValue()`` and ``getEnumValue(Class)`` read +it, and ``getKind(Class)`` maps the name to ``VacuumStatement.OptionKind``, +``Analyze.OptionKind`` or ``ClusterStatement.OptionKind``. The unparenthesized keywords are +options too: ``VACUUM FULL t`` holds the option ``FULL`` like ``VACUUM (FULL) t``, and +``isParenthesizedOptions()`` tells the forms apart. As in PostgreSQL's grammar, an option list +takes any name and any Boolean word, number, string or word as its value, as long as an unquoted +name or word is not one that PostgreSQL reserves (``ANALYZE`` and ``ANALYSE`` are names); +PostgreSQL checks the names and values when it runs the statement. ``getKind(Class)`` is null for a name the statement does not know, +such as ``VACUUM (FOO) t``, and the typed readers are null for a value of another type, such as +``PARALLEL 1.5``. A double-quoted name keeps its case, as in PostgreSQL: ``"verbose"`` is +``VERBOSE``, and ``"VERBOSE"`` is an unknown option. ``EXPLAIN (...)`` reads its options +through the same production but keeps ``ExplainStatement.Option``, and accepts only the names +and values it accepted before. + +A ``MaintenanceTarget`` of VACUUM and ANALYZE is ``[ONLY] table [*] [(column, ...)]``: its +``getReference()`` is the ``TableReference`` of ``LOCK`` and ``TRUNCATE``, and +``getColumns()`` lists the columns. ``Analyze.getTargets()`` holds the tables of both forms; +``getTable()`` and ``getTables()`` are derived from it, and ``getTables()`` now returns a +read-only snapshot. + +.. code-block:: java + + VacuumStatement vacuum = (VacuumStatement) CCJSqlParserUtil.parse( + "VACUUM (PARALLEL 2, INDEX_CLEANUP OFF) t (a)", + parser -> parser.withDialect(Dialect.POSTGRESQL)); + vacuum.getOptions().get(0).getLongValue(); // 2 + vacuum.getOptions().get(1).getBooleanValue(); // false + vacuum.getTargets().get(0).getColumns(); // [a] + +``VACUUM``, ``CLUSTER``, ``FREEZE``, ``CHECKPOINT`` and ``ANALYSE`` remain identifiers elsewhere. As in +PostgreSQL, ``FULL``, ``FREEZE``, ``VERBOSE`` and ``ANALYZE`` directly after ``VACUUM`` are +options, never table names. With ``Dialect.POSTGRESQL``, ``ANALYZE verbose`` now means the +``VERBOSE`` option; without a dialect it is still a table named ``verbose`` unless a table +name follows, and other dialects keep that reading. Without a dialect, a statement starting +with one of these words is a PostgreSQL statement only when its next tokens take the +PostgreSQL form, so other products' variants such as ``VACUUM INTO 'file'`` remain +unsupported statements. Other dialects do not parse these statements, nor ``ANALYSE``. + +Table visitors report the tables of the targets and the CLUSTER table, but not the column +lists or the index. Validators report the ``analyze``, ``vacuum``, ``cluster`` and +``checkpoint`` features, which the PostgreSQL capabilities enable; the MySQL and MariaDB +capabilities now enable ``analyze`` as well. Feature analysis reports ``MODIFIES_SCHEMA`` and +``READS_DATA`` for VACUUM and CLUSTER, as for ANALYZE, and ``MODIFIES_SESSION`` for +CHECKPOINT, a server operation like ``ALTER SYSTEM CHECKPOINT``. + MySQL account management statements ----------------------------------- diff --git a/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java b/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java index 6ce2622099..0ac7a4e84a 100644 --- a/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java +++ b/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java @@ -137,7 +137,16 @@ public void testStatementsTheCorpusLacks() throws JSQLParserException { "CREATE TABLE t AS EXECUTE p", "ALTER DEFAULT PRIVILEGES GRANT SELECT ON TABLES TO r", "IF a = 1 SELECT 1;", - "BEGIN SELECT 1; END;"}) { + "BEGIN SELECT 1; END;", + "VACUUM (VERBOSE, PARALLEL 2) ONLY s.t (a, b), u *", + "VACUUM FULL FREEZE VERBOSE ANALYZE t", + "ANALYZE VERBOSE t (a)", + "ANALYZE (SKIP_LOCKED false)", + "ANALYZE TABLE t UPDATE HISTOGRAM ON c WITH 8 BUCKETS AUTO UPDATE", + "CLUSTER (VERBOSE) t USING i", + "CLUSTER VERBOSE i ON t", + "ANALYSE (\"verbose\", PARALLEL -1) t", + "CHECKPOINT"}) { new Walk(sql, sql, problems).check(CCJSqlParserUtil.parseStatements(sql)); } String block = "BEGIN x := 1; NULL; foo(1); END;"; diff --git a/src/test/java/net/sf/jsqlparser/statement/UtilityOptionTest.java b/src/test/java/net/sf/jsqlparser/statement/UtilityOptionTest.java new file mode 100644 index 0000000000..e6a451fbc5 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/UtilityOptionTest.java @@ -0,0 +1,134 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +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.List; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.UtilityOption.ValueType; +import net.sf.jsqlparser.statement.maintenance.VacuumStatement; +import net.sf.jsqlparser.statement.maintenance.VacuumStatement.OptionKind; +import org.junit.jupiter.api.Test; + +class UtilityOptionTest { + + private enum IndexCleanup { + AUTO, ON, OFF + } + + @Test + void factoriesWriteTypedValues() { + assertEquals("VERBOSE", new UtilityOption("VERBOSE").toString()); + assertEquals("SKIP_LOCKED TRUE", UtilityOption.of("SKIP_LOCKED", true).toString()); + assertEquals("PARALLEL 3", UtilityOption.of("PARALLEL", 3).toString()); + assertEquals("BUFFER_USAGE_LIMIT 'it''s'", + UtilityOption.ofString("BUFFER_USAGE_LIMIT", "it's").toString()); + assertEquals(ValueType.WORD, UtilityOption.ofWord("INDEX_CLEANUP", "auto").getValueType()); + assertEquals(ValueType.BOOLEAN, + UtilityOption.ofWord("INDEX_CLEANUP", "off").getValueType()); + } + + @Test + void readsValuesAsPostgreSqlDoes() { + UtilityOption option = new UtilityOption("VERBOSE"); + assertFalse(option.hasValue()); + assertNull(option.getBooleanValue()); + assertNull(option.getStringValue()); + assertNull(option.getEnumValue(IndexCleanup.class)); + + assertEquals(Boolean.TRUE, option.withValue(ValueType.BOOLEAN, "on").getBooleanValue()); + assertEquals(Boolean.FALSE, option.withValue(ValueType.NUMBER, "0").getBooleanValue()); + assertNull(option.withValue(ValueType.NUMBER, "2").getBooleanValue()); + assertEquals(Boolean.TRUE, option.withValue(ValueType.STRING, "'true'").getBooleanValue()); + assertNull(option.withValue(ValueType.WORD, "yes").getBooleanValue()); + assertEquals(-4L, option.withValue(ValueType.NUMBER, "-4").getLongValue()); + assertNull(option.withValue(ValueType.STRING, "'4'").getLongValue()); + assertEquals("a\nb", option.withValue(ValueType.STRING, "E'a\\nb'").getStringValue()); + assertEquals(IndexCleanup.AUTO, + option.withValue(ValueType.WORD, "auto").getEnumValue(IndexCleanup.class)); + + option.removeValue(); + assertFalse(option.hasValue()); + assertThrows(IllegalArgumentException.class, () -> option.setValue(ValueType.WORD, null)); + assertThrows(NullPointerException.class, () -> option.setName(null)); + } + + @Test + void namesAndKinds() { + UtilityOption analyse = new UtilityOption("analyse"); + assertTrue(analyse.isNamed("ANALYZE")); + assertEquals(OptionKind.ANALYZE, analyse.getKind(OptionKind.class)); + assertNull(new UtilityOption("FORMAT").getKind(OptionKind.class)); + assertEquals(VacuumStatement.OptionKind.PARALLEL, + analyse.withName("Parallel").getKind(OptionKind.class)); + + // PostgreSQL keeps the case of a quoted name and compares it with the lower-case name + UtilityOption quoted = new UtilityOption("\"verbose\""); + assertEquals(OptionKind.VERBOSE, quoted.getKind(OptionKind.class)); + assertTrue(quoted.isNamed("VERBOSE")); + assertNull(quoted.withName("\"VERBOSE\"").getKind(OptionKind.class)); + assertNull(quoted.withName("\"analyse\"").getKind(OptionKind.class)); + assertEquals("a\"b", quoted.withValue(ValueType.WORD, "\"a\"\"b\"").getStringValue()); + } + + @Test + void rendersParenthesizedOrKeywordForms() { + String[] keywords = {"FULL", "VERBOSE"}; + StringBuilder builder = new StringBuilder("X"); + UtilityOption.appendOptions(builder, List.of(), false, keywords); + assertEquals("X", builder.toString()); + + builder = new StringBuilder("X"); + UtilityOption.appendOptions(builder, + List.of(new UtilityOption("verbose"), new UtilityOption("full")), false, keywords); + assertEquals("X full verbose", builder.toString()); + + builder = new StringBuilder("X"); + UtilityOption.appendOptions(builder, List.of(UtilityOption.of("FULL", true)), false, + keywords); + assertEquals("X (FULL TRUE)", builder.toString()); + + builder = new StringBuilder("X"); + UtilityOption.appendOptions(builder, List.of(new UtilityOption("SKIP_LOCKED")), false, + keywords); + assertEquals("X (SKIP_LOCKED)", builder.toString()); + + // a quoted name would read as a table name without parentheses + builder = new StringBuilder("X"); + UtilityOption.appendOptions(builder, List.of(new UtilityOption("\"full\"")), false, + keywords); + assertEquals("X (\"full\")", builder.toString()); + assertTrue(UtilityOption.isKeywordForm(List.of(), keywords)); + assertFalse(UtilityOption.isKeywordForm( + List.of(new UtilityOption("FULL"), new UtilityOption("full")), keywords)); + } + + @Test + void explainReadsTheSharedOptionList() throws JSQLParserException { + for (String sql : List.of("EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT * FROM t", + "EXPLAIN (ANALYZE false, COSTS 0, SERIALIZE TEXT, MEMORY) SELECT 1")) { + assertEquals(sql, CCJSqlParserUtil.parse(sql).toString()); + } + // EXPLAIN keeps its own option names and values, which accept no more than before + for (String sql : List.of("EXPLAIN (FULL) SELECT 1", "EXPLAIN (COSTS 'on') SELECT 1", + "EXPLAIN (FORMAT 'json') SELECT 1", "EXPLAIN (ANALYZE +1) SELECT 1", + "EXPLAIN (FOO) SELECT 1", "EXPLAIN (\"costs\") SELECT 1", + "EXPLAIN (COSTS \"on\") SELECT 1", "EXPLAIN (COSTS 1.0) SELECT 1", + "EXPLAIN (COSTS -1) SELECT 1", "EXPLAIN (ANALYSE) SELECT 1")) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql), sql); + } + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/analyze/PostgreSqlAnalyzeTest.java b/src/test/java/net/sf/jsqlparser/statement/analyze/PostgreSqlAnalyzeTest.java new file mode 100644 index 0000000000..bd3bd74411 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/analyze/PostgreSqlAnalyzeTest.java @@ -0,0 +1,312 @@ +/*- + * #%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.analyze; + +import static net.sf.jsqlparser.test.TestUtils.assertDeparse; +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.assertj.core.api.Assertions.assertThat; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.UnsupportedStatement; +import net.sf.jsqlparser.statement.UtilityOption; +import net.sf.jsqlparser.statement.analyze.Analyze.OptionKind; +import net.sf.jsqlparser.statement.maintenance.MaintenanceTarget; +import net.sf.jsqlparser.util.TablesNamesFinder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class PostgreSqlAnalyzeTest { + + private static final Consumer POSTGRESQL = + parser -> parser.withDialect(Dialect.POSTGRESQL); + + private static Analyze parse(String sql) throws JSQLParserException { + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, true, POSTGRESQL); + assertEquals(sql, assertSqlCanBeParsedAndDeparsed(sql, true).toString()); + return (Analyze) statement; + } + + private static List columns(MaintenanceTarget target) { + return target.getColumns().stream().map(Column::getColumnName) + .collect(Collectors.toList()); + } + + @Test + void verboseWithColumnList() throws JSQLParserException { + Analyze analyze = parse("ANALYZE VERBOSE t (a)"); + + assertFalse(analyze.isParenthesizedOptions()); + assertEquals(OptionKind.VERBOSE, analyze.getOptions().get(0).getKind(OptionKind.class)); + assertEquals(List.of("a"), columns(analyze.getTargets().get(0))); + assertEquals("t", analyze.getTable().getFullyQualifiedName()); + assertFalse(analyze.isTableKeyword()); + assertNull(analyze.getModifier()); + } + + @Test + void parenthesizedOptionsAndTableList() throws JSQLParserException { + Analyze analyze = parse("ANALYZE (SKIP_LOCKED) t, s"); + + assertTrue(analyze.isParenthesizedOptions()); + assertEquals(OptionKind.SKIP_LOCKED, + analyze.getOptions().get(0).getKind(OptionKind.class)); + assertEquals(List.of("t", "s"), analyze.getTables().stream() + .map(Table::getFullyQualifiedName).collect(Collectors.toList())); + } + + @Test + void everyOptionWithValuesAndOnlyTarget() throws JSQLParserException { + Analyze analyze = parse( + "ANALYZE (VERBOSE false, SKIP_LOCKED, BUFFER_USAGE_LIMIT 256) ONLY s.t (a, b), u *"); + + assertEquals(Boolean.FALSE, analyze.getOptions().get(0).getBooleanValue()); + assertEquals(256L, analyze.getOptions().get(2).getLongValue()); + MaintenanceTarget only = analyze.getTargets().get(0); + assertTrue(only.getReference().isOnly()); + assertEquals(List.of("a", "b"), columns(only)); + assertTrue(analyze.getTargets().get(1).getReference().isIncludeDescendants()); + } + + @Test + void bareAnalyze() throws JSQLParserException { + Analyze analyze = parse("ANALYZE"); + + assertTrue(analyze.getTargets().isEmpty()); + assertNull(analyze.getTable()); + assertTrue(analyze.getTables().isEmpty()); + assertEquals("ANALYZE", analyze.toString()); + assertEquals("ANALYZE (VERBOSE)", parse("ANALYZE (VERBOSE)").toString()); + } + + @Test + void verboseWordDependsOnDialect() throws JSQLParserException { + Analyze postgres = (Analyze) CCJSqlParserUtil.parse("ANALYZE verbose", POSTGRESQL); + assertEquals("verbose", postgres.getOptions().get(0).getName()); + assertTrue(postgres.getTargets().isEmpty()); + + // without a dialect and in other dialects a lone verbose stays a table name + Analyze generic = (Analyze) CCJSqlParserUtil.parse("ANALYZE verbose"); + assertTrue(generic.getOptions().isEmpty()); + assertEquals("verbose", generic.getTable().getFullyQualifiedName()); + Analyze mysql = (Analyze) CCJSqlParserUtil.parse("ANALYZE verbose, t", + parser -> parser.withDialect(Dialect.MYSQL)); + assertEquals(2, mysql.getTables().size()); + + // a following table name selects the option without a dialect too + Analyze option = (Analyze) CCJSqlParserUtil.parse("ANALYZE VERBOSE t"); + assertEquals("VERBOSE", option.getOptions().get(0).getName()); + assertEquals("t", option.getTable().getFullyQualifiedName()); + } + + @Test + void mySqlFormsAreUnchanged() throws JSQLParserException { + for (String sql : List.of("ANALYZE TABLE t1 UPDATE HISTOGRAM ON c1, c2 WITH 64 BUCKETS", + "ANALYZE TABLE t1 UPDATE HISTOGRAM ON c1 WITH 32 BUCKETS AUTO UPDATE", + "ANALYZE TABLE t1 UPDATE HISTOGRAM ON c1 MANUAL UPDATE", + "ANALYZE TABLE t1 UPDATE HISTOGRAM ON c1 USING DATA '{}'", + "ANALYZE NO_WRITE_TO_BINLOG TABLE t, s", + "ANALYZE LOCAL TABLE t DROP HISTOGRAM ON c", + "ANALYZE t1, t2")) { + for (Dialect dialect : new Dialect[] {Dialect.MYSQL, Dialect.MARIADB, + Dialect.POSTGRESQL}) { + Analyze analyze = (Analyze) assertSqlCanBeParsedAndDeparsed(sql, true, + parser -> parser.withDialect(dialect)); + assertTrue(analyze.getOptions().isEmpty()); + assertTrue(analyze.getTargets().stream().allMatch(MaintenanceTarget::isPlainTable)); + } + } + } + + @Test + void acceptsEveryOptionOfTheGrammar() throws JSQLParserException { + Analyze analyze = parse("ANALYZE (FULL, VERBOSE 2, BUFFER_USAGE_LIMIT) t"); + + assertNull(analyze.getOptions().get(0).getKind(OptionKind.class)); + assertEquals(UtilityOption.ValueType.NUMBER, analyze.getOptions().get(1).getValueType()); + assertNull(analyze.getOptions().get(1).getBooleanValue()); + assertEquals(OptionKind.BUFFER_USAGE_LIMIT, + analyze.getOptions().get(2).getKind(OptionKind.class)); + assertFalse(analyze.getOptions().get(2).hasValue()); + + Analyze option = parse("ANALYZE (t)"); + assertEquals("t", option.getOptions().get(0).getName()); + assertTrue(option.getTargets().isEmpty()); + } + + @Test + void analyseSpelling() throws JSQLParserException { + for (String sql : List.of("ANALYSE", "ANALYSE t", "ANALYSE VERBOSE t (a)", + "ANALYSE (VERBOSE, SKIP_LOCKED) ONLY t, s")) { + Analyze analyze = parse(sql); + assertEquals(Analyze.Command.ANALYSE, analyze.getCommand(), sql); + } + Analyze lower = (Analyze) CCJSqlParserUtil.parse("analyse verbose t", POSTGRESQL); + assertEquals("ANALYSE verbose t", lower.toString()); + assertEquals(Analyze.Command.ANALYZE, parse("ANALYZE t").getCommand()); + + assertDeparse(new Analyze().withCommand(Analyze.Command.ANALYSE) + .addOptions(new UtilityOption("VERBOSE")).addTables(new Table("t")), + "ANALYSE VERBOSE t"); + assertThrows(NullPointerException.class, () -> new Analyze().setCommand(null)); + } + + @Test + void analyseRemainsAnIdentifier() throws JSQLParserException { + for (String sql : List.of("SELECT analyse FROM analyse AS analyse", + "CREATE TABLE analyse (analyse INT)", "INSERT INTO analyse (analyse) VALUES (1)")) { + assertSqlCanBeParsedAndDeparsed(sql, true, POSTGRESQL); + assertSqlCanBeParsedAndDeparsed(sql, true); + } + assertSqlCanBeParsedAndDeparsed("SELECT analyse FROM analyse", true, + parser -> parser.withDialect(Dialect.MYSQL)); + assertSqlCanBeParsedAndDeparsed("SELECT * FROM t PROCEDURE ANALYSE()", true, + parser -> parser.withDialect(Dialect.MYSQL)); + } + + @Test + void analyseStartsAStatementOnlyInThePostgreSqlForm() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "SELECT 1; ANALYSE t\nANALYSE; ANALYSE (VERBOSE)\nSELECT 2", POSTGRESQL); + assertEquals(5, statements.size()); + assertEquals("ANALYSE t", statements.get(1).toString()); + assertEquals("ANALYSE", statements.get(2).toString()); + assertEquals("ANALYSE (VERBOSE)", statements.get(3).toString()); + + // without a dialect other forms stay unsupported statements, and other dialects never + // read ANALYSE as a statement + assertInstanceOf(UnsupportedStatement.class, CCJSqlParserUtil.parse("ANALYSE t FOO", + parser -> parser.withUnsupportedStatements(true))); + assertInstanceOf(Analyze.class, CCJSqlParserUtil.parse("ANALYSE t", + parser -> parser.withUnsupportedStatements(true))); + assertInstanceOf(UnsupportedStatement.class, CCJSqlParserUtil.parse("ANALYSE t", + parser -> parser.withDialect(Dialect.MYSQL).withUnsupportedStatements(true))); + } + + @ParameterizedTest + @ValueSource(strings = { + "ANALYSE TABLE t", + "ANALYSE LOCAL t", + "ANALYSE t UPDATE HISTOGRAM ON a", + "ANALYZE (SELECT) t", + "ANALYZE (TABLE) t", + "ANALYZE (VERBOSE on, SKIP_LOCKED only) t", + "ANALYZE TABLE t (a)", + "ANALYZE LOCAL (VERBOSE) t", + "ANALYZE VERBOSE TABLE t", + "ANALYZE (VERBOSE) t UPDATE HISTOGRAM ON a", + "ANALYZE ONLY t DROP HISTOGRAM ON a", + "ANALYZE (VERBOSE) t UPDATE HISTOGRAM ON a WITH 8 BUCKETS AUTO UPDATE", + "ANALYZE VERBOSE t UPDATE HISTOGRAM ON a MANUAL UPDATE", + "ANALYZE t (a) UPDATE HISTOGRAM ON a USING DATA '{}'", + "ANALYZE t * UPDATE HISTOGRAM ON a", + "ANALYZE UPDATE HISTOGRAM ON a", + "ANALYZE TABLE", + "ANALYZE LOCAL", + "ANALYZE t (a.b)", + "ANALYZE t (a) UPDATE HISTOGRAM ON a" + }) + void rejectsMixedOrInvalidForms(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, POSTGRESQL)); + } + + @ParameterizedTest + @ValueSource(strings = {"ANALYZE", "ANALYZE (VERBOSE) t", "ANALYZE VERBOSE t (a)", + "ANALYZE t (a)", "ANALYZE ONLY t", "ANALYSE t", "ANALYSE"}) + void otherDialectsKeepTheirReading(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(Dialect.MYSQL))); + } + + @Test + void endsAtStatementBoundaries() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "SHOW search_path; ANALYZE; CHECKPOINT\nANALYZE VERBOSE t (a)\nSELECT 1", + POSTGRESQL); + + assertEquals(5, statements.size()); + assertInstanceOf(Analyze.class, statements.get(1)); + assertEquals("ANALYZE", statements.get(1).toString()); + assertEquals("ANALYZE VERBOSE t (a)", statements.get(3).toString()); + } + + @Test + void legacyAccessorsAreDerivedFromTargets() throws JSQLParserException { + Analyze analyze = parse("ANALYZE (VERBOSE) ONLY t (a), s"); + + List

tables = analyze.getTables(); + assertThrows(UnsupportedOperationException.class, () -> tables.add(new Table("x"))); + analyze.addTables(new Table("x")); + assertEquals(2, tables.size()); + assertEquals(3, analyze.getTables().size()); + + analyze.setTable(new Table("u")); + assertDeparse(analyze, "ANALYZE (VERBOSE) u"); + analyze.setTables(List.of(new Table("a"), new Table("b"))); + assertDeparse(analyze, "ANALYZE (VERBOSE) a, b"); + analyze.setTable(null); + assertDeparse(analyze, "ANALYZE (VERBOSE)"); + } + + @Test + void histogramClauseKeepsPlainTargets() throws JSQLParserException { + Analyze analyze = (Analyze) assertSqlCanBeParsedAndDeparsed( + "ANALYZE TABLE t1, t2 UPDATE HISTOGRAM ON c1 WITH 16 BUCKETS AUTO UPDATE"); + + assertEquals(2, analyze.getTargets().size()); + assertTrue(analyze.getTargets().stream().allMatch(MaintenanceTarget::isPlainTable)); + assertTrue(analyze.getOptions().isEmpty()); + assertEquals(16, analyze.getHistogramBucketCount()); + assertEquals(Analyze.HistogramUpdateMode.AUTO, analyze.getHistogramUpdateMode()); + + analyze.setTable(new Table("u")); + assertDeparse(analyze, + "ANALYZE TABLE u UPDATE HISTOGRAM ON c1 WITH 16 BUCKETS AUTO UPDATE"); + } + + @Test + void buildsAndDeparses() { + Analyze analyze = new Analyze().addOptions(new UtilityOption("VERBOSE")) + .addTargets(new MaintenanceTarget(new Table("t")).addColumns(new Column("a"))); + assertDeparse(analyze, "ANALYZE VERBOSE t (a)"); + + analyze.withParenthesizedOptions(true) + .addOptions(UtilityOption.ofString("BUFFER_USAGE_LIMIT", "1MB")); + assertDeparse(analyze, "ANALYZE (VERBOSE, BUFFER_USAGE_LIMIT '1MB') t (a)"); + + analyze.withOptions(null).withTargets(null); + assertDeparse(analyze, "ANALYZE"); + } + + @Test + void findsTablesButNotColumns() throws JSQLParserException { + Statement statement = CCJSqlParserUtil.parse("ANALYZE VERBOSE a (x), ONLY s.b (y)", + POSTGRESQL); + + assertThat(new TablesNamesFinder<>().getTables(statement)) + .containsExactlyInAnyOrder("a", "s.b"); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/maintenance/CheckpointStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/maintenance/CheckpointStatementTest.java new file mode 100644 index 0000000000..b54e4b95d7 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/maintenance/CheckpointStatementTest.java @@ -0,0 +1,92 @@ +/*- + * #%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.maintenance; + +import static net.sf.jsqlparser.test.TestUtils.assertDeparse; +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.function.Consumer; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.StmtFeature; +import net.sf.jsqlparser.statement.UnsupportedStatement; +import net.sf.jsqlparser.util.TablesNamesFinder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class CheckpointStatementTest { + + private static final Consumer POSTGRESQL = + parser -> parser.withDialect(Dialect.POSTGRESQL); + + @Test + void parsesWithPostgreSqlAndWithoutDialect() throws JSQLParserException { + assertInstanceOf(CheckpointStatement.class, + assertSqlCanBeParsedAndDeparsed("CHECKPOINT", true, POSTGRESQL)); + assertInstanceOf(CheckpointStatement.class, + assertSqlCanBeParsedAndDeparsed("checkpoint", true)); + assertDeparse(new CheckpointStatement(), "CHECKPOINT"); + } + + @ParameterizedTest + @ValueSource(strings = {"CHECKPOINT (FLUSH_UNLOGGED)", "CHECKPOINT 10", "CHECKPOINT t"}) + void rejectsArguments(String sql) { + // PostgreSQL 18 accepts no options; CHECKPOINT (...) arrives in a later release + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, POSTGRESQL)); + } + + @Test + void otherProductsVariantsStayUnsupported() throws JSQLParserException { + for (String sql : new String[] {"CHECKPOINT 10", "CHECKPOINT SYNC"}) { + assertInstanceOf(UnsupportedStatement.class, CCJSqlParserUtil.parse(sql, + parser -> parser.withUnsupportedStatements(true)), sql); + } + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse("CHECKPOINT", + parser -> parser.withDialect(Dialect.SQLSERVER))); + } + + @Test + void endsAtStatementBoundaries() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "SHOW search_path; CHECKPOINT\nCHECKPOINT;CHECKPOINT", POSTGRESQL); + assertEquals(4, statements.size()); + + Statements afterUnsupported = CCJSqlParserUtil.parseStatements("LISTEN ch; CHECKPOINT", + parser -> parser.withDialect(Dialect.POSTGRESQL).withUnsupportedStatements(true)); + assertEquals(2, afterUnsupported.size()); + assertInstanceOf(UnsupportedStatement.class, afterUnsupported.get(0)); + assertInstanceOf(CheckpointStatement.class, afterUnsupported.get(1)); + } + + @Test + void namesNoTableAndModifiesNoData() throws JSQLParserException { + Statement statement = CCJSqlParserUtil.parse("CHECKPOINT", POSTGRESQL); + + assertThat(new TablesNamesFinder<>().getTables(statement)).isEmpty(); + assertThat(statement.getFeatures().getCertain()) + .containsExactly(StmtFeature.MODIFIES_SESSION); + } + + @Test + void wordRemainsAnIdentifier() throws JSQLParserException { + assertSqlCanBeParsedAndDeparsed("SELECT checkpoint FROM checkpoint AS checkpoint", true, + POSTGRESQL); + assertSqlCanBeParsedAndDeparsed("ALTER SYSTEM CHECKPOINT", true); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/maintenance/ClusterStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/maintenance/ClusterStatementTest.java new file mode 100644 index 0000000000..37b26b3fda --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/maintenance/ClusterStatementTest.java @@ -0,0 +1,218 @@ +/*- + * #%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.maintenance; + +import static net.sf.jsqlparser.test.TestUtils.assertDeparse; +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.assertj.core.api.Assertions.assertThat; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.function.Consumer; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.StmtFeature; +import net.sf.jsqlparser.statement.UnsupportedStatement; +import net.sf.jsqlparser.statement.UtilityOption; +import net.sf.jsqlparser.statement.alter.Alter; +import net.sf.jsqlparser.statement.maintenance.ClusterStatement.OptionKind; +import net.sf.jsqlparser.util.TablesNamesFinder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class ClusterStatementTest { + + private static final Consumer POSTGRESQL = + parser -> parser.withDialect(Dialect.POSTGRESQL); + + private static ClusterStatement parse(String sql) throws JSQLParserException { + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, true, POSTGRESQL); + assertEquals(sql, assertSqlCanBeParsedAndDeparsed(sql, true).toString()); + return (ClusterStatement) statement; + } + + @Test + void tableUsingIndex() throws JSQLParserException { + ClusterStatement cluster = parse("CLUSTER t USING t_pkey"); + + assertEquals("t", cluster.getTable().getFullyQualifiedName()); + assertEquals("t_pkey", cluster.getIndexName()); + assertTrue(cluster.getOptions().isEmpty()); + } + + @Test + void verboseWithoutTable() throws JSQLParserException { + ClusterStatement cluster = parse("CLUSTER VERBOSE"); + + assertNull(cluster.getTable()); + assertFalse(cluster.isParenthesizedOptions()); + assertEquals(OptionKind.VERBOSE, cluster.getOptions().get(0).getKind(OptionKind.class)); + } + + @Test + void otherForms() throws JSQLParserException { + assertNull(parse("CLUSTER").getTable()); + ClusterStatement qualified = parse("CLUSTER VERBOSE s.t"); + assertEquals("s.t", qualified.getTable().getFullyQualifiedName()); + assertNull(qualified.getIndexName()); + + ClusterStatement options = parse("CLUSTER (VERBOSE false) s.t USING i"); + assertTrue(options.isParenthesizedOptions()); + assertEquals(Boolean.FALSE, options.getOptions().get(0).getBooleanValue()); + assertEquals("i", options.getIndexName()); + assertEquals("CLUSTER (VERBOSE)", parse("CLUSTER (VERBOSE)").toString()); + } + + @Test + void indexOnTable() throws JSQLParserException { + ClusterStatement cluster = parse("CLUSTER t_pkey ON t"); + + assertEquals(ClusterStatement.IndexSyntax.ON, cluster.getIndexSyntax()); + assertEquals("t_pkey", cluster.getIndexName()); + assertEquals("t", cluster.getTable().getFullyQualifiedName()); + assertTrue(cluster.getOptions().isEmpty()); + + ClusterStatement verbose = parse("CLUSTER VERBOSE t_pkey ON s.t"); + assertEquals(OptionKind.VERBOSE, verbose.getOptions().get(0).getKind(OptionKind.class)); + assertEquals("s.t", verbose.getTable().getFullyQualifiedName()); + assertEquals(ClusterStatement.IndexSyntax.USING, + parse("CLUSTER t USING t_pkey").getIndexSyntax()); + } + + @Test + void acceptsEveryOptionOfTheGrammar() throws JSQLParserException { + ClusterStatement cluster = parse("CLUSTER (FULL, VERBOSE maybe) t"); + + assertNull(cluster.getOptions().get(0).getKind(OptionKind.class)); + assertEquals(OptionKind.VERBOSE, cluster.getOptions().get(1).getKind(OptionKind.class)); + assertNull(cluster.getOptions().get(1).getBooleanValue()); + } + + @ParameterizedTest + @ValueSource(strings = {"CLUSTER VERBOSE (t)", "CLUSTER ONLY t", "CLUSTER t *", + "CLUSTER t USING", "CLUSTER USING i", "CLUSTER t, s", "CLUSTER t (a)", + "CLUSTER (VERBOSE) idx ON t", "CLUSTER s.idx ON t", "CLUSTER idx ON", + "CLUSTER idx ON t USING i", "CLUSTER t USING s.i"}) + void rejectsFormsOutsidePostgreSqlGrammar(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, POSTGRESQL)); + } + + @Test + void otherDialectsDoNotParseCluster() throws JSQLParserException { + for (String sql : List.of("CLUSTER t", "CLUSTER idx ON t")) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(Dialect.MYSQL)), sql); + } + Statement legacy = CCJSqlParserUtil.parse("CLUSTER idx ON t", + parser -> parser.withUnsupportedStatements(true)); + assertInstanceOf(ClusterStatement.class, legacy); + Statement other = CCJSqlParserUtil.parse("CLUSTER idx FOO t", + parser -> parser.withUnsupportedStatements(true)); + assertInstanceOf(UnsupportedStatement.class, other); + } + + @Test + void endsAtStatementBoundaries() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "SHOW search_path; CLUSTER VERBOSE; CLUSTER t USING i\nCLUSTER i ON t\nSELECT 1", + POSTGRESQL); + + assertEquals(5, statements.size()); + assertEquals("CLUSTER VERBOSE", statements.get(1).toString()); + assertEquals("CLUSTER t USING i", statements.get(2).toString()); + assertEquals("CLUSTER i ON t", statements.get(3).toString()); + } + + @Test + void endsAnUnsupportedStatement() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements("LISTEN ch; CLUSTER t", + parser -> parser.withDialect(Dialect.POSTGRESQL).withUnsupportedStatements(true)); + + assertEquals(2, statements.size()); + assertInstanceOf(ClusterStatement.class, statements.get(1)); + } + + @Test + void buildsAndDeparses() { + ClusterStatement cluster = new ClusterStatement().withTable(new Table("t")) + .withIndexName("i").addOptions(new UtilityOption("VERBOSE")); + assertDeparse(cluster, "CLUSTER VERBOSE t USING i"); + + cluster.withParenthesizedOptions(true); + assertDeparse(cluster, "CLUSTER (VERBOSE) t USING i"); + + cluster.withOptions(List.of(UtilityOption.of("VERBOSE", false))); + assertDeparse(cluster, "CLUSTER (VERBOSE FALSE) t USING i"); + + cluster.setTable(null); + assertNull(cluster.getIndexName()); + assertDeparse(cluster, "CLUSTER (VERBOSE FALSE)"); + assertThrows(IllegalStateException.class, () -> cluster.setIndexName("i")); + } + + @Test + void rendersTheOnFormOnlyWhenItCanExpressTheStatement() { + ClusterStatement cluster = new ClusterStatement().withTable(new Table("t")) + .withIndexName("i").withIndexSyntax(ClusterStatement.IndexSyntax.ON); + assertDeparse(cluster, "CLUSTER i ON t"); + + cluster.addOptions(new UtilityOption("VERBOSE")); + assertDeparse(cluster, "CLUSTER VERBOSE i ON t"); + + // the ON form takes no option list, so the USING form carries it + cluster.withParenthesizedOptions(true); + assertDeparse(cluster, "CLUSTER (VERBOSE) t USING i"); + + cluster.withParenthesizedOptions(false).withIndexName(null); + assertDeparse(cluster, "CLUSTER VERBOSE t"); + assertThrows(NullPointerException.class, () -> cluster.setIndexSyntax(null)); + } + + @Test + void findsTheTableButNotTheIndex() throws JSQLParserException { + assertThat(new TablesNamesFinder<>().getTables( + CCJSqlParserUtil.parse("CLUSTER s.t USING i", POSTGRESQL))) + .containsExactly("s.t"); + assertThat(new TablesNamesFinder<>().getTables( + CCJSqlParserUtil.parse("CLUSTER i ON s.t", POSTGRESQL))) + .containsExactly("s.t"); + assertThat(new TablesNamesFinder<>().getTables( + CCJSqlParserUtil.parse("CLUSTER", POSTGRESQL))).isEmpty(); + } + + @Test + void reportsStatementFeatures() throws JSQLParserException { + assertThat(CCJSqlParserUtil.parse("CLUSTER t").getFeatures().getCertain()) + .containsExactlyInAnyOrder(StmtFeature.MODIFIES_SCHEMA, StmtFeature.READS_DATA); + } + + @Test + void wordRemainsAnIdentifier() throws JSQLParserException { + assertSqlCanBeParsedAndDeparsed("SELECT cluster FROM cluster.cluster AS cluster", true, + POSTGRESQL); + assertSqlCanBeParsedAndDeparsed("CLUSTER cluster USING cluster", true, POSTGRESQL); + assertSqlCanBeParsedAndDeparsed("CLUSTER cluster ON cluster", true, POSTGRESQL); + assertInstanceOf(Alter.class, assertSqlCanBeParsedAndDeparsed( + "ALTER TABLE t CLUSTER ON ix", true, POSTGRESQL)); + assertSqlCanBeParsedAndDeparsed("ALTER TABLE t SET WITHOUT CLUSTER", true, POSTGRESQL); + assertSqlCanBeParsedAndDeparsed("CREATE TABLE ds.t (id INT64) CLUSTER BY id", true); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/maintenance/MaintenanceNodeTest.java b/src/test/java/net/sf/jsqlparser/statement/maintenance/MaintenanceNodeTest.java new file mode 100644 index 0000000000..1e76de82e3 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/maintenance/MaintenanceNodeTest.java @@ -0,0 +1,110 @@ +/*- + * #%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.maintenance; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.function.Consumer; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.ASTNodeAccess; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.parser.Node; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.analyze.Analyze; +import org.junit.jupiter.api.Test; + +/** The maintenance statements, their options and their targets map back to their source. */ +class MaintenanceNodeTest { + + private static final Consumer POSTGRESQL = + parser -> parser.withDialect(Dialect.POSTGRESQL); + + private static String source(String sql, ASTNodeAccess part) { + Node node = part.getASTNode(); + return sql.substring(node.jjtGetFirstToken().absoluteBegin - 1, + node.jjtGetLastToken().absoluteEnd - 1); + } + + private static Statement parse(String sql) throws JSQLParserException { + return CCJSqlParserUtil.parse(sql, POSTGRESQL); + } + + @Test + void vacuumOptionsTargetsAndColumns() throws JSQLParserException { + String sql = "VACUUM (VERBOSE, PARALLEL 2) ONLY s.t (a, b), u *"; + VacuumStatement vacuum = (VacuumStatement) parse(sql); + + assertEquals(sql, source(sql, vacuum)); + assertEquals("VERBOSE", source(sql, vacuum.getOptions().get(0))); + assertEquals("PARALLEL 2", source(sql, vacuum.getOptions().get(1))); + MaintenanceTarget only = vacuum.getTargets().get(0); + assertEquals("ONLY s.t (a, b)", source(sql, only)); + assertEquals("s.t", source(sql, only.getTable())); + assertEquals("b", source(sql, only.getColumns().get(1))); + assertEquals("u *", source(sql, vacuum.getTargets().get(1))); + + sql = "VACUUM FULL VERBOSE t"; + vacuum = (VacuumStatement) parse(sql); + assertEquals("FULL", source(sql, vacuum.getOptions().get(0))); + assertEquals("VERBOSE", source(sql, vacuum.getOptions().get(1))); + assertEquals("t", source(sql, vacuum.getTargets().get(0))); + } + + @Test + void analyzeOfBothForms() throws JSQLParserException { + String sql = "ANALYZE VERBOSE t (a)"; + Analyze analyze = (Analyze) parse(sql); + + assertEquals(sql, source(sql, analyze)); + assertEquals("VERBOSE", source(sql, analyze.getOptions().get(0))); + assertEquals("t (a)", source(sql, analyze.getTargets().get(0))); + assertEquals("a", source(sql, analyze.getTargets().get(0).getColumns().get(0))); + + sql = "ANALYZE (SKIP_LOCKED false) t"; + analyze = (Analyze) parse(sql); + assertEquals("SKIP_LOCKED false", source(sql, analyze.getOptions().get(0))); + + sql = "ANALYSE (\"verbose\" on) ONLY t"; + analyze = (Analyze) parse(sql); + assertEquals(sql, source(sql, analyze)); + assertEquals("\"verbose\" on", source(sql, analyze.getOptions().get(0))); + assertEquals("ONLY t", source(sql, analyze.getTargets().get(0))); + + sql = "ANALYZE TABLE t1, t2 UPDATE HISTOGRAM ON c WITH 8 BUCKETS MANUAL UPDATE"; + analyze = (Analyze) CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(Dialect.MYSQL)); + assertEquals(sql, source(sql, analyze)); + assertEquals("t2", source(sql, analyze.getTargets().get(1))); + } + + @Test + void clusterAndCheckpoint() throws JSQLParserException { + String sql = "CLUSTER (VERBOSE) s.t USING i"; + ClusterStatement cluster = (ClusterStatement) parse(sql); + + assertEquals(sql, source(sql, cluster)); + assertEquals("VERBOSE", source(sql, cluster.getOptions().get(0))); + assertEquals("s.t", source(sql, cluster.getTable())); + + sql = "CLUSTER VERBOSE"; + cluster = (ClusterStatement) parse(sql); + assertEquals("VERBOSE", source(sql, cluster.getOptions().get(0))); + + sql = "CLUSTER VERBOSE i ON s.t"; + cluster = (ClusterStatement) parse(sql); + assertEquals(sql, source(sql, cluster)); + assertEquals("s.t", source(sql, cluster.getTable())); + + sql = "CHECKPOINT"; + assertEquals(sql, source(sql, (CheckpointStatement) parse(sql))); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/maintenance/VacuumStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/maintenance/VacuumStatementTest.java new file mode 100644 index 0000000000..4e6ffb7446 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/maintenance/VacuumStatementTest.java @@ -0,0 +1,338 @@ +/*- + * #%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.maintenance; + +import static net.sf.jsqlparser.test.TestUtils.assertDeparse; +import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.assertj.core.api.Assertions.assertThat; +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import java.util.function.Consumer; +import java.util.stream.Collectors; +import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParser; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.schema.TableReference; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementFeatures; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.StmtFeature; +import net.sf.jsqlparser.statement.UnsupportedStatement; +import net.sf.jsqlparser.statement.UtilityOption; +import net.sf.jsqlparser.statement.UtilityOption.ValueType; +import net.sf.jsqlparser.statement.maintenance.VacuumStatement.OptionKind; +import net.sf.jsqlparser.util.TablesNamesFinder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class VacuumStatementTest { + + private static final Consumer POSTGRESQL = + parser -> parser.withDialect(Dialect.POSTGRESQL); + + private static VacuumStatement parse(String sql) throws JSQLParserException { + Statement statement = assertSqlCanBeParsedAndDeparsed(sql, true, POSTGRESQL); + assertEquals(sql, assertSqlCanBeParsedAndDeparsed(sql, true).toString()); + return (VacuumStatement) statement; + } + + private static List names(List options) { + return options.stream().map(UtilityOption::getName).collect(Collectors.toList()); + } + + @Test + void parenthesizedOptions() throws JSQLParserException { + VacuumStatement vacuum = parse("VACUUM (VERBOSE, ANALYZE) t"); + + assertTrue(vacuum.isParenthesizedOptions()); + assertEquals(List.of("VERBOSE", "ANALYZE"), names(vacuum.getOptions())); + assertEquals(OptionKind.ANALYZE, vacuum.getOptions().get(1).getKind(OptionKind.class)); + assertEquals("t", vacuum.getTargets().get(0).getTable().getFullyQualifiedName()); + } + + @Test + void unparenthesizedKeywordsAreOptions() throws JSQLParserException { + VacuumStatement vacuum = parse("VACUUM FULL t"); + + assertFalse(vacuum.isParenthesizedOptions()); + assertEquals(List.of("FULL"), names(vacuum.getOptions())); + assertFalse(vacuum.getOptions().get(0).hasValue()); + + VacuumStatement all = parse("VACUUM FULL FREEZE VERBOSE ANALYZE t (a, b), s.u"); + assertEquals(List.of(OptionKind.FULL, OptionKind.FREEZE, OptionKind.VERBOSE, + OptionKind.ANALYZE), + all.getOptions().stream().map(o -> o.getKind(OptionKind.class)) + .collect(Collectors.toList())); + assertEquals(2, all.getTargets().size()); + assertEquals("s.u", all.getTargets().get(1).getTable().getFullyQualifiedName()); + } + + @Test + void typedOptionValuesAndColumns() throws JSQLParserException { + VacuumStatement vacuum = parse("VACUUM (PARALLEL 2, INDEX_CLEANUP OFF) t (a)"); + + UtilityOption parallel = vacuum.getOptions().get(0); + assertEquals(OptionKind.PARALLEL, parallel.getKind(OptionKind.class)); + assertEquals(ValueType.NUMBER, parallel.getValueType()); + assertEquals(2L, parallel.getLongValue()); + + UtilityOption cleanup = vacuum.getOptions().get(1); + assertEquals(ValueType.BOOLEAN, cleanup.getValueType()); + assertEquals(Boolean.FALSE, cleanup.getBooleanValue()); + + MaintenanceTarget target = vacuum.getTargets().get(0); + assertEquals(List.of("a"), target.getColumns().stream() + .map(Column::getColumnName).collect(Collectors.toList())); + assertFalse(target.isPlainTable()); + } + + @Test + void everyDocumentedOption() throws JSQLParserException { + VacuumStatement vacuum = parse("VACUUM (FULL, FREEZE true, VERBOSE on, ANALYZE 1, " + + "DISABLE_PAGE_SKIPPING false, SKIP_LOCKED off, INDEX_CLEANUP AUTO, " + + "PROCESS_MAIN 0, PROCESS_TOAST TRUE, TRUNCATE FALSE, PARALLEL 0, " + + "SKIP_DATABASE_STATS, ONLY_DATABASE_STATS 'on', BUFFER_USAGE_LIMIT '256kB')"); + + assertTrue(vacuum.getTargets().isEmpty()); + assertEquals(OptionKind.values().length, vacuum.getOptions().size()); + for (int i = 0; i < OptionKind.values().length; i++) { + assertEquals(OptionKind.values()[i], + vacuum.getOptions().get(i).getKind(OptionKind.class)); + } + UtilityOption cleanup = vacuum.getOptions().get(6); + assertEquals(ValueType.WORD, cleanup.getValueType()); + assertNull(cleanup.getBooleanValue()); + assertEquals(Boolean.TRUE, vacuum.getOptions().get(12).getBooleanValue()); + UtilityOption limit = vacuum.getOptions().get(13); + assertEquals(ValueType.STRING, limit.getValueType()); + assertEquals("'256kB'", limit.getValue()); + assertEquals("256kB", limit.getStringValue()); + } + + @Test + void bareVacuumAndLowerCaseKeywords() throws JSQLParserException { + VacuumStatement bare = parse("VACUUM"); + assertTrue(bare.getOptions().isEmpty()); + assertTrue(bare.getTargets().isEmpty()); + + VacuumStatement lower = (VacuumStatement) CCJSqlParserUtil.parse("vacuum freeze analyse t", + POSTGRESQL); + assertEquals("VACUUM freeze analyse t", lower.toString()); + assertEquals(OptionKind.ANALYZE, lower.getOptions().get(1).getKind(OptionKind.class)); + } + + @Test + void onlyAndDescendantTargets() throws JSQLParserException { + VacuumStatement vacuum = parse("VACUUM (ANALYZE) ONLY t (a), u *, ONLY (v)"); + + TableReference only = vacuum.getTargets().get(0).getReference(); + assertTrue(only.isOnly()); + assertTrue(vacuum.getTargets().get(1).getReference().isIncludeDescendants()); + assertTrue(vacuum.getTargets().get(2).getReference().isParenthesized()); + } + + @Test + void signedAndDecimalNumbers() throws JSQLParserException { + VacuumStatement vacuum = parse("VACUUM (PARALLEL +2, BUFFER_USAGE_LIMIT 1.5) t"); + + assertEquals(2L, vacuum.getOptions().get(0).getLongValue()); + assertNull(vacuum.getOptions().get(1).getLongValue()); + assertEquals("1.5", vacuum.getOptions().get(1).getValue()); + } + + @Test + void acceptsEveryOptionOfTheGrammar() throws JSQLParserException { + // PostgreSQL's grammar takes any name and value; the server checks them when it runs + VacuumStatement vacuum = parse("VACUUM (FOO, PARALLEL -1, PARALLEL 1.5, VERBOSE maybe, " + + "INDEX_CLEANUP full, BUFFER_USAGE_LIMIT, PARALLEL) t"); + + UtilityOption foo = vacuum.getOptions().get(0); + assertNull(foo.getKind(OptionKind.class)); + assertFalse(foo.hasValue()); + UtilityOption negative = vacuum.getOptions().get(1); + assertEquals(ValueType.NUMBER, negative.getValueType()); + assertEquals(-1L, negative.getLongValue()); + UtilityOption decimal = vacuum.getOptions().get(2); + assertEquals(ValueType.NUMBER, decimal.getValueType()); + assertNull(decimal.getLongValue()); + UtilityOption maybe = vacuum.getOptions().get(3); + assertEquals(OptionKind.VERBOSE, maybe.getKind(OptionKind.class)); + assertEquals(ValueType.WORD, maybe.getValueType()); + assertNull(maybe.getBooleanValue()); + assertEquals(ValueType.WORD, vacuum.getOptions().get(4).getValueType()); + assertFalse(vacuum.getOptions().get(5).hasValue()); + + // a lone name in parentheses is an option too, as in PostgreSQL + VacuumStatement option = parse("VACUUM (t)"); + assertEquals("t", option.getOptions().get(0).getName()); + assertTrue(option.getTargets().isEmpty()); + } + + @Test + void quotedNamesAndDollarQuotedValues() throws JSQLParserException { + VacuumStatement vacuum = parse( + "VACUUM (\"verbose\", \"VERBOSE\" off, INDEX_CLEANUP \"auto\", " + + "BUFFER_USAGE_LIMIT $$256kB$$) t"); + + // like PostgreSQL, a quoted name matches only in lower case + assertEquals(OptionKind.VERBOSE, vacuum.getOptions().get(0).getKind(OptionKind.class)); + assertNull(vacuum.getOptions().get(1).getKind(OptionKind.class)); + UtilityOption cleanup = vacuum.getOptions().get(2); + assertEquals(ValueType.WORD, cleanup.getValueType()); + assertEquals("auto", cleanup.getStringValue()); + UtilityOption limit = vacuum.getOptions().get(3); + assertEquals(ValueType.STRING, limit.getValueType()); + assertEquals("256kB", limit.getStringValue()); + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse("VACUUM ([verbose]) t", POSTGRESQL)); + + // a quoted name or value may spell a word that PostgreSQL reserves + VacuumStatement reserved = parse("VACUUM (\"table\", VERBOSE \"user\", ANALYSE true) t"); + assertNull(reserved.getOptions().get(0).getKind(OptionKind.class)); + assertEquals("user", reserved.getOptions().get(1).getStringValue()); + assertEquals(OptionKind.ANALYZE, reserved.getOptions().get(2).getKind(OptionKind.class)); + } + + @ParameterizedTest + @ValueSource(strings = { + "VACUUM () t", + "VACUUM (VERBOSE,) t", + "VACUUM (VERBOSE, ) t", + "VACUUM (SELECT) t", + "VACUUM (TABLE) t", + "VACUUM (USER) t", + "VACUUM (VERBOSE analyze) t", + "VACUUM (INDEX_CLEANUP default) t", + "VACUUM (VERBOSE 'a' 'b') t", + "VACUUM VERBOSE FULL t", + "VACUUM ANALYZE VERBOSE t", + "VACUUM ANALYZE FULL t", + "VACUUM t USING x", + "VACUUM FULL (VERBOSE) t", + "VACUUM ONLY t *", + "VACUUM t ()", + "VACUUM t (a.b)", + "VACUUM t, ", + "VACUUM t INTO 'f'" + }) + void rejectsFormsOutsidePostgreSqlGrammar(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, POSTGRESQL)); + } + + @Test + void otherDialectsDoNotParseVacuum() { + for (Dialect dialect : List.of(Dialect.MYSQL, Dialect.ORACLE, Dialect.SQLSERVER)) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse("VACUUM t", + parser -> parser.withDialect(dialect)), dialect.name()); + } + } + + @Test + void otherProductsVariantsStayUnsupportedWithoutDialect() throws JSQLParserException { + for (String sql : List.of("VACUUM INTO 'backup.db'", "VACUUM main INTO 'backup.db'", + "VACUUM t RETAIN 168 HOURS", "VACUUM SORT ONLY t")) { + Statement statement = CCJSqlParserUtil.parse(sql, + parser -> parser.withUnsupportedStatements(true)); + assertInstanceOf(UnsupportedStatement.class, statement, sql); + } + Statement postgres = CCJSqlParserUtil.parse("VACUUM t", + parser -> parser.withUnsupportedStatements(true)); + assertInstanceOf(VacuumStatement.class, postgres); + } + + @Test + void endsAtStatementBoundaries() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "SHOW search_path; VACUUM t\nCHECKPOINT\nVACUUM; VACUUM (ANALYZE) a, b (c)\nSELECT 1", + POSTGRESQL); + + assertEquals(6, statements.size()); + assertEquals("VACUUM t", statements.get(1).toString()); + assertEquals("CHECKPOINT", statements.get(2).toString()); + assertEquals("VACUUM", statements.get(3).toString()); + assertEquals("VACUUM (ANALYZE) a, b (c)", statements.get(4).toString()); + assertEquals("SELECT 1", statements.get(5).toString()); + } + + @Test + void endsAnUnsupportedStatement() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "LISTEN ch; VACUUM FULL t", parser -> parser.withDialect(Dialect.POSTGRESQL) + .withUnsupportedStatements(true)); + + assertEquals(2, statements.size()); + assertInstanceOf(UnsupportedStatement.class, statements.get(0)); + assertEquals("VACUUM FULL t", statements.get(1).toString()); + } + + @Test + void buildsAndDeparses() { + VacuumStatement vacuum = new VacuumStatement() + .addOptions(new UtilityOption("VERBOSE"), new UtilityOption("FULL")) + .addTargets(new MaintenanceTarget(new Table("t")).addColumns(new Column("a"))); + // the unparenthesized form is rendered in PostgreSQL's keyword order + assertDeparse(vacuum, "VACUUM FULL VERBOSE t (a)"); + + vacuum.addOptions(UtilityOption.of("PARALLEL", 4)); + assertDeparse(vacuum, "VACUUM (VERBOSE, FULL, PARALLEL 4) t (a)"); + + vacuum.setOptions(List.of(new UtilityOption("FULL"))); + vacuum.setParenthesizedOptions(true); + assertDeparse(vacuum, "VACUUM (FULL) t (a)"); + + vacuum.setOptions(List.of(new UtilityOption("FULL"), new UtilityOption("FULL"))); + vacuum.setParenthesizedOptions(false); + assertDeparse(vacuum, "VACUUM (FULL, FULL) t (a)"); + + vacuum.setOptions(null); + vacuum.withTargets(null); + assertDeparse(vacuum, "VACUUM"); + } + + @Test + void findsTablesButNotColumns() throws JSQLParserException { + Statement statement = CCJSqlParserUtil.parse("VACUUM (ANALYZE) a (x, y), ONLY s.b", + POSTGRESQL); + + assertThat(new TablesNamesFinder<>().getTables(statement)) + .containsExactlyInAnyOrder("a", "s.b"); + assertThat(new TablesNamesFinder<>().getTables( + CCJSqlParserUtil.parse("VACUUM", POSTGRESQL))).isEmpty(); + } + + @Test + void reportsStatementFeatures() throws JSQLParserException { + StatementFeatures features = CCJSqlParserUtil.parse("VACUUM FULL t").getFeatures(); + + assertThat(features.getCertain()).containsExactlyInAnyOrder(StmtFeature.MODIFIES_SCHEMA, + StmtFeature.READS_DATA); + } + + @Test + void wordsRemainIdentifiers() throws JSQLParserException { + assertSqlCanBeParsedAndDeparsed("SELECT vacuum, freeze FROM vacuum.freeze AS vacuum", + true, POSTGRESQL); + assertSqlCanBeParsedAndDeparsed("CREATE TABLE vacuum (vacuum INT, freeze INT)", true, + POSTGRESQL); + assertSqlCanBeParsedAndDeparsed("VACUUM vacuum (vacuum)", true, POSTGRESQL); + assertSqlCanBeParsedAndDeparsed("VACUUM FULL cluster, checkpoint", true, POSTGRESQL); + assertSqlCanBeParsedAndDeparsed("INSERT INTO vacuum (freeze) VALUES (1)", true); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/reindex/ReindexTest.java b/src/test/java/net/sf/jsqlparser/statement/reindex/ReindexTest.java index c68ab29664..e6482373f2 100644 --- a/src/test/java/net/sf/jsqlparser/statement/reindex/ReindexTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/reindex/ReindexTest.java @@ -133,7 +133,7 @@ void validationAndFeatures() throws JSQLParserException { @Test void startsAStatementAmongUnsupportedStatements() throws JSQLParserException { Statements statements = CCJSqlParserUtil.parseStatements( - "VACUUM t; REINDEX TABLE t; REINDEX t", + "LISTEN ch; REINDEX TABLE t; REINDEX t", parser -> parser.withUnsupportedStatements(true)); assertEquals(3, statements.size()); diff --git a/src/test/java/net/sf/jsqlparser/statement/securitylabel/SecurityLabelTest.java b/src/test/java/net/sf/jsqlparser/statement/securitylabel/SecurityLabelTest.java index 211abadac4..6d906eed85 100644 --- a/src/test/java/net/sf/jsqlparser/statement/securitylabel/SecurityLabelTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/securitylabel/SecurityLabelTest.java @@ -194,7 +194,7 @@ void wordsRemainIdentifiers() throws JSQLParserException { @Test void startsAStatementAmongUnsupportedStatements() throws JSQLParserException { Statements statements = CCJSqlParserUtil.parseStatements( - "VACUUM t; SECURITY LABEL ON TABLE t IS 'x'", + "LISTEN ch; SECURITY LABEL ON TABLE t IS 'x'", parser -> parser.withUnsupportedStatements(true)); assertEquals(2, statements.size()); diff --git a/src/test/java/net/sf/jsqlparser/util/validation/validator/MaintenanceValidatorTest.java b/src/test/java/net/sf/jsqlparser/util/validation/validator/MaintenanceValidatorTest.java new file mode 100644 index 0000000000..81244cbac3 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/util/validation/validator/MaintenanceValidatorTest.java @@ -0,0 +1,56 @@ +/*- + * #%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.util.validation.validator; + +import net.sf.jsqlparser.parser.feature.Feature; +import net.sf.jsqlparser.util.validation.ValidationTestAsserts; +import net.sf.jsqlparser.util.validation.feature.DatabaseType; +import net.sf.jsqlparser.util.validation.feature.FeaturesAllowed; +import net.sf.jsqlparser.util.validation.feature.MariaDbVersion; +import net.sf.jsqlparser.util.validation.feature.MySqlVersion; +import net.sf.jsqlparser.util.validation.feature.PostgresqlVersion; +import org.junit.jupiter.api.Test; + +public class MaintenanceValidatorTest extends ValidationTestAsserts { + + @Test + public void allowedForPostgresql() { + for (String sql : new String[] {"VACUUM (VERBOSE, ANALYZE) t", "VACUUM FULL t", + "VACUUM", "ANALYZE VERBOSE t (a)", "ANALYZE (SKIP_LOCKED) t, s", "ANALYZE", + "CLUSTER t USING t_pkey", "CLUSTER VERBOSE", "CHECKPOINT"}) { + validateNoErrors(sql, 1, PostgresqlVersion.V10, PostgresqlVersion.V14, + DatabaseType.POSTGRESQL); + } + } + + @Test + public void analyzeAllowedForMySqlAndMariaDb() { + for (String sql : new String[] {"ANALYZE TABLE t1 UPDATE HISTOGRAM ON c1 WITH 4 BUCKETS", + "ANALYZE NO_WRITE_TO_BINLOG TABLE t, s"}) { + validateNoErrors(sql, 1, MySqlVersion.V8_0, MariaDbVersion.V10_5_4, + DatabaseType.MYSQL, DatabaseType.MARIADB); + } + } + + @Test + public void notSupportedByMySql() { + validateNotSupported("VACUUM t", 1, 1, MySqlVersion.V8_0, Feature.vacuum); + validateNotSupported("CLUSTER t", 1, 1, MySqlVersion.V8_0, Feature.cluster); + validateNotSupported("CHECKPOINT", 1, 1, MySqlVersion.V8_0, Feature.checkpoint); + } + + @Test + public void featureNotAllowed() { + validateNotAllowed("VACUUM t", 1, 1, FeaturesAllowed.SELECT, Feature.vacuum); + validateNotAllowed("CLUSTER", 1, 1, FeaturesAllowed.DML, Feature.cluster); + validateNotAllowed("CHECKPOINT", 1, 1, FeaturesAllowed.DDL, Feature.checkpoint); + validateNotAllowed("ANALYZE t", 1, 1, FeaturesAllowed.SELECT, Feature.analyze); + } +}