From 1e648c92539828560638cc800f45897d95cddb2b Mon Sep 17 00:00:00 2001 From: minleejae Date: Fri, 6 Nov 2026 08:56:08 +0900 Subject: [PATCH] feat: parse PostgreSQL DISCARD, LISTEN, UNLISTEN, SHOW phrases and pre-9.0 COPY options DISCARD, LISTEN and UNLISTEN failed, SHOW TIME ZONE, SHOW TRANSACTION ISOLATION LEVEL and SHOW SESSION AUTHORIZATION were unsupported statements, and COPY rejected the option syntax from before 9.0 that PostgreSQL 18 still accepts, such as COPY t TO STDOUT CSV HEADER, and COPY BINARY t. DISCARD parses to DiscardStatement with a Target enum, and LISTEN and UNLISTEN to ListenStatement next to NotifyStatement; LISTEN and UNLISTEN become non-reserved keywords. SET, RESET and SHOW read TIME ZONE and PostgreSQL's TRANSACTION ISOLATION LEVEL and SESSION AUTHORIZATION through one ConfigurationParameterName() rule: the SHOW phrases parse to ShowStatement and RESET takes them too. CopyStatement keeps the options in parentheses in getOptions() and adds getLegacyOptions(), a typed CopyLegacyOption per option in source order, and isBinary(). The forms are read with Dialect.POSTGRESQL and without a dialect; other dialects are unchanged. The statements are wired into the deparser, visitors, table finder, validation (discard, listen, and show for PostgreSQL) and feature analysis. Signed-off-by: minleejae --- README.md | 1 + .../sf/jsqlparser/parser/feature/Feature.java | 12 + .../statement/CopyLegacyOption.java | 192 +++++++++++ .../jsqlparser/statement/CopyStatement.java | 58 +++- .../statement/DiscardStatement.java | 79 +++++ .../statement/StatementFeatureVisitor.java | 16 + .../statement/StatementVisitor.java | 17 + .../statement/StatementVisitorAdapter.java | 11 + .../statement/notify/ListenStatement.java | 88 +++++ .../sf/jsqlparser/util/TablesNamesFinder.java | 14 + .../util/deparser/StatementDeParser.java | 12 + .../validation/feature/PostgresqlVersion.java | 6 + .../validator/StatementValidator.java | 14 + .../net/sf/jsqlparser/parser/JSqlParserCC.jjt | 309 +++++++++++++++++- src/site/sphinx/index.rst | 2 + src/site/sphinx/usage.rst | 50 +++ .../parser/ASTNodeCoverageTest.java | 5 + .../statement/DiscardStatementTest.java | 145 ++++++++ .../statement/PostgreSqlCopyTest.java | 197 +++++++++++ .../statement/ResetStatementTest.java | 58 ++++ .../statement/ShowStatementTest.java | 66 ++++ .../maintenance/CheckpointStatementTest.java | 3 +- .../maintenance/ClusterStatementTest.java | 3 +- .../maintenance/VacuumStatementTest.java | 3 +- .../statement/notify/ListenStatementTest.java | 155 +++++++++ .../statement/reindex/ReindexTest.java | 2 +- .../securitylabel/SecurityLabelTest.java | 2 +- ...stgreSqlSessionStatementValidatorTest.java | 51 +++ 28 files changed, 1559 insertions(+), 12 deletions(-) create mode 100644 src/main/java/net/sf/jsqlparser/statement/CopyLegacyOption.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/DiscardStatement.java create mode 100644 src/main/java/net/sf/jsqlparser/statement/notify/ListenStatement.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/DiscardStatementTest.java create mode 100644 src/test/java/net/sf/jsqlparser/statement/notify/ListenStatementTest.java create mode 100644 src/test/java/net/sf/jsqlparser/util/validation/validator/PostgreSqlSessionStatementValidatorTest.java diff --git a/README.md b/README.md index e7a8db4521..8f83615052 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 dc3d137e37..de8355e11f 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 0000000000..cbeafc4d6f --- /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 2697677dbb..e253143685 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 0000000000..9d9987d6e0 --- /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 df67903b03..69be59e68a 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 4063637f73..4f3a24d8bc 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 9cb08129e4..b0a3dfabac 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 0000000000..b2f579b99e --- /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 e1cf00f3fb..c25a0fd7ba 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 0e9adaba31..0bc95f368c 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 0600b12420..27be13dc14 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 ec1b85f29e..85cfec9bfa 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 4cf4101880..f5b854c010 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 e0ff2d8a4d..26470b0c44 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 93bee33396..8a0eaafaf2 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 25dd26d38d..34482d76cf 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 0000000000..db9014b1dc --- /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 451e4a6906..a2cfbba0f3 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 b3372ff736..5af78ca172 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 23830cbc9e..698c1f9811 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 b54e4b95d7..a7a75b486d 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 37b26b3fda..b914774528 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 4e6ffb7446..82ee1b87aa 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 0000000000..37f0b2eda4 --- /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 e6482373f2..b0e260a35b 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 6d906eed85..b7c03a634a 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 0000000000..ac8a71c026 --- /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); + } +}