Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 59 additions & 0 deletions crates/polyglot-sql/src/parser.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21947,19 +21947,70 @@ impl Parser {
fn parse_describe(&mut self) -> Result<Expression> {
// Accept DESCRIBE, DESC, and EXPLAIN (Var token)
// Capture leading comments from the first token
let mut is_explain = false;
let leading_comments = if self.check(TokenType::Describe) {
let token = self.advance();
token.comments
} else if self.check(TokenType::Desc) {
let token = self.advance();
token.comments
} else if self.check(TokenType::Var) && self.peek().text.eq_ignore_ascii_case("EXPLAIN") {
is_explain = true;
let token = self.advance(); // consume EXPLAIN
token.comments
} else {
return Err(self.parse_error("Expected DESCRIBE, DESC, or EXPLAIN"));
};

// PostgreSQL-family: EXPLAIN ( option [, ...] ) statement
// e.g. EXPLAIN (VERBOSE, COSTS OFF, FORMAT JSON) SELECT ...
// These are planner/output directives (COSTS, BUFFERS, VERBOSE, FORMAT,
// SETTINGS, WAL, TIMING, SUMMARY, MEMORY, GENERIC_PLAN, SERIALIZE) with no
// cross-dialect meaning, so they are parsed and discarded here - consistent
// with the way EXPLAIN is normalized to DESCRIBE. ANALYZE is folded into the
// DESCRIBE style below so `EXPLAIN (ANALYZE) ...` matches bare `EXPLAIN ANALYZE ...`.
// A parenthesized subquery (`EXPLAIN (SELECT ...)`) is left for the target parser.
let mut analyze_option = false;
if is_explain
&& self.check(TokenType::LParen)
&& matches!(
self.config.dialect,
Some(crate::dialects::DialectType::PostgreSQL)
| Some(crate::dialects::DialectType::CockroachDB)
| Some(crate::dialects::DialectType::RisingWave)
| Some(crate::dialects::DialectType::Materialize)
)
&& !matches!(
self.peek_nth(1).map(|t| t.token_type),
Some(TokenType::Select) | Some(TokenType::With) | Some(TokenType::LParen)
)
{
self.advance(); // consume '('
loop {
if self.check(TokenType::RParen) || self.is_at_end() {
break;
}
// option name (identifier or keyword, e.g. ANALYZE, VERBOSE, FORMAT)
let name = self.advance().text.to_ascii_uppercase();
// optional single-token argument (boolean, or a word for FORMAT/SERIALIZE)
let value = if !self.check(TokenType::Comma) && !self.check(TokenType::RParen) {
Some(self.advance().text.to_ascii_uppercase())
} else {
None
};
if name == "ANALYZE" {
analyze_option = match value.as_deref() {
None => true,
Some(v) => matches!(v, "TRUE" | "ON" | "1"),
};
}
if !self.match_token(TokenType::Comma) {
break;
}
}
self.expect(TokenType::RParen)?;
}

// Check for EXTENDED or FORMATTED keywords
let extended = self.match_identifier("EXTENDED");
let formatted = if !extended {
Expand Down Expand Up @@ -22040,6 +22091,14 @@ impl Parser {
None
};

// Fold a parenthesized ANALYZE option (EXPLAIN (ANALYZE) ...) into the
// DESCRIBE style, matching bare `EXPLAIN ANALYZE ...`.
let style = if analyze_option && style.is_none() {
Some("ANALYZE".to_string())
} else {
style
};

// Check for object kind like SEMANTIC VIEW, TABLE, INPUT, OUTPUT, etc.
let kind = if self.match_identifier("SEMANTIC") {
if self.match_token(TokenType::View) {
Expand Down
106 changes: 106 additions & 0 deletions crates/polyglot-sql/tests/postgres_explain_options.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//! Regression tests for PostgreSQL's parenthesized `EXPLAIN ( option [, ...] )` form.
//!
//! PostgreSQL (and the PostgreSQL-compatible dialects CockroachDB, RisingWave and
//! Materialize) accept `EXPLAIN ( VERBOSE, COSTS OFF, FORMAT JSON, ... ) <statement>`.
//! These options are planner/output directives with no cross-dialect meaning, so they
//! are parsed and discarded - consistent with the way `EXPLAIN` is normalized to
//! `DESCRIBE`. The `ANALYZE` option is folded into the `DESCRIBE` style so that
//! `EXPLAIN (ANALYZE) ...` matches bare `EXPLAIN ANALYZE ...`.

use polyglot_sql::{transpile, DialectType};

fn pg(sql: &str) -> String {
transpile(sql, DialectType::PostgreSQL, DialectType::PostgreSQL)
.expect("PostgreSQL EXPLAIN should transpile")
.join("; ")
}

#[test]
fn parenthesized_options_are_parsed_and_discarded() {
let cases = [
("EXPLAIN (VERBOSE, COSTS OFF) SELECT 1", "DESCRIBE SELECT 1"),
("EXPLAIN (FORMAT json) SELECT 1", "DESCRIBE SELECT 1"),
(
"EXPLAIN (VERBOSE, COSTS OFF) SELECT max(unique1) FROM tenk1",
"DESCRIBE SELECT MAX(unique1) FROM tenk1",
),
(
"EXPLAIN (BUFFERS, SETTINGS, WAL, TIMING OFF, SUMMARY, GENERIC_PLAN) SELECT 1",
"DESCRIBE SELECT 1",
),
];
for (input, expected) in cases {
assert_eq!(pg(input), expected, "input: {input}");
}
}

#[test]
fn analyze_option_folds_into_describe_style() {
// ANALYZE (with or without a truthy argument, in any position) behaves like bare
// `EXPLAIN ANALYZE`.
let cases = [
("EXPLAIN (ANALYZE) SELECT 1", "DESCRIBE ANALYZE SELECT 1"),
(
"EXPLAIN (ANALYZE true) SELECT 1",
"DESCRIBE ANALYZE SELECT 1",
),
("EXPLAIN (ANALYZE ON) SELECT 1", "DESCRIBE ANALYZE SELECT 1"),
(
"EXPLAIN (COSTS OFF, ANALYZE) SELECT 1",
"DESCRIBE ANALYZE SELECT 1",
),
(
"EXPLAIN (ANALYZE true, BUFFERS) SELECT max(a) FROM t",
"DESCRIBE ANALYZE SELECT MAX(a) FROM t",
),
];
for (input, expected) in cases {
assert_eq!(pg(input), expected, "input: {input}");
}
}

#[test]
fn analyze_false_is_not_folded() {
assert_eq!(pg("EXPLAIN (ANALYZE false) SELECT 1"), "DESCRIBE SELECT 1");
assert_eq!(pg("EXPLAIN (ANALYZE off) SELECT 1"), "DESCRIBE SELECT 1");
}

#[test]
fn bare_explain_is_unchanged() {
assert_eq!(pg("EXPLAIN SELECT 1"), "DESCRIBE SELECT 1");
assert_eq!(pg("EXPLAIN ANALYZE SELECT 1"), "DESCRIBE ANALYZE SELECT 1");
}

#[test]
fn parenthesized_subquery_is_not_treated_as_options() {
// `EXPLAIN (SELECT ...)` must still be parsed as a parenthesized subquery target,
// not as an option list.
assert_eq!(pg("EXPLAIN (SELECT 1)"), "DESCRIBE (SELECT 1)");
}

#[test]
fn options_transpile_to_clickhouse_without_error() {
// The primary motivation: PostgreSQL EXPLAIN statements must reach a target dialect
// (here ClickHouse) instead of failing at parse time.
let out = transpile(
"EXPLAIN (VERBOSE, COSTS OFF) SELECT max(unique1) FROM tenk1",
DialectType::PostgreSQL,
DialectType::ClickHouse,
)
.expect("PostgreSQL EXPLAIN (options) should transpile to ClickHouse");
assert_eq!(out, vec!["DESCRIBE SELECT max(unique1) FROM tenk1"]);
}

#[test]
fn options_accepted_for_postgres_compatible_dialects() {
// CockroachDB / RisingWave / Materialize share the parenthesized EXPLAIN syntax.
for read in [
DialectType::CockroachDB,
DialectType::RisingWave,
DialectType::Materialize,
] {
let out = transpile("EXPLAIN (VERBOSE) SELECT 1", read, read)
.unwrap_or_else(|e| panic!("{read:?} EXPLAIN (options) should transpile: {e}"));
assert_eq!(out.len(), 1, "{read:?}");
}
}