From d561779a8c6a1645092bfc2ebd225638261c1b82 Mon Sep 17 00:00:00 2001 From: minleejae Date: Tue, 3 Nov 2026 15:11:14 +0900 Subject: [PATCH] feat: parse PostgreSQL VACUUM, ANALYZE, CLUSTER and CHECKPOINT with shared utility options PostgreSQL maintenance scripts failed to parse: VACUUM, CLUSTER and CHECKPOINT had no statement, and ANALYZE accepted only the MySQL form, so option lists, ANALYZE VERBOSE, column lists and a bare ANALYZE failed. Add UtilityOption, one name [value] element of PostgreSQL's utility_option_list with a typed value, and the shared UtilityOptionList()/UtilityOptionArgument() productions. VACUUM, ANALYZE and CLUSTER use them and, like PostgreSQL's grammar, accept any option name and value but PostgreSQL's reserved words; the server checks them when it runs the statement, and getKind() maps known names to each statement's OptionKind. EXPLAIN reads its options through the same production and accepts no more than before. The unparenthesized keywords (VACUUM FULL, ANALYZE VERBOSE, CLUSTER VERBOSE) are kept as options with isParenthesizedOptions() false. Add VacuumStatement, ClusterStatement (also the pre-8.3 CLUSTER index ON table) and CheckpointStatement, and extend Analyze with options, the ANALYSE spelling and MaintenanceTarget tables ([ONLY] t [*] [(columns)]). Analyze keeps its MySQL form with the whole histogram clause (bucket count, AUTO/MANUAL UPDATE, USING DATA); the MySQL clauses and the PostgreSQL options and targets exclude each other, and getTables() is derived from the targets. The statements, options and targets carry AST nodes spanning their source. VACUUM and CLUSTER become non-reserved keywords. The PostgreSQL forms are read with Dialect.POSTGRESQL and without a dialect; other dialects are unchanged. Wire the statements into the deparser, visitors, table finder, validation (vacuum, cluster, checkpoint; analyze is now in the MySQL, MariaDB and PostgreSQL capabilities) and feature analysis. Signed-off-by: minleejae --- README.md | 1 + .../sf/jsqlparser/parser/feature/Feature.java | 24 ++ .../statement/StatementFeatureVisitor.java | 27 ++ .../statement/StatementVisitor.java | 27 ++ .../statement/StatementVisitorAdapter.java | 18 + .../jsqlparser/statement/UtilityOption.java | 318 ++++++++++++++ .../jsqlparser/statement/analyze/Analyze.java | 150 ++++++- .../maintenance/CheckpointStatement.java | 33 ++ .../maintenance/ClusterStatement.java | 173 ++++++++ .../maintenance/MaintenanceTarget.java | 114 +++++ .../maintenance/VacuumStatement.java | 123 ++++++ .../sf/jsqlparser/util/TablesNamesFinder.java | 38 ++ .../util/deparser/StatementDeParser.java | 22 +- .../validation/feature/MariaDbVersion.java | 2 +- .../util/validation/feature/MySqlVersion.java | 2 +- .../validation/feature/PostgresqlVersion.java | 6 + .../validator/ClusterValidator.java | 28 ++ .../validator/StatementValidator.java | 36 ++ .../validation/validator/VacuumValidator.java | 26 ++ .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 407 +++++++++++++++++- src/site/sphinx/index.rst | 2 + src/site/sphinx/usage.rst | 65 +++ .../parser/ASTNodeCoverageTest.java | 11 +- .../statement/UtilityOptionTest.java | 134 ++++++ .../analyze/PostgreSqlAnalyzeTest.java | 312 ++++++++++++++ .../maintenance/CheckpointStatementTest.java | 92 ++++ .../maintenance/ClusterStatementTest.java | 218 ++++++++++ .../maintenance/MaintenanceNodeTest.java | 110 +++++ .../maintenance/VacuumStatementTest.java | 338 +++++++++++++++ .../statement/reindex/ReindexTest.java | 2 +- .../securitylabel/SecurityLabelTest.java | 2 +- .../validator/MaintenanceValidatorTest.java | 56 +++ 32 files changed, 2879 insertions(+), 38 deletions(-) create mode 100644 src/main/java/net/sf/jsqlparser/statement/UtilityOption.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/maintenance/CheckpointStatement.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/maintenance/ClusterStatement.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/maintenance/MaintenanceTarget.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/maintenance/VacuumStatement.java create mode 100644 src/main/java/net/sf/jsqlparser/util/validation/validator/ClusterValidator.java create mode 100644 src/main/java/net/sf/jsqlparser/util/validation/validator/VacuumValidator.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/UtilityOptionTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/analyze/PostgreSqlAnalyzeTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/maintenance/CheckpointStatementTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/maintenance/ClusterStatementTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/maintenance/MaintenanceNodeTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/maintenance/VacuumStatementTest.java create mode 100644 src/test/java/net/sf/jsqlparser/util/validation/validator/MaintenanceValidatorTest.java diff --git a/README.md b/README.md index d4b0e2848a..d8855f6771 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ and missing syntax gets added on demand — [open an issue](https://github.com/J | **Oracle vector types** | `VECTOR(*, *)` · `VECTOR(*, FLOAT32)` · `VECTOR(3, *)`, including dense and sparse storage formats | | **PostgreSQL ownership** | `DROP OWNED` · `REASSIGN OWNED` | | **MySQL table maintenance** | `ANALYZE` · `CHECK` · `CHECKSUM` · `OPTIMIZE` · `REPAIR TABLE` | +| **PostgreSQL maintenance** | `VACUUM` · `ANALYZE` · `CLUSTER` · `CHECKPOINT` | | **MySQL account management** | `CREATE`/`ALTER`/`RENAME USER` · `CREATE ROLE` · `SET ROLE` · `SET DEFAULT ROLE` · `SET PASSWORD` | | **Tablespaces** | MySQL and PostgreSQL `CREATE TABLESPACE` · `ALTER TABLESPACE` | | **PostgreSQL RLS** | `CREATE POLICY` · `ALTER TABLE … ENABLE`/`DISABLE`/`FORCE`/`NO FORCE ROW LEVEL SECURITY` | diff --git a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java index 3ce49efd6a..e8508a3e8a 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -32,6 +32,9 @@ import net.sf.jsqlparser.statement.alter.AlterEventTrigger; import net.sf.jsqlparser.statement.alter.sequence.AlterSequence; import net.sf.jsqlparser.statement.analyze.Analyze; +import net.sf.jsqlparser.statement.maintenance.CheckpointStatement; +import net.sf.jsqlparser.statement.maintenance.ClusterStatement; +import net.sf.jsqlparser.statement.maintenance.VacuumStatement; import net.sf.jsqlparser.statement.comment.Comment; import net.sf.jsqlparser.statement.create.database.CreateDatabase; import net.sf.jsqlparser.statement.create.function.CreateFunction; @@ -485,6 +488,27 @@ public enum Feature { */ tableMaintenance, + /** + * PostgreSQL "VACUUM" statement is allowed + * + * @see VacuumStatement + */ + vacuum, + + /** + * PostgreSQL "CLUSTER" statement is allowed + * + * @see ClusterStatement + */ + cluster, + + /** + * PostgreSQL "CHECKPOINT" statement is allowed + * + * @see CheckpointStatement + */ + checkpoint, + /** * SQL "TRUNCATE" statement is allowed * diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index 8f4e7aa8f7..4abb61e85a 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -97,6 +97,9 @@ import net.sf.jsqlparser.statement.insert.InsertBulk; import net.sf.jsqlparser.statement.load.LoadDataStatement; import net.sf.jsqlparser.statement.lock.LockStatement; +import net.sf.jsqlparser.statement.maintenance.CheckpointStatement; +import net.sf.jsqlparser.statement.maintenance.ClusterStatement; +import net.sf.jsqlparser.statement.maintenance.VacuumStatement; import net.sf.jsqlparser.statement.merge.Merge; import net.sf.jsqlparser.statement.oracle.OracleAssignment; import net.sf.jsqlparser.statement.oracle.OracleBlock; @@ -748,6 +751,30 @@ public Void visit(TableMaintenanceStatement statement, S context) { return null; } + @Override + public Void visit(VacuumStatement vacuum, S context) { + // reclaims storage and, with ANALYZE, rewrites statistics; FULL rewrites the tables + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SCHEMA, StmtFeature.READS_DATA); + return null; + } + + @Override + public Void visit(ClusterStatement cluster, S context) { + // rewrites the table in index order + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SCHEMA, StmtFeature.READS_DATA); + return null; + } + + @Override + public Void visit(CheckpointStatement checkpoint, S context) { + // a server operation like ALTER SYSTEM CHECKPOINT; no data or schema changes + analysis.claimTopLevel(); + analysis.certain(StmtFeature.MODIFIES_SESSION); + return null; + } + private Void schemaOnly() { analysis.claimTopLevel(); analysis.certain(StmtFeature.MODIFIES_SCHEMA); diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index c802107c0b..5693198da7 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -91,6 +91,9 @@ import net.sf.jsqlparser.statement.insert.InsertBulk; import net.sf.jsqlparser.statement.insert.ParenthesedInsert; import net.sf.jsqlparser.statement.lock.LockStatement; +import net.sf.jsqlparser.statement.maintenance.CheckpointStatement; +import net.sf.jsqlparser.statement.maintenance.ClusterStatement; +import net.sf.jsqlparser.statement.maintenance.VacuumStatement; import net.sf.jsqlparser.statement.merge.Merge; import net.sf.jsqlparser.statement.refresh.RefreshMaterializedViewStatement; import net.sf.jsqlparser.statement.reindex.Reindex; @@ -371,6 +374,30 @@ default void visit(TableMaintenanceStatement statement) { this.visit(statement, null); } + default T visit(VacuumStatement vacuum, S context) { + return null; + } + + default void visit(VacuumStatement vacuum) { + this.visit(vacuum, null); + } + + default T visit(ClusterStatement cluster, S context) { + return null; + } + + default void visit(ClusterStatement cluster) { + this.visit(cluster, null); + } + + default T visit(CheckpointStatement checkpoint, S context) { + return null; + } + + default void visit(CheckpointStatement checkpoint) { + this.visit(checkpoint, null); + } + default void visit(Execute execute) { this.visit(execute, null); } diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index 5367cc9a0c..adc28b2b56 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -94,6 +94,9 @@ import net.sf.jsqlparser.statement.insert.InsertConflictAction; import net.sf.jsqlparser.statement.insert.ParenthesedInsert; import net.sf.jsqlparser.statement.lock.LockStatement; +import net.sf.jsqlparser.statement.maintenance.CheckpointStatement; +import net.sf.jsqlparser.statement.maintenance.ClusterStatement; +import net.sf.jsqlparser.statement.maintenance.VacuumStatement; import net.sf.jsqlparser.statement.merge.Merge; import net.sf.jsqlparser.statement.merge.MergeOperationVisitor; import net.sf.jsqlparser.statement.merge.MergeOperationVisitorAdapter; @@ -708,6 +711,21 @@ public T visit(TableMaintenanceStatement statement, S context) { return null; } + @Override + public T visit(VacuumStatement vacuum, S context) { + return null; + } + + @Override + public T visit(ClusterStatement cluster, S context) { + return null; + } + + @Override + public T visit(CheckpointStatement checkpoint, S context) { + return null; + } + @Override public T visit(SavepointStatement savepointStatement, S context) { // @todo: do something usefully here diff --git a/src/main/java/net/sf/jsqlparser/statement/UtilityOption.java b/src/main/java/net/sf/jsqlparser/statement/UtilityOption.java new file mode 100644 index 0000000000..60c0c1fdb2 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/UtilityOption.java @@ -0,0 +1,318 @@ +/*- + * #%L + * JSQLParser library + * %% + * Copyright (C) 2004 - 2026 JSQLParser + * %% + * Dual licensed under GNU LGPL 2.1 or Apache License 2.0 + * #L% + */ +package net.sf.jsqlparser.statement; + +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; + +/** + * One element {@code name [value]} of a PostgreSQL utility option list, such as + * {@code VACUUM (VERBOSE, PARALLEL 2) t}. The same list follows {@code VACUUM}, {@code ANALYZE} and + * {@code CLUSTER}. + *

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

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

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

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

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

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