diff --git a/README.md b/README.md index e7a8db452..8f8361505 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,7 @@ and missing syntax gets added on demand — [open an issue](https://github.com/J | **MySQL table maintenance** | `ANALYZE` · `CHECK` · `CHECKSUM` · `OPTIMIZE` · `REPAIR TABLE` | | **PostgreSQL maintenance** | `VACUUM` · `ANALYZE` · `CLUSTER` · `CHECKPOINT` | | **MySQL locks** | `LOCK TABLES` · `UNLOCK TABLES` · `LOCK INSTANCE FOR BACKUP` · `UNLOCK INSTANCE` | +| **PostgreSQL session statements** | `DISCARD` · `LISTEN` · `UNLISTEN` · `SHOW`/`RESET TIME ZONE`/`TRANSACTION ISOLATION LEVEL`/`SESSION AUTHORIZATION` · `COPY` options from before 9.0 | | **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 dc3d137e3..de8355e11 100644 --- a/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java +++ b/src/main/java/net/sf/jsqlparser/parser/feature/Feature.java @@ -930,6 +930,18 @@ public enum Feature { * @see ResetStatement */ reset, + /** + * PostgreSQL "DISCARD" statement is allowed + * + * @see net.sf.jsqlparser.statement.DiscardStatement + */ + discard, + /** + * PostgreSQL "LISTEN" and "UNLISTEN" statements are allowed + * + * @see net.sf.jsqlparser.statement.notify.ListenStatement + */ + listen, /** * @see Pivot */ diff --git a/src/main/java/net/sf/jsqlparser/statement/CopyLegacyOption.java b/src/main/java/net/sf/jsqlparser/statement/CopyLegacyOption.java new file mode 100644 index 000000000..cbeafc4d6 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/CopyLegacyOption.java @@ -0,0 +1,192 @@ +/*- + * #%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.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Objects; +import net.sf.jsqlparser.expression.StringValue; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; +import net.sf.jsqlparser.schema.Column; + +/** + * One option of the COPY option syntax PostgreSQL used before 9.0 and still accepts: a keyword + * without parentheses or commas, such as {@code CSV}, {@code HEADER}, {@code DELIMITER AS ';'} or + * {@code FORCE QUOTE *}. The options follow the file name and the optional {@code WITH} in any + * order, as {@code copy_opt_item} in PostgreSQL's grammar. + * + * @see COPY, section Compatibility + */ +public class CopyLegacyOption extends ASTNodeAccessImpl { + private Kind kind; + private boolean asKeyword; + private StringValue value; + private final List columns = new ArrayList<>(); + private boolean allColumns; + + /** The option keyword, and what it takes after it. */ + public enum Kind { + /** BINARY, the binary format */ + BINARY("BINARY", Argument.NONE), + /** FREEZE */ + FREEZE("FREEZE", Argument.NONE), + /** DELIMITER [AS] 'string' */ + DELIMITER("DELIMITER", Argument.STRING_AFTER_OPTIONAL_AS), + /** NULL [AS] 'string' */ + NULL("NULL", Argument.STRING_AFTER_OPTIONAL_AS), + /** CSV, the CSV format */ + CSV("CSV", Argument.NONE), + /** HEADER */ + HEADER("HEADER", Argument.NONE), + /** QUOTE [AS] 'string' */ + QUOTE("QUOTE", Argument.STRING_AFTER_OPTIONAL_AS), + /** ESCAPE [AS] 'string' */ + ESCAPE("ESCAPE", Argument.STRING_AFTER_OPTIONAL_AS), + /** FORCE QUOTE {column, ... | *} */ + FORCE_QUOTE("FORCE QUOTE", Argument.COLUMNS), + /** FORCE NOT NULL {column, ... | *} */ + FORCE_NOT_NULL("FORCE NOT NULL", Argument.COLUMNS), + /** FORCE NULL {column, ... | *} */ + FORCE_NULL("FORCE NULL", Argument.COLUMNS), + /** ENCODING 'string' */ + ENCODING("ENCODING", Argument.STRING); + + private final String keyword; + private final Argument argument; + + Kind(String keyword, Argument argument) { + this.keyword = keyword; + this.argument = argument; + } + + /** The keywords of the option, such as {@code FORCE NOT NULL}. */ + public String getKeyword() { + return keyword; + } + + /** Whether a string follows the keyword. */ + public boolean takesString() { + return argument == Argument.STRING || argument == Argument.STRING_AFTER_OPTIONAL_AS; + } + + /** Whether {@code AS} may precede the string. */ + public boolean allowsAsKeyword() { + return argument == Argument.STRING_AFTER_OPTIONAL_AS; + } + + /** Whether a column list or {@code *} follows the keyword. */ + public boolean takesColumns() { + return argument == Argument.COLUMNS; + } + } + + private enum Argument { + NONE, STRING, STRING_AFTER_OPTIONAL_AS, COLUMNS + } + + public CopyLegacyOption(Kind kind) { + setKind(kind); + } + + public Kind getKind() { + return kind; + } + + public void setKind(Kind kind) { + this.kind = Objects.requireNonNull(kind, "kind"); + } + + /** Whether {@code AS} precedes the string, as in {@code DELIMITER AS ','}. */ + public boolean isAsKeyword() { + return asKeyword; + } + + public void setAsKeyword(boolean asKeyword) { + this.asKeyword = asKeyword; + } + + public CopyLegacyOption withAsKeyword(boolean asKeyword) { + setAsKeyword(asKeyword); + return this; + } + + /** The string of DELIMITER, NULL, QUOTE, ESCAPE or ENCODING; null for the other options. */ + public StringValue getValue() { + return value; + } + + public void setValue(StringValue value) { + this.value = value; + } + + public CopyLegacyOption withValue(StringValue value) { + setValue(value); + return this; + } + + /** The columns of a FORCE option; empty for {@code *} and for the other options. */ + 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 CopyLegacyOption addColumns(Column... columns) { + Collections.addAll(this.columns, columns); + return this; + } + + public CopyLegacyOption withColumns(Collection columns) { + setColumns(columns); + return this; + } + + /** Whether a FORCE option applies to every column, written {@code *}. */ + public boolean isAllColumns() { + return allColumns; + } + + public void setAllColumns(boolean allColumns) { + this.allColumns = allColumns; + } + + public CopyLegacyOption withAllColumns(boolean allColumns) { + setAllColumns(allColumns); + return this; + } + + @Override + public StringBuilder appendTo(StringBuilder builder) { + builder.append(kind.getKeyword()); + if (value != null) { + builder.append(asKeyword ? " AS " : " ").append(value); + } + if (allColumns) { + builder.append(" *"); + } else { + for (int i = 0; i < columns.size(); i++) { + builder.append(i == 0 ? " " : ", ").append(columns.get(i).getFullyQualifiedName()); + } + } + return builder; + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/CopyStatement.java b/src/main/java/net/sf/jsqlparser/statement/CopyStatement.java index 2697677db..e25314368 100644 --- a/src/main/java/net/sf/jsqlparser/statement/CopyStatement.java +++ b/src/main/java/net/sf/jsqlparser/statement/CopyStatement.java @@ -16,15 +16,21 @@ import net.sf.jsqlparser.statement.select.PlainSelect; import net.sf.jsqlparser.statement.select.Select; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; import java.util.List; import java.util.function.Consumer; /** * {@code COPY table [(columns)] FROM path [(options)]} and * {@code COPY {table | (query)} TO path [(options)]}, the bulk import and export statement in - * DuckDB and PostgreSQL. + * DuckDB and PostgreSQL. PostgreSQL also accepts its option syntax from before 9.0, options without + * parentheses such as {@code CSV HEADER} (see {@link #getLegacyOptions()}), and + * {@code COPY BINARY table}. * * @see COPY + * @see PostgreSQL COPY */ public class CopyStatement extends ASTNodeAccessImpl implements Statement { private Table table; @@ -34,6 +40,8 @@ public class CopyStatement extends ASTNodeAccessImpl implements Statement { private String path; private List options; private boolean withKeyword; + private boolean binary; + private final List legacyOptions = new ArrayList<>(); public Table getTable() { return table; @@ -101,6 +109,7 @@ public CopyStatement withPath(String path) { return this; } + /** The options in parentheses, each as written; null when the statement has none. */ public List getOptions() { return options; } @@ -128,6 +137,47 @@ public CopyStatement withWithKeyword(boolean withKeyword) { return this; } + /** Whether the table is preceded by BINARY, PostgreSQL's spelling from before 7.3. */ + public boolean isBinary() { + return binary; + } + + public void setBinary(boolean binary) { + this.binary = binary; + } + + public CopyStatement withBinary(boolean binary) { + setBinary(binary); + return this; + } + + /** + * The options of PostgreSQL's syntax from before 9.0, in source order, such as {@code CSV} and + * {@code HEADER} in {@code COPY t TO STDOUT CSV HEADER}; empty when the statement has none. A + * statement has either these options or the options in parentheses. + */ + public List getLegacyOptions() { + return legacyOptions; + } + + public void setLegacyOptions(Collection legacyOptions) { + List replacement = legacyOptions == null + ? Collections.emptyList() + : new ArrayList<>(legacyOptions); + this.legacyOptions.clear(); + this.legacyOptions.addAll(replacement); + } + + public CopyStatement addLegacyOptions(CopyLegacyOption... legacyOptions) { + Collections.addAll(this.legacyOptions, legacyOptions); + return this; + } + + public CopyStatement withLegacyOptions(Collection legacyOptions) { + setLegacyOptions(legacyOptions); + return this; + } + public StringBuilder appendTo(StringBuilder builder) { return appendTo(builder, builder::append); } @@ -140,6 +190,9 @@ public StringBuilder appendTo(StringBuilder builder, Consumer selectPrin if (withKeyword) { builder.append(" WITH"); } + for (CopyLegacyOption legacyOption : legacyOptions) { + legacyOption.appendTo(builder.append(' ')); + } if (options != null && !options.isEmpty()) { builder.append(" ").append(PlainSelect.getStringList(options, true, true)); } diff --git a/src/main/java/net/sf/jsqlparser/statement/DiscardStatement.java b/src/main/java/net/sf/jsqlparser/statement/DiscardStatement.java new file mode 100644 index 000000000..9d9987d6e --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/DiscardStatement.java @@ -0,0 +1,79 @@ +/*- + * #%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.Objects; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; + +/** + * PostgreSQL {@code DISCARD {ALL | PLANS | SEQUENCES | TEMP | TEMPORARY}}, which releases the + * session state that the target names. + * + * @see DISCARD + */ +public class DiscardStatement extends ASTNodeAccessImpl implements Statement { + private Target target = Target.ALL; + + /** + * What DISCARD releases. {@code TEMP} and {@code TEMPORARY} are one target, both kept so that + * the statement prints as written. + */ + public enum Target { + /** Every kind of session state, including the other targets. */ + ALL, + /** Cached query plans. */ + PLANS, + /** Cached sequence state. */ + SEQUENCES, + /** Temporary tables, spelled TEMP. */ + TEMP, + /** Temporary tables, spelled TEMPORARY. */ + TEMPORARY + } + + public DiscardStatement() {} + + public DiscardStatement(Target target) { + setTarget(target); + } + + public Target getTarget() { + return target; + } + + public void setTarget(Target target) { + this.target = Objects.requireNonNull(target, "target"); + } + + public DiscardStatement withTarget(Target target) { + setTarget(target); + return this; + } + + /** Whether the statement drops the temporary tables of the session: ALL, TEMP or TEMPORARY. */ + public boolean isDroppingTemporaryTables() { + return target == Target.ALL || target == Target.TEMP || target == Target.TEMPORARY; + } + + @Override + public StringBuilder appendTo(StringBuilder builder) { + return builder.append("DISCARD ").append(target.name()); + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java index df67903b0..69be59e68 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementFeatureVisitor.java @@ -105,6 +105,7 @@ 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.notify.ListenStatement; import net.sf.jsqlparser.statement.oracle.OracleAssignment; import net.sf.jsqlparser.statement.oracle.OracleBlock; import net.sf.jsqlparser.statement.oracle.OracleNullStatement; @@ -981,6 +982,21 @@ public Void visit(DeclareStatement declareStatement, S context) { return sessionOnly(); } + @Override + public Void visit(ListenStatement listen, S context) { + return sessionOnly(); + } + + @Override + public Void visit(DiscardStatement discard, S context) { + sessionOnly(); + if (discard.isDroppingTemporaryTables()) { + // the temporary tables of the session, if it has any + analysis.possible(StmtFeature.MODIFIES_SCHEMA); + } + return null; + } + @Override public Void visit(AlterSystemStatement alterSystemStatement, S context) { analysis.claimTopLevel(); diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java index 4063637f7..4f3a24d8b 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitor.java @@ -20,6 +20,7 @@ import net.sf.jsqlparser.statement.create.textsearch.CreateTextSearchConfiguration; import net.sf.jsqlparser.statement.alter.AlterTextSearchConfiguration; import net.sf.jsqlparser.statement.create.rule.CreateRule; +import net.sf.jsqlparser.statement.notify.ListenStatement; import net.sf.jsqlparser.statement.notify.NotifyStatement; import net.sf.jsqlparser.statement.create.collation.CreateCollation; import net.sf.jsqlparser.statement.alter.AlterCollation; @@ -1039,6 +1040,22 @@ default void visit(NotifyStatement statement) { visit(statement, null); } + default T visit(ListenStatement listen, S context) { + return null; + } + + default void visit(ListenStatement listen) { + visit(listen, null); + } + + default T visit(DiscardStatement discard, S context) { + return null; + } + + default void visit(DiscardStatement discard) { + visit(discard, null); + } + default T visit(CreateCollation statement, S context) { return null; } diff --git a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java index 9cb08129e..b0a3dfaba 100644 --- a/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java +++ b/src/main/java/net/sf/jsqlparser/statement/StatementVisitorAdapter.java @@ -25,6 +25,7 @@ import net.sf.jsqlparser.statement.alter.AlterUserMapping; import net.sf.jsqlparser.statement.create.rule.CreateRule; import net.sf.jsqlparser.statement.create.collation.CreateCollation; +import net.sf.jsqlparser.statement.notify.ListenStatement; import net.sf.jsqlparser.statement.notify.NotifyStatement; import net.sf.jsqlparser.statement.foreign.ForeignDataStatement; @@ -1137,6 +1138,16 @@ public T visit(NotifyStatement statement, S context) { return null; } + @Override + public T visit(ListenStatement listen, S context) { + return null; + } + + @Override + public T visit(DiscardStatement discard, S context) { + return null; + } + @Override public T visit(CreateCollation statement, S context) { statement.visitExpressions(expression -> expression.accept(expressionVisitor, context)); diff --git a/src/main/java/net/sf/jsqlparser/statement/notify/ListenStatement.java b/src/main/java/net/sf/jsqlparser/statement/notify/ListenStatement.java new file mode 100644 index 000000000..b2f579b99 --- /dev/null +++ b/src/main/java/net/sf/jsqlparser/statement/notify/ListenStatement.java @@ -0,0 +1,88 @@ +/*- + * #%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.notify; + +import java.util.Objects; +import net.sf.jsqlparser.parser.ASTNodeAccessImpl; +import net.sf.jsqlparser.statement.Statement; +import net.sf.jsqlparser.statement.StatementVisitor; + +/** + * PostgreSQL {@code LISTEN channel} and {@code UNLISTEN {channel | *}}, which register the session + * as a listener on a channel that {@link NotifyStatement} signals and remove it again. + * + * @see LISTEN + * @see UNLISTEN + */ +public class ListenStatement extends ASTNodeAccessImpl implements Statement { + private Action action = Action.LISTEN; + private String channel; + + public enum Action { + /** LISTEN channel */ + LISTEN, + /** UNLISTEN {channel | *} */ + UNLISTEN + } + + public ListenStatement() {} + + public ListenStatement(Action action, String channel) { + setAction(action); + setChannel(channel); + } + + public Action getAction() { + return action; + } + + public void setAction(Action action) { + this.action = Objects.requireNonNull(action, "action"); + } + + public ListenStatement withAction(Action action) { + setAction(action); + return this; + } + + /** The channel name as written, or null for {@code UNLISTEN *}. */ + public String getChannel() { + return channel; + } + + public void setChannel(String channel) { + this.channel = channel; + } + + public ListenStatement withChannel(String channel) { + setChannel(channel); + return this; + } + + /** Whether the statement is {@code UNLISTEN *}, which names no channel. */ + public boolean isAllChannels() { + return channel == null; + } + + @Override + public StringBuilder appendTo(StringBuilder builder) { + return builder.append(action.name()).append(' ').append(channel == null ? "*" : channel); + } + + @Override + public String toString() { + return appendTo(new StringBuilder()).toString(); + } + + @Override + public T accept(StatementVisitor statementVisitor, S context) { + return statementVisitor.visit(this, context); + } +} diff --git a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java index e1cf00f3f..c25a0fd7b 100644 --- a/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java +++ b/src/main/java/net/sf/jsqlparser/util/TablesNamesFinder.java @@ -83,6 +83,7 @@ import net.sf.jsqlparser.statement.DeallocateStatement; import net.sf.jsqlparser.statement.DeclareStatement; import net.sf.jsqlparser.statement.DescribeStatement; +import net.sf.jsqlparser.statement.DiscardStatement; import net.sf.jsqlparser.statement.DetachStatement; import net.sf.jsqlparser.statement.DisconnectStatement; import net.sf.jsqlparser.statement.DoStatement; @@ -206,6 +207,7 @@ import net.sf.jsqlparser.statement.merge.MergeOperation; import net.sf.jsqlparser.statement.merge.MergeOperationVisitor; import net.sf.jsqlparser.statement.merge.MergeUpdate; +import net.sf.jsqlparser.statement.notify.ListenStatement; import net.sf.jsqlparser.statement.notify.NotifyStatement; import net.sf.jsqlparser.statement.oracle.OracleAssignment; import net.sf.jsqlparser.statement.oracle.OracleBlock; @@ -3370,6 +3372,18 @@ public Void visit(NotifyStatement statement, S context) { return null; } + @Override + public Void visit(ListenStatement listen, S context) { + // a notification channel is no table + return null; + } + + @Override + public Void visit(DiscardStatement discard, S context) { + // DISCARD TEMP drops the temporary tables of the session without naming one + return null; + } + @Override public Void visit(CreateCollation statement, S context) { statement.visitExpressions(expression -> expression.accept(this, 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 0e9adaba3..0bc95f368 100644 --- a/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java +++ b/src/main/java/net/sf/jsqlparser/util/deparser/StatementDeParser.java @@ -20,6 +20,7 @@ import net.sf.jsqlparser.statement.create.textsearch.CreateTextSearchConfiguration; import net.sf.jsqlparser.statement.alter.AlterTextSearchConfiguration; import net.sf.jsqlparser.statement.create.rule.CreateRule; +import net.sf.jsqlparser.statement.notify.ListenStatement; import net.sf.jsqlparser.statement.notify.NotifyStatement; import net.sf.jsqlparser.statement.create.collation.CreateCollation; import net.sf.jsqlparser.statement.alter.AlterCollation; @@ -65,6 +66,7 @@ import net.sf.jsqlparser.statement.CreateFunctionalStatement; import net.sf.jsqlparser.statement.DeclareStatement; import net.sf.jsqlparser.statement.DescribeStatement; +import net.sf.jsqlparser.statement.DiscardStatement; import net.sf.jsqlparser.statement.DoStatement; import net.sf.jsqlparser.statement.ExplainStatement; import net.sf.jsqlparser.statement.IfElseStatement; @@ -1067,6 +1069,16 @@ public StringBuilder visit(NotifyStatement statement, S context) { expression -> expression.accept(expressionDeParser, context)); } + @Override + public StringBuilder visit(ListenStatement listen, S context) { + return listen.appendTo(builder); + } + + @Override + public StringBuilder visit(DiscardStatement discard, S context) { + return discard.appendTo(builder); + } + @Override public StringBuilder visit(CreateCollation statement, S context) { return statement.appendTo(builder, 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 0600b1242..27be13dc1 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 @@ -154,6 +154,12 @@ public enum PostgresqlVersion implements Version { // https://www.postgresql.org/docs/current/sql-lock.html Feature.copy, Feature.prepare, Feature.deallocate, Feature.lock, + // https://www.postgresql.org/docs/current/sql-show.html + // https://www.postgresql.org/docs/current/sql-discard.html + // https://www.postgresql.org/docs/current/sql-listen.html + // https://www.postgresql.org/docs/current/sql-unlisten.html + Feature.show, Feature.discard, Feature.listen, + // https://www.postgresql.org/docs/current/sql-alterfunction.html // https://www.postgresql.org/docs/current/sql-alterprocedure.html // https://www.postgresql.org/docs/current/sql-alterroutine.html 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 ec1b85f29..85cfec9bf 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 @@ -29,6 +29,7 @@ import net.sf.jsqlparser.statement.create.textsearch.CreateTextSearchConfiguration; import net.sf.jsqlparser.statement.alter.AlterTextSearchConfiguration; import net.sf.jsqlparser.statement.create.rule.CreateRule; +import net.sf.jsqlparser.statement.notify.ListenStatement; import net.sf.jsqlparser.statement.notify.NotifyStatement; import net.sf.jsqlparser.statement.create.collation.CreateCollation; import net.sf.jsqlparser.statement.alter.AlterCollation; @@ -67,6 +68,7 @@ import net.sf.jsqlparser.statement.DoStatement; import net.sf.jsqlparser.statement.DeclareStatement; import net.sf.jsqlparser.statement.DescribeStatement; +import net.sf.jsqlparser.statement.DiscardStatement; import net.sf.jsqlparser.statement.ExplainStatement; import net.sf.jsqlparser.statement.IfElseStatement; import net.sf.jsqlparser.statement.PurgeStatement; @@ -1402,6 +1404,18 @@ public Void visit(NotifyStatement statement, S context) { return null; } + @Override + public Void visit(ListenStatement listen, S context) { + validateFeature(Feature.listen); + return null; + } + + @Override + public Void visit(DiscardStatement discard, S context) { + validateFeature(Feature.discard); + return null; + } + @Override public Void visit(CreateCollation statement, S context) { validateFeature(Feature.createCollation); diff --git a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt index 4cf410188..f5b854c01 100644 --- a/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt +++ b/src/main/jjtree/net/sf/jsqlparser/parser/JSqlParserCC.jjt @@ -57,6 +57,7 @@ import net.sf.jsqlparser.statement.create.usermapping.CreateUserMapping; import net.sf.jsqlparser.statement.create.textsearch.CreateTextSearchConfiguration; import net.sf.jsqlparser.statement.create.rule.CreateRule; import net.sf.jsqlparser.statement.notify.NotifyStatement; +import net.sf.jsqlparser.statement.notify.ListenStatement; import net.sf.jsqlparser.statement.create.collation.CreateCollation; import net.sf.jsqlparser.statement.alter.schema.AlterSchema; import net.sf.jsqlparser.statement.alter.database.AlterDatabase; @@ -172,6 +173,10 @@ public class CCJSqlParser extends AbstractJSqlParser { return "LABEL".equalsIgnoreCase(getToken(2).image); case K_REINDEX: return isReindexAhead(); + case K_DISCARD: + return isDiscardAhead(); + case K_LISTEN: case K_UNLISTEN: + return isListenAhead(); case K_VACUUM: case K_ANALYSE: case K_CLUSTER: case K_CHECKPOINT: return isPostgreSqlMaintenanceAhead(); case K_UNLOCK: @@ -2600,6 +2605,113 @@ public class CCJSqlParser extends AbstractJSqlParser { return dialect == null || Dialect.POSTGRESQL.name().equals(dialect); } + /** + * PostgreSQL's utility forms that other products lack, such as DISCARD, LISTEN, the SHOW and + * RESET names of several words and COPY's option syntax from before 9.0: read with that + * dialect and without one, never with another dialect. + */ + private boolean isPostgreSqlUtilityDialect() { + String dialect = getAsString(Feature.dialect); + return dialect == null || Dialect.POSTGRESQL.name().equals(dialect); + } + + /** + * Whether PostgreSQL's DISCARD starts here. The PostgreSQL dialect always reads DISCARD as the + * statement; without a dialect one of its targets must follow, so that other uses of the word + * stay unsupported statements. + */ + private boolean isDiscardAhead() { + if (getToken(1).kind != K_DISCARD || !isPostgreSqlUtilityDialect()) { + return false; + } + Token target = getToken(2); + return getAsString(Feature.dialect) != null || target.kind == K_ALL + || target.kind == K_TEMP || target.kind == K_TEMPORARY + || "PLANS".equalsIgnoreCase(target.image) || "SEQUENCES".equalsIgnoreCase(target.image); + } + + /** + * Whether PostgreSQL's LISTEN or UNLISTEN starts here. The PostgreSQL dialect always reads the + * words as the statements; without a dialect a channel, or * after UNLISTEN, must follow. + */ + private boolean isListenAhead() { + int kind = getToken(1).kind; + if (kind != K_LISTEN && kind != K_UNLISTEN || !isPostgreSqlUtilityDialect()) { + return false; + } + Token channel = getToken(2); + return getAsString(Feature.dialect) != null || isIdentifierishNameToken(channel) + || kind == K_UNLISTEN && "*".equals(channel.image); + } + + /** The name SET, RESET and SHOW give the configuration parameter written TIME ZONE. */ + private static final String TIME_ZONE_PARAMETER = "Time Zone"; + + /** Whether TIME ZONE follows, the configuration parameter SET, RESET and SHOW name in two words. */ + private boolean isTimeZoneParameterAhead() { + Token time = getToken(1); + return time.kind == K_DATETIMELITERAL && "TIME".equalsIgnoreCase(time.image) + && getToken(2).kind == K_ZONE; + } + + /** + * Whether TRANSACTION ISOLATION LEVEL follows, PostgreSQL's name in RESET and SHOW for the + * parameter transaction_isolation. Other dialects do not read it. + */ + private boolean isTransactionIsolationLevelParameterAhead() { + Token transaction = getToken(1); + return transaction.kind == S_IDENTIFIER && "TRANSACTION".equalsIgnoreCase(transaction.image) + && "ISOLATION".equalsIgnoreCase(getToken(2).image) && getToken(3).kind == K_LEVEL + && isPostgreSqlUtilityDialect(); + } + + /** + * Whether SESSION AUTHORIZATION follows, PostgreSQL's name in RESET and SHOW for the parameter + * session_authorization. Other dialects do not read it. + */ + private boolean isSessionAuthorizationParameterAhead() { + return getToken(1).kind == K_SESSION && getToken(2).kind == K_AUTHORIZATION + && isPostgreSqlUtilityDialect(); + } + + /** Whether a configuration parameter named in several words follows. */ + private boolean isConfigurationParameterPhraseAhead() { + return isTimeZoneParameterAhead() || isTransactionIsolationLevelParameterAhead() + || isSessionAuthorizationParameterAhead(); + } + + /** + * Whether BINARY starts PostgreSQL's COPY BINARY table, its spelling from before 7.3, rather + * than naming a table called binary. + */ + private boolean isCopyBinaryAhead() { + Token next = getToken(2); + return getToken(1).kind == K_BINARY && isPostgreSqlUtilityDialect() + && next.kind != K_FROM && next.kind != K_TO && isIdentifierishNameToken(next); + } + + /** Whether an option of PostgreSQL's COPY syntax from before 9.0, as copy_opt_item, follows. */ + private boolean isCopyLegacyOptionAhead() { + if (!isPostgreSqlUtilityDialect()) { + return false; + } + Token token = getToken(1); + switch (token.kind) { + case K_BINARY: case K_DELIMITER: case K_NULL: case K_CSV: case K_ESCAPE: case K_ENCODING: + return true; + case K_FORCE: + Token next = getToken(2); + return next.kind == K_NOT || next.kind == K_NULL || isCopyLegacyWord(next, "QUOTE"); + default: + return isCopyLegacyWord(token, "FREEZE") || isCopyLegacyWord(token, "HEADER") + || isCopyLegacyWord(token, "QUOTE"); + } + } + + private static boolean isCopyLegacyWord(Token token, String word) { + return token.kind == S_IDENTIFIER && word.equalsIgnoreCase(token.image); + } + /** * 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. @@ -2616,6 +2728,15 @@ public class CCJSqlParser extends AbstractJSqlParser { "TO", "TRAILING", "TRUE", "UNION", "UNIQUE", "USER", "USING", "VARIADIC", "WHEN", "WHERE", "WINDOW", "WITH")); + /** + * PostgreSQL 18's type and function name keywords. Like its reserved keywords, its grammar + * takes none of them as a column identifier (ColId). + */ + private static final Set POSTGRESQL_TYPE_FUNC_NAME_KEYWORDS = new HashSet(Arrays.asList( + "AUTHORIZATION", "BINARY", "COLLATION", "CONCURRENTLY", "CROSS", "CURRENT_SCHEMA", + "FREEZE", "FULL", "ILIKE", "INNER", "IS", "ISNULL", "JOIN", "LEFT", "LIKE", "NATURAL", + "NOTNULL", "OUTER", "OVERLAPS", "RIGHT", "SIMILAR", "TABLESAMPLE", "VERBOSE")); + /** 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); @@ -2624,6 +2745,14 @@ public class CCJSqlParser extends AbstractJSqlParser { "PostgreSQL reserves " + word + " in utility options"); } + /** Rejects an unquoted name that PostgreSQL does not take as a column or channel name. */ + private void requirePostgreSqlColId(String name) throws ParseException { + String upper = name.toUpperCase(Locale.ROOT); + requireDdlSyntax(!POSTGRESQL_RESERVED_KEYWORDS.contains(upper) + && !POSTGRESQL_TYPE_FUNC_NAME_KEYWORDS.contains(upper), + "PostgreSQL does not take " + name + " as a name here"); + } + /** Whether a PostgreSQL identifier in double quotes follows, not one in brackets or backticks. */ private boolean isDoubleQuotedIdentifierAhead() { Token token = getToken(1); @@ -4374,6 +4503,7 @@ String NonReservedWordPart1() : | tk= | tk= | tk= + | tk= | tk= | tk= | tk= @@ -4582,6 +4712,7 @@ String NonReservedWordPart2() : | tk= | tk= | tk= + | tk= | tk= | tk= | tk= @@ -5277,6 +5408,10 @@ Statement SingleStatement() : | stm = Comment() | + LOOKAHEAD({ isListenAhead() }) stm = ListenStatement() + | + LOOKAHEAD({ isDiscardAhead() }) stm = DiscardStatement() + | LOOKAHEAD({ isKeywordAhead("NOTIFY") }) stm = NotifyStatement() | stm = Describe() @@ -6817,7 +6952,7 @@ void SetAssignment(SetStatement set): || (isKeywordAhead("CHARSET") && !"=".equals(getToken(2).image) && !":=".equals(getToken(2).image)) }) SetCharacterSet(set) | - ( LOOKAHEAD(2) { name = "Time Zone"; } + ( LOOKAHEAD({ isTimeZoneParameterAhead() }) name=ConfigurationParameterName() | name=UserVariable() | name=IdentifierChain() ) [ "=" { operator = SetStatement.AssignmentOperator.EQUALS; } @@ -6828,7 +6963,7 @@ void SetAssignment(SetStatement set): expression=SetAssignmentValue() { if (operator == SetStatement.AssignmentOperator.TO - && (name instanceof UserVariable || "Time Zone".equals(name))) { + && (name instanceof UserVariable || TIME_ZONE_PARAMETER.equals(name))) { throw new ParseException("SET TO requires a configuration parameter name"); } set.add(name, linkElements(new ExpressionList(expression)), operator); @@ -6906,10 +7041,36 @@ ResetStatement Reset(): { Token all; } { - ( LOOKAHEAD(2) {name = "Time Zone"; } | name = RelObjectName() | all = {name = all.image; } ) + ( name = ConfigurationParameterName() | all = {name = all.image; } ) { reset = new ResetStatement(name); return reset; } } +/** + * A configuration parameter as SET, RESET and SHOW name it. TIME ZONE is the parameter "Time Zone". + * PostgreSQL's RESET and SHOW also take the SQL standard's TRANSACTION ISOLATION LEVEL and SESSION + * AUTHORIZATION, which are read with Dialect.POSTGRESQL and without a dialect. SET enters this + * production only for TIME ZONE, since its other phrases set a transaction mode or the session + * user instead of a parameter. + */ +String ConfigurationParameterName(): +{ String name; } +{ + ( + LOOKAHEAD({ isTimeZoneParameterAhead() }) + { name = TIME_ZONE_PARAMETER; } + | + LOOKAHEAD({ isTransactionIsolationLevelParameterAhead() }) + ContextualKeyword("TRANSACTION") ContextualKeyword("ISOLATION") + { name = "TRANSACTION ISOLATION LEVEL"; } + | + LOOKAHEAD({ isSessionAuthorizationParameterAhead() }) + { name = "SESSION AUTHORIZATION"; } + | + name = RelObjectName() + ) + { return name; } +} + RenameTableStatement RenameTableStatement(): { RenameTableStatement renameTableStatement; Table oldName; @@ -7014,9 +7175,92 @@ CreateMacro.Parameter MacroParameter(): { return new CreateMacro.Parameter(name, type, defaultValue); } } +/** + * One option of PostgreSQL's COPY syntax from before 9.0, as copy_opt_item in its grammar. The + * options follow each other without commas, in any order. + */ +CopyLegacyOption CopyLegacyOption(): +{ + Token first = getToken(1); + CopyLegacyOption option; +} +{ + ( + { option = new CopyLegacyOption(CopyLegacyOption.Kind.BINARY); } + | + { option = new CopyLegacyOption(CopyLegacyOption.Kind.CSV); } + | + LOOKAHEAD({ isKeywordAhead("FREEZE") }) ContextualKeyword("FREEZE") + { option = new CopyLegacyOption(CopyLegacyOption.Kind.FREEZE); } + | + LOOKAHEAD({ isKeywordAhead("HEADER") }) ContextualKeyword("HEADER") + { option = new CopyLegacyOption(CopyLegacyOption.Kind.HEADER); } + | + { option = new CopyLegacyOption(CopyLegacyOption.Kind.DELIMITER); } + CopyLegacyOptionString(option) + | + { option = new CopyLegacyOption(CopyLegacyOption.Kind.NULL); } + CopyLegacyOptionString(option) + | + LOOKAHEAD({ isKeywordAhead("QUOTE") }) ContextualKeyword("QUOTE") + { option = new CopyLegacyOption(CopyLegacyOption.Kind.QUOTE); } + CopyLegacyOptionString(option) + | + { option = new CopyLegacyOption(CopyLegacyOption.Kind.ESCAPE); } + CopyLegacyOptionString(option) + | + { option = new CopyLegacyOption(CopyLegacyOption.Kind.ENCODING); } + CopyLegacyOptionString(option) + | + + ( + LOOKAHEAD({ isKeywordAhead("QUOTE") }) ContextualKeyword("QUOTE") + { option = new CopyLegacyOption(CopyLegacyOption.Kind.FORCE_QUOTE); } + | + { option = new CopyLegacyOption(CopyLegacyOption.Kind.FORCE_NOT_NULL); } + | + { option = new CopyLegacyOption(CopyLegacyOption.Kind.FORCE_NULL); } + ) + CopyLegacyOptionColumns(option) + ) + { return linked(option, first); } +} + +/** The string of a COPY option from before 9.0; AS may precede it except after ENCODING. */ +void CopyLegacyOptionString(CopyLegacyOption option): +{ Token token; } +{ + [ { option.setAsKeyword(true); } ] + token= + { + requireDdlSyntax(!option.isAsKeyword() || option.getKind().allowsAsKeyword(), + option.getKind().getKeyword() + " takes no AS"); + option.setValue(stringValue(token)); + } +} + +/** The columns of COPY's FORCE QUOTE, FORCE NOT NULL and FORCE NULL from before 9.0: names or *. */ +void CopyLegacyOptionColumns(CopyLegacyOption option): +{ String column; } +{ + "*" { option.setAllColumns(true); } + | + column=CopyLegacyColumnName() { option.addColumns(linkToLastToken(new Column(column))); } + ( "," column=CopyLegacyColumnName() { option.addColumns(linkToLastToken(new Column(column))); } )* +} + +/** A column of a COPY FORCE option, a PostgreSQL ColId without qualifier. */ +String CopyLegacyColumnName(): +{ String name; } +{ + name=RelObjectName() { requirePostgreSqlColId(name); return name; } +} + /** * DuckDB's COPY table [(columns)] FROM path [(options)] and - * COPY {table | (query)} TO path [(options)]. + * COPY {table | (query)} TO path [(options)]. PostgreSQL's COPY [BINARY] table and its options + * from before 9.0, written without parentheses, are read with Dialect.POSTGRESQL and without a + * dialect. */ CopyStatement CopyStatement() #CopyStatement: { @@ -7024,6 +7268,7 @@ CopyStatement CopyStatement() #CopyStatement: ExpressionList columns = null; List options = new ArrayList(); String option; + CopyLegacyOption legacyOption; Select select; Table table; Token token; @@ -7033,6 +7278,7 @@ CopyStatement CopyStatement() #CopyStatement: ( LOOKAHEAD(2) "(" select=Select() ")" { copyStatement.setSelect(select); } | + [ LOOKAHEAD({ isCopyBinaryAhead() }) { copyStatement.setBinary(true); } ] table=Table() { copyStatement.setTable(table); } [ LOOKAHEAD(2) "(" columns=ColumnList() ")" { copyStatement.setColumns(columns); } ] ) @@ -7048,7 +7294,7 @@ CopyStatement CopyStatement() #CopyStatement: ) [ LOOKAHEAD({ getToken(1).kind == K_WITH }) { copyStatement.setWithKeyword(true); } ] - [ + ( LOOKAHEAD(2) "(" option=DatabaseOption() { options.add(option); } ( "," option=DatabaseOption() { options.add(option); } )* ")" { @@ -7058,7 +7304,10 @@ CopyStatement CopyStatement() #CopyStatement: } copyStatement.setOptions(options); } - ] + | + ( LOOKAHEAD({ isCopyLegacyOptionAhead() }) + legacyOption=CopyLegacyOption() { copyStatement.addLegacyOptions(legacyOption); } )* + ) { return copyStatement; } } @@ -7614,6 +7863,7 @@ Statement Show(): { Statement statement; List captureRest; + String name; } { @@ -7632,6 +7882,11 @@ Statement Show(): | LOOKAHEAD(ShowCreate() ShowStatementEnd()) statement = ShowCreate() | + // PostgreSQL's SHOW TIME ZONE, TRANSACTION ISOLATION LEVEL and SESSION AUTHORIZATION + LOOKAHEAD(ConfigurationParameterName() ShowStatementEnd(), + { isConfigurationParameterPhraseAhead() && isPostgreSqlUtilityDialect() }) + name = ConfigurationParameterName() { statement = new ShowStatement(name); } + | // any of the RDBMS specific SHOW syntax captureRest = captureRest() { @@ -26476,6 +26731,48 @@ NotifyStatement NotifyStatement(): { return statement; } } +/** + * PostgreSQL's DISCARD {ALL | PLANS | SEQUENCES | TEMP | TEMPORARY}. + */ +DiscardStatement DiscardStatement(): +{ DiscardStatement statement = new DiscardStatement(); } +{ + + ( + { statement.setTarget(DiscardStatement.Target.ALL); } + | + { statement.setTarget(DiscardStatement.Target.TEMP); } + | + { statement.setTarget(DiscardStatement.Target.TEMPORARY); } + | + LOOKAHEAD({ isKeywordAhead("PLANS") }) ContextualKeyword("PLANS") + { statement.setTarget(DiscardStatement.Target.PLANS); } + | + ContextualKeyword("SEQUENCES") { statement.setTarget(DiscardStatement.Target.SEQUENCES); } + ) + { return statement; } +} + +/** + * PostgreSQL's LISTEN channel and UNLISTEN {channel | *}. + */ +ListenStatement ListenStatement(): +{ ListenStatement statement = new ListenStatement(); String channel; } +{ + ( + channel=RelObjectName() { statement.setChannel(channel); } + | + { statement.setAction(ListenStatement.Action.UNLISTEN); } + ( "*" | channel=RelObjectName() { statement.setChannel(channel); } ) + ) + { + if (statement.getChannel() != null) { + requirePostgreSqlColId(statement.getChannel()); + } + return statement; + } +} + CreateCollation CreateCollation(): { CreateCollation statement = new CreateCollation(); String name; List options; diff --git a/src/site/sphinx/index.rst b/src/site/sphinx/index.rst index e0ff2d8a4..26470b0c4 100644 --- a/src/site/sphinx/index.rst +++ b/src/site/sphinx/index.rst @@ -164,6 +164,8 @@ One grammar covers the SQL standard plus all major RDBMS. Missing syntax gets ad - ``VACUUM`` · ``ANALYZE`` · ``CLUSTER`` · ``CHECKPOINT`` * - **MySQL locks** - ``LOCK TABLES`` · ``UNLOCK TABLES`` · ``LOCK INSTANCE FOR BACKUP`` · ``UNLOCK INSTANCE`` + * - **PostgreSQL session statements** + - ``DISCARD`` · ``LISTEN`` · ``UNLISTEN`` · ``SHOW``/``RESET TIME ZONE``/``TRANSACTION ISOLATION LEVEL``/``SESSION AUTHORIZATION`` · ``COPY`` options from before 9.0 * - **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 93bee3339..8a0eaafaf 100644 --- a/src/site/sphinx/usage.rst +++ b/src/site/sphinx/usage.rst @@ -2606,6 +2606,56 @@ Table visitors report the locked tables but not their aliases. Validators report three, MariaDB the first two, and PostgreSQL and Oracle ``lock``. Feature analysis reports ``MODIFIES_TRANSACTION``. +PostgreSQL session statements and COPY options +---------------------------------------------- + +With ``Dialect.POSTGRESQL`` and without a dialect, PostgreSQL's session statements parse to +typed statements: + +* `DISCARD `_ + ``{ALL | PLANS | SEQUENCES | TEMP | TEMPORARY}`` parses to ``DiscardStatement``. ``getTarget()`` + keeps the target as written; ``TEMP`` and ``TEMPORARY`` name the same state. +* `LISTEN `_ ``channel`` and + `UNLISTEN `_ ``{channel | *}`` parse to + ``ListenStatement``, next to ``NotifyStatement``. ``getAction()`` is ``LISTEN`` or + ``UNLISTEN``, and ``getChannel()`` is null for ``UNLISTEN *`` (``isAllChannels()``). +* `SHOW `_ ``TIME ZONE``, + ``SHOW TRANSACTION ISOLATION LEVEL`` and ``SHOW SESSION AUTHORIZATION`` parse to the + ``ShowStatement`` of other ``SHOW name`` statements instead of ``UnsupportedStatement``, and + `RESET `_ takes the same names. ``SET``, + ``RESET`` and ``SHOW`` read them through one rule: ``getName()`` is ``Time Zone``, as before + for ``SET`` and ``RESET``, ``TRANSACTION ISOLATION LEVEL`` or ``SESSION AUTHORIZATION``. + +`COPY `_ also accepts the option syntax that +PostgreSQL used before 9.0 and still accepts: keywords without parentheses or commas after the file +name and the optional ``WITH``, in any order (``BINARY``, ``FREEZE``, ``DELIMITER [AS] 'x'``, +``NULL [AS] 'x'``, ``CSV``, ``HEADER``, ``QUOTE [AS] 'x'``, ``ESCAPE [AS] 'x'``, +``FORCE QUOTE {column, ... | *}``, ``FORCE NOT NULL {column, ... | *}``, +``FORCE NULL {column, ... | *}`` and ``ENCODING 'x'``), and ``COPY BINARY table``. +``CopyStatement.getLegacyOptions()`` holds a ``CopyLegacyOption`` per option, in source order, +with its ``Kind``, its string (``getValue()``) and whether ``AS`` preceded it, or the columns of a +``FORCE`` option (``isAllColumns()`` for ``*``). ``isBinary()`` records ``COPY BINARY``. The +options in parentheses keep their reading through ``getOptions()``; a statement takes one of +the two forms. + +.. code-block:: java + + CopyStatement copy = (CopyStatement) CCJSqlParserUtil.parse( + "COPY t TO STDOUT WITH CSV HEADER FORCE QUOTE a, b"); + copy.getLegacyOptions().get(0).getKind(); // CSV + copy.getLegacyOptions().get(2).getColumns(); // [a, b] + +Other dialects keep rejecting these forms, and ``SHOW TIME ZONE`` remains an +``UnsupportedStatement`` there. ``LISTEN`` and ``UNLISTEN`` become non-reserved keywords and, +like ``DISCARD``, remain usable as names. As in PostgreSQL's grammar, a channel or a ``FORCE`` +column cannot be an unquoted word that PostgreSQL reserves, such as ``ALL``. + +Table visitors report the copied table and no table for the session statements. Validators +report the ``discard`` and ``listen`` features, which the PostgreSQL capabilities enable +together with ``show``. Feature analysis reports ``MODIFIES_SESSION`` for ``DISCARD``, +``LISTEN`` and ``UNLISTEN``, and a possible ``MODIFIES_SCHEMA`` for ``DISCARD ALL``, ``TEMP`` +and ``TEMPORARY``, which drop the temporary tables of the session. + 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 25dd26d38..34482d76c 100644 --- a/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java +++ b/src/test/java/net/sf/jsqlparser/parser/ASTNodeCoverageTest.java @@ -138,6 +138,11 @@ public void testStatementsTheCorpusLacks() throws JSQLParserException { "ALTER DEFAULT PRIVILEGES GRANT SELECT ON TABLES TO r", "IF a = 1 SELECT 1;", "BEGIN SELECT 1; END;", + "DISCARD TEMPORARY", "LISTEN \"Ch 1\"", "UNLISTEN *", + "SHOW TRANSACTION ISOLATION LEVEL", "RESET SESSION AUTHORIZATION", + "COPY s.t (a) TO STDOUT WITH NULL AS 'x' CSV FORCE QUOTE a, \"B\"", + "COPY BINARY t FROM STDIN FREEZE ENCODING 'UTF8'", + "COPY t FROM STDIN DELIMITER AS ';' CSV FORCE NOT NULL *", "VACUUM (VERBOSE, PARALLEL 2) ONLY s.t (a, b), u *", "VACUUM FULL FREEZE VERBOSE ANALYZE t", "ANALYZE VERBOSE t (a)", diff --git a/src/test/java/net/sf/jsqlparser/statement/DiscardStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/DiscardStatementTest.java new file mode 100644 index 000000000..db9014b1d --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/DiscardStatementTest.java @@ -0,0 +1,145 @@ +/*- + * #%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 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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +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.statement.DiscardStatement.Target; +import net.sf.jsqlparser.statement.alter.Alter; +import net.sf.jsqlparser.util.TablesNamesFinder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; + +class DiscardStatementTest { + + private static final Consumer POSTGRESQL = + parser -> parser.withDialect(Dialect.POSTGRESQL); + private static final List> POSTGRESQL_AND_NO_DIALECT = + Arrays.asList(POSTGRESQL, parser -> { + }); + + @ParameterizedTest + @EnumSource(Target.class) + void parsesEveryTargetWithPostgreSqlAndWithoutDialect(Target target) + throws JSQLParserException { + String sql = "DISCARD " + target.name(); + for (Consumer mode : POSTGRESQL_AND_NO_DIALECT) { + DiscardStatement discard = assertInstanceOf(DiscardStatement.class, + assertSqlCanBeParsedAndDeparsed(sql, false, mode)); + assertEquals(target, discard.getTarget()); + } + } + + @Test + void keepsTheSpellingOfTheTarget() throws JSQLParserException { + assertEquals("DISCARD TEMP", CCJSqlParserUtil.parse("discard temp", POSTGRESQL).toString()); + assertEquals("DISCARD TEMPORARY", + CCJSqlParserUtil.parse("Discard Temporary", POSTGRESQL).toString()); + assertEquals("DISCARD SEQUENCES", + CCJSqlParserUtil.parse("discard sequences", POSTGRESQL).toString()); + } + + @Test + void buildsAndDeparses() { + assertDeparse(new DiscardStatement(), "DISCARD ALL"); + DiscardStatement discard = new DiscardStatement(Target.PLANS); + assertDeparse(discard, "DISCARD PLANS"); + assertDeparse(discard.withTarget(Target.TEMPORARY), "DISCARD TEMPORARY"); + assertThrows(NullPointerException.class, () -> discard.setTarget(null)); + } + + @ParameterizedTest + @ValueSource(strings = {"DISCARD", "DISCARD TABLES", "DISCARD ALL PLANS", "DISCARD PLAN", + "DISCARD SEQUENCE", "DISCARD TEMPORARY TABLES", "DISCARD 'ALL'", "DISCARD (ALL)"}) + void rejectsOtherTargets(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, POSTGRESQL)); + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql)); + } + + @Test + void otherDialectsAndOtherUsesKeepTheirReading() throws JSQLParserException { + for (Dialect dialect : new Dialect[] {Dialect.MYSQL, Dialect.MARIADB, Dialect.ORACLE, + Dialect.SQLSERVER}) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse("DISCARD ALL", + parser -> parser.withDialect(dialect)), dialect.name()); + } + assertInstanceOf(UnsupportedStatement.class, CCJSqlParserUtil.parse("DISCARD ALL", + parser -> parser.withDialect(Dialect.MYSQL).withUnsupportedStatements(true))); + assertInstanceOf(UnsupportedStatement.class, CCJSqlParserUtil.parse("DISCARD foo", + parser -> parser.withUnsupportedStatements(true))); + assertInstanceOf(Alter.class, assertSqlCanBeParsedAndDeparsed( + "ALTER TABLE t DISCARD TABLESPACE", true, + parser -> parser.withDialect(Dialect.MYSQL))); + assertSqlCanBeParsedAndDeparsed("SELECT discard FROM discard AS discard", true, + POSTGRESQL); + } + + @Test + void endsAtStatementBoundaries() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "DISCARD ALL; DISCARD PLANS\nSELECT 1;\nDISCARD PLANS\nDISCARD TEMP;DISCARD SEQUENCES", + POSTGRESQL); + assertEquals(6, statements.size()); + assertEquals("DISCARD PLANS", statements.get(1).toString()); + assertEquals("DISCARD PLANS", statements.get(3).toString()); + assertEquals("DISCARD TEMP", statements.get(4).toString()); + assertEquals("DISCARD SEQUENCES", statements.get(5).toString()); + + Statements unsupported = CCJSqlParserUtil.parseStatements( + "IMPORT FOREIGN SCHEMA s FROM SERVER v INTO t; DISCARD ALL", + parser -> parser.withUnsupportedStatements(true)); + assertEquals(2, unsupported.size()); + assertInstanceOf(DiscardStatement.class, unsupported.get(1)); + } + + @Test + void reachesStatementVisitors() throws JSQLParserException { + Statement statement = CCJSqlParserUtil.parse("DISCARD SEQUENCES", POSTGRESQL); + String visited = statement.accept(new StatementVisitorAdapter() { + @Override + public String visit(DiscardStatement discard, S context) { + return discard.getTarget().name(); + } + }, null); + assertEquals("SEQUENCES", visited); + assertThat(new TablesNamesFinder<>().getTables(statement)).isEmpty(); + } + + @Test + void modifiesTheSessionAndMayDropTemporaryTables() throws JSQLParserException { + StatementFeatures plans = CCJSqlParserUtil.parse("DISCARD PLANS").getFeatures(); + assertThat(plans.getCertain()).containsExactly(StmtFeature.MODIFIES_SESSION); + assertThat(plans.getUncertain()).isEmpty(); + + for (String sql : new String[] {"DISCARD ALL", "DISCARD TEMP", "DISCARD TEMPORARY"}) { + StatementFeatures features = CCJSqlParserUtil.parse(sql).getFeatures(); + assertThat(features.getCertain()).containsExactly(StmtFeature.MODIFIES_SESSION); + assertThat(features.getUncertain()).containsExactly(StmtFeature.MODIFIES_SCHEMA); + } + assertTrue(new DiscardStatement(Target.ALL).isDroppingTemporaryTables()); + assertFalse(new DiscardStatement(Target.SEQUENCES).isDroppingTemporaryTables()); + } +} diff --git a/src/test/java/net/sf/jsqlparser/statement/PostgreSqlCopyTest.java b/src/test/java/net/sf/jsqlparser/statement/PostgreSqlCopyTest.java index 451e4a690..a2cfbba0f 100644 --- a/src/test/java/net/sf/jsqlparser/statement/PostgreSqlCopyTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/PostgreSqlCopyTest.java @@ -9,15 +9,26 @@ */ package net.sf.jsqlparser.statement; +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.Arrays; +import java.util.List; +import java.util.stream.Collectors; import net.sf.jsqlparser.JSQLParserException; import net.sf.jsqlparser.expression.LongValue; +import net.sf.jsqlparser.expression.StringValue; import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; import net.sf.jsqlparser.parser.CCJSqlParserUtil; +import net.sf.jsqlparser.schema.Column; +import net.sf.jsqlparser.schema.Table; +import net.sf.jsqlparser.statement.CopyLegacyOption.Kind; +import net.sf.jsqlparser.util.TablesNamesFinder; import net.sf.jsqlparser.util.deparser.ExpressionDeParser; import net.sf.jsqlparser.util.deparser.SelectDeParser; import net.sf.jsqlparser.util.deparser.StatementDeParser; @@ -87,6 +98,192 @@ void rejectsMalformedWithOptions(String options) { assertThrows(JSQLParserException.class, () -> parse("COPY users TO STDOUT " + options)); } + @ParameterizedTest + @ValueSource(strings = { + "COPY t TO STDOUT CSV HEADER", + "COPY t FROM STDIN WITH CSV HEADER DELIMITER ';' NULL ''", + "COPY t (a, b) TO '/tmp/x.csv' WITH DELIMITER AS ',' CSV QUOTE AS '\"' ESCAPE AS '\\' " + + "FORCE QUOTE a, b", + "COPY t FROM STDIN CSV FORCE NOT NULL a", + "COPY t FROM STDIN CSV FORCE NOT NULL *", + "COPY t FROM STDIN CSV FORCE NULL a, b", + "COPY t FROM STDIN CSV FORCE NULL *", + "COPY t TO STDOUT BINARY", + "COPY t TO STDOUT CSV FORCE QUOTE *", + "COPY t TO STDOUT CSV FORCE QUOTE \"A b\", c", + "COPY t TO STDOUT CSV FORCE QUOTE \"all\", header, quote", + "COPY t FROM STDIN WITH BINARY", + "COPY t FROM STDIN FREEZE CSV", + "COPY t FROM STDIN ENCODING 'UTF8'", + "COPY t FROM STDIN NULL AS 'x' DELIMITER AS '|'", + "COPY t TO STDOUT HEADER CSV", + "COPY t TO STDOUT DELIMITER '|' CSV", + "COPY t TO STDOUT QUOTE '\"' ESCAPE E'\\\\'", + "COPY (SELECT a FROM t) TO STDOUT WITH CSV HEADER", + "COPY BINARY t TO STDOUT", + "COPY BINARY t (a, b) FROM '/tmp/x'", + "COPY BINARY s.t TO STDOUT WITH ENCODING 'UTF8'", + "COPY BINARY t FROM STDIN FREEZE"}) + void parsesTheOptionSyntaxFromBefore90(String sql) throws JSQLParserException { + for (CopyStatement copy : new CopyStatement[] {parse(sql), + (CopyStatement) CCJSqlParserUtil.parse(sql)}) { + assertEquals(sql, copy.toString()); + assertNull(copy.getOptions()); + assertRoundTrip(copy); + } + } + + @Test + void keepsEachLegacyOptionTyped() throws JSQLParserException { + CopyStatement copy = parse("COPY t (a, b) TO '/tmp/x.csv' WITH DELIMITER AS ',' CSV " + + "QUOTE AS '\"' ESCAPE '\\' FORCE QUOTE a, \"B\" ENCODING 'UTF8'"); + assertTrue(copy.isWithKeyword()); + assertFalse(copy.isBinary()); + assertEquals("'/tmp/x.csv'", copy.getPath()); + List options = copy.getLegacyOptions(); + assertEquals(Arrays.asList(Kind.DELIMITER, Kind.CSV, Kind.QUOTE, Kind.ESCAPE, + Kind.FORCE_QUOTE, Kind.ENCODING), + options.stream().map(CopyLegacyOption::getKind).collect(Collectors.toList())); + + CopyLegacyOption delimiter = options.get(0); + assertTrue(delimiter.isAsKeyword()); + assertEquals(",", delimiter.getValue().getValue()); + assertNull(options.get(1).getValue()); + assertFalse(options.get(3).isAsKeyword()); + assertEquals("\\", options.get(3).getValue().getValue()); + + CopyLegacyOption forceQuote = options.get(4); + assertFalse(forceQuote.isAllColumns()); + assertEquals(Arrays.asList("a", "\"B\""), forceQuote.getColumns().stream() + .map(Column::getColumnName).collect(Collectors.toList())); + assertEquals("FORCE QUOTE a, \"B\"", forceQuote.toString()); + assertEquals("UTF8", options.get(5).getValue().getValue()); + + CopyLegacyOption all = parse("COPY t FROM STDIN CSV FORCE NOT NULL *").getLegacyOptions() + .get(1); + assertEquals(Kind.FORCE_NOT_NULL, all.getKind()); + assertTrue(all.isAllColumns()); + assertTrue(all.getColumns().isEmpty()); + + CopyStatement binary = parse("COPY BINARY s.t (a) FROM STDIN"); + assertTrue(binary.isBinary()); + assertEquals("s.t", binary.getTable().getFullyQualifiedName()); + assertTrue(binary.getLegacyOptions().isEmpty()); + } + + @Test + void describesWhatEachKindTakes() { + assertFalse(Kind.CSV.takesString()); + assertTrue(Kind.NULL.takesString()); + assertTrue(Kind.NULL.allowsAsKeyword()); + assertTrue(Kind.ENCODING.takesString()); + assertFalse(Kind.ENCODING.allowsAsKeyword()); + assertTrue(Kind.FORCE_NULL.takesColumns()); + assertFalse(Kind.HEADER.takesColumns()); + assertEquals("FORCE NOT NULL", Kind.FORCE_NOT_NULL.getKeyword()); + } + + @Test + void buildsAndDeparsesLegacyOptions() throws JSQLParserException { + CopyStatement copy = new CopyStatement().withTable(new Table("t")).withFrom(true) + .withPath("STDIN").withWithKeyword(true) + .addLegacyOptions(new CopyLegacyOption(Kind.CSV), + new CopyLegacyOption(Kind.NULL).withAsKeyword(true) + .withValue(new StringValue("x")), + new CopyLegacyOption(Kind.FORCE_NULL).addColumns(new Column("a"), + new Column("b")), + new CopyLegacyOption(Kind.FORCE_NOT_NULL).withAllColumns(true)); + assertEquals("COPY t FROM STDIN WITH CSV NULL AS 'x' FORCE NULL a, b FORCE NOT NULL *", + copy.toString()); + assertRoundTrip(copy); + + copy.setLegacyOptions(null); + assertEquals("COPY t FROM STDIN WITH", copy.toString()); + copy.withBinary(true).withLegacyOptions( + Arrays.asList(new CopyLegacyOption(Kind.FREEZE))); + assertEquals("COPY BINARY t FROM STDIN WITH FREEZE", copy.toString()); + assertRoundTrip(copy); + copy.setBinary(false); + copy.withLegacyOptions(Arrays.asList(new CopyLegacyOption(Kind.CSV), + new CopyLegacyOption(Kind.HEADER))); + assertEquals("COPY t FROM STDIN WITH CSV HEADER", copy.toString()); + assertRoundTrip(copy); + } + + @ParameterizedTest + @ValueSource(strings = { + "COPY t TO STDOUT CSV (FORMAT csv)", + "COPY t TO STDOUT (FORMAT csv) CSV", + "COPY t TO STDOUT ENCODING AS 'UTF8'", + "COPY t TO STDOUT ENCODING UTF8", + "COPY t TO STDOUT DELIMITER", + "COPY t TO STDOUT DELIMITER ,", + "COPY t TO STDOUT DELIMITER AS AS ','", + "COPY t TO STDOUT NULL 1", + "COPY t TO STDOUT CSV QUOTE", + "COPY t TO STDOUT HEADER true", + "COPY t TO STDOUT CSV, HEADER", + "COPY t TO STDOUT CSV HEADER WITH", + "COPY t TO STDOUT WITH WITH CSV", + "COPY t TO STDOUT FORCE QUOTE", + "COPY t TO STDOUT FORCE QUOTE a,", + "COPY t TO STDOUT FORCE QUOTE (a)", + "COPY t TO STDOUT FORCE QUOTE t.a", + "COPY t TO STDOUT FORCE QUOTE a, *", + "COPY t TO STDOUT FORCE QUOTE all", + "COPY t TO STDOUT FORCE NOT NULL a, freeze", + "COPY t TO STDOUT FORCE NOT a", + "COPY t TO STDOUT FORCE", + "COPY BINARY (SELECT 1) TO STDOUT", + "COPY BINARY t", + "COPY BINARY BINARY t TO STDOUT"}) + void rejectsWhatPostgreSqlRejects(String sql) { + assertThrows(JSQLParserException.class, () -> parse(sql)); + } + + @Test + void otherDialectsKeepRejectingTheLegacyForms() throws JSQLParserException { + for (Dialect dialect : new Dialect[] {Dialect.MYSQL, Dialect.ORACLE, Dialect.SQLSERVER}) { + for (String sql : new String[] {"COPY t TO STDOUT CSV HEADER", + "COPY t FROM STDIN NULL ''", "COPY BINARY t TO STDOUT"}) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(dialect)), dialect + " " + sql); + } + } + // without a word after it, BINARY still names DuckDB's table + CopyStatement duckDb = + (CopyStatement) CCJSqlParserUtil.parse("COPY binary TO 'out.csv' (FORMAT CSV)"); + assertFalse(duckDb.isBinary()); + assertEquals("binary", duckDb.getTable().getName()); + } + + @Test + void endsAtStatementBoundaries() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "COPY t FROM STDIN CSV HEADER; COPY t TO STDOUT BINARY\nSELECT 1;" + + "COPY BINARY t TO STDOUT WITH;COPY t TO STDOUT FORCE QUOTE a, b", + parser -> parser.withDialect(Dialect.POSTGRESQL)); + assertEquals(5, statements.size()); + assertEquals("COPY t TO STDOUT BINARY", statements.get(1).toString()); + assertEquals("COPY BINARY t TO STDOUT WITH", statements.get(3).toString()); + + // DuckDB's FORCE INSTALL starts the next statement, FORCE QUOTE continues the options + Statements duckDb = CCJSqlParserUtil.parseStatements( + "SELECT 1; COPY t TO 'x.csv' CSV\nFORCE INSTALL httpfs"); + assertEquals(3, duckDb.size()); + assertEquals("COPY t TO 'x.csv' CSV", duckDb.get(1).toString()); + assertInstanceOf(ExtensionStatement.class, duckDb.get(2)); + } + + @Test + void findsTheTableBehindTheLegacyOptions() throws JSQLParserException { + assertThat(new TablesNamesFinder<>().getTables( + parse("COPY BINARY s.t TO STDOUT CSV FORCE QUOTE a"))) + .containsExactly("s.t"); + assertThat(parse("COPY t FROM STDIN CSV").getFeatures().getCertain()) + .containsExactlyInAnyOrder(StmtFeature.READS_DATA, StmtFeature.MODIFIES_DATA); + } + private static CopyStatement parse(String sql) throws JSQLParserException { return (CopyStatement) CCJSqlParserUtil.parse(sql, p -> p.withDialect(Dialect.POSTGRESQL)); } diff --git a/src/test/java/net/sf/jsqlparser/statement/ResetStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/ResetStatementTest.java index b3372ff73..5af78ca17 100644 --- a/src/test/java/net/sf/jsqlparser/statement/ResetStatementTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/ResetStatementTest.java @@ -10,10 +10,16 @@ package net.sf.jsqlparser.statement; import net.sf.jsqlparser.JSQLParserException; +import net.sf.jsqlparser.parser.AbstractJSqlParser.Dialect; +import net.sf.jsqlparser.parser.CCJSqlParserUtil; import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; public class ResetStatementTest { @@ -27,6 +33,58 @@ public void tesResetAll() throws JSQLParserException { assertSqlCanBeParsedAndDeparsed("RESET ALL"); } + @ParameterizedTest + @CsvSource(delimiter = '|', value = { + "RESET TIME ZONE | Time Zone", + "RESET TRANSACTION ISOLATION LEVEL | TRANSACTION ISOLATION LEVEL", + "RESET SESSION AUTHORIZATION | SESSION AUTHORIZATION", + "RESET transaction | transaction", + "RESET session | session", + "RESET time | time"}) + void readsTheParameterNamesOfShow(String sql, String name) throws JSQLParserException { + for (ResetStatement reset : new ResetStatement[] { + (ResetStatement) CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(Dialect.POSTGRESQL)), + (ResetStatement) CCJSqlParserUtil.parse(sql)}) { + assertEquals(name, reset.getName()); + assertEquals("RESET " + name, reset.toString()); + assertSqlCanBeParsedAndDeparsed(reset.toString()); + } + } + + @ParameterizedTest + @ValueSource(strings = {"RESET TRANSACTION ISOLATION", "RESET TRANSACTION LEVEL", + "RESET SESSION AUTHORIZATION x", "RESET DATE ZONE", "RESET TIME ZONE x"}) + void rejectsIncompletePhrases(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(Dialect.POSTGRESQL))); + } + + @Test + void timeZoneNeedsTheWordTime() throws JSQLParserException { + // other date/time keywords before ZONE were read as TIME ZONE + for (String sql : new String[] {"RESET DATE ZONE", "RESET TIMESTAMP ZONE", + "SET DATE ZONE 'UTC'"}) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql), sql); + } + assertEquals("SET Time Zone 'UTC'", + CCJSqlParserUtil.parse("SET time zone 'UTC'").toString()); + assertEquals("SET LOCAL Time Zone LOCAL", + CCJSqlParserUtil.parse("SET LOCAL TIME ZONE LOCAL").toString()); + } + + @Test + void otherDialectsKeepRejectingThePostgreSqlPhrases() { + for (Dialect dialect : new Dialect[] {Dialect.MYSQL, Dialect.ORACLE}) { + for (String sql : new String[] {"RESET TRANSACTION ISOLATION LEVEL", + "RESET SESSION AUTHORIZATION"}) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(dialect)), + dialect + " " + sql); + } + } + } + @Test public void testObject() { ResetStatement resetStatement = new ResetStatement(); diff --git a/src/test/java/net/sf/jsqlparser/statement/ShowStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/ShowStatementTest.java index 23830cbc9..698c1f981 100644 --- a/src/test/java/net/sf/jsqlparser/statement/ShowStatementTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/ShowStatementTest.java @@ -10,11 +10,21 @@ package net.sf.jsqlparser.statement; 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.show.ShowCreateStatement; +import net.sf.jsqlparser.util.TablesNamesFinder; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.function.Consumer; import static net.sf.jsqlparser.test.TestUtils.assertSqlCanBeParsedAndDeparsed; +import static org.assertj.core.api.Assertions.assertThat; /** * @@ -22,6 +32,9 @@ */ public class ShowStatementTest { + private static final Consumer POSTGRESQL = + parser -> parser.withDialect(Dialect.POSTGRESQL); + @Test public void testSimpleUse() throws JSQLParserException { assertSqlCanBeParsedAndDeparsed("SHOW mydatabase"); @@ -39,6 +52,59 @@ void testShowIndexesFromTable() throws JSQLParserException { assertSqlCanBeParsedAndDeparsed(sqlStr, true); } + @ParameterizedTest + @CsvSource(delimiter = '|', value = { + "SHOW TIME ZONE | Time Zone", + "SHOW TRANSACTION ISOLATION LEVEL | TRANSACTION ISOLATION LEVEL", + "SHOW SESSION AUTHORIZATION | SESSION AUTHORIZATION", + "show time zone | Time Zone", + "show transaction isolation level | TRANSACTION ISOLATION LEVEL"}) + void readsPostgreSqlParameterPhrasesWithPostgreSqlAndWithoutDialect(String sql, String name) + throws JSQLParserException { + for (Statement statement : new Statement[] {CCJSqlParserUtil.parse(sql, POSTGRESQL), + CCJSqlParserUtil.parse(sql)}) { + ShowStatement show = Assertions.assertInstanceOf(ShowStatement.class, statement); + Assertions.assertEquals(name, show.getName()); + Assertions.assertEquals("SHOW " + name, show.toString()); + assertSqlCanBeParsedAndDeparsed(show.toString(), false, POSTGRESQL); + assertThat(new TablesNamesFinder<>().getTables(statement)).isEmpty(); + assertThat(statement.getFeatures().getCertain()) + .containsExactly(StmtFeature.RETURNS_RESULT_SET); + } + } + + @ParameterizedTest + @ValueSource(strings = {"SHOW TIME ZONE x", "SHOW TRANSACTION ISOLATION", + "SHOW TRANSACTION ISOLATION LEVEL x", "SHOW DATE ZONE", + "SHOW SESSION AUTHORIZATION ALL"}) + void otherWordsStayUnsupported(String sql) throws JSQLParserException { + Assertions.assertInstanceOf(UnsupportedStatement.class, + CCJSqlParserUtil.parse(sql, POSTGRESQL)); + } + + @Test + void otherDialectsKeepTheGenericReading() throws JSQLParserException { + for (Dialect dialect : new Dialect[] {Dialect.MYSQL, Dialect.ORACLE, Dialect.SNOWFLAKE}) { + for (String sql : new String[] {"SHOW TIME ZONE", "SHOW TRANSACTION ISOLATION LEVEL", + "SHOW SESSION AUTHORIZATION"}) { + Assertions.assertInstanceOf(UnsupportedStatement.class, + CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(dialect)), sql); + } + } + } + + @Test + void parameterPhrasesEndAtStatementBoundaries() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "SHOW TIME ZONE; SHOW SESSION AUTHORIZATION\nSELECT 1;" + + "SHOW TRANSACTION ISOLATION LEVEL\nDISCARD ALL", + POSTGRESQL); + Assertions.assertEquals(5, statements.size()); + Assertions.assertEquals("SHOW SESSION AUTHORIZATION", statements.get(1).toString()); + Assertions.assertEquals("SHOW TRANSACTION ISOLATION LEVEL", statements.get(3).toString()); + Assertions.assertInstanceOf(DiscardStatement.class, statements.get(4)); + } + @Test void testShowCreateTable() throws JSQLParserException { String sqlStr = diff --git a/src/test/java/net/sf/jsqlparser/statement/maintenance/CheckpointStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/maintenance/CheckpointStatementTest.java index b54e4b95d..a7a75b486 100644 --- a/src/test/java/net/sf/jsqlparser/statement/maintenance/CheckpointStatementTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/maintenance/CheckpointStatementTest.java @@ -67,7 +67,8 @@ void endsAtStatementBoundaries() throws JSQLParserException { "SHOW search_path; CHECKPOINT\nCHECKPOINT;CHECKPOINT", POSTGRESQL); assertEquals(4, statements.size()); - Statements afterUnsupported = CCJSqlParserUtil.parseStatements("LISTEN ch; CHECKPOINT", + Statements afterUnsupported = CCJSqlParserUtil.parseStatements( + "IMPORT FOREIGN SCHEMA s FROM SERVER srv INTO t; CHECKPOINT", parser -> parser.withDialect(Dialect.POSTGRESQL).withUnsupportedStatements(true)); assertEquals(2, afterUnsupported.size()); assertInstanceOf(UnsupportedStatement.class, afterUnsupported.get(0)); diff --git a/src/test/java/net/sf/jsqlparser/statement/maintenance/ClusterStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/maintenance/ClusterStatementTest.java index 37b26b3fd..b91477452 100644 --- a/src/test/java/net/sf/jsqlparser/statement/maintenance/ClusterStatementTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/maintenance/ClusterStatementTest.java @@ -143,7 +143,8 @@ void endsAtStatementBoundaries() throws JSQLParserException { @Test void endsAnUnsupportedStatement() throws JSQLParserException { - Statements statements = CCJSqlParserUtil.parseStatements("LISTEN ch; CLUSTER t", + Statements statements = CCJSqlParserUtil.parseStatements( + "IMPORT FOREIGN SCHEMA s FROM SERVER srv INTO t; CLUSTER t", parser -> parser.withDialect(Dialect.POSTGRESQL).withUnsupportedStatements(true)); assertEquals(2, statements.size()); diff --git a/src/test/java/net/sf/jsqlparser/statement/maintenance/VacuumStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/maintenance/VacuumStatementTest.java index 4e6ffb744..82ee1b87a 100644 --- a/src/test/java/net/sf/jsqlparser/statement/maintenance/VacuumStatementTest.java +++ b/src/test/java/net/sf/jsqlparser/statement/maintenance/VacuumStatementTest.java @@ -274,7 +274,8 @@ void endsAtStatementBoundaries() throws JSQLParserException { @Test void endsAnUnsupportedStatement() throws JSQLParserException { Statements statements = CCJSqlParserUtil.parseStatements( - "LISTEN ch; VACUUM FULL t", parser -> parser.withDialect(Dialect.POSTGRESQL) + "IMPORT FOREIGN SCHEMA s FROM SERVER srv INTO t; VACUUM FULL t", + parser -> parser.withDialect(Dialect.POSTGRESQL) .withUnsupportedStatements(true)); assertEquals(2, statements.size()); diff --git a/src/test/java/net/sf/jsqlparser/statement/notify/ListenStatementTest.java b/src/test/java/net/sf/jsqlparser/statement/notify/ListenStatementTest.java new file mode 100644 index 000000000..37f0b2eda --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/statement/notify/ListenStatementTest.java @@ -0,0 +1,155 @@ +/*- + * #%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.notify; + +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.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.StatementVisitorAdapter; +import net.sf.jsqlparser.statement.Statements; +import net.sf.jsqlparser.statement.StmtFeature; +import net.sf.jsqlparser.statement.UnsupportedStatement; +import net.sf.jsqlparser.statement.notify.ListenStatement.Action; +import net.sf.jsqlparser.util.TablesNamesFinder; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.ValueSource; + +class ListenStatementTest { + + private static final Consumer POSTGRESQL = + parser -> parser.withDialect(Dialect.POSTGRESQL); + + @ParameterizedTest + @CsvSource(delimiter = '|', value = { + "LISTEN ch | LISTEN | ch", + "LISTEN \"Ch 1\" | LISTEN | \"Ch 1\"", + "UNLISTEN ch | UNLISTEN | ch", + "UNLISTEN \"Ch 1\" | UNLISTEN | \"Ch 1\"", + "UNLISTEN * | UNLISTEN | ", + "LISTEN listen | LISTEN | listen", + "LISTEN \"all\" | LISTEN | \"all\"", + "LISTEN transaction | LISTEN | transaction", + "UNLISTEN unlisten | UNLISTEN | unlisten"}) + void parsesWithPostgreSqlAndWithoutDialect(String sql, Action action, String channel) + throws JSQLParserException { + for (ListenStatement listen : new ListenStatement[] { + (ListenStatement) assertSqlCanBeParsedAndDeparsed(sql, false, POSTGRESQL), + (ListenStatement) assertSqlCanBeParsedAndDeparsed(sql, false)}) { + assertEquals(action, listen.getAction()); + assertEquals(channel, listen.getChannel()); + assertEquals(channel == null, listen.isAllChannels()); + } + } + + @Test + void writesTheKeywordsInUpperCase() throws JSQLParserException { + assertEquals("LISTEN ch", CCJSqlParserUtil.parse("listen ch", POSTGRESQL).toString()); + assertEquals("UNLISTEN *", CCJSqlParserUtil.parse("unlisten *", POSTGRESQL).toString()); + } + + @Test + void buildsAndDeparses() { + ListenStatement listen = new ListenStatement(); + assertEquals(Action.LISTEN, listen.getAction()); + assertDeparse(listen.withChannel("ch"), "LISTEN ch"); + assertDeparse(listen.withAction(Action.UNLISTEN), "UNLISTEN ch"); + ListenStatement all = new ListenStatement(Action.UNLISTEN, null); + assertTrue(all.isAllChannels()); + assertDeparse(all, "UNLISTEN *"); + assertFalse(new ListenStatement(Action.LISTEN, "\"Ch\"").isAllChannels()); + assertThrows(NullPointerException.class, () -> listen.setAction(null)); + } + + @ParameterizedTest + @ValueSource(strings = {"LISTEN", "LISTEN *", "LISTEN a.b", "LISTEN 'ch'", "LISTEN a b", + "LISTEN a, b", "UNLISTEN", "UNLISTEN a, b", "UNLISTEN a.b", "UNLISTEN 'ch'", + "UNLISTEN * *", "UNLISTEN ALL", "LISTEN all", "LISTEN freeze", "UNLISTEN binary", + "LISTEN select"}) + void rejectsWhatPostgreSqlRejects(String sql) { + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse(sql, POSTGRESQL)); + } + + @Test + void ruleActionsStayLimitedToNotify() { + // PostgreSQL's RuleActionStmt takes NOTIFY but neither LISTEN nor UNLISTEN + assertThrows(JSQLParserException.class, () -> CCJSqlParserUtil.parse( + "CREATE RULE r AS ON INSERT TO t DO ALSO LISTEN ch", POSTGRESQL)); + } + + @Test + void otherDialectsKeepTheirReading() throws JSQLParserException { + for (Dialect dialect : new Dialect[] {Dialect.MYSQL, Dialect.ORACLE, Dialect.SQLSERVER}) { + for (String sql : new String[] {"LISTEN ch", "UNLISTEN *"}) { + assertThrows(JSQLParserException.class, + () -> CCJSqlParserUtil.parse(sql, parser -> parser.withDialect(dialect)), + dialect + " " + sql); + assertInstanceOf(UnsupportedStatement.class, CCJSqlParserUtil.parse(sql, + parser -> parser.withDialect(dialect).withUnsupportedStatements(true))); + } + } + assertInstanceOf(UnsupportedStatement.class, CCJSqlParserUtil.parse("LISTEN 'ch'", + parser -> parser.withUnsupportedStatements(true))); + } + + @Test + void wordsRemainIdentifiers() throws JSQLParserException { + assertSqlCanBeParsedAndDeparsed("SELECT listen, unlisten FROM listen AS unlisten", true, + POSTGRESQL); + assertSqlCanBeParsedAndDeparsed("SELECT listen.unlisten FROM unlisten.listen", true, + parser -> parser.withDialect(Dialect.MYSQL)); + assertSqlCanBeParsedAndDeparsed("CREATE TABLE listen (unlisten INT)", true, POSTGRESQL); + assertSqlCanBeParsedAndDeparsed("NOTIFY listen, 'x'", true, POSTGRESQL); + } + + @Test + void endsAtStatementBoundaries() throws JSQLParserException { + Statements statements = CCJSqlParserUtil.parseStatements( + "LISTEN a; UNLISTEN *\nNOTIFY a, 'x'\nUNLISTEN a;SELECT 1;\nLISTEN b", POSTGRESQL); + assertEquals(6, statements.size()); + assertEquals("UNLISTEN *", statements.get(1).toString()); + assertEquals("NOTIFY a, 'x'", statements.get(2).toString()); + assertEquals("UNLISTEN a", statements.get(3).toString()); + assertEquals("LISTEN b", statements.get(5).toString()); + } + + @Test + void reachesVisitorsAndModifiesTheSession() throws JSQLParserException { + Statement statement = CCJSqlParserUtil.parse("UNLISTEN ch", POSTGRESQL); + Action visited = statement.accept(new StatementVisitorAdapter() { + @Override + public Action visit(ListenStatement listen, S context) { + return listen.getAction(); + } + }, null); + assertEquals(Action.UNLISTEN, visited); + assertNull(statement.accept(new StatementVisitorAdapter(), null)); + + assertThat(new TablesNamesFinder<>().getTables(statement)).isEmpty(); + assertThat(statement.getFeatures().getCertain()) + .containsExactly(StmtFeature.MODIFIES_SESSION); + assertThat(CCJSqlParserUtil.parse("LISTEN ch").getFeatures().getCertain()) + .containsExactly(StmtFeature.MODIFIES_SESSION); + } +} 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 e6482373f..b0e260a35 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( - "LISTEN ch; REINDEX TABLE t; REINDEX t", + "IMPORT FOREIGN SCHEMA s FROM SERVER srv INTO t; 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 6d906eed8..b7c03a634 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( - "LISTEN ch; SECURITY LABEL ON TABLE t IS 'x'", + "IMPORT FOREIGN SCHEMA s FROM SERVER srv INTO t; 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/PostgreSqlSessionStatementValidatorTest.java b/src/test/java/net/sf/jsqlparser/util/validation/validator/PostgreSqlSessionStatementValidatorTest.java new file mode 100644 index 000000000..ac8a71c02 --- /dev/null +++ b/src/test/java/net/sf/jsqlparser/util/validation/validator/PostgreSqlSessionStatementValidatorTest.java @@ -0,0 +1,51 @@ +/*- + * #%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.JSQLParserException; +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.MySqlVersion; +import net.sf.jsqlparser.util.validation.feature.PostgresqlVersion; +import org.junit.jupiter.api.Test; + +public class PostgreSqlSessionStatementValidatorTest extends ValidationTestAsserts { + + private static final String[] STATEMENTS = {"DISCARD ALL", "DISCARD TEMP", "LISTEN ch", + "UNLISTEN *", "SHOW TIME ZONE", "SHOW TRANSACTION ISOLATION LEVEL", + "SHOW SESSION AUTHORIZATION", "SHOW search_path", "RESET SESSION AUTHORIZATION", + "COPY t TO STDOUT CSV HEADER FORCE QUOTE *", "COPY BINARY t FROM STDIN"}; + + @Test + public void testAllowedForPostgresql() throws JSQLParserException { + for (String sql : STATEMENTS) { + validateNoErrors(sql, 1, PostgresqlVersion.V10, PostgresqlVersion.V14, + DatabaseType.POSTGRESQL); + } + } + + @Test + public void testSessionStatementsNotAllowedForMySql() throws JSQLParserException { + validate("DISCARD PLANS", 1, 1, MySqlVersion.V8_0); + validate("LISTEN ch", 1, 1, MySqlVersion.V8_0); + validate("UNLISTEN ch", 1, 1, MySqlVersion.V8_0); + } + + @Test + public void testFeatureNotAllowed() throws JSQLParserException { + validateNotAllowed("DISCARD SEQUENCES", 1, 1, FeaturesAllowed.SELECT, Feature.discard); + validateNotAllowed("LISTEN ch", 1, 1, FeaturesAllowed.SELECT, Feature.listen); + validateNotAllowed("UNLISTEN *", 1, 1, FeaturesAllowed.SELECT, Feature.listen); + validateNotAllowed("SHOW TIME ZONE", 1, 1, FeaturesAllowed.SELECT, Feature.show); + validateNotAllowed("COPY t TO STDOUT CSV", 1, 1, FeaturesAllowed.SELECT, Feature.copy); + } +}