diff --git a/crates/polyglot-sql/src/parser.rs b/crates/polyglot-sql/src/parser.rs index 8b0d80e..f95c133 100644 --- a/crates/polyglot-sql/src/parser.rs +++ b/crates/polyglot-sql/src/parser.rs @@ -21947,6 +21947,7 @@ impl Parser { fn parse_describe(&mut self) -> Result { // 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 @@ -21954,12 +21955,62 @@ impl Parser { 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 { @@ -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) { diff --git a/crates/polyglot-sql/tests/postgres_explain_options.rs b/crates/polyglot-sql/tests/postgres_explain_options.rs new file mode 100644 index 0000000..8845efa --- /dev/null +++ b/crates/polyglot-sql/tests/postgres_explain_options.rs @@ -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, ... ) `. +//! 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:?}"); + } +}