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 extends Table> 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 extends MaintenanceTarget> 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 extends MaintenanceTarget> 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 extends UtilityOption> 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 extends UtilityOption> 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 extends Table> 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 extends UtilityOption> 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 extends UtilityOption> 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 extends Column> 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 extends Column> 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 extends UtilityOption> options) {
+ replace(this.options, options);
+ }
+
+ public VacuumStatement addOptions(UtilityOption... options) {
+ Collections.addAll(this.options, options);
+ return this;
+ }
+
+ public VacuumStatement withOptions(Collection extends UtilityOption> 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 extends MaintenanceTarget> targets) {
+ replace(this.targets, targets);
+ }
+
+ public VacuumStatement addTargets(MaintenanceTarget... targets) {
+ Collections.addAll(this.targets, targets);
+ return this;
+ }
+
+ public VacuumStatement withTargets(Collection extends MaintenanceTarget> targets) {
+ setTargets(targets);
+ return this;
+ }
+
+ private static void replace(List list, Collection extends E> 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);
+ }
+}