diff --git a/Makefile b/Makefile index 0bd5cde8..70a60bdd 100644 --- a/Makefile +++ b/Makefile @@ -251,6 +251,7 @@ test-rust-feature-gates: cargo check -p polyglot-sql --no-default-features --features transpile,dialect-clickhouse,dialect-postgresql cargo check -p polyglot-sql --no-default-features --features transpile,dialect-tsql cargo check -p polyglot-sql --no-default-features --features transpile,dialect-fabric + cargo check -p polyglot-sql --no-default-features --features transpile,dialect-vertica cargo check -p polyglot-sql --no-default-features --features dialect-snowflake cargo check -p polyglot-sql --no-default-features --features generate,dialect-snowflake cargo check -p polyglot-sql --no-default-features --features transpile,dialect-snowflake diff --git a/README.md b/README.md index 647bbf29..9ef7de28 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,8 @@ Release notes are tracked in [`CHANGELOG.md`](CHANGELOG.md). | MySQL | Oracle | PostgreSQL | Presto | Redshift | | RisingWave | SingleStore | Snowflake | Solr | Spark | | SQLite | StarRocks | Tableau | Teradata | TiDB | -| Trino | TSQL | DataFusion | SAP HANA | Generic SQL | +| Trino | TSQL | Vertica | DataFusion | SAP HANA | +| Generic SQL | | | | | ## Quick Start diff --git a/crates/polyglot-sql-ffi/src/dialects.rs b/crates/polyglot-sql-ffi/src/dialects.rs index de46a4f5..7c06daad 100644 --- a/crates/polyglot-sql-ffi/src/dialects.rs +++ b/crates/polyglot-sql-ffi/src/dialects.rs @@ -3,7 +3,7 @@ use polyglot_sql::dialects::DialectType; use std::os::raw::c_char; use std::ptr; -const DIALECTS: [DialectType; 35] = [ +const DIALECTS: &[DialectType] = &[ DialectType::Generic, DialectType::PostgreSQL, DialectType::MySQL, @@ -39,6 +39,7 @@ const DIALECTS: [DialectType; 35] = [ DialectType::Exasol, DialectType::DataFusion, DialectType::HANA, + DialectType::Vertica, ]; /// Return supported dialect names as JSON. diff --git a/crates/polyglot-sql-ffi/tests/ffi_tests.rs b/crates/polyglot-sql-ffi/tests/ffi_tests.rs index 08db2506..7fa2c460 100644 --- a/crates/polyglot-sql-ffi/tests/ffi_tests.rs +++ b/crates/polyglot-sql-ffi/tests/ffi_tests.rs @@ -2050,7 +2050,7 @@ fn test_dialect_list_and_count() { let list: Vec = serde_json::from_str(&json).expect("invalid dialect list json"); let count = polyglot_dialect_count(); assert_eq!(list.len() as i32, count); - assert_eq!(count, 35); + assert_eq!(count, 36); let unique: BTreeSet<&str> = list.iter().map(String::as_str).collect(); assert_eq!(unique.len(), list.len()); assert!(list.iter().any(|d| d == "generic")); diff --git a/crates/polyglot-sql-python/README.md b/crates/polyglot-sql-python/README.md index aafe8159..d33d7ec0 100644 --- a/crates/polyglot-sql-python/README.md +++ b/crates/polyglot-sql-python/README.md @@ -329,7 +329,7 @@ All functions are exported from `polyglot_sql`. Current dialect names returned by `polyglot_sql.dialects()`: -`athena`, `bigquery`, `clickhouse`, `cockroachdb`, `datafusion`, `databricks`, `doris`, `dremio`, `drill`, `druid`, `duckdb`, `dune`, `exasol`, `fabric`, `generic`, `hana`, `hive`, `materialize`, `mysql`, `oracle`, `postgres`, `presto`, `redshift`, `risingwave`, `singlestore`, `snowflake`, `solr`, `spark`, `sqlite`, `starrocks`, `tableau`, `teradata`, `tidb`, `trino`, `tsql`. +`athena`, `bigquery`, `clickhouse`, `cockroachdb`, `datafusion`, `databricks`, `doris`, `dremio`, `drill`, `druid`, `duckdb`, `dune`, `exasol`, `fabric`, `generic`, `hana`, `hive`, `materialize`, `mysql`, `oracle`, `postgres`, `presto`, `redshift`, `risingwave`, `singlestore`, `snowflake`, `solr`, `spark`, `sqlite`, `starrocks`, `tableau`, `teradata`, `tidb`, `trino`, `tsql`, `vertica`. ## Error Handling diff --git a/crates/polyglot-sql-python/python/polyglot_sql/__init__.py b/crates/polyglot-sql-python/python/polyglot_sql/__init__.py index 5e58db5b..b1f202b0 100644 --- a/crates/polyglot-sql-python/python/polyglot_sql/__init__.py +++ b/crates/polyglot-sql-python/python/polyglot_sql/__init__.py @@ -1170,6 +1170,7 @@ def dense_rank(): Variance, VectorSearch, Version, + Vertica, ViewAttributeProperty, VolatileProperty, WatermarkColumnConstraint, @@ -1301,6 +1302,7 @@ def dense_rank(): "Select", "Column", "Table", + "Vertica", "Literal", "Identifier", "Star", diff --git a/crates/polyglot-sql-python/python/polyglot_sql/__init__.pyi b/crates/polyglot-sql-python/python/polyglot_sql/__init__.pyi index d2c659e1..b46cbb8d 100644 --- a/crates/polyglot-sql-python/python/polyglot_sql/__init__.pyi +++ b/crates/polyglot-sql-python/python/polyglot_sql/__init__.pyi @@ -1429,6 +1429,7 @@ class Variadic(Expression): ... class Variance(Expression): ... class VectorSearch(Expression): ... class Version(Expression): ... +class Vertica(Expression): ... class ViewAttributeProperty(Expression): ... class VolatileProperty(Expression): ... class WatermarkColumnConstraint(Expression): ... diff --git a/crates/polyglot-sql-python/src/dialects.rs b/crates/polyglot-sql-python/src/dialects.rs index 97840f0e..492d5a58 100644 --- a/crates/polyglot-sql-python/src/dialects.rs +++ b/crates/polyglot-sql-python/src/dialects.rs @@ -36,6 +36,7 @@ const DIALECT_NAMES: &[&str] = &[ "tidb", "trino", "tsql", + "vertica", ]; #[pyfunction] diff --git a/crates/polyglot-sql-python/src/expr_types.rs b/crates/polyglot-sql-python/src/expr_types.rs index b5cb040c..d39a2c9e 100644 --- a/crates/polyglot-sql-python/src/expr_types.rs +++ b/crates/polyglot-sql-python/src/expr_types.rs @@ -63,6 +63,7 @@ macro_rules! define_expression_subclasses { } define_expression_subclasses!( + Vertica, Literal, Boolean, Null, diff --git a/crates/polyglot-sql-python/tests/test_dialects.py b/crates/polyglot-sql-python/tests/test_dialects.py index 07414ed3..22bb184e 100644 --- a/crates/polyglot-sql-python/tests/test_dialects.py +++ b/crates/polyglot-sql-python/tests/test_dialects.py @@ -37,6 +37,7 @@ "tidb", "trino", "tsql", + "vertica", } diff --git a/crates/polyglot-sql-python/tests/test_transpile.py b/crates/polyglot-sql-python/tests/test_transpile.py index 741a581c..26805db4 100644 --- a/crates/polyglot-sql-python/tests/test_transpile.py +++ b/crates/polyglot-sql-python/tests/test_transpile.py @@ -198,3 +198,15 @@ def test_hana_native_clauses_and_source_semantics(): ) == ["SELECT STRPOS('abcabc', 'bc')"] with pytest.raises(polyglot_sql.TranspileError): polyglot_sql.transpile(sql, read="hana", write="duckdb") + + +def test_vertica_structured_syntax_and_semantic_errors(): + sql = "COPY t FROM LOCAL '/tmp/data.json' PARSER FJSONPARSER(flatten_maps=TRUE)" + ast = polyglot_sql.parse_one(sql, dialect="vertica") + assert ast.to_dict()["vertica"]["kind"] == "copy" + assert "FJSONPARSER(flatten_maps = TRUE)" in ast.sql(dialect="vertica") + assert polyglot_sql.transpile("SELECT B'101100'", read="vertica", write="duckdb") == ["SELECT UNHEX('2c')"] + for level in ("ignore", "warn", "raise", "immediate"): + for sql in ("SELECT x::!INT FROM t", "SELECT LISTAGG(x) FROM t", "SELECT id FROM t FOR UPDATE"): + with pytest.raises(polyglot_sql.TranspileError): + polyglot_sql.transpile(sql, read="vertica", write="postgres", unsupported_level=level) diff --git a/crates/polyglot-sql-wasm/Cargo.toml b/crates/polyglot-sql-wasm/Cargo.toml index b7921011..8fc5ee09 100644 --- a/crates/polyglot-sql-wasm/Cargo.toml +++ b/crates/polyglot-sql-wasm/Cargo.toml @@ -74,6 +74,7 @@ all-dialects = [ "dialect-druid", "dialect-solr", "dialect-tableau", "dialect-dune", "dialect-fabric", "dialect-drill", "dialect-dremio", "dialect-exasol", "dialect-datafusion", "dialect-hana", + "dialect-vertica", ] dialect-postgresql = ["polyglot-sql/dialect-postgresql"] dialect-mysql = ["polyglot-sql/dialect-mysql"] @@ -109,6 +110,7 @@ dialect-dremio = ["polyglot-sql/dialect-dremio"] dialect-exasol = ["polyglot-sql/dialect-exasol"] dialect-datafusion = ["polyglot-sql/dialect-datafusion"] dialect-hana = ["polyglot-sql/dialect-hana"] +dialect-vertica = ["polyglot-sql/dialect-vertica"] function-catalog-clickhouse = [ "dialect-clickhouse", "semantic", diff --git a/crates/polyglot-sql-wasm/src/lib.rs b/crates/polyglot-sql-wasm/src/lib.rs index d310ae71..e0699f74 100644 --- a/crates/polyglot-sql-wasm/src/lib.rs +++ b/crates/polyglot-sql-wasm/src/lib.rs @@ -853,6 +853,8 @@ fn get_dialects_internal() -> Vec<&'static str> { dialects.push("datafusion"); #[cfg(feature = "dialect-hana")] dialects.push("hana"); + #[cfg(feature = "dialect-vertica")] + dialects.push("vertica"); dialects } @@ -2929,12 +2931,13 @@ mod tests { let dialects: Vec = serde_json::from_str(&result).unwrap(); let unique: std::collections::BTreeSet<&str> = dialects.iter().map(String::as_str).collect(); - assert_eq!(dialects.len(), 35); + assert_eq!(dialects.len(), 36); assert_eq!(unique.len(), dialects.len()); assert!(unique.contains("generic")); assert!(unique.contains("postgresql")); assert!(unique.contains("datafusion")); assert!(unique.contains("hana")); + assert!(unique.contains("vertica")); } #[test] @@ -3585,6 +3588,7 @@ mod tests { "dremio", "exasol", "datafusion", + "vertica", ]; for dialect in dialects { @@ -4527,7 +4531,7 @@ mod tests { // ============================================================================ /// When all-dialects is disabled, get_dialects() must always include "generic" - /// and must NOT include all 34 dialects. + /// and must NOT include all 36 dialects. #[test] #[cfg(not(feature = "all-dialects"))] fn test_per_dialect_get_dialects_subset() { @@ -4539,8 +4543,8 @@ mod tests { dialects ); assert!( - dialects.len() < 34, - "Per-dialect build should have fewer than 34 dialects, got {}", + dialects.len() < 36, + "Per-dialect build should have fewer than 36 dialects, got {}", dialects.len() ); } diff --git a/crates/polyglot-sql/Cargo.toml b/crates/polyglot-sql/Cargo.toml index 6b1090dc..a3915f5a 100644 --- a/crates/polyglot-sql/Cargo.toml +++ b/crates/polyglot-sql/Cargo.toml @@ -48,6 +48,7 @@ all-dialects = [ "dialect-druid", "dialect-solr", "dialect-tableau", "dialect-dune", "dialect-fabric", "dialect-drill", "dialect-dremio", "dialect-exasol", "dialect-datafusion", "dialect-hana", + "dialect-vertica", ] dialect-postgresql = [] dialect-mysql = [] @@ -83,6 +84,7 @@ dialect-dremio = [] dialect-exasol = [] dialect-datafusion = [] dialect-hana = [] +dialect-vertica = [] function-catalog-clickhouse = [ "semantic", "dep:polyglot-sql-function-catalogs", diff --git a/crates/polyglot-sql/README.md b/crates/polyglot-sql/README.md index 408cb420..2adfe6b5 100644 --- a/crates/polyglot-sql/README.md +++ b/crates/polyglot-sql/README.md @@ -488,7 +488,7 @@ assert_eq!(err.line(), None); ## Supported Dialects -Athena, BigQuery, ClickHouse, CockroachDB, DataFusion, Databricks, Doris, Dremio, Drill, Druid, DuckDB, Dune, Exasol, Fabric, Generic SQL, SAP HANA, Hive, Materialize, MySQL, Oracle, PostgreSQL, Presto, Redshift, RisingWave, SingleStore, Snowflake, Solr, Spark, SQLite, StarRocks, Tableau, Teradata, TiDB, Trino, TSQL +Athena, BigQuery, ClickHouse, CockroachDB, DataFusion, Databricks, Doris, Dremio, Drill, Druid, DuckDB, Dune, Exasol, Fabric, Generic SQL, SAP HANA, Hive, Materialize, MySQL, Oracle, PostgreSQL, Presto, Redshift, RisingWave, SingleStore, Snowflake, Solr, Spark, SQLite, StarRocks, Tableau, Teradata, TiDB, Trino, TSQL, Vertica ## Feature Flags diff --git a/crates/polyglot-sql/src/ast_mutation.rs b/crates/polyglot-sql/src/ast_mutation.rs index 9a4b8fae..fb899c4d 100644 --- a/crates/polyglot-sql/src/ast_mutation.rs +++ b/crates/polyglot-sql/src/ast_mutation.rs @@ -520,19 +520,13 @@ pub(crate) fn set_case_else(expression: &mut Expression, result: Expression) -> Ok(()) } -pub(crate) fn subquery( - query: Expression, - alias: Option, - modifiers_inside: bool, -) -> Result { - if !is_query(&query) { - return Err(invalid_method("subquery", &query)); - } - Ok(Expression::Subquery(Box::new(Subquery { - this: query, +/// Wrap an expression in a derived table with an explicit AS alias. +pub(crate) fn derived_table(this: Expression, alias: Option) -> Expression { + Expression::Subquery(Box::new(Subquery { + this, alias, column_aliases: Vec::new(), - alias_explicit_as: false, + alias_explicit_as: true, alias_keyword: None, order_by: None, limit: None, @@ -541,10 +535,35 @@ pub(crate) fn subquery( sort_by: None, cluster_by: None, lateral: false, - modifiers_inside, + modifiers_inside: true, trailing_comments: Vec::new(), inferred_type: None, - }))) + })) +} + +/// Qualify a column without losing the output identifier's quoting. +pub(crate) fn qualified_column(table: &str, name: &Identifier) -> Expression { + let Expression::Column(mut c) = Expression::qualified_column(table, &name.name) else { + unreachable!() + }; + c.name = name.clone(); + Expression::Column(c) +} + +pub(crate) fn subquery( + query: Expression, + alias: Option, + modifiers_inside: bool, +) -> Result { + if !is_query(&query) { + return Err(invalid_method("subquery", &query)); + } + let Expression::Subquery(mut subquery) = derived_table(query, alias) else { + unreachable!() + }; + subquery.alias_explicit_as = false; + subquery.modifiers_inside = modifiers_inside; + Ok(Expression::Subquery(subquery)) } pub(crate) fn merge(target: Expression) -> Expression { diff --git a/crates/polyglot-sql/src/ast_transforms.rs b/crates/polyglot-sql/src/ast_transforms.rs index 9b0b301a..af00a95f 100644 --- a/crates/polyglot-sql/src/ast_transforms.rs +++ b/crates/polyglot-sql/src/ast_transforms.rs @@ -13,6 +13,63 @@ use crate::ast_mutation as engine; use crate::expressions::*; use crate::traversal::{is_aggregate, ExpressionWalk}; +/// Allocate case-insensitively unique names for synthesized AST scopes. +#[cfg(feature = "generate")] +#[derive(Default)] +pub(crate) struct AstNames(HashSet); + +#[cfg(feature = "generate")] +impl AstNames { + pub(crate) fn collect(&mut self, expression: &Expression) { + for node in expression.dfs() { + match node { + Expression::Column(c) => { + self.reserve(&c.name); + if let Some(table) = &c.table { + self.reserve(table); + } + } + Expression::Identifier(id) => self.reserve(id), + Expression::Alias(a) => { + self.reserve(&a.alias); + for id in &a.column_aliases { + self.reserve(id); + } + } + Expression::Table(t) => { + self.reserve(&t.name); + if let Some(alias) = &t.alias { + self.reserve(alias); + } + } + Expression::Subquery(s) => { + if let Some(alias) = &s.alias { + self.reserve(alias); + } + for id in &s.column_aliases { + self.reserve(id); + } + } + _ => {} + } + } + } + + fn reserve(&mut self, id: &Identifier) { + self.0.insert(id.name.to_ascii_lowercase()); + } + + pub(crate) fn fresh(&mut self, stem: &str) -> String { + let mut name = stem.to_string(); + let mut suffix = 0; + while !self.0.insert(name.to_ascii_lowercase()) { + suffix += 1; + name = format!("{stem}_{suffix}"); + } + name + } +} + /// Apply a bottom-up transformation to every node in the tree. /// Wraps `crate::traversal::transform` with a simpler signature for this module. fn xform Expression>(expr: Expression, fun: F) -> Expression { @@ -309,6 +366,32 @@ pub fn remove_nodes bool>(expr: Expression, predicate: F) // Convenience getters // --------------------------------------------------------------------------- +/// Return a single named projection while preserving identifier quoting. +#[cfg(feature = "generate")] +pub(crate) fn output_identifier(expression: &Expression) -> Option<&Identifier> { + match expression { + Expression::Alias(a) if a.column_aliases.is_empty() => Some(&a.alias), + Expression::Column(c) => Some(&c.name), + _ => None, + } +} + +/// Borrow the leftmost projection list through query wrappers and set operations. +#[cfg(feature = "generate")] +pub(crate) fn query_projections(mut query: &Expression) -> Option<&[Expression]> { + loop { + query = match query { + Expression::Select(s) => return Some(&s.expressions), + Expression::Union(s) => &s.left, + Expression::Intersect(s) => &s.left, + Expression::Except(s) => &s.left, + Expression::Subquery(s) => &s.this, + Expression::Paren(p) => &p.this, + _ => return None, + }; + } +} + /// Collect all column names (as `String`) referenced in the expression tree. pub fn get_column_names(expr: &Expression) -> Vec { expr.find_all(|e| matches!(e, Expression::Column(_))) diff --git a/crates/polyglot-sql/src/builder/mod.rs b/crates/polyglot-sql/src/builder/mod.rs index 9a53a516..3d61805a 100644 --- a/crates/polyglot-sql/src/builder/mod.rs +++ b/crates/polyglot-sql/src/builder/mod.rs @@ -1159,6 +1159,7 @@ impl Expr { /// call default to ascending order when passed to [`SelectBuilder::order_by()`]. pub fn asc(self) -> Expr { Expr(Expression::Ordered(Box::new(Ordered { + nulls_auto: false, this: self.0, desc: false, nulls_first: None, @@ -1172,6 +1173,7 @@ impl Expr { /// Used in ORDER BY clauses. pub fn desc(self) -> Expr { Expr(Expression::Ordered(Box::new(Ordered { + nulls_auto: false, this: self.0, desc: true, nulls_first: None, @@ -2275,6 +2277,7 @@ impl WindowDefBuilder { match expr { Expression::Ordered(o) => *o, other => Ordered { + nulls_auto: false, this: other, desc: false, nulls_first: None, diff --git a/crates/polyglot-sql/src/dialects/bigquery.rs b/crates/polyglot-sql/src/dialects/bigquery.rs index 221bdd91..24a2f525 100644 --- a/crates/polyglot-sql/src/dialects/bigquery.rs +++ b/crates/polyglot-sql/src/dialects/bigquery.rs @@ -764,6 +764,7 @@ impl DialectImpl for BigQueryDialect { })), }; let inner_select = Expression::Select(Box::new(Select { + vertica: None, expressions: vec![Expression::Literal(Box::new(Literal::Number( "1".to_string(), )))], diff --git a/crates/polyglot-sql/src/dialects/duckdb.rs b/crates/polyglot-sql/src/dialects/duckdb.rs index 02ac4c58..6062f468 100644 --- a/crates/polyglot-sql/src/dialects/duckdb.rs +++ b/crates/polyglot-sql/src/dialects/duckdb.rs @@ -197,9 +197,11 @@ impl DialectImpl for DuckDBDialect { // GROUP_CONCAT -> LISTAGG in DuckDB Expression::GroupConcat(f) => Ok(Expression::ListAgg(Box::new( crate::expressions::ListAggFunc { + source_dialect: None, this: f.this, separator: f.separator, on_overflow: None, + max_length: None, order_by: f.order_by, distinct: f.distinct, filter: f.filter, @@ -213,9 +215,11 @@ impl DialectImpl for DuckDBDialect { // STRING_AGG -> LISTAGG in DuckDB (normalize to LISTAGG) Expression::StringAgg(f) => Ok(Expression::ListAgg(Box::new( crate::expressions::ListAggFunc { + source_dialect: None, this: f.this, separator: f.separator, on_overflow: None, + max_length: None, order_by: f.order_by, distinct: f.distinct, filter: f.filter, @@ -5821,6 +5825,7 @@ impl DuckDBDialect { window_name: None, partition_by: vec![], order_by: vec![crate::expressions::Ordered { + nulls_auto: false, this: Expression::number(1), desc: false, nulls_first: Some(true), diff --git a/crates/polyglot-sql/src/dialects/exasol.rs b/crates/polyglot-sql/src/dialects/exasol.rs index 8360a095..c6d19e9d 100644 --- a/crates/polyglot-sql/src/dialects/exasol.rs +++ b/crates/polyglot-sql/src/dialects/exasol.rs @@ -127,9 +127,11 @@ impl DialectImpl for ExasolDialect { // GROUP_CONCAT -> LISTAGG in Exasol (with WITHIN GROUP for ORDER BY) Expression::GroupConcat(f) => Ok(Expression::ListAgg(Box::new(ListAggFunc { + source_dialect: None, this: f.this, separator: f.separator, on_overflow: None, + max_length: None, order_by: f.order_by, distinct: f.distinct, filter: f.filter, diff --git a/crates/polyglot-sql/src/dialects/mod.rs b/crates/polyglot-sql/src/dialects/mod.rs index 4f89a2c6..98e11191 100644 --- a/crates/polyglot-sql/src/dialects/mod.rs +++ b/crates/polyglot-sql/src/dialects/mod.rs @@ -91,6 +91,8 @@ mod tidb; mod trino; #[cfg(any(feature = "dialect-tsql", feature = "dialect-fabric"))] mod tsql; +#[cfg(feature = "dialect-vertica")] +mod vertica; pub use generic::GenericDialect; // Always available @@ -162,6 +164,8 @@ pub use tidb::TiDBDialect; pub use trino::TrinoDialect; #[cfg(feature = "dialect-tsql")] pub use tsql::TSQLDialect; +#[cfg(feature = "dialect-vertica")] +pub use vertica::VerticaDialect; use crate::error::Result; #[cfg(feature = "transpile")] @@ -281,6 +285,8 @@ pub enum DialectType { DataFusion, /// SAP HANA Cloud and SAP HANA Platform SQL. HANA, + /// Vertica (OpenText Analytics Database) -- columnar MPP analytic database. + Vertica, } impl DialectType { @@ -338,6 +344,7 @@ impl std::fmt::Display for DialectType { DialectType::Exasol => write!(f, "exasol"), DialectType::DataFusion => write!(f, "datafusion"), DialectType::HANA => write!(f, "hana"), + DialectType::Vertica => write!(f, "vertica"), } } } @@ -382,6 +389,7 @@ impl std::str::FromStr for DialectType { "exasol" => Ok(DialectType::Exasol), "datafusion" | "arrow-datafusion" | "arrow_datafusion" => Ok(DialectType::DataFusion), "hana" | "saphana" | "sap_hana" => Ok(DialectType::HANA), + "vertica" => Ok(DialectType::Vertica), _ => Err(crate::error::Error::parse( format!("Unknown dialect: {}", s), 0, @@ -708,7 +716,8 @@ where Expression::Union(set_op) => set_op.with.is_none() && set_op.order_by.is_none(), Expression::Intersect(set_op) => set_op.with.is_none() && set_op.order_by.is_none(), Expression::Except(set_op) => set_op.with.is_none() && set_op.order_by.is_none(), - Expression::Literal(_) + Expression::Vertica(_) + | Expression::Literal(_) | Expression::Boolean(_) | Expression::Null(_) | Expression::Identifier(_) @@ -1081,6 +1090,23 @@ where .into_iter() .map(|hint| transform_recursive(hint, transform_fn)) .collect::>>()?; + if let Some(extension) = &mut select.vertica { + use crate::ast_children::AstNode; + let mut failure = None; + extension.visit_expressions_mut(&mut |child| { + if failure.is_none() { + let old = + std::mem::replace(child, Expression::Null(crate::expressions::Null)); + match transform_recursive(old, transform_fn) { + Ok(new) => *child = new, + Err(error) => failure = Some(error), + } + } + }); + if let Some(error) = failure { + return Err(error); + } + } select.expressions = select .expressions .into_iter() @@ -2433,6 +2459,7 @@ cached_dialect!(CACHED_DREMIO, DremioDialect, "dialect-dremio"); cached_dialect!(CACHED_EXASOL, ExasolDialect, "dialect-exasol"); cached_dialect!(CACHED_DATAFUSION, DataFusionDialect, "dialect-datafusion"); cached_dialect!(CACHED_HANA, HanaDialect, "dialect-hana"); +cached_dialect!(CACHED_VERTICA, VerticaDialect, "dialect-vertica"); fn configs_for_dialect_type(dt: DialectType) -> DialectConfigs { /// Clone configs from a cached static and pair with a fresh transform closure. @@ -2517,6 +2544,8 @@ fn configs_for_dialect_type(dt: DialectType) -> DialectConfigs { DialectType::DataFusion => from_cache!(CACHED_DATAFUSION, DataFusionDialect), #[cfg(feature = "dialect-hana")] DialectType::HANA => from_cache!(CACHED_HANA, HanaDialect), + #[cfg(feature = "dialect-vertica")] + DialectType::Vertica => from_cache!(CACHED_VERTICA, VerticaDialect), _ => from_cache!(CACHED_GENERIC, GenericDialect), } } @@ -3269,6 +3298,7 @@ impl Dialect { feature = "dialect-oracle", feature = "dialect-clickhouse", feature = "dialect-fabric", + feature = "dialect-vertica", ))] use crate::transforms; @@ -3438,6 +3468,13 @@ impl Dialect { // DataFusion supports QUALIFY and semi/anti joins natively #[cfg(feature = "dialect-datafusion")] DialectType::DataFusion => Ok(expr), + // Vertica doesn't support QUALIFY or semi/anti join syntax + #[cfg(feature = "dialect-vertica")] + DialectType::Vertica => { + let expr = transforms::eliminate_qualify(expr)?; + let expr = transforms::eliminate_semi_and_anti_joins(expr)?; + Ok(expr) + } // Oracle doesn't support QUALIFY #[cfg(feature = "dialect-oracle")] DialectType::Oracle => { @@ -3532,6 +3569,9 @@ impl Dialect { target_dialect.generator_config.dialect.unwrap_or_default(), )?; + let expr = + normalization::vertica::prepare_conversion(expr, self.dialect_type, target)?; + normalization::vertica::validate_conversion(&expr, self.dialect_type, target)?; // DuckDB source: normalize VARCHAR/CHAR to TEXT (DuckDB doesn't support // VARCHAR length constraints). This emulates Python sqlglot's DuckDB parser // where VARCHAR_LENGTH = None and VARCHAR maps to TEXT. @@ -4723,7 +4763,10 @@ impl Dialect { Self::push_unsupported_diagnostic(&mut diagnostics, "UNNEST"); } - if !Self::target_supports_remaining_explode(target) && Self::node_is_explode(node) { + if !Self::target_supports_remaining_explode(target) + && Self::node_is_explode(node) + && !(source == DialectType::Vertica && target == DialectType::Vertica) + { Self::push_unsupported_diagnostic(&mut diagnostics, "EXPLODE"); } diff --git a/crates/polyglot-sql/src/dialects/normalization/aggregates.rs b/crates/polyglot-sql/src/dialects/normalization/aggregates.rs index f093a4eb..9f73b4aa 100644 --- a/crates/polyglot-sql/src/dialects/normalization/aggregates.rs +++ b/crates/polyglot-sql/src/dialects/normalization/aggregates.rs @@ -422,9 +422,11 @@ pub(super) fn rewrite( // STRING_AGG(x, sep) -> LISTAGG(x, sep) Ok(Expression::ListAgg(Box::new( crate::expressions::ListAggFunc { + source_dialect: None, this: sa.this, separator: sa.separator, on_overflow: None, + max_length: None, order_by: sa.order_by, distinct: sa.distinct, filter: None, @@ -542,9 +544,11 @@ pub(super) fn rewrite( let this = wrap_concat_args_in_varchar_cast(gc.this); Ok(Expression::ListAgg(Box::new( crate::expressions::ListAggFunc { + source_dialect: None, this, separator: Some(sep), on_overflow: None, + max_length: None, order_by: gc.order_by, distinct: gc.distinct, filter: gc.filter, @@ -635,9 +639,11 @@ pub(super) fn rewrite( let sep = gc.separator.unwrap_or(Expression::string(",")); Ok(Expression::ListAgg(Box::new( crate::expressions::ListAggFunc { + source_dialect: None, this: gc.this, separator: Some(sep), on_overflow: None, + max_length: None, order_by: gc.order_by, distinct: gc.distinct, filter: None, @@ -1240,6 +1246,7 @@ pub(super) fn rewrite( this: column.clone(), percentile, order_by: Some(vec![Ordered { + nulls_auto: false, this: column, desc: false, nulls_first: None, diff --git a/crates/polyglot-sql/src/dialects/normalization/mod.rs b/crates/polyglot-sql/src/dialects/normalization/mod.rs index 756b4249..ef52eba1 100644 --- a/crates/polyglot-sql/src/dialects/normalization/mod.rs +++ b/crates/polyglot-sql/src/dialects/normalization/mod.rs @@ -16,6 +16,7 @@ mod scalar; mod statements; pub(in crate::dialects) mod temporal; mod types; +pub(in crate::dialects) mod vertica; #[derive(Debug, Clone, Copy)] struct NormalizationContext { @@ -99,6 +100,12 @@ pub(super) fn normalize( { return Ok(e); } + let e = if matches!(source, DialectType::Vertica) && !matches!(target, DialectType::Vertica) + { + vertica::normalize_from_vertica(e, target)? + } else { + e + }; if matches!(source, DialectType::DataFusion) && matches!(target, DialectType::DuckDB) { if let Expression::Function(ref function) = e { @@ -2377,15 +2384,18 @@ pub(super) fn normalize( | DialectType::Teradata | DialectType::Spark | DialectType::Databricks - | DialectType::Redshift => Action::None, + | DialectType::Redshift + | DialectType::Vertica => Action::None, _ => Action::Scalar(scalar::Action::Nvl2Expand), } } Expression::Decode(_) | Expression::DecodeCase(_) => { // DECODE(a, b, c[, d, e[, ...]]) -> CASE WHEN with null-safe comparisons - // Keep as DECODE for Oracle/Snowflake + // Keep as DECODE for Oracle/Snowflake/Vertica match target { - DialectType::Oracle | DialectType::Snowflake => Action::None, + DialectType::Oracle | DialectType::Snowflake | DialectType::Vertica => { + Action::None + } _ => Action::Scalar(scalar::Action::DecodeSimplify), } } @@ -2742,8 +2752,11 @@ pub(super) fn normalize( | DialectType::StarRocks | DialectType::Doris ); + // Vertica's implicit NULL placement depends on the sort key's data + // type (NULLS AUTO), so there is no source default to make explicit. if o.nulls_first.is_none() && source != target + && source != DialectType::Vertica && (target_supports_nulls || target_rewrites_nulls) { Action::Operators(operators::Action::NullsOrdering) diff --git a/crates/polyglot-sql/src/dialects/normalization/operators.rs b/crates/polyglot-sql/src/dialects/normalization/operators.rs index 2a95794c..0ba234c4 100644 --- a/crates/polyglot-sql/src/dialects/normalization/operators.rs +++ b/crates/polyglot-sql/src/dialects/normalization/operators.rs @@ -507,8 +507,11 @@ pub(super) fn rewrite( is_asc }; - // Only add explicit nulls ordering if source and target defaults differ - if source_nulls_first != target_nulls_first { + // Only add explicit nulls ordering if source and target defaults differ. + // Vertica's default is data-type dependent, so always make it explicit. + if source_nulls_first != target_nulls_first + || matches!(target, DialectType::Vertica) + { o.nulls_first = Some(source_nulls_first); } // If they match, leave nulls_first as None so the generator won't output it diff --git a/crates/polyglot-sql/src/dialects/normalization/scalar.rs b/crates/polyglot-sql/src/dialects/normalization/scalar.rs index 1aa176e8..01592e1c 100644 --- a/crates/polyglot-sql/src/dialects/normalization/scalar.rs +++ b/crates/polyglot-sql/src/dialects/normalization/scalar.rs @@ -1130,6 +1130,7 @@ pub(super) fn rewrite( crate::expressions::WithinGroup { this: inner, order_by: vec![crate::expressions::Ordered { + nulls_auto: false, this: column, desc: false, nulls_first: None, @@ -1173,6 +1174,7 @@ pub(super) fn rewrite( crate::expressions::WithinGroup { this: inner, order_by: vec![crate::expressions::Ordered { + nulls_auto: false, this: column, desc: false, nulls_first: None, @@ -5600,7 +5602,7 @@ pub(super) fn rewrite( )))) } } - DialectType::Redshift => { + DialectType::Redshift | DialectType::Vertica => { let unit = Expression::Identifier(Identifier::new("DAY")); Ok(Expression::Function(Box::new(Function::new( "DATEDIFF".to_string(), @@ -6853,9 +6855,12 @@ pub(super) fn rewrite( // GETDATE() -> CURRENT_TIMESTAMP for non-TSQL targets "GETDATE" if f.args.is_empty() => match target { DialectType::TSQL => Ok(Expression::Function(f)), - DialectType::Redshift => Ok(Expression::Function(Box::new( - Function::new("GETDATE".to_string(), vec![]), - ))), + DialectType::Redshift | DialectType::Vertica => { + Ok(Expression::Function(Box::new(Function::new( + "GETDATE".to_string(), + vec![], + )))) + } _ => Ok(Expression::CurrentTimestamp( crate::expressions::CurrentTimestamp { precision: None, @@ -7122,6 +7127,10 @@ pub(super) fn rewrite( DialectType::Oracle | DialectType::Redshift => { Ok(Expression::Function(f)) } + // Vertica: SYSDATE is a synonym for GETDATE() + DialectType::Vertica => Ok(Expression::Function(Box::new( + Function::new("GETDATE".to_string(), vec![]), + ))), DialectType::Snowflake => { // Snowflake uses SYSDATE() with parens let mut f = *f; @@ -10122,6 +10131,7 @@ pub(super) fn rewrite( | DialectType::Teradata | DialectType::Spark | DialectType::Databricks + | DialectType::Vertica ); if keep_as_decode { return Ok(Expression::Function(f)); @@ -11108,9 +11118,11 @@ pub(super) fn rewrite( let this = cpa.expressions[0].clone(); let separator = cpa.params.first().cloned(); crate::expressions::ListAggFunc { + source_dialect: None, this, separator, on_overflow: None, + max_length: None, order_by: None, distinct: false, filter: None, @@ -11261,6 +11273,7 @@ pub(super) fn rewrite( inferred_type: None, })); new_order_by.push(crate::expressions::Ordered { + nulls_auto: false, this: case_expr, desc: false, nulls_first: None, @@ -11706,6 +11719,7 @@ pub(super) fn rewrite( | DialectType::Teradata | DialectType::Spark | DialectType::Databricks + | DialectType::Vertica ); let (a, b, c) = if let Expression::Nvl2(nvl2) = e { if nvl2_native { diff --git a/crates/polyglot-sql/src/dialects/normalization/vertica.rs b/crates/polyglot-sql/src/dialects/normalization/vertica.rs new file mode 100644 index 00000000..2ed91aa2 --- /dev/null +++ b/crates/polyglot-sql/src/dialects/normalization/vertica.rs @@ -0,0 +1,880 @@ +//! Rewrites for Vertica-specific source semantics. +//! +//! Vertica shares most of its surface syntax with PostgreSQL, but a few functions +//! carry Vertica-only meaning that must be lowered before a foreign target sees them. + +use super::*; +use crate::ast_transforms::{output_identifier, query_projections, AstNames}; +use crate::expressions::{ + AggFunc, AtTimeZone, DateAddFunc, Interval, IntervalUnit, IntervalUnitSpec, VarArgFunc, +}; + +/// Validate before source transforms erase parameters or failure semantics. +/// These are correctness errors, independently of the diagnostic verbosity. +pub(in crate::dialects) fn validate_conversion( + expression: &Expression, + source: DialectType, + target: DialectType, +) -> Result<()> { + if source == DialectType::Vertica || target == DialectType::Vertica { + for node in expression.dfs() { + if let Expression::Select(select) = node { + for lock in &select.locks { + if target == DialectType::Vertica + && (lock.update.is_none() || lock.key.is_some() || lock.wait.is_some()) + { + return Err(crate::error::Error::unsupported( + "Vertica supports only FOR UPDATE [OF tables]", + target.to_string(), + )); + } + if source != target { + return Err(crate::error::Error::unsupported( + "Vertica locking semantics", + target.to_string(), + )); + } + } + } + } + } + if source == target || (source != DialectType::Vertica && target != DialectType::Vertica) { + return Ok(()); + } + for node in expression.dfs() { + if target == DialectType::Vertica { + match node { + Expression::Cast(cast) => { + crate::generator::Generator::validate_vertica_cast_type(&cast.to)?; + } + Expression::Function(function) => { + crate::generator::Generator::validate_vertica_function(function, Some(source))?; + } + _ => {} + } + } + let feature = match node { + Expression::Select(select) + if source == DialectType::Vertica + && select + .vertica + .as_ref() + .is_some_and(|v| v.timeseries.is_some() || v.match_clause.is_some()) => + { + Some("Vertica event-series query") + } + Expression::CreateTable(table) + if source == DialectType::Vertica + && table.columns.iter().any(|column| column.encoding.is_some()) => + { + Some("Vertica column encoding") + } + Expression::Subscript(_) | Expression::ArraySlice(_) + if target == DialectType::Vertica => + { + Some("array index and bound semantics when targeting Vertica") + } + Expression::Vertica(v) + if source == DialectType::Vertica + && !matches!( + v.as_ref(), + crate::expressions::VerticaExpression::Binary { .. } + | crate::expressions::VerticaExpression::ArrayAccess { .. } + | crate::expressions::VerticaExpression::ArraySlice { .. } + ) => + { + Some("native Vertica expression") + } + Expression::Function(f) + if source == DialectType::Vertica + && !f.quoted + && matches!( + f.name.to_ascii_uppercase().as_str(), + "TIME_SLICE" + | "ROW" + | "MATCH_COLUMNS" + | "CONDITIONAL_TRUE_EVENT" + | "CONDITIONAL_CHANGE_EVENT" + | "APPROXIMATE_PERCENTILE" + | "REGEXP_SUBSTR" + ) => + { + Some("Vertica function semantics without a verified target mapping") + } + Expression::Function(f) + if source == DialectType::Vertica + && target == DialectType::DuckDB + && !f.quoted + && f.args.is_empty() + && matches!( + f.name.to_ascii_uppercase().as_str(), + "GETDATE" | "GETUTCDATE" | "SYSDATE" + ) => + { + Some("Vertica statement-start timestamps have no verified DuckDB equivalent") + } + Expression::CurrentTimestamp(ts) + if source == DialectType::Vertica + && target == DialectType::DuckDB + && ts.sysdate => + { + Some("Vertica statement-start timestamps have no verified DuckDB equivalent") + } + Expression::Raw(_) | Expression::Command(_) if source == DialectType::Vertica => { + Some("unstructured Vertica statement") + } + Expression::Select(select) + if source == DialectType::Vertica && select.hint.is_some() => + { + Some("Vertica optimizer hint") + } + Expression::ListAgg(_) => Some("Vertica LISTAGG byte limit and overflow behavior"), + Expression::StringAgg(_) | Expression::GroupConcat(_) + if target == DialectType::Vertica => + { + Some("string aggregation without Vertica's byte limit and overflow behavior") + } + Expression::TryCast(_) | Expression::SafeCast(_) if target == DialectType::Vertica => { + Some("safe casts: Vertica ::! does not suppress constant cast failures") + } + _ => None, + }; + if let Some(feature) = feature { + return Err(crate::error::Error::unsupported( + feature, + target.to_string(), + )); + } + } + Ok(()) +} + +fn known_local_datetime(expression: &Expression) -> bool { + match expression { + Expression::Cast(c) => matches!( + c.to, + DataType::Date + | DataType::Timestamp { + timezone: false, + .. + } + ), + Expression::Literal(l) => matches!(l.as_ref(), Literal::Date(_) | Literal::Timestamp(_)), + _ => matches!( + expression.inferred_type(), + Some( + DataType::Date + | DataType::Timestamp { + timezone: false, + .. + } + ) + ), + } +} + +/// Resolve defaults while the enclosing ordering context is still available. +pub(in crate::dialects) fn prepare_conversion( + expr: Expression, + source: DialectType, + target: DialectType, +) -> Result { + // BigQuery's COUNTIF returns zero for an empty input/frame. Preserve that + // before the source transform converts it to a generic function call. + if source == DialectType::BigQuery && target == DialectType::Vertica { + return transform_recursive(expr, &|node| match node { + Expression::CountIf(mut count) => { + if !count.order_by.is_empty() + || count.limit.is_some() + || count.having_max.is_some() + || count.ignore_nulls.is_some() + { + return Err(crate::error::Error::unsupported( + "COUNTIF modifiers", + "vertica", + )); + } + let mut predicate = count.this; + if let Some(filter) = count.filter.take() { + predicate = Expression::And(Box::new(BinaryOp::new(predicate, filter))); + } + count.this = Expression::Case(Box::new(crate::expressions::Case { + operand: None, + whens: vec![(predicate, Expression::number(1))], + else_: None, + comments: Vec::new(), + inferred_type: None, + })); + Ok(Expression::Count(Box::new(crate::expressions::CountFunc { + this: Some(count.this), + star: false, + distinct: count.distinct, + filter: None, + ignore_nulls: None, + original_name: None, + inferred_type: count.inferred_type, + }))) + } + other => Ok(other), + }); + } + if source != DialectType::Vertica || source == target { + return Ok(expr); + } + fn analytic(ordered: &mut crate::expressions::Ordered, target: DialectType) -> Result<()> { + if ordered.nulls_auto { + return Err(crate::error::Error::unsupported( + "Vertica NULLS AUTO", + target.to_string(), + )); + } + ordered.nulls_first.get_or_insert(ordered.desc); + Ok(()) + } + transform_recursive(expr, &|mut node| { + match &mut node { + Expression::WindowFunction(window) => { + for order in &mut window.over.order_by { + analytic(order, target)?; + } + } + Expression::WithinGroup(group) => { + for order in &mut group.order_by { + analytic(order, target)?; + } + } + Expression::Select(select) => { + if let Some(windows) = &mut select.windows { + for window in windows { + for order in &mut window.spec.order_by { + analytic(order, target)?; + } + } + } + if let Some(over) = select.vertica.as_mut().and_then(|v| v.limit_over.as_mut()) { + for order in &mut over.order_by { + analytic(order, target)?; + } + } + if let Some(order) = &mut select.order_by { + prepare_select_order(order, &select.expressions, target)?; + } + } + Expression::Union(set) => { + prepare_set_order(&mut set.order_by, &set.left, &set.right, target)? + } + Expression::Intersect(set) => { + prepare_set_order(&mut set.order_by, &set.left, &set.right, target)? + } + Expression::Except(set) => { + prepare_set_order(&mut set.order_by, &set.left, &set.right, target)? + } + Expression::Subquery(sub) => { + if let Some(order) = &mut sub.order_by { + prepare_query_order(order, &[&sub.this], target)?; + } + } + _ => {} + } + Ok(node) + }) +} + +fn unknown_order(target: DialectType) -> crate::error::Error { + crate::error::Error::unsupported( + "Vertica ORDER BY requires a known, unambiguous sort-key type", + target.to_string(), + ) +} + +fn projection_index(key: &Expression, projections: &[Expression]) -> Option { + match key { + Expression::Literal(lit) => match lit.as_ref() { + Literal::Number(n) => n + .parse::() + .ok() + .and_then(|n| n.checked_sub(1)) + .filter(|&i| i < projections.len()), + _ => None, + }, + Expression::Column(c) if c.table.is_none() => { + let mut matches = projections.iter().enumerate().filter(|(_, p)| { + output_identifier(p).is_some_and(|n| n.name.eq_ignore_ascii_case(&c.name.name)) + }); + let first = matches.next()?.0; + matches.next().is_none().then_some(first) + } + _ => None, + } +} + +fn nulls_low(mut key: &Expression) -> Option { + while let Expression::Alias(a) = key { + key = &a.this; + } + if let Expression::Paren(p) = key { + return nulls_low(&p.this); + } + let data_type = match key { + Expression::Cast(c) => Some(&c.to), + _ => key.inferred_type(), + }; + match data_type { + Some( + DataType::Int { .. } + | DataType::BigInt { .. } + | DataType::SmallInt { .. } + | DataType::TinyInt { .. } + | DataType::Date + | DataType::Time { .. } + | DataType::Timestamp { .. }, + ) => Some(true), + Some( + DataType::Float { .. } + | DataType::Double { .. } + | DataType::Boolean + | DataType::Char { .. } + | DataType::VarChar { .. } + | DataType::Text + | DataType::String { .. } + | DataType::Array { .. }, + ) => Some(false), + _ => match key { + Expression::Literal(l) => match l.as_ref() { + Literal::Number(n) => Some(n.parse::().is_ok()), + Literal::String(_) => Some(false), + Literal::Date(_) | Literal::Timestamp(_) => Some(true), + _ => None, + }, + Expression::Boolean(_) | Expression::Array(_) => Some(false), + _ => None, + }, + } +} + +fn prepare_select_order( + order: &mut crate::expressions::OrderBy, + projections: &[Expression], + target: DialectType, +) -> Result<()> { + for ordered in &mut order.expressions { + if ordered.nulls_auto { + return Err(unknown_order(target)); + } + if ordered.nulls_first.is_some() { + continue; + } + let key = + projection_index(&ordered.this, projections).map_or(&ordered.this, |i| &projections[i]); + let low = nulls_low(key).ok_or_else(|| unknown_order(target))?; + ordered.nulls_first = Some(low != ordered.desc); + } + Ok(()) +} + +fn query_nulls_low(query: &Expression, index: usize) -> Option { + let mut pending = vec![query]; + let mut low = None; + while let Some(query) = pending.pop() { + let (left, right) = match query { + Expression::Select(s) => { + let current = nulls_low(s.expressions.get(index)?)?; + if low.is_some_and(|low| low != current) { + return None; + } + low = Some(current); + continue; + } + Expression::Subquery(s) => { + pending.push(&s.this); + continue; + } + Expression::Paren(p) => { + pending.push(&p.this); + continue; + } + Expression::Union(s) if !s.by_name && !s.corresponding => (&s.left, &s.right), + Expression::Intersect(s) if !s.by_name && !s.corresponding => (&s.left, &s.right), + Expression::Except(s) if !s.by_name && !s.corresponding => (&s.left, &s.right), + _ => return None, + }; + pending.extend([left, right]); + } + low +} + +fn prepare_query_order( + order: &mut crate::expressions::OrderBy, + queries: &[&Expression], + target: DialectType, +) -> Result<()> { + let projections = query_projections(queries[0]).ok_or_else(|| unknown_order(target))?; + for ordered in &mut order.expressions { + if ordered.nulls_auto { + return Err(unknown_order(target)); + } + if ordered.nulls_first.is_some() { + continue; + } + let index = + projection_index(&ordered.this, projections).ok_or_else(|| unknown_order(target))?; + let low = query_nulls_low(queries[0], index).ok_or_else(|| unknown_order(target))?; + if queries + .iter() + .skip(1) + .any(|q| query_nulls_low(q, index) != Some(low)) + { + return Err(unknown_order(target)); + } + ordered.nulls_first = Some(low != ordered.desc); + } + Ok(()) +} + +fn prepare_set_order( + order: &mut Option, + left: &Expression, + right: &Expression, + target: DialectType, +) -> Result<()> { + if let Some(order) = order { + prepare_query_order(order, &[left, right], target)?; + } + Ok(()) +} + +/// Rewrite a single node parsed as Vertica for a non-Vertica target. +pub(super) fn normalize_from_vertica(e: Expression, target: DialectType) -> Result { + match e { + Expression::Select(select) + if select + .vertica + .as_ref() + .is_some_and(|v| v.limit_over.is_some()) => + { + lower_partitioned_limit(*select, target) + } + Expression::DateDiff(diff) => { + let unit = diff.unit.ok_or_else(|| { + crate::error::Error::unsupported( + "Vertica DATEDIFF dynamic unit", + target.to_string(), + ) + })?; + if unit == IntervalUnit::Week { + return Err(crate::error::Error::unsupported( + "Vertica DATEDIFF week cutoff requires engine verification", + target.to_string(), + )); + } + if !known_local_datetime(&diff.this) || !known_local_datetime(&diff.expression) { + return Err(crate::error::Error::unsupported( + "Vertica DATEDIFF requires known DATE or TIMESTAMP WITHOUT TIME ZONE operands", + target.to_string(), + )); + } + Ok(Expression::Vertica(Box::new( + crate::expressions::VerticaExpression::BoundaryDateDiff { + start: diff.expression, + end: diff.this, + unit, + }, + ))) + } + Expression::Function(f) if f.args.is_empty() && !f.quoted => { + match f.name.to_ascii_uppercase().as_str() { + // GETDATE() and SYSDATE are the statement-start local timestamp, not the + // transaction-start CURRENT_TIMESTAMP. + "GETDATE" | "SYSDATE" => { + Ok(statement_timestamp(target, false).unwrap_or(Expression::Function(f))) + } + "GETUTCDATE" => { + Ok(statement_timestamp(target, true).unwrap_or(Expression::Function(f))) + } + _ => Ok(Expression::Function(f)), + } + } + Expression::CurrentTimestamp(ts) if ts.sysdate => { + Ok(statement_timestamp(target, false).unwrap_or(Expression::CurrentTimestamp(ts))) + } + Expression::Function(f) if !f.quoted => { + match (f.name.to_ascii_uppercase().as_str(), f.args.len()) { + ("DATEDIFF" | "TIMESTAMPDIFF", 3) => { + let unit = timestamp_unit(&f.args[0]).ok_or_else(|| { + crate::error::Error::unsupported( + "Vertica DATEDIFF dynamic unit", + target.to_string(), + ) + })?; + normalize_from_vertica( + Expression::DateDiff(Box::new(crate::expressions::DateDiffFunc { + this: f.args[2].clone(), + expression: f.args[1].clone(), + unit: Some(unit), + })), + target, + ) + } + // ZEROIFNULL(x) -> COALESCE(x, 0), except where it is native + ("NULLIFZERO", 1) => Ok(Expression::NullIf(Box::new( + crate::expressions::BinaryFunc { + original_name: None, + this: f.args[0].clone(), + expression: Expression::number(0), + inferred_type: None, + }, + ))), + ("ZEROIFNULL", 1) if !matches!(target, DialectType::Snowflake) => { + let mut args = f.args; + args.push(Expression::number(0)); + Ok(Expression::Coalesce(Box::new(VarArgFunc { + original_name: None, + expressions: args, + inferred_type: None, + }))) + } + // TIMESTAMPADD(unit, n, ts) -> the portable date-add node + ("TIMESTAMPADD", 3) => match timestamp_unit(&f.args[0]) { + Some(unit) => { + let mut args = f.args; + let this = args.pop().unwrap(); + let interval = args.pop().unwrap(); + if is_postgres_family(target) { + return Ok(postgres_interval_add(this, interval, unit)); + } + Ok(Expression::DateAdd(Box::new(DateAddFunc { + this, + interval, + unit, + }))) + } + None => Ok(Expression::Function(f)), + }, + // APPROXIMATE_COUNT_DISTINCT(x) -> the portable approximate-distinct node + ("APPROXIMATE_COUNT_DISTINCT", 1) => { + let this = f.args.into_iter().next().unwrap(); + Ok(Expression::ApproxDistinct(Box::new(AggFunc { + this, + distinct: false, + filter: None, + order_by: Vec::new(), + name: None, + ignore_nulls: None, + having_max: None, + limit: None, + inferred_type: None, + }))) + } + _ => Ok(Expression::Function(f)), + } + } + other => Ok(other), + } +} + +/// Vertica statement-start timestamps, for targets that can express them. +fn statement_timestamp(target: DialectType, utc: bool) -> Option { + let timestamp = |this: Expression| { + Expression::Cast(Box::new(Cast { + this, + to: DataType::Timestamp { + precision: None, + timezone: false, + }, + trailing_comments: Vec::new(), + double_colon_syntax: false, + format: None, + default: None, + inferred_type: None, + })) + }; + let at_utc = |this: Expression| { + Expression::AtTimeZone(Box::new(AtTimeZone { + this, + zone: Expression::string("UTC"), + })) + }; + match target { + // PostgreSQL has a true statement-start clock + DialectType::PostgreSQL => { + let now = Expression::Function(Box::new(Function::new( + "STATEMENT_TIMESTAMP".to_string(), + vec![], + ))); + Some(timestamp(if utc { at_utc(now) } else { now })) + } + // These targets have the same statement-start functions natively + DialectType::TSQL | DialectType::Fabric => None, + DialectType::Redshift if !utc => None, + _ if utc => Some(timestamp(at_utc(Expression::CurrentTimestamp( + crate::expressions::CurrentTimestamp { + precision: None, + sysdate: false, + }, + )))), + _ => None, + } +} + +/// Vertica datetime units accepted by TIMESTAMPADD, including the ODBC SQL_TSI_ forms. +fn timestamp_unit(unit: &Expression) -> Option { + let name = temporal::get_unit_str_static(unit); + Some(match name.trim_start_matches("SQL_TSI_") { + "YEAR" | "YEARS" | "YY" | "YYYY" => IntervalUnit::Year, + "QUARTER" | "QUARTERS" | "QQ" | "Q" => IntervalUnit::Quarter, + "MONTH" | "MONTHS" | "MM" | "M" => IntervalUnit::Month, + "WEEK" | "WEEKS" | "WK" | "WW" => IntervalUnit::Week, + "DAY" | "DAYS" | "DD" | "D" | "DAYOFYEAR" | "DY" | "Y" => IntervalUnit::Day, + "HOUR" | "HOURS" | "HH" => IntervalUnit::Hour, + "MINUTE" | "MINUTES" | "MI" | "N" => IntervalUnit::Minute, + "SECOND" | "SECONDS" | "SS" | "S" => IntervalUnit::Second, + "MILLISECOND" | "MILLISECONDS" | "MS" => IntervalUnit::Millisecond, + "MICROSECOND" | "MICROSECONDS" | "US" | "MCS" => IntervalUnit::Microsecond, + _ => return None, + }) +} + +fn is_postgres_family(target: DialectType) -> bool { + matches!( + target, + DialectType::PostgreSQL + | DialectType::Materialize + | DialectType::RisingWave + | DialectType::CockroachDB + ) +} + +/// `ts + INTERVAL 'n UNIT'` for literal amounts, `ts + INTERVAL '1 UNIT' * n` otherwise. +fn postgres_interval_add(ts: Expression, amount: Expression, unit: IntervalUnit) -> Expression { + let interval = |value: String| { + Expression::Interval(Box::new(Interval { + this: Some(Expression::string(&value)), + unit: Some(IntervalUnitSpec::Simple { + unit, + use_plural: false, + }), + })) + }; + let offset = match amount { + Expression::Literal(ref lit) if matches!(lit.as_ref(), Literal::Number(_)) => { + let Literal::Number(n) = lit.as_ref() else { + unreachable!() + }; + interval(n.clone()) + } + other => Expression::Mul(Box::new(BinaryOp::new(interval("1".to_string()), other))), + }; + Expression::Add(Box::new(BinaryOp::new(ts, offset))) +} + +fn lower_partitioned_limit( + mut base: crate::expressions::Select, + target: DialectType, +) -> Result { + use crate::expressions::{Alias, From, Select, Where, WindowFunction}; + let unsupported = || { + crate::error::Error::unsupported("Vertica partitioned LIMIT requires named outputs, resolvable ordering and no locking or event-series clauses", target.to_string()) + }; + if !matches!(target, DialectType::PostgreSQL | DialectType::DuckDB) + || !base.locks.is_empty() + || base.into.is_some() + || base.offset.is_some() + { + return Err(unsupported()); + } + let extension = base.vertica.take().ok_or_else(unsupported)?; + if extension.timeseries.is_some() || extension.match_clause.is_some() { + return Err(unsupported()); + } + let mut over = extension.limit_over.ok_or_else(unsupported)?; + if over.partition_by.is_empty() + || over.order_by.is_empty() + || over.frame.is_some() + || over.window_name.is_some() + { + return Err(unsupported()); + } + let limit = base.limit.take().ok_or_else(unsupported)?; + if !matches!(&limit.this, Expression::Literal(lit) if matches!(lit.as_ref(), Literal::Number(n) if n.parse::().is_ok_and(|n| n > 0))) + { + return Err(unsupported()); + } + let mut outer_order = base.order_by.take(); + let outer_offset = base.offset.take(); + let outer_with = base.with.take(); + let outer_comments = std::mem::take(&mut base.leading_comments); + let original = base.expressions.clone(); + let mut names = AstNames::default(); + // Collect identifiers directly, without cloning/serializing the complete AST. + let base_node = Expression::Select(Box::new(base)); + names.collect(&base_node); + for key in &over.partition_by { + names.collect(key); + } + for key in &over.order_by { + names.collect(&key.this); + } + if let Some(order) = &outer_order { + for key in &order.expressions { + names.collect(&key.this); + } + } + let Expression::Select(base_box) = base_node else { + unreachable!() + }; + let mut base = *base_box; + let source_alias = names.fresh("_vertica_source"); + let ranked_alias = names.fresh("_vertica_ranked"); + let rank_name = names.fresh("_vertica_row_number"); + let mut output_names = Vec::new(); + let mut internal_names = Vec::new(); + let mut visible_names = std::collections::HashSet::new(); + // Preserve the base query's aliases: GROUP BY, WHERE, HAVING and later + // projections may still refer to them in the original scope. + for expression in &original { + let name = output_identifier(expression) + .ok_or_else(unsupported)? + .clone(); + if !visible_names.insert(name.name.to_ascii_lowercase()) { + return Err(unsupported()); + } + output_names.push(name.clone()); + internal_names.push(name); + } + let references_alias = |expression: &Expression| { + expression.dfs().any(|node| { + let Expression::Column(c) = node else { + return false; + }; + c.table.is_none() + && original.iter().any(|p| { + matches!(p, Expression::Alias(a) if a.alias.name.eq_ignore_ascii_case(&c.name.name)) + }) + }) + }; + // PostgreSQL does not accept SELECT aliases in predicates. Without schema + // information alias/input-name collisions cannot be resolved safely. + if target == DialectType::PostgreSQL + && (base + .where_clause + .as_ref() + .is_some_and(|w| references_alias(&w.this)) + || base + .having + .as_ref() + .is_some_and(|h| references_alias(&h.this))) + { + return Err(unsupported()); + } + let visible_count = internal_names.len(); + let mut resolve = |key: &Expression, table: &str, ordinal: bool| -> Result { + if !ordinal + && matches!( + key, + Expression::Literal(_) | Expression::Boolean(_) | Expression::Null(_) + ) + { + return Ok(key.clone()); + } + let projected = match key { + Expression::Literal(lit) if ordinal => match lit.as_ref() { + Literal::Number(n) => n + .parse::() + .ok() + .and_then(|i| i.checked_sub(1)) + .filter(|&i| i < visible_count), + _ => None, + }, + Expression::Column(column) if column.table.is_none() => output_names + .iter() + .position(|name| name.name.eq_ignore_ascii_case(&column.name.name)), + _ => None, + } + .or_else(|| { + original.iter().position(|expression| { + let value = if let Expression::Alias(alias) = expression { + &alias.this + } else { + expression + }; + value == key + }) + }); + let name = if let Some(i) = projected { + internal_names[i].clone() + } else { + if base.distinct || base.group_by.is_some() || base.having.is_some() { + return Err(unsupported()); + } + if target == DialectType::PostgreSQL && references_alias(key) { + return Err(unsupported()); + } + let name = names.fresh("_vertica_hidden"); + base.expressions.push(key.clone().alias(&name)); + let name = Identifier::new(name); + internal_names.push(name.clone()); + name + }; + Ok(crate::ast_mutation::qualified_column(table, &name)) + }; + for key in &mut over.partition_by { + *key = resolve(key, &source_alias, false)?; + } + for key in &mut over.order_by { + key.this = resolve(&key.this, &source_alias, false)?; + key.nulls_first.get_or_insert(key.desc); + } + if let Some(order) = &mut outer_order { + for key in &mut order.expressions { + key.this = resolve(&key.this, &ranked_alias, true)?; + } + } + let subquery = |select: Select, name: String| { + crate::ast_mutation::derived_table( + Expression::Select(Box::new(select)), + Some(Identifier::new(name)), + ) + }; + let mut ranked = Select::new(); + ranked.expressions = internal_names + .iter() + .map(|name| crate::ast_mutation::qualified_column(&source_alias, name)) + .collect(); + ranked.expressions.push( + Expression::WindowFunction(Box::new(WindowFunction { + this: Expression::RowNumber(crate::expressions::RowNumber), + over, + keep: None, + inferred_type: None, + })) + .alias(&rank_name), + ); + ranked.from = Some(From { + expressions: vec![subquery(base, source_alias)], + }); + let mut result = Select::new(); + result.expressions = output_names + .into_iter() + .enumerate() + .map(|(i, name)| { + Expression::Alias(Box::new(Alias::new( + crate::ast_mutation::qualified_column(&ranked_alias, &internal_names[i]), + name, + ))) + }) + .collect(); + result.where_clause = Some(Where { + this: Expression::Lte(Box::new(BinaryOp::new( + Expression::qualified_column(&ranked_alias, rank_name), + limit.this, + ))), + }); + result.from = Some(From { + expressions: vec![subquery(ranked, ranked_alias)], + }); + result.order_by = outer_order; + result.offset = outer_offset; + result.with = outer_with; + result.leading_comments = outer_comments; + Ok(Expression::Select(Box::new(result))) +} diff --git a/crates/polyglot-sql/src/dialects/snowflake.rs b/crates/polyglot-sql/src/dialects/snowflake.rs index 8f8f971a..f0e9016d 100644 --- a/crates/polyglot-sql/src/dialects/snowflake.rs +++ b/crates/polyglot-sql/src/dialects/snowflake.rs @@ -200,9 +200,11 @@ impl DialectImpl for SnowflakeDialect { // GROUP_CONCAT -> LISTAGG in Snowflake Expression::GroupConcat(f) => Ok(Expression::ListAgg(Box::new(ListAggFunc { + source_dialect: None, this: f.this, separator: f.separator, on_overflow: None, + max_length: None, order_by: f.order_by, distinct: f.distinct, filter: f.filter, diff --git a/crates/polyglot-sql/src/dialects/vertica.rs b/crates/polyglot-sql/src/dialects/vertica.rs new file mode 100644 index 00000000..a2de0c56 --- /dev/null +++ b/crates/polyglot-sql/src/dialects/vertica.rs @@ -0,0 +1,319 @@ +//! Vertica Dialect +//! +//! Vertica (OpenText Analytics Database) is a columnar MPP analytic database whose +//! SQL surface is largely PostgreSQL-compatible. +//! Reference: https://docs.vertica.com/latest/en/sql-reference/ +//! Semantics cross-checked against https://github.com/luisdelatorre012/vertica-sqlglot-dialect +//! +//! Key characteristics: +//! - Double-quote identifiers, case-insensitive (folded to lowercase) +//! - `::` casts, ILIKE, `||` concatenation, LIMIT/OFFSET +//! - MINUS is an alias for EXCEPT +//! - All integer types are 64-bit (INT, SMALLINT, TINYINT are BIGINT) +//! - REAL / FLOAT are DOUBLE PRECISION +//! - LONG VARCHAR / LONG VARBINARY types +//! - NVL, NVL2, DECODE, ZEROIFNULL, LISTAGG, DATEDIFF(unit, a, b), TIMESTAMPADD +//! - GETDATE() / SYSDATE are statement-start timestamps +//! - No QUALIFY, TRY_CAST or semi/anti join syntax +//! - No nested comments + +use super::{DialectImpl, DialectType}; +#[cfg(feature = "transpile")] +use crate::error::Result; +#[cfg(feature = "transpile")] +use crate::expressions::{ + AggFunc, Case, Expression, Function, Identifier, IntervalUnit, ListAggFunc, Literal, VarArgFunc, +}; +#[cfg(feature = "generate")] +use crate::generator::GeneratorConfig; +use crate::tokens::TokenizerConfig; + +/// Vertica dialect +pub struct VerticaDialect; + +impl DialectImpl for VerticaDialect { + fn dialect_type(&self) -> DialectType { + DialectType::Vertica + } + + fn tokenizer_config(&self) -> TokenizerConfig { + use crate::tokens::TokenType; + let mut config = TokenizerConfig::default(); + // Vertica uses double quotes for identifiers (PostgreSQL-style) + config.identifiers.insert('"', '"'); + // Vertica does NOT support nested comments + config.nested_comments = false; + // `//` is integer division + config.double_slash_int_div = true; + // MINUS is an alias for EXCEPT in Vertica + config + .keywords + .insert("MINUS".to_string(), TokenType::Except); + config + } + + #[cfg(feature = "generate")] + fn generator_config(&self) -> GeneratorConfig { + use crate::generator::{IdentifierQuoteStyle, LimitFetchStyle}; + GeneratorConfig { + identifier_quote: '"', + identifier_quote_style: IdentifierQuoteStyle::DOUBLE_QUOTE, + dialect: Some(DialectType::Vertica), + single_string_interval: true, + locking_reads_supported: true, + limit_fetch_style: LimitFetchStyle::Limit, + nvl2_supported: true, + supports_median: true, + aggregate_filter_supported: false, + ..Default::default() + } + } + + #[cfg(feature = "transpile")] + fn transform_expr(&self, expr: Expression) -> Result { + match expr { + // IFNULL -> COALESCE in Vertica + Expression::IfNull(f) => Ok(Expression::Coalesce(Box::new(VarArgFunc { + original_name: None, + expressions: vec![f.this, f.expression], + inferred_type: None, + }))), + + // Coalesce with original_name (e.g., IFNULL parsed as Coalesce) -> clear original_name + Expression::Coalesce(mut f) => { + f.original_name = None; + Ok(Expression::Coalesce(f)) + } + + Expression::TryCast(_) | Expression::SafeCast(_) => { + Err(crate::error::Error::unsupported( + "safe casts: Vertica ::! does not suppress constant cast failures", + "vertica", + )) + } + + // CountIf -> SUM(CASE WHEN condition THEN 1 ELSE 0 END) + Expression::CountIf(f) => { + let case_expr = Expression::Case(Box::new(Case { + operand: None, + whens: vec![(f.this.clone(), Expression::number(1))], + else_: Some(Expression::number(0)), + comments: Vec::new(), + inferred_type: None, + })); + Ok(Expression::Sum(Box::new(AggFunc { + ignore_nulls: None, + having_max: None, + this: case_expr, + distinct: f.distinct, + filter: f.filter, + order_by: Vec::new(), + name: None, + limit: None, + inferred_type: None, + }))) + } + + // RAND -> RANDOM in Vertica + Expression::Rand(r) => { + let _ = r.seed; + Ok(Expression::Random(crate::expressions::Random)) + } + + // DAYOFWEEK_ISO has no shared generator form + Expression::DayOfWeekIso(f) => Ok(Expression::Function(Box::new(Function::new( + "DAYOFWEEK_ISO".to_string(), + vec![f.this], + )))), + + // DATE_ADD / DATEADD -> TIMESTAMPADD(unit, n, ts); Vertica has no DATEADD + Expression::DateAdd(f) => Ok(timestamp_add( + interval_unit_name(&f.unit), + f.interval, + f.this, + )), + + // APPROX_COUNT_DISTINCT -> APPROXIMATE_COUNT_DISTINCT + Expression::ApproxDistinct(mut f) | Expression::ApproxCountDistinct(mut f) => { + if !f.order_by.is_empty() + || f.limit.is_some() + || f.having_max.is_some() + || f.ignore_nulls.is_some() + { + return Err(crate::error::Error::unsupported( + "approximate distinct aggregate modifiers", + "vertica", + )); + } + if let Some(filter) = f.filter.take() { + f.this = Expression::Case(Box::new(Case { + operand: None, + whens: vec![(filter, f.this)], + else_: None, + comments: Vec::new(), + inferred_type: None, + })); + } + // DISTINCT is redundant for an approximate distinct count. + f.distinct = false; + f.name = None; + Ok(Expression::ApproxDistinct(f)) + } + + // GROUP_CONCAT / STRING_AGG -> LISTAGG + Expression::GroupConcat(f) => Ok(Expression::ListAgg(Box::new(ListAggFunc { + source_dialect: None, + this: f.this, + separator: f.separator, + on_overflow: None, + max_length: None, + order_by: f.order_by, + distinct: f.distinct, + filter: f.filter, + inferred_type: None, + }))), + Expression::StringAgg(f) => Ok(Expression::ListAgg(Box::new(ListAggFunc { + source_dialect: None, + this: f.this, + separator: f.separator, + on_overflow: None, + max_length: None, + order_by: f.order_by, + distinct: f.distinct, + filter: f.filter, + inferred_type: None, + }))), + + // Generic function transformations + Expression::Function(f) => self.transform_function(*f), + + // Pass through everything else + _ => Ok(expr), + } + } +} + +#[cfg(feature = "transpile")] +impl VerticaDialect { + fn transform_function(&self, f: Function) -> Result { + let name_upper = f.name.to_uppercase(); + match name_upper.as_str() { + // IFNULL / ISNULL -> COALESCE + "IFNULL" | "ISNULL" if f.args.len() == 2 => { + Ok(Expression::Coalesce(Box::new(VarArgFunc { + original_name: None, + expressions: f.args, + inferred_type: None, + }))) + } + + // SYSDATE is a synonym for GETDATE (statement-start timestamp) + "SYSDATE" if f.args.is_empty() => Ok(Expression::Function(Box::new(Function::new( + "GETDATE".to_string(), + vec![], + )))), + + // TIMESTAMPDIFF(unit, a, b) is a synonym for DATEDIFF(unit, a, b) + "TIMESTAMPDIFF" if f.args.len() == 3 => { + let mut args = f.args; + upper_unit(&mut args[0]); + Ok(Expression::Function(Box::new(Function::new( + "DATEDIFF".to_string(), + args, + )))) + } + + // APPROX_COUNT_DISTINCT -> APPROXIMATE_COUNT_DISTINCT + "APPROX_COUNT_DISTINCT" if !f.args.is_empty() => Ok(Expression::Function(Box::new( + Function::new("APPROXIMATE_COUNT_DISTINCT".to_string(), f.args), + ))), + + // DATEADD(unit, n, ts) -> TIMESTAMPADD(unit, n, ts) + "DATEADD" | "DATE_ADD" if f.args.len() == 3 => { + let mut args = f.args; + let ts = args.pop().unwrap(); + let n = args.pop().unwrap(); + let unit = args.pop().unwrap(); + let unit = match unit { + Expression::Literal(lit) => match *lit { + Literal::String(s) => { + Expression::Identifier(Identifier::new(s.to_ascii_uppercase())) + } + other => Expression::Literal(Box::new(other)), + }, + other => other, + }; + Ok(Expression::Function(Box::new(Function::new( + "TIMESTAMPADD".to_string(), + vec![unit, n, ts], + )))) + } + + // TIMESTAMPADD(unit, n, ts): normalize the unit keyword to upper case + "TIMESTAMPADD" if f.args.len() == 3 => { + let mut f = f; + upper_unit(&mut f.args[0]); + Ok(Expression::Function(Box::new(f))) + } + + // CHARINDEX(substr, str[, start]) -> INSTR(str, substr[, start]) + "CHARINDEX" if f.args.len() >= 2 => { + let mut args = f.args; + let substr = args.remove(0); + let string = args.remove(0); + let mut new_args = vec![string, substr]; + new_args.extend(args); + Ok(Expression::Function(Box::new(Function::new( + "INSTR".to_string(), + new_args, + )))) + } + + // LEN -> LENGTH + "LEN" if f.args.len() == 1 => Ok(Expression::Function(Box::new(Function::new( + "LENGTH".to_string(), + f.args, + )))), + + // Pass through everything else + _ => Ok(Expression::Function(Box::new(f))), + } + } +} + +#[cfg(feature = "transpile")] +fn interval_unit_name(unit: &IntervalUnit) -> &'static str { + match unit { + IntervalUnit::Year => "YEAR", + IntervalUnit::Quarter => "QUARTER", + IntervalUnit::Month => "MONTH", + IntervalUnit::Week => "WEEK", + IntervalUnit::Day => "DAY", + IntervalUnit::Hour => "HOUR", + IntervalUnit::Minute => "MINUTE", + IntervalUnit::Second => "SECOND", + IntervalUnit::Millisecond => "MILLISECOND", + IntervalUnit::Microsecond => "MICROSECOND", + IntervalUnit::Nanosecond => "NANOSECOND", + } +} + +#[cfg(feature = "transpile")] +fn timestamp_add(unit: &str, amount: Expression, ts: Expression) -> Expression { + Expression::Function(Box::new(Function::new( + "TIMESTAMPADD".to_string(), + vec![Expression::Identifier(Identifier::new(unit)), amount, ts], + ))) +} + +/// Upper-case a bare datetime unit keyword (`day` -> `DAY`), leaving expressions alone. +#[cfg(feature = "transpile")] +fn upper_unit(unit: &mut Expression) { + match unit { + Expression::Identifier(id) if !id.quoted => id.name = id.name.to_ascii_uppercase(), + Expression::Column(col) if col.table.is_none() && !col.name.quoted => { + *unit = Expression::Identifier(Identifier::new(col.name.name.to_ascii_uppercase())); + } + _ => {} + } +} diff --git a/crates/polyglot-sql/src/expressions.rs b/crates/polyglot-sql/src/expressions.rs index b3d00a9d..8a32b5fc 100644 --- a/crates/polyglot-sql/src/expressions.rs +++ b/crates/polyglot-sql/src/expressions.rs @@ -78,6 +78,8 @@ fn is_true(v: &bool) -> bool { #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "bindings", ts(export))] pub enum Expression { + /// Structured Vertica syntax whose semantics are not shared by other dialects. + Vertica(Box), // Literals Literal(Box), Boolean(BooleanLiteral), @@ -1129,6 +1131,7 @@ impl Expression { match self { Self::Function(f) => f.source_dialect, Self::AggregateFunction(f) => f.source_dialect, + Self::ListAgg(f) => f.source_dialect, Self::Select(s) => s.source_dialect, Self::CreateTable(t) => t.source_dialect, Self::Upsert(u) => u.source_dialect, @@ -1886,6 +1889,7 @@ impl Expression { /// This is much faster than serializing to JSON and extracting the key. pub fn variant_name(&self) -> &'static str { match self { + Expression::Vertica(_) => "vertica", Expression::Literal(_) => "literal", Expression::Boolean(_) => "boolean", Expression::Null(_) => "null", @@ -3703,6 +3707,8 @@ pub struct Star { #[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "bindings", derive(TS))] pub struct Select { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vertica: Option>, /// The select-list: columns, expressions, aliases, and wildcards. pub expressions: Vec, /// The FROM clause, containing one or more table sources. @@ -3793,6 +3799,7 @@ pub struct Select { impl Select { pub fn new() -> Self { Self { + vertica: None, expressions: Vec::new(), from: None, joins: Vec::new(), @@ -5499,6 +5506,9 @@ pub struct OrderBy { #[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "bindings", derive(TS))] pub struct Ordered { + /// Vertica lets the engine select null placement for aggregate/window ordering. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub nulls_auto: bool, /// The expression to sort by. pub this: Expression, /// Whether the sort direction is descending (true) or ascending (false). @@ -5516,6 +5526,7 @@ pub struct Ordered { impl Ordered { pub fn asc(expr: Expression) -> Self { Self { + nulls_auto: false, this: expr, desc: false, nulls_first: None, @@ -5526,6 +5537,7 @@ impl Ordered { pub fn desc(expr: Expression) -> Self { Self { + nulls_auto: false, this: expr, desc: true, nulls_first: None, @@ -6186,6 +6198,9 @@ pub enum OracleTimestampTimeZone { #[cfg_attr(feature = "bindings", derive(TS))] #[serde(tag = "data_type", rename_all = "snake_case")] pub enum DataType { + Vertica { + vertica_type: Box, + }, // Numeric Boolean, TinyInt { @@ -7121,9 +7136,16 @@ pub struct StringAggFunc { #[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "bindings", derive(TS))] pub struct ListAggFunc { + /// Retain source byte-limit and overflow semantics through AST generation. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ast(skip)] + pub source_dialect: Option, pub this: Expression, pub separator: Option, pub on_overflow: Option, + /// Vertica `USING PARAMETERS max_length = n` + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_length: Option>, pub order_by: Option>, pub distinct: bool, pub filter: Option, @@ -15795,6 +15817,227 @@ pub struct NextValueFor { pub order: Option>, } +// Vertica dialect support. +/// Native Vertica syntax, retaining semantics through serialization and traversal. +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum VerticaExpression { + PhysicalTable { + this: Expression, + physical: VerticaPhysical, + }, + FlexTable { + this: Expression, + physical: VerticaPhysical, + }, + Projection { + name: TableRef, + columns: Vec, + query: Expression, + physical: VerticaPhysical, + }, + Copy { + table: TableRef, + columns: Vec, + local: bool, + sources: Vec, + parser: Option, + options: Vec, + }, + ExportParquet { + options: Vec, + over: Option, + query: Expression, + }, + Interpolate { + left: Expression, + right: Expression, + previous: bool, + }, + /// Zero-based array access retains source bounds semantics from parsing. + ArrayAccess { + this: Expression, + indices: Vec, + }, + ArraySlice { + this: Expression, + start: Option, + end: Option, + }, + BoundaryDateDiff { + start: Expression, + end: Expression, + unit: IntervalUnit, + }, + UsingParameters { + this: Expression, + parameters: Vec, + }, + SafeCast { + this: Expression, + to: DataType, + }, + Set { + values: Vec, + }, + Interval { + value: Expression, + precision: Option, + year_month: bool, + unit: Option, + end_unit: Option, + }, + /// Hexadecimal byte value, distinct from PostgreSQL's bit strings. + Binary { + hex: String, + }, + Historical { + query: Expression, + point: VerticaHistoricalPoint, + }, + PartitionBest, +} + +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +pub struct VerticaParameter { + pub name: Identifier, + pub value: Expression, +} + +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum VerticaHistoricalPoint { + Latest, + Epoch(Expression), + Time(Expression), +} + +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum VerticaDataType { + Array { + element_type: DataType, + bound: Option, + }, + Set { + element_type: DataType, + bound: Option, + }, + Row { + fields: Vec, + }, + LongVarBinary { + length: Option, + }, + Interval { + precision: Option, + year_month: bool, + unit: Option, + end_unit: Option, + }, +} + +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum VerticaCollectionBound { + Elements(u32), + Bytes(u32), +} + +#[derive( + polyglot_sql_ast_derive::AstNode, Debug, Clone, Default, PartialEq, Serialize, Deserialize, +)] +#[cfg_attr(feature = "bindings", derive(TS))] +pub struct VerticaSelectExtensions { + pub timeseries: Option, + pub match_clause: Option, + pub limit_over: Option, +} + +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +pub struct VerticaTimeseries { + pub alias: Identifier, + pub interval: Expression, + pub over: Over, +} + +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +pub struct VerticaMatch { + pub partition_by: Vec, + pub order_by: Vec, + pub definitions: Vec, + pub name: Identifier, + pub pattern: VerticaPattern, + /// None = default; true = FIRST EVENT; false = ALL EVENTS. + pub first_event: Option, +} + +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum VerticaPattern { + Event(Identifier), + Sequence(Vec), + Alternative(Vec), + Group(Box), + Repeat { + pattern: Box, + quantifier: String, + }, +} + +#[derive( + polyglot_sql_ast_derive::AstNode, Debug, Clone, Default, PartialEq, Serialize, Deserialize, +)] +#[cfg_attr(feature = "bindings", derive(TS))] +pub struct VerticaPhysical { + pub order_by: Vec, + pub segmentation: Option, + pub ksafe: Option, +} + +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +#[serde(tag = "kind", content = "value", rename_all = "snake_case")] +pub enum VerticaKsafe { + Default, + Level(u32), +} + +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum VerticaSegmentation { + Segmented { + expression: Expression, + offset: Option, + }, + Unsegmented { + node: Option, + }, +} + +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +pub struct VerticaProjectionColumn { + pub name: Identifier, + pub encoding: Option, +} + +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +pub struct VerticaParserCall { + pub name: TableRef, + pub parameters: Vec, +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/polyglot-sql/src/generator.rs b/crates/polyglot-sql/src/generator.rs index f16c5d85..2e778883 100644 --- a/crates/polyglot-sql/src/generator.rs +++ b/crates/polyglot-sql/src/generator.rs @@ -13,6 +13,7 @@ use std::borrow::Cow; use std::sync::Arc; +use crate::ast_transforms::{output_identifier, query_projections, AstNames}; use crate::error::Result; use crate::expressions::*; use crate::guard::{enforce_generate_ast, ComplexityGuardOptions}; @@ -2658,6 +2659,14 @@ impl Generator { } fn generate_expression_inner(&mut self, expr: &Expression) -> Result<()> { + if self.config.dialect == Some(DialectType::Vertica) + && matches!(expr, Expression::StringAgg(_) | Expression::GroupConcat(_)) + { + return Err(crate::error::Error::unsupported( + "string aggregation without Vertica's byte limit and overflow behavior", + "vertica", + )); + } if self.config.dialect == Some(DialectType::HANA) && matches!( expr, @@ -2717,6 +2726,7 @@ impl Generator { Expression::Insert(insert) => self.generate_insert(insert), Expression::Update(update) => self.generate_update(update), Expression::Delete(delete) => self.generate_delete(delete), + Expression::Vertica(node) => self.generate_vertica(node), Expression::Literal(lit) => self.generate_literal(lit), Expression::Boolean(b) => self.generate_boolean(b), Expression::Null(_) => { @@ -2901,6 +2911,7 @@ impl Generator { Expression::AnyValue(f) => self.generate_agg_func("ANY_VALUE", f), Expression::ApproxDistinct(f) => { match self.config.dialect { + Some(DialectType::Vertica) => self.generate_vertica_approximate_count(f), Some(DialectType::Hive) | Some(DialectType::Spark) | Some(DialectType::Databricks) @@ -2922,7 +2933,11 @@ impl Generator { } } Expression::ApproxCountDistinct(f) => { - self.generate_agg_func("APPROX_COUNT_DISTINCT", f) + if self.config.dialect == Some(DialectType::Vertica) { + self.generate_vertica_approximate_count(f) + } else { + self.generate_agg_func("APPROX_COUNT_DISTINCT", f) + } } Expression::ApproxPercentile(f) => self.generate_approx_percentile(f), Expression::Percentile(f) => self.generate_percentile("PERCENTILE", f), @@ -3437,8 +3452,11 @@ impl Generator { self.generate_expression(&f.expression)?; self.write(")"); Ok(()) - } else if matches!(self.config.dialect, Some(DialectType::DuckDB)) { - // DuckDB uses // operator for integer division + } else if matches!( + self.config.dialect, + Some(DialectType::DuckDB) | Some(DialectType::Vertica) + ) { + // DuckDB and Vertica use // operator for integer division self.generate_expression(&f.this)?; self.write(" // "); self.generate_expression(&f.expression)?; @@ -4793,6 +4811,7 @@ impl Generator { .alias(null_alias.clone()), ); outer_order_expressions.push(Ordered { + nulls_auto: false, this: Expression::column(null_alias), desc: ordered.nulls_first == Some(true), nulls_first: None, @@ -4807,6 +4826,7 @@ impl Generator { .expressions .push(sort_expression.alias(key_alias.clone())); outer_order_expressions.push(Ordered { + nulls_auto: false, this: Expression::column(key_alias), desc: ordered.desc, nulls_first: None, @@ -4869,9 +4889,159 @@ impl Generator { Ok(()) } + /// Sort outside DISTINCT/set operations so emulated NULL ordering may add CASE keys + /// without changing the result columns or the deduplication operation. + fn wrap_vertica_ordered_query(mut query: Expression) -> Result { + let unsupported = || { + crate::error::Error::unsupported("Vertica ordered DISTINCT/set operation requires unique named outputs and resolvable sort keys", "vertica") + }; + let projections = query_projections(&query).ok_or_else(unsupported)?.to_vec(); + let output_names = projections + .iter() + .map(|p| output_identifier(p).cloned().ok_or_else(unsupported)) + .collect::>>()?; + let mut unique = std::collections::HashSet::new(); + if output_names + .iter() + .any(|n| !unique.insert(n.name.to_ascii_lowercase())) + { + return Err(unsupported()); + } + let mut names = AstNames::default(); + names.collect(&query); + let table = names.fresh("_vertica_ordered"); + let mut outer = Select::new(); + macro_rules! take_set_modifiers { + ($set:expr) => {{ + if $set.by_name || $set.corresponding || $set.side.is_some() || $set.kind.is_some() + { + return Err(unsupported()); + } + outer.with = $set.with.take(); + outer.order_by = $set.order_by.take(); + outer.limit = $set.limit.take().map(|l| Limit { + this: *l, + percent: false, + comments: Vec::new(), + }); + outer.offset = $set.offset.take().map(|o| Offset { + this: *o, + rows: None, + }); + }}; + } + match &mut query { + Expression::Select(s) => { + if s.into.is_some() || !s.locks.is_empty() || s.vertica.is_some() { + return Err(unsupported()); + } + outer.with = s.with.take(); + outer.order_by = s.order_by.take(); + outer.limit = s.limit.take(); + outer.offset = s.offset.take(); + outer.fetch = s.fetch.take(); + outer.top = s.top.take(); + } + Expression::Union(s) => take_set_modifiers!(s), + Expression::Intersect(s) => take_set_modifiers!(s), + Expression::Except(s) => take_set_modifiers!(s), + Expression::Subquery(s) => { + outer.order_by = s.order_by.take(); + outer.limit = s.limit.take(); + outer.offset = s.offset.take(); + query = std::mem::replace(&mut s.this, Expression::Null(Null)); + } + _ => return Err(unsupported()), + } + for ordered in &mut outer.order_by.as_mut().ok_or_else(unsupported)?.expressions { + let index = match &ordered.this { + Expression::Literal(l) => match l.as_ref() { + Literal::Number(n) => n + .parse::() + .ok() + .and_then(|i| i.checked_sub(1)) + .filter(|i| *i < output_names.len()), + _ => None, + }, + Expression::Column(c) if c.table.is_none() => output_names + .iter() + .position(|n| n.name.eq_ignore_ascii_case(&c.name.name)), + _ => None, + } + .or_else(|| { + projections.iter().position(|p| { + let value = if let Expression::Alias(a) = p { + &a.this + } else { + p + }; + value == &ordered.this + }) + }) + .ok_or_else(unsupported)?; + ordered.this = crate::ast_mutation::qualified_column(&table, &output_names[index]); + } + outer.expressions = output_names + .iter() + .map(|name| { + Expression::Alias(Box::new(Alias::new( + crate::ast_mutation::qualified_column(&table, name), + name.clone(), + ))) + }) + .collect(); + outer.from = Some(From { + expressions: vec![crate::ast_mutation::derived_table( + query, + Some(Identifier::new(table)), + )], + }); + Ok(Expression::Select(Box::new(outer))) + } + + /// Keep the enclosing subquery's alias/column aliases while moving its ordering + /// into an ordinary SELECT that can emulate Vertica's top-level NULL placement. + fn wrap_vertica_subquery_order(mut query: Subquery) -> Result { + if query.distribute_by.is_some() || query.sort_by.is_some() || query.cluster_by.is_some() { + return Err(crate::error::Error::unsupported( + "ordered subquery modifiers", + "vertica", + )); + } + let mut ordered = crate::ast_mutation::derived_table( + std::mem::replace(&mut query.this, Expression::Null(Null)), + None, + ); + let Expression::Subquery(inner) = &mut ordered else { + unreachable!() + }; + inner.order_by = query.order_by.take(); + inner.limit = query.limit.take(); + inner.offset = query.offset.take(); + query.this = Self::wrap_vertica_ordered_query(ordered)?; + Ok(query) + } + fn generate_select(&mut self, select: &Select) -> Result<()> { Self::validate_hana_select(select, self.config.dialect.unwrap_or_default())?; + if self.config.dialect == Some(DialectType::Vertica) + && select.distinct + && select + .order_by + .as_ref() + .is_some_and(|o| o.expressions.iter().any(|o| o.nulls_first.is_some())) + { + let wrapped = + Self::wrap_vertica_ordered_query(Expression::Select(Box::new(select.clone())))?; + return self.generate_expression(&wrapped); + } + if select.vertica.is_some() && self.config.dialect != Some(DialectType::Vertica) { + return Err(crate::error::Error::unsupported( + "Vertica query extension", + format!("{:?}", self.config.dialect), + )); + } use crate::dialects::DialectType; if let Some(resolved_select) = self.resolve_tsql_null_ordering_for_select(select) { @@ -4938,6 +5108,7 @@ impl Generator { }); let outer_select = Select { + vertica: None, expressions: vec![star], from: Some(crate::expressions::From { expressions: vec![Expression::Subquery(Box::new(subquery))], @@ -5292,6 +5463,10 @@ impl Generator { self.generate_connect(connect)?; } + if let Some(clause) = select.vertica.as_ref().and_then(|v| v.timeseries.as_ref()) { + self.generate_vertica_timeseries(clause)?; + } + // GROUP BY if let Some(group_by) = &select.group_by { if self.config.pretty { @@ -5477,6 +5652,14 @@ impl Generator { } } + if let Some(clause) = select + .vertica + .as_ref() + .and_then(|v| v.match_clause.as_ref()) + { + self.generate_vertica_match(clause)?; + } + // DISTRIBUTE BY (Hive/Spark) if let Some(distribute_by) = &select.distribute_by { self.write_clause_expressions("DISTRIBUTE BY", &distribute_by.expressions)?; @@ -5512,7 +5695,11 @@ impl Generator { } else { "ORDER BY" }; - self.write_order_clause(keyword, &order_by.expressions)?; + if self.config.dialect == Some(DialectType::Vertica) { + self.vertica_top_order(&order_by.expressions, &select.expressions)?; + } else { + self.write_order_clause(keyword, &order_by.expressions)?; + } } // TSQL: FETCH requires ORDER BY. If there's a FETCH but no ORDER BY, add ORDER BY (SELECT NULL) OFFSET 0 ROWS @@ -5629,6 +5816,12 @@ impl Generator { } } + if let Some(over) = select.vertica.as_ref().and_then(|v| v.limit_over.as_ref()) { + self.write(" OVER ("); + self.generate_over(over)?; + self.write(")"); + } + // Convert TOP to LIMIT for non-TOP dialects if select.top.is_some() && !is_top_dialect && select.limit.is_none() { if let Some(top) = &select.top { @@ -7364,6 +7557,7 @@ impl Generator { }; let mut outer_select = Select { + vertica: None, expressions: vec![Expression::Star(Star { table: None, except: None, @@ -7398,12 +7592,14 @@ impl Generator { pub(crate) fn dummy_tsql_order_by() -> OrderBy { let null_select = Expression::Select(Box::new(Select { + vertica: None, expressions: vec![Expression::Null(Null)], ..Select::new() })); OrderBy { expressions: vec![Ordered { + nulls_auto: false, this: Expression::Subquery(Box::new(Subquery { this: null_select, alias: None, @@ -7432,6 +7628,16 @@ impl Generator { } fn generate_union(&mut self, outermost: &Union) -> Result<()> { + if self.config.dialect == Some(DialectType::Vertica) + && outermost + .order_by + .as_ref() + .is_some_and(|o| o.expressions.iter().any(|o| o.nulls_first.is_some())) + { + let wrapped = + Self::wrap_vertica_ordered_query(Expression::Union(Box::new(outermost.clone())))?; + return self.generate_expression(&wrapped); + } if self.should_wrap_set_operation_modifiers( &outermost.order_by, &outermost.limit, @@ -7611,6 +7817,17 @@ impl Generator { } fn generate_intersect(&mut self, outermost: &Intersect) -> Result<()> { + if self.config.dialect == Some(DialectType::Vertica) + && outermost + .order_by + .as_ref() + .is_some_and(|o| o.expressions.iter().any(|o| o.nulls_first.is_some())) + { + let wrapped = Self::wrap_vertica_ordered_query(Expression::Intersect(Box::new( + outermost.clone(), + )))?; + return self.generate_expression(&wrapped); + } if self.should_wrap_set_operation_modifiers( &outermost.order_by, &outermost.limit, @@ -7785,6 +8002,16 @@ impl Generator { } fn generate_except(&mut self, outermost: &Except) -> Result<()> { + if self.config.dialect == Some(DialectType::Vertica) + && outermost + .order_by + .as_ref() + .is_some_and(|o| o.expressions.iter().any(|o| o.nulls_first.is_some())) + { + let wrapped = + Self::wrap_vertica_ordered_query(Expression::Except(Box::new(outermost.clone())))?; + return self.generate_expression(&wrapped); + } if self.should_wrap_set_operation_modifiers( &outermost.order_by, &outermost.limit, @@ -10190,7 +10417,13 @@ impl Generator { ConstraintType::Encode => { if let Some(ref encoding) = col.encoding { self.write_space(); - self.write_keyword("ENCODE"); + self.write_keyword( + if self.config.dialect == Some(DialectType::Vertica) { + "ENCODING" + } else { + "ENCODE" + }, + ); self.write_space(); self.write(encoding); } @@ -10416,7 +10649,11 @@ impl Generator { // Redshift: ENCODE encoding_type (legacy path) if let Some(ref encoding) = col.encoding { self.write_space(); - self.write_keyword("ENCODE"); + self.write_keyword(if self.config.dialect == Some(DialectType::Vertica) { + "ENCODING" + } else { + "ENCODE" + }); self.write_space(); self.write(encoding); } @@ -17634,7 +17871,7 @@ impl Generator { None | // No dialect = preserve everything Some(DialectType::Oracle) | Some(DialectType::MySQL) | Some(DialectType::Spark) | Some(DialectType::Hive) | - Some(DialectType::Databricks) | Some(DialectType::PostgreSQL) + Some(DialectType::Databricks) | Some(DialectType::PostgreSQL) | Some(DialectType::Vertica) ); if !supports_hints || hint.expressions.is_empty() { @@ -18400,8 +18637,81 @@ impl Generator { Ok(()) } + pub(crate) fn validate_vertica_function( + function: &Function, + source: Option, + ) -> Result<()> { + if matches!(source, Some(DialectType::MySQL | DialectType::TiDB)) + && !function.quoted + && function.qualified_name.is_empty() + && function.name.eq_ignore_ascii_case("TIMESTAMPDIFF") + { + // MySQL counts complete elapsed units, whereas Vertica's + // identically named function counts calendar boundaries. + return Err(crate::error::Error::unsupported( + "MySQL TIMESTAMPDIFF elapsed-unit semantics have no verified Vertica mapping", + "vertica", + )); + } + Ok(()) + } + + /// Storage types may widen, but an explicit cast must retain its source range + /// and precision. Native Vertica aliases are already BIGINT/DOUBLE in the AST. + pub(crate) fn validate_vertica_cast_type(data_type: &DataType) -> Result<()> { + match data_type { + DataType::TinyInt { .. } + | DataType::SmallInt { .. } + | DataType::Int { .. } + | DataType::Float { .. } => { + return Err(crate::error::Error::unsupported( + "Narrow numeric CAST to Vertica requires a verified source conversion; widening changes precision or overflow behavior", + "vertica", + )); + } + DataType::Array { element_type, .. } + | DataType::List { element_type } + | DataType::Nullable { + inner: element_type, + } + | DataType::Vector { + element_type: Some(element_type), + .. + } => { + Self::validate_vertica_cast_type(element_type)?; + } + DataType::Map { + key_type, + value_type, + } => { + Self::validate_vertica_cast_type(key_type)?; + Self::validate_vertica_cast_type(value_type)?; + } + DataType::Struct { fields, .. } => { + for field in fields { + Self::validate_vertica_cast_type(&field.data_type)?; + } + } + DataType::Union { fields } => { + for (_, data_type) in fields { + Self::validate_vertica_cast_type(data_type)?; + } + } + DataType::Object { fields, .. } => { + for (_, data_type, _) in fields { + Self::validate_vertica_cast_type(data_type)?; + } + } + _ => {} + } + Ok(()) + } + fn generate_cast(&mut self, cast: &Cast) -> Result<()> { self.validate_cast_to_hana(cast)?; + if self.config.dialect == Some(DialectType::Vertica) { + Self::validate_vertica_cast_type(&cast.to)?; + } if self.config.dialect != Some(DialectType::HANA) { if let DataType::Hana { hana_type } = &cast.to { if matches!( @@ -19904,6 +20214,12 @@ impl Generator { } fn generate_function(&mut self, func: &Function) -> Result<()> { + if self.config.dialect == Some(DialectType::Vertica) { + Self::validate_vertica_function( + func, + func.source_dialect.or(self.config.source_dialect), + )?; + } if func.source_dialect == Some(DialectType::HANA) && self.config.dialect != Some(DialectType::HANA) { @@ -19971,6 +20287,31 @@ impl Generator { func.name ))); } + // Vertica spells factorial as the postfix `!` operator + if self.config.dialect == Some(DialectType::Vertica) + && func.name.eq_ignore_ascii_case("FACTORIAL") + && func.args.len() == 1 + && !func.quoted + { + let operand = &func.args[0]; + let atomic = matches!( + operand, + Expression::Literal(_) + | Expression::Column(_) + | Expression::Identifier(_) + | Expression::Paren(_) + ); + if !atomic { + self.write("("); + } + self.generate_expression(operand)?; + if !atomic { + self.write(")"); + } + self.write("!"); + return Ok(()); + } + // Normalize function name based on dialect settings let normalized_name = if func.name.eq_ignore_ascii_case("GROUPING") && func.args.len() > 1 @@ -20851,13 +21192,7 @@ impl Generator { } if let Some(filter) = &func.filter { - self.write_space(); - self.write_keyword("FILTER"); - self.write("("); - self.write_keyword("WHERE"); - self.write_space(); - self.generate_expression(filter)?; - self.write(")"); + self.generate_aggregate_filter_clause(filter)?; } Ok(()) @@ -20959,7 +21294,7 @@ impl Generator { if has_content { self.write_space(); } - self.write_keyword("PARTITION BY"); + self.write_keyword(if over.partition_by.len() == 1 && matches!(&over.partition_by[0], Expression::Vertica(v) if matches!(v.as_ref(), VerticaExpression::PartitionBest)) { "PARTITION" } else { "PARTITION BY" }); self.write_space(); for (i, expr) in over.partition_by.iter().enumerate() { if i > 0 { @@ -22371,6 +22706,12 @@ impl Generator { self.write("()"); return Ok(()); } + Some(DialectType::Vertica) => { + // Vertica SYSDATE is a synonym for GETDATE() + self.write_keyword("GETDATE"); + self.write("()"); + return Ok(()); + } _ => { // Other dialects use CURRENT_TIMESTAMP for SYSDATE } @@ -22681,8 +23022,11 @@ impl Generator { fn generate_if_func(&mut self, f: &IfFunc) -> Result<()> { use crate::dialects::DialectType; - // Generic mode: normalize IF to CASE WHEN - if self.config.dialect.is_none() || self.config.dialect == Some(DialectType::Generic) { + // Generic mode and dialects without an IF function: normalize IF to CASE WHEN + if matches!( + self.config.dialect, + None | Some(DialectType::Generic) | Some(DialectType::Vertica) + ) { self.write_keyword("CASE WHEN"); self.write_space(); self.generate_expression(&f.condition)?; @@ -22756,7 +23100,51 @@ impl Generator { // Typed aggregate function generators + fn generate_aggregate_filter_clause(&mut self, filter: &Expression) -> Result<()> { + // Vertica filters must be lowered by a verified aggregate mapping before + // reaching this shared renderer. Never emit unsupported FILTER syntax. + if self.config.dialect == Some(DialectType::Vertica) { + return Err(crate::error::Error::unsupported( + "aggregate FILTER without a verified CASE rewrite", + "vertica", + )); + } + self.write_space(); + self.write_keyword("FILTER"); + self.write("("); + self.write_keyword("WHERE"); + self.write_space(); + self.generate_expression(filter)?; + self.write(")"); + Ok(()) + } + + fn generate_filtered_argument( + &mut self, + filter: &Expression, + argument: Option<&Expression>, + ) -> Result<()> { + self.write_keyword("CASE WHEN"); + self.write_space(); + self.generate_expression(filter)?; + self.write_space(); + self.write_keyword("THEN"); + self.write_space(); + if let Some(argument) = argument { + self.generate_expression(argument)?; + } else { + self.write("1"); + } + self.write_space(); + self.write_keyword("END"); + Ok(()) + } + fn generate_count(&mut self, f: &CountFunc) -> Result<()> { + let case_filter = f + .filter + .as_ref() + .filter(|_| self.config.dialect == Some(DialectType::Vertica)); // Use normalize_functions for COUNT to respect ClickHouse case preservation let count_name = match self.config.normalize_functions { NormalizeFunctions::Upper => "COUNT".to_string(), @@ -22772,7 +23160,15 @@ impl Generator { self.write_keyword("DISTINCT"); self.write_space(); } - if f.star { + if let Some(filter) = case_filter { + if matches!(&f.this, Some(Expression::Tuple(_))) { + return Err(crate::error::Error::unsupported( + "multi-argument COUNT FILTER", + "vertica", + )); + } + self.generate_filtered_argument(filter, if f.star { None } else { f.this.as_ref() })?; + } else if f.star { self.write("*"); } else if let Some(ref expr) = f.this { // For COUNT(DISTINCT a, b), unwrap the Tuple to avoid extra parentheses @@ -22837,19 +23233,29 @@ impl Generator { self.write_keyword("RESPECT NULLS"); } } - if let Some(ref filter) = f.filter { - self.write_space(); - self.write_keyword("FILTER"); - self.write("("); - self.write_keyword("WHERE"); - self.write_space(); - self.generate_expression(filter)?; - self.write(")"); + if let Some(ref filter) = f.filter.as_ref().filter(|_| case_filter.is_none()) { + self.generate_aggregate_filter_clause(filter)?; } Ok(()) } fn generate_agg_func(&mut self, name: &str, f: &AggFunc) -> Result<()> { + let case_filter = f + .filter + .as_ref() + .filter(|_| self.config.dialect == Some(DialectType::Vertica)); + if case_filter.is_some() + && (!matches!(name, "SUM" | "AVG" | "MIN" | "MAX") + || f.ignore_nulls.is_some() + || f.having_max.is_some() + || !f.order_by.is_empty() + || f.limit.is_some()) + { + return Err(crate::error::Error::unsupported( + "aggregate FILTER without a verified CASE rewrite", + "vertica", + )); + } // Apply function name normalization based on config let func_name: Cow<'_, str> = match self.config.normalize_functions { NormalizeFunctions::Upper => Cow::Owned(name.to_ascii_uppercase()), @@ -22874,7 +23280,9 @@ impl Generator { // Other aggregates may legitimately receive NULL as an explicit argument. let is_zero_arg_mode = name.eq_ignore_ascii_case("MODE") && matches!(f.this, Expression::Null(_)); - if !is_zero_arg_mode { + if let Some(filter) = case_filter { + self.generate_filtered_argument(filter, Some(&f.this))?; + } else if !is_zero_arg_mode { self.generate_expression(&f.this)?; } // Generate IGNORE NULLS / RESPECT NULLS inside parens if config says so (BigQuery style) @@ -22956,14 +23364,8 @@ impl Generator { None => {} } } - if let Some(ref filter) = f.filter { - self.write_space(); - self.write_keyword("FILTER"); - self.write("("); - self.write_keyword("WHERE"); - self.write_space(); - self.generate_expression(filter)?; - self.write(")"); + if let Some(ref filter) = f.filter.as_ref().filter(|_| case_filter.is_none()) { + self.generate_aggregate_filter_clause(filter)?; } Ok(()) } @@ -23045,13 +23447,7 @@ impl Generator { } self.write(")"); if let Some(ref filter) = f.filter { - self.write_space(); - self.write_keyword("FILTER"); - self.write("("); - self.write_keyword("WHERE"); - self.write_space(); - self.generate_expression(filter)?; - self.write(")"); + self.generate_aggregate_filter_clause(filter)?; } Ok(()) } @@ -23110,13 +23506,7 @@ impl Generator { } } if let Some(ref filter) = f.filter { - self.write_space(); - self.write_keyword("FILTER"); - self.write("("); - self.write_keyword("WHERE"); - self.write_space(); - self.generate_expression(filter)?; - self.write(")"); + self.generate_aggregate_filter_clause(filter)?; } Ok(()) } @@ -23171,8 +23561,53 @@ impl Generator { ) } + /// Vertica passes LISTAGG options as `USING PARAMETERS name = value, ...`. + fn generate_vertica_listagg_parameters(&mut self, f: &ListAggFunc) -> Result<()> { + let mut params: Vec<(&str, Option<&Expression>, Option<&str>)> = Vec::new(); + if let Some(ref sep) = f.separator { + params.push(("separator", Some(sep), None)); + } + if let Some(ref max_length) = f.max_length { + params.push(("max_length", Some(max_length), None)); + } + match f.on_overflow { + Some(ListAggOverflow::Error) => params.push(("on_overflow", None, Some("'ERROR'"))), + Some(ListAggOverflow::Truncate { .. }) => { + params.push(("on_overflow", None, Some("'TRUNCATE'"))) + } + None => {} + } + if params.is_empty() { + return Ok(()); + } + self.write_space(); + self.write_keyword("USING PARAMETERS"); + self.write_space(); + for (i, (name, value, literal)) in params.into_iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.write(name); + self.write(" = "); + match (value, literal) { + (Some(value), _) => self.generate_expression(value)?, + (None, Some(literal)) => self.write(literal), + (None, None) => {} + } + } + Ok(()) + } + fn generate_listagg(&mut self, f: &ListAggFunc) -> Result<()> { use crate::dialects::DialectType; + if (f.source_dialect == Some(DialectType::Vertica)) + != (self.config.dialect == Some(DialectType::Vertica)) + { + return Err(crate::error::Error::unsupported( + "Vertica LISTAGG byte limit and overflow behavior", + self.config.dialect.unwrap_or_default().to_string(), + )); + } let order_inside_args = matches!(self.config.dialect, Some(DialectType::DuckDB)); self.write_keyword("LISTAGG"); self.write("("); @@ -23181,7 +23616,15 @@ impl Generator { self.write_space(); } self.generate_expression(&f.this)?; - if let Some(ref sep) = f.separator { + if f.max_length.is_some() && self.config.dialect != Some(DialectType::Vertica) { + return Err(crate::error::Error::unsupported( + "Vertica LISTAGG byte limit", + format!("{:?}", self.config.dialect), + )); + } + if self.config.dialect == Some(DialectType::Vertica) { + self.generate_vertica_listagg_parameters(f)?; + } else if let Some(ref sep) = f.separator { self.write(", "); self.generate_expression(sep)?; } else if matches!( @@ -23191,7 +23634,11 @@ impl Generator { // Trino/Presto require explicit separator; default to ',' self.write(", ','"); } - if let Some(ref overflow) = f.on_overflow { + if let Some(ref overflow) = f + .on_overflow + .as_ref() + .filter(|_| self.config.dialect != Some(DialectType::Vertica)) + { self.write_space(); self.write_keyword("ON OVERFLOW"); self.write_space(); @@ -23244,13 +23691,7 @@ impl Generator { } } if let Some(ref filter) = f.filter { - self.write_space(); - self.write_keyword("FILTER"); - self.write("("); - self.write_keyword("WHERE"); - self.write_space(); - self.generate_expression(filter)?; - self.write(")"); + self.generate_aggregate_filter_clause(filter)?; } Ok(()) } @@ -23263,13 +23704,7 @@ impl Generator { self.generate_expression(&f.condition)?; self.write(")"); if let Some(ref filter) = f.filter { - self.write_space(); - self.write_keyword("FILTER"); - self.write("("); - self.write_keyword("WHERE"); - self.write_space(); - self.generate_expression(filter)?; - self.write(")"); + self.generate_aggregate_filter_clause(filter)?; } Ok(()) } @@ -23286,13 +23721,7 @@ impl Generator { } self.write(")"); if let Some(ref filter) = f.filter { - self.write_space(); - self.write_keyword("FILTER"); - self.write("("); - self.write_keyword("WHERE"); - self.write_space(); - self.generate_expression(filter)?; - self.write(")"); + self.generate_aggregate_filter_clause(filter)?; } Ok(()) } @@ -23318,13 +23747,7 @@ impl Generator { self.write(")"); } if let Some(ref filter) = f.filter { - self.write_space(); - self.write_keyword("FILTER"); - self.write("("); - self.write_keyword("WHERE"); - self.write_space(); - self.generate_expression(filter)?; - self.write(")"); + self.generate_aggregate_filter_clause(filter)?; } Ok(()) } @@ -24727,13 +25150,7 @@ impl Generator { } self.write(")"); if let Some(ref filter) = f.filter { - self.write_space(); - self.write_keyword("FILTER"); - self.write("("); - self.write_keyword("WHERE"); - self.write_space(); - self.generate_expression(filter)?; - self.write(")"); + self.generate_aggregate_filter_clause(filter)?; } Ok(()) } @@ -24753,13 +25170,7 @@ impl Generator { } self.write(")"); if let Some(ref filter) = f.filter { - self.write_space(); - self.write_keyword("FILTER"); - self.write("("); - self.write_keyword("WHERE"); - self.write_space(); - self.generate_expression(filter)?; - self.write(")"); + self.generate_aggregate_filter_clause(filter)?; } Ok(()) } @@ -25093,6 +25504,12 @@ impl Generator { fn generate_try_cast(&mut self, cast: &Cast) -> Result<()> { self.validate_cast_to_hana(cast)?; + if self.config.dialect == Some(DialectType::Vertica) { + return Err(crate::error::Error::unsupported( + "safe casts: Vertica ::! does not suppress constant cast failures", + "vertica", + )); + } use crate::dialects::DialectType; // SingleStore uses !:> syntax for try cast @@ -25156,6 +25573,12 @@ impl Generator { fn generate_safe_cast(&mut self, cast: &Cast) -> Result<()> { self.validate_cast_to_hana(cast)?; + if self.config.dialect == Some(DialectType::Vertica) { + return Err(crate::error::Error::unsupported( + "safe casts: Vertica ::! does not suppress constant cast failures", + "vertica", + )); + } self.write_keyword("SAFE_CAST"); self.write("("); self.generate_expression(&cast.this)?; @@ -25179,6 +25602,12 @@ impl Generator { // Array/struct/map access generators fn generate_subscript(&mut self, s: &Subscript) -> Result<()> { + if self.config.dialect == Some(DialectType::Vertica) { + return Err(crate::error::Error::unsupported( + "array index and bound semantics when targeting Vertica", + "vertica", + )); + } // Wrap the base expression in parentheses when it uses arrow syntax (->) // which has lower precedence than bracket subscript ([]). // E.g., (t.v -> '$.a')[s.x] instead of t.v -> '$.a'[s.x] @@ -25236,6 +25665,12 @@ impl Generator { } fn generate_array_slice(&mut self, s: &ArraySlice) -> Result<()> { + if self.config.dialect == Some(DialectType::Vertica) { + return Err(crate::error::Error::unsupported( + "array index and bound semantics when targeting Vertica", + "vertica", + )); + } // Check if we need to wrap the inner expression in parentheses // JSON arrow expressions have lower precedence than array subscript let needs_parens = matches!( @@ -26127,6 +26562,15 @@ impl Generator { } fn generate_subquery(&mut self, subquery: &Subquery) -> Result<()> { + if self.config.dialect == Some(DialectType::Vertica) + && subquery + .order_by + .as_ref() + .is_some_and(|o| o.expressions.iter().any(|o| o.nulls_first.is_some())) + { + let wrapped = Self::wrap_vertica_subquery_order(subquery.clone())?; + return self.generate_subquery(&wrapped); + } if subquery.lateral { self.write_keyword("LATERAL"); self.write_space(); @@ -26792,6 +27236,15 @@ impl Generator { self.write_space(); self.generate_with_fill(with_fill)?; } + if ordered.nulls_auto { + if self.config.dialect != Some(DialectType::Vertica) || ordered.nulls_first.is_some() { + return Err(crate::error::Error::unsupported( + "Vertica NULLS AUTO", + format!("{:?}", self.config.dialect), + )); + } + self.write(" NULLS AUTO"); + } Ok(()) } @@ -27343,6 +27796,47 @@ impl Generator { Ok(()) } + /// Vertica type spellings that differ from the shared defaults. + /// + /// Every Vertica integer is 64-bit and every float is an 8-byte double, so + /// narrower types widen instead of being silently truncated. Returns false + /// when the shared generator should handle the type. + fn generate_vertica_data_type(&mut self, dt: &DataType) -> bool { + match dt { + DataType::TinyInt { .. } + | DataType::SmallInt { .. } + | DataType::Int { .. } + | DataType::BigInt { .. } => self.write_keyword("BIGINT"), + DataType::Float { .. } | DataType::Double { .. } => { + self.write_keyword("DOUBLE PRECISION") + } + DataType::Text => self.write_keyword("LONG VARCHAR"), + DataType::String { length: None } => self.write_keyword("VARCHAR"), + DataType::TextWithLength { length } => { + self.write_keyword("LONG VARCHAR"); + self.write(&format!("({})", length)); + } + DataType::String { + length: Some(length), + } => { + self.write_keyword("VARCHAR"); + self.write(&format!("({})", length)); + } + DataType::Blob => self.write_keyword("LONG VARBINARY"), + DataType::Time { + precision, + timezone: true, + } => { + self.write_keyword("TIMETZ"); + if let Some(p) = precision { + self.write(&format!("({})", p)); + } + } + _ => return false, + } + true + } + fn generate_data_type(&mut self, dt: &DataType) -> Result<()> { if self.config.dialect != Some(DialectType::HANA) { Self::validate_hana_data_type(dt, self.config.dialect.unwrap_or_default())?; @@ -27354,6 +27848,11 @@ impl Generator { fn generate_data_type_inner(&mut self, dt: &DataType) -> Result<()> { use crate::dialects::DialectType; + if self.config.dialect == Some(DialectType::Vertica) && self.generate_vertica_data_type(dt) + { + return Ok(()); + } + match dt { DataType::Boolean => { // Dialect-specific boolean type mappings @@ -27579,6 +28078,51 @@ impl Generator { } } DataType::Decimal { precision, scale } => { + // Vertica's omitted scale is 15, unlike the standard zero used + // by PostgreSQL and DuckDB. Native Vertica types already carry + // their defaults in the AST; make foreign defaults explicit for + // casts, column definitions, and nested types alike. + let (precision, scale) = if self.config.dialect == Some(DialectType::Vertica) { + match (*precision, *scale) { + (Some(p), s) => (Some(p), Some(s.unwrap_or(0))), + (None, None) if self.config.source_dialect == Some(DialectType::DuckDB) => { + (Some(18), Some(3)) + } + _ => { + return Err(crate::error::Error::unsupported( + "DECIMAL without explicit precision has no verified Vertica mapping; unconstrained NUMERIC cannot be represented by a fixed precision and scale", + "vertica", + )); + } + } + } else { + (*precision, *scale) + }; + + // Do not silently narrow a source decimal or emit a declaration + // rejected by the target. This also protects direct generation. + let max_precision = match self.config.dialect { + Some(DialectType::DuckDB) => Some(38), + Some(DialectType::PostgreSQL) => Some(1000), + Some(DialectType::Vertica) => Some(1024), + _ => None, + }; + if let (Some(p), Some(max)) = (precision, max_precision) { + if p == 0 || p > max { + return Err(crate::error::Error::unsupported( + format!("DECIMAL precision {p} exceeds the target range 1..={max}"), + self.config.dialect.unwrap_or_default().to_string(), + )); + } + if self.config.dialect == Some(DialectType::Vertica) + && scale.is_some_and(|s| s > p) + { + return Err(crate::error::Error::unsupported( + format!("DECIMAL scale exceeds the target precision {p}"), + "vertica", + )); + } + } // Dialect-specific decimal type mappings match self.config.dialect { Some(DialectType::ClickHouse) => { @@ -27626,6 +28170,7 @@ impl Generator { } } DataType::Hana { hana_type } => self.generate_hana_data_type(hana_type)?, + DataType::Vertica { vertica_type } => self.generate_vertica_type(vertica_type)?, DataType::Oracle { oracle_type } => { self.write_oracle_data_type(oracle_type)?; } @@ -32785,7 +33330,11 @@ impl Generator { fn generate_encode(&mut self, e: &Encode) -> Result<()> { // ENCODE(string, charset) - self.write_keyword("ENCODE"); + self.write_keyword(if self.config.dialect == Some(DialectType::Vertica) { + "ENCODING" + } else { + "ENCODE" + }); self.write("("); self.generate_expression(&e.this)?; if let Some(charset) = &e.charset { @@ -32801,7 +33350,11 @@ impl Generator { if e.key.is_some() { self.write_keyword("KEY "); } - self.write_keyword("ENCODE"); + self.write_keyword(if self.config.dialect == Some(DialectType::Vertica) { + "ENCODING" + } else { + "ENCODE" + }); self.write_space(); self.generate_expression(&e.this)?; if !e.properties.is_empty() { @@ -33134,6 +33687,12 @@ impl Generator { fn generate_filter(&mut self, e: &Filter) -> Result<()> { // agg_func FILTER(WHERE condition) + if self.config.dialect == Some(DialectType::Vertica) { + return Err(crate::error::Error::unsupported( + "aggregate FILTER without a verified CASE rewrite", + "vertica", + )); + } self.generate_expression(&e.this)?; self.write_space(); self.write_keyword("FILTER"); @@ -39176,6 +39735,7 @@ impl Generator { | Some(DialectType::Oracle) | Some(DialectType::BigQuery) | Some(DialectType::Teradata) + | Some(DialectType::Vertica) ) { self.write_keyword("INSTR"); self.write("("); @@ -43328,6 +43888,873 @@ impl Default for Generator { } } +// Vertica dialect support. +impl Generator { + fn generate_vertica_approximate_count(&mut self, aggregate: &AggFunc) -> Result<()> { + if !aggregate.order_by.is_empty() + || aggregate.limit.is_some() + || aggregate.having_max.is_some() + || aggregate.ignore_nulls.is_some() + { + return Err(crate::error::Error::unsupported( + "approximate distinct aggregate modifiers", + "vertica", + )); + } + let mut aggregate = aggregate.clone(); + if let Some(filter) = aggregate.filter.take() { + aggregate.this = Expression::Case(Box::new(Case { + operand: None, + whens: vec![(filter, aggregate.this)], + else_: None, + comments: Vec::new(), + inferred_type: None, + })); + } + aggregate.distinct = false; + aggregate.name = None; + self.generate_agg_func("APPROXIMATE_COUNT_DISTINCT", &aggregate) + } + + fn vertica_top_order( + &mut self, + expressions: &[Ordered], + projections: &[Expression], + ) -> Result<()> { + let mut order = Vec::new(); + for expression in expressions { + if expression.nulls_auto { + return Err(crate::error::Error::unsupported( + "top-level NULLS AUTO", + "vertica", + )); + } + if let Some(first) = expression.nulls_first { + let mut key = &expression.this; + if let Expression::Literal(lit) = key { + if let Literal::Number(n) = lit.as_ref() { + key = n + .parse::() + .ok() + .and_then(|n| n.checked_sub(1)) + .and_then(|n| projections.get(n)) + .ok_or_else(|| { + crate::error::Error::unsupported( + "unresolved ORDER BY ordinal", + "vertica", + ) + })?; + } + } + if let Expression::Column(column) = key { + if column.table.is_none() { + if let Some(Expression::Alias(alias)) = projections.iter().find(|e| matches!(e, Expression::Alias(a) if a.alias.name.eq_ignore_ascii_case(&column.name.name))) { key = &alias.this; } + } + } + if let Expression::Alias(alias) = key { + key = &alias.this; + } + fn repeatable(key: &Expression) -> bool { + match key { + Expression::Column(_) + | Expression::Literal(_) + | Expression::Null(_) + | Expression::Boolean(_) => true, + Expression::Cast(c) => repeatable(&c.this), + _ => false, + } + } + if !repeatable(key) { + return Err(crate::error::Error::unsupported( + "explicit top-level null ordering of a potentially volatile expression", + "vertica", + )); + } + let null_test = Expression::IsNull(Box::new(IsNull { + this: key.clone(), + not: false, + postfix_form: false, + })); + let case = Expression::Case(Box::new(Case { + operand: None, + whens: vec![(null_test, Expression::number(if first { 0 } else { 1 }))], + else_: Some(Expression::number(if first { 1 } else { 0 })), + comments: Vec::new(), + inferred_type: None, + })); + order.push(Ordered::asc(case)); + } + let mut expression = expression.clone(); + expression.nulls_first = None; + order.push(expression); + } + self.write_order_clause("ORDER BY", &order) + } + + fn generate_vertica_parameters(&mut self, parameters: &[VerticaParameter]) -> Result<()> { + for (i, parameter) in parameters.iter().enumerate() { + if i != 0 { + self.write(", "); + } + self.generate_identifier(¶meter.name)?; + self.write(" = "); + self.generate_expression(¶meter.value)?; + } + Ok(()) + } + + fn generate_vertica_physical(&mut self, physical: &VerticaPhysical) -> Result<()> { + if !physical.order_by.is_empty() { + self.write(" ORDER BY "); + for (i, ordered) in physical.order_by.iter().enumerate() { + if i != 0 { + self.write(", "); + } + self.generate_ordered(ordered)?; + } + } + if let Some(segmentation) = &physical.segmentation { + match segmentation { + VerticaSegmentation::Segmented { expression, offset } => { + self.write(" SEGMENTED BY "); + self.generate_expression(expression)?; + self.write(" ALL NODES"); + if let Some(offset) = offset { + self.write(&format!(" OFFSET {offset}")); + } + } + VerticaSegmentation::Unsegmented { node } => { + if let Some(node) = node { + self.write(" UNSEGMENTED NODE "); + self.generate_identifier(node)?; + } else { + self.write(" UNSEGMENTED ALL NODES"); + } + } + } + } + if let Some(ksafe) = &physical.ksafe { + self.write(" KSAFE"); + if let VerticaKsafe::Level(value) = ksafe { + self.write(&format!(" {value}")); + } + } + Ok(()) + } + + fn generate_vertica_timeseries(&mut self, clause: &VerticaTimeseries) -> Result<()> { + self.write(" TIMESERIES "); + self.generate_identifier(&clause.alias)?; + self.write(" AS "); + self.generate_expression(&clause.interval)?; + self.write(" OVER ("); + self.generate_over(&clause.over)?; + self.write(")"); + Ok(()) + } + + fn generate_vertica_match(&mut self, clause: &VerticaMatch) -> Result<()> { + self.write(" MATCH ("); + if !clause.partition_by.is_empty() { + self.write("PARTITION BY "); + for (i, value) in clause.partition_by.iter().enumerate() { + if i != 0 { + self.write(", "); + } + self.generate_expression(value)?; + } + self.write(" "); + } + self.write("ORDER BY "); + for (i, value) in clause.order_by.iter().enumerate() { + if i != 0 { + self.write(", "); + } + self.generate_ordered(value)?; + } + self.write(" DEFINE "); + for (i, event) in clause.definitions.iter().enumerate() { + if i != 0 { + self.write(", "); + } + self.generate_identifier(&event.name)?; + self.write(" AS "); + self.generate_expression(&event.value)?; + } + self.write(" PATTERN "); + self.generate_identifier(&clause.name)?; + self.write(" AS ("); + self.generate_vertica_pattern(&clause.pattern)?; + self.write(")"); + if let Some(first) = clause.first_event { + self.write(if first { + " ROWS MATCH FIRST EVENT" + } else { + " ROWS MATCH ALL EVENTS" + }); + } + self.write(")"); + Ok(()) + } + + fn generate_vertica_pattern(&mut self, pattern: &VerticaPattern) -> Result<()> { + match pattern { + VerticaPattern::Event(name) => self.generate_identifier(name)?, + VerticaPattern::Sequence(items) | VerticaPattern::Alternative(items) => { + let separator = if matches!(pattern, VerticaPattern::Sequence(_)) { + " " + } else { + " | " + }; + for (i, item) in items.iter().enumerate() { + if i != 0 { + self.write(separator); + } + self.generate_vertica_pattern(item)?; + } + } + VerticaPattern::Group(value) => { + self.write("("); + self.generate_vertica_pattern(value)?; + self.write(")"); + } + VerticaPattern::Repeat { + pattern, + quantifier, + } => { + if !matches!( + quantifier.as_str(), + "*" | "+" | "?" | "*?" | "+?" | "??" | "*+" | "++" | "?+" + ) { + return Err(crate::error::Error::generate("Invalid MATCH quantifier")); + } + self.generate_vertica_pattern(pattern)?; + self.write(quantifier); + } + } + Ok(()) + } + + fn repeatable_vertica_index(index: &Expression) -> bool { + match index { + Expression::Column(_) + | Expression::Identifier(_) + | Expression::Literal(_) + | Expression::Null(_) => true, + Expression::Paren(p) => Self::repeatable_vertica_index(&p.this), + Expression::Neg(n) => Self::repeatable_vertica_index(&n.this), + Expression::Cast(c) => Self::repeatable_vertica_index(&c.this), + Expression::Add(b) | Expression::Sub(b) | Expression::Mul(b) | Expression::Mod(b) => { + Self::repeatable_vertica_index(&b.left) && Self::repeatable_vertica_index(&b.right) + } + _ => false, + } + } + + fn adjust_vertica_index(index: Expression) -> Expression { + // PostgreSQL subscripts are signed int32. Fold constants so ordinary array + // access retains the target's native vectorized plan, without a CASE or join. + if let Expression::Literal(lit) = &index { + if let Literal::Number(n) = lit.as_ref() { + if let Ok(n) = n.parse::() { + return if (0..i32::MAX as i128).contains(&n) { + Expression::number((n + 1) as i64) + } else { + Expression::Null(Null) + }; + } + } + } + if matches!(index, Expression::Null(_)) { + return index; + } + if let Expression::Neg(n) = &index { + if matches!(&n.this, Expression::Literal(l) if matches!(l.as_ref(), Literal::Number(v) if v.parse::().is_ok_and(|v| v > 0))) + { + return Expression::Null(Null); + } + } + Expression::Case(Box::new(Case { + operand: None, + whens: vec![( + Expression::Or(Box::new(BinaryOp::new( + Expression::Lt(Box::new(BinaryOp::new( + index.clone(), + Expression::number(0), + ))), + Expression::Gte(Box::new(BinaryOp::new( + index.clone(), + Expression::number(i64::from(i32::MAX)), + ))), + ))), + Expression::Null(Null), + )], + else_: Some(Expression::Add(Box::new(BinaryOp::new( + index, + Expression::number(1), + )))), + comments: Vec::new(), + inferred_type: None, + })) + } + + /// Evaluate a non-repeatable index once, without a correlated subquery (which + /// can evaluate volatile calls once per distinct outer value rather than per row). + fn adjust_vertica_index_once(index: Expression) -> Expression { + let cast = |this, to| { + Expression::Cast(Box::new(Cast { + this, + to, + trailing_comments: Vec::new(), + double_colon_syntax: false, + format: None, + default: None, + inferred_type: None, + })) + }; + let call = |name: &str, args| { + Expression::Function(Box::new(Function::new(name.to_string(), args))) + }; + // Clamp before adding one, so int64 extremes cannot overflow. Map both + // sentinels to zero (out of bounds in a normal one-based target array) + // before converting to PostgreSQL's int32. All arithmetic stays in int64. + // Do not use NULLIF: DuckDB expands it to a CASE that repeats its first arg. + let wide = cast(index, DataType::BigInt { length: None }); + let upper = i64::from(i32::MAX) + 1; + let bounded = call( + "LEAST", + vec![ + call("GREATEST", vec![wide, Expression::number(-1)]), + Expression::number(i64::from(i32::MAX)), + ], + ); + let offset = Expression::Add(Box::new(BinaryOp::new(bounded, Expression::number(1)))); + let adjusted = Expression::Mod(Box::new(BinaryOp::new(offset, Expression::number(upper)))); + cast( + adjusted, + DataType::Int { + length: None, + integer_spelling: false, + }, + ) + } + + /// Lower only the index so generation can borrow the base, including nested + /// array accesses, without repeatedly cloning an entire expression subtree. + fn lower_vertica_array_index(index: Expression) -> Expression { + if Self::repeatable_vertica_index(&index) { + Self::adjust_vertica_index(index) + } else { + Self::adjust_vertica_index_once(index) + } + } + + fn generate_vertica(&mut self, node: &VerticaExpression) -> Result<()> { + use VerticaExpression as V; + if matches!( + self.config.dialect, + Some(DialectType::PostgreSQL | DialectType::DuckDB) + ) { + match node { + V::ArrayAccess { this, indices } => { + let parens = !matches!( + this, + Expression::Column(_) + | Expression::Identifier(_) + | Expression::Paren(_) + | Expression::Subscript(_) + ) && !matches!(this, Expression::Vertica(v) if matches!(v.as_ref(), V::ArrayAccess { .. } | V::ArraySlice { .. })); + if parens { + self.write("("); + } + self.generate_expression(this)?; + if parens { + self.write(")"); + } + for index in indices { + self.write("["); + self.generate_expression(&Self::lower_vertica_array_index(index.clone()))?; + self.write("]"); + } + return Ok(()); + } + V::ArraySlice { this, start, end } => { + let bound = |e: &Expression| match e { + Expression::Literal(l) => match l.as_ref() { + Literal::Number(n) => n.parse::().ok(), + _ => None, + }, + _ => None, + }; + if start.as_ref().is_some_and(|s| bound(s).is_none()) + || end.as_ref().is_some_and(|e| bound(e).is_none()) + { + return Err(crate::error::Error::unsupported( + "Vertica array slices with dynamic or negative bounds", + format!("{:?}", self.config.dialect), + )); + } + self.write("("); + self.generate_expression(this)?; + self.write(")["); + let first = start + .as_ref() + .and_then(bound) + .unwrap_or(0) + .checked_add(1) + .ok_or_else(|| { + crate::error::Error::generate("Array slice bound overflow") + })?; + self.write(&first.to_string()); + self.write(":"); + if let Some(end) = end { + self.generate_expression(end)?; + } + self.write("]"); + return Ok(()); + } + V::BoundaryDateDiff { start, end, unit } => { + self.generate_vertica_boundary_diff(start, end, unit)?; + return Ok(()); + } + _ => {} + } + } + if let V::Binary { hex } = node { + match self.config.dialect { + Some(DialectType::PostgreSQL) => { + self.write("DECODE('"); + self.write(hex); + self.write("', 'hex')"); + return Ok(()); + } + Some(DialectType::DuckDB) => { + self.write("UNHEX('"); + self.write(hex); + self.write("')"); + return Ok(()); + } + _ => {} + } + } + if self.config.dialect != Some(DialectType::Vertica) { + return Err(crate::error::Error::unsupported( + "native Vertica expression", + format!("{:?}", self.config.dialect), + )); + } + match node { + V::PhysicalTable { this, physical } => { + self.generate_expression(this)?; + self.generate_vertica_physical(physical)?; + } + V::FlexTable { this, physical } => { + let sql = self.generate_to_string(this)?; + let Some(rest) = sql.strip_prefix("CREATE TABLE ") else { + return Err(crate::error::Error::generate( + "Invalid FLEX TABLE definition", + )); + }; + self.write("CREATE FLEX TABLE "); + self.write(rest); + if matches!(this, Expression::CreateTable(table) if table.columns.is_empty() && table.as_select.is_none()) + { + self.write("()"); + } + self.generate_vertica_physical(physical)?; + } + V::Projection { + name, + columns, + query, + physical, + } => { + self.write("CREATE PROJECTION "); + self.generate_table(name)?; + if !columns.is_empty() { + self.write(" ("); + for (i, column) in columns.iter().enumerate() { + if i != 0 { + self.write(", "); + } + self.generate_identifier(&column.name)?; + if let Some(encoding) = &column.encoding { + self.write(" ENCODING "); + self.generate_identifier(encoding)?; + } + } + self.write(")"); + } + self.write(" AS "); + self.generate_expression(query)?; + self.generate_vertica_physical(physical)?; + } + V::Copy { + table, + columns, + local, + sources, + parser, + options, + } => { + self.write("COPY "); + self.generate_table(table)?; + if !columns.is_empty() { + self.write(" ("); + for (i, column) in columns.iter().enumerate() { + if i != 0 { + self.write(", "); + } + self.generate_identifier(column)?; + } + self.write(")"); + } + self.write(if *local { " FROM LOCAL " } else { " FROM " }); + for (i, source) in sources.iter().enumerate() { + if i != 0 { + self.write(", "); + } + self.generate_expression(source)?; + } + if let Some(parser) = parser { + self.write(" PARSER "); + self.generate_table(&parser.name)?; + self.write("("); + self.generate_vertica_parameters(&parser.parameters)?; + self.write(")"); + } + for option in options { + if !matches!( + option.name.name.as_str(), + "DELIMITER" | "NULL" | "ESCAPE" | "SKIP" | "REJECTMAX" + ) { + return Err(crate::error::Error::generate("Invalid COPY option")); + } + self.write(" "); + self.write(&option.name.name); + self.write(" "); + self.generate_expression(&option.value)?; + } + } + V::ExportParquet { + options, + over, + query, + } => { + self.write("EXPORT TO PARQUET ("); + self.generate_vertica_parameters(options)?; + self.write(")"); + if let Some(over) = over { + self.write(" OVER ("); + self.generate_over(over)?; + self.write(")"); + } + self.write(" AS "); + self.generate_expression(query)?; + } + V::Interpolate { + left, + right, + previous, + } => { + self.generate_expression(left)?; + self.write(if *previous { + " INTERPOLATE PREVIOUS VALUE " + } else { + " INTERPOLATE NEXT VALUE " + }); + self.generate_expression(right)?; + } + V::ArrayAccess { this, indices } => { + self.generate_expression(this)?; + for index in indices { + self.write("["); + self.generate_expression(index)?; + self.write("]"); + } + } + V::ArraySlice { this, start, end } => { + self.generate_expression(this)?; + self.write("["); + if let Some(start) = start { + self.generate_expression(start)?; + } + self.write(":"); + if let Some(end) = end { + self.generate_expression(end)?; + } + self.write("]"); + } + V::BoundaryDateDiff { .. } => { + return Err(crate::error::Error::unsupported( + "lowered Vertica semantics", + "vertica", + )) + } + V::UsingParameters { this, parameters } => { + let sql = self.generate_to_string(this)?; + let Some(call) = sql.strip_suffix(')') else { + return Err(crate::error::Error::generate( + "USING PARAMETERS requires a function call", + )); + }; + self.write(call); + if !parameters.is_empty() { + self.write(" USING PARAMETERS "); + } + for (i, param) in parameters.iter().enumerate() { + if i != 0 { + self.write(", "); + } + self.generate_identifier(¶m.name)?; + self.write(" = "); + self.generate_expression(¶m.value)?; + } + self.write(")"); + } + V::SafeCast { this, to } => { + let parens = !matches!( + this, + Expression::Column(_) + | Expression::Identifier(_) + | Expression::Literal(_) + | Expression::Paren(_) + ); + if parens { + self.write("("); + } + self.generate_expression(this)?; + if parens { + self.write(")"); + } + self.write("::!"); + self.generate_data_type(to)?; + } + V::Set { values } => { + self.write("SET["); + for (i, value) in values.iter().enumerate() { + if i != 0 { + self.write(", "); + } + self.generate_expression(value)?; + } + self.write("]"); + } + V::Interval { + value, + precision, + year_month, + unit, + end_unit, + } => { + self.write(if *year_month { + "INTERVALYM" + } else { + "INTERVAL" + }); + if let Some(p) = precision { + self.write(&format!("({p})")); + } + self.write(" "); + self.generate_expression(value)?; + self.generate_vertica_interval_qualifiers(unit, end_unit); + } + V::Binary { hex } => { + self.write("X'"); + self.write(hex); + self.write("'"); + } + V::Historical { query, point } => { + match point { + VerticaHistoricalPoint::Latest => self.write("AT EPOCH LATEST "), + VerticaHistoricalPoint::Epoch(value) => { + self.write("AT EPOCH "); + self.generate_expression(value)?; + self.write(" "); + } + VerticaHistoricalPoint::Time(value) => { + self.write("AT TIME "); + self.generate_expression(value)?; + self.write(" "); + } + } + self.generate_expression(query)?; + } + V::PartitionBest => self.write("BEST"), + } + Ok(()) + } + + fn generate_vertica_boundary_diff( + &mut self, + start: &Expression, + end: &Expression, + unit: &IntervalUnit, + ) -> Result<()> { + if self.config.dialect == Some(DialectType::DuckDB) && *unit != IntervalUnit::Week { + let unit = match unit { + IntervalUnit::Year => "year", + IntervalUnit::Quarter => "quarter", + IntervalUnit::Month => "month", + IntervalUnit::Day => "day", + IntervalUnit::Hour => "hour", + IntervalUnit::Minute => "minute", + IntervalUnit::Second => "second", + IntervalUnit::Millisecond => "millisecond", + IntervalUnit::Microsecond => "microsecond", + _ => { + return Err(crate::error::Error::unsupported( + "Vertica DATEDIFF unit", + "duckdb", + )) + } + }; + self.write("DATE_DIFF('"); + self.write(unit); + self.write("', "); + self.generate_expression(start)?; + self.write(", "); + self.generate_expression(end)?; + self.write(")"); + return Ok(()); + } + // Bind operands once: calendar formulas can refer to both month and year. + let ordinal = |value: &str| -> Result { + Ok(match unit { + IntervalUnit::Year => format!("EXTRACT(YEAR FROM {value})"), + IntervalUnit::Quarter => format!( + "EXTRACT(YEAR FROM {value}) * 4 + FLOOR((EXTRACT(MONTH FROM {value}) - 1) / 3)" + ), + IntervalUnit::Month => { + format!("EXTRACT(YEAR FROM {value}) * 12 + EXTRACT(MONTH FROM {value})") + } + IntervalUnit::Week => { + return Err(crate::error::Error::unsupported( + "Vertica DATEDIFF week cutoff requires engine verification", + "target", + )) + } + IntervalUnit::Day => { + format!("EXTRACT(EPOCH FROM DATE_TRUNC('day', {value})) / 86400") + } + IntervalUnit::Hour => { + format!("EXTRACT(EPOCH FROM DATE_TRUNC('hour', {value})) / 3600") + } + IntervalUnit::Minute => { + format!("EXTRACT(EPOCH FROM DATE_TRUNC('minute', {value})) / 60") + } + IntervalUnit::Second => { + format!("EXTRACT(EPOCH FROM DATE_TRUNC('second', {value}))") + } + IntervalUnit::Millisecond => { + format!("EXTRACT(EPOCH FROM DATE_TRUNC('milliseconds', {value})) * 1000") + } + IntervalUnit::Microsecond => format!("EXTRACT(EPOCH FROM {value}) * 1000000"), + _ => { + return Err(crate::error::Error::unsupported( + "Vertica DATEDIFF unit", + "target", + )) + } + }) + }; + let first = ordinal("_polyglot_d.start_value")?; + let last = ordinal("_polyglot_d.end_value")?; + self.write(&format!( + "(SELECT CAST(({last}) - ({first}) AS BIGINT) FROM (SELECT " + )); + self.generate_expression(start)?; + self.write(" AS start_value, "); + self.generate_expression(end)?; + self.write(" AS end_value) AS _polyglot_d)"); + Ok(()) + } + + fn generate_vertica_interval_qualifiers( + &mut self, + unit: &Option, + end: &Option, + ) { + if let Some(unit) = unit { + self.write(" "); + self.write(unit); + } + if let Some(end) = end { + self.write(" TO "); + self.write(end); + } + } + + fn generate_vertica_type(&mut self, data_type: &VerticaDataType) -> Result<()> { + use VerticaDataType as V; + if self.config.dialect != Some(DialectType::Vertica) { + return Err(crate::error::Error::unsupported( + "Vertica datatype constraints", + format!("{:?}", self.config.dialect), + )); + } + match data_type { + V::Array { + element_type, + bound, + } + | V::Set { + element_type, + bound, + } => { + self.write(if matches!(data_type, V::Array { .. }) { + "ARRAY[" + } else { + "SET[" + }); + self.generate_data_type(element_type)?; + if let Some(VerticaCollectionBound::Elements(n)) = bound { + self.write(&format!(", {n}")); + } + self.write("]"); + if let Some(VerticaCollectionBound::Bytes(n)) = bound { + self.write(&format!("({n})")); + } + } + V::Row { fields } => { + self.write("ROW("); + for (i, field) in fields.iter().enumerate() { + if i != 0 { + self.write(", "); + } + self.generate_type_field_name(&field.name); + self.write(" "); + self.generate_data_type(&field.data_type)?; + } + self.write(")"); + } + V::LongVarBinary { length } => { + self.write("LONG VARBINARY"); + if let Some(n) = length { + self.write(&format!("({n})")); + } + } + V::Interval { + precision, + year_month, + unit, + end_unit, + } => { + self.write(if *year_month { + "INTERVALYM" + } else { + "INTERVAL" + }); + if let Some(p) = precision { + self.write(&format!("({p})")); + } + self.generate_vertica_interval_qualifiers(unit, end_unit); + } + } + Ok(()) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/polyglot-sql/src/optimizer/set_operation_types.rs b/crates/polyglot-sql/src/optimizer/set_operation_types.rs index fd1a3ad1..97f95cd3 100644 --- a/crates/polyglot-sql/src/optimizer/set_operation_types.rs +++ b/crates/polyglot-sql/src/optimizer/set_operation_types.rs @@ -328,7 +328,7 @@ fn family(dialect: DialectType) -> Family { DataFusion => Family::Arrow, Doris | StarRocks => Family::Standard, Drill | Dremio => Family::Limited, - Exasol => Family::Standard, + Exasol | Vertica => Family::Standard, HANA => Family::Limited, Druid | Solr | Tableau => Family::Limited, } @@ -681,6 +681,7 @@ fn decimal_common(l: &DataType, r: &DataType, dialect: DialectType) -> Option 65, DialectType::Exasol => 36, + DialectType::Vertica => 1024, DialectType::ClickHouse => 76, _ => 38, }; diff --git a/crates/polyglot-sql/src/parser.rs b/crates/polyglot-sql/src/parser.rs index c0baeebf..ad7f6076 100644 --- a/crates/polyglot-sql/src/parser.rs +++ b/crates/polyglot-sql/src/parser.rs @@ -1220,6 +1220,15 @@ impl Parser { return Err(self.parse_error("Unexpected end of input")); } + if self.is_vertica() && self.peek_text().eq_ignore_ascii_case("AT") { + return self.parse_vertica_historical(); + } + if self.is_vertica() { + if let Some(statement) = self.parse_vertica_statement()? { + return Ok(statement); + } + } + if self.should_preserve_clickhouse_with_expression_probe(self.current) { return self.fallback_to_command(self.current); } @@ -2729,6 +2738,8 @@ impl Parser { None }; + let timeseries = self.parse_vertica_timeseries()?; + // Parse CONNECT BY clause (Oracle hierarchical queries) let connect = self.parse_connect()?; @@ -2794,6 +2805,8 @@ impl Parser { None }; + let match_clause = self.parse_vertica_match()?; + // Parse QUALIFY clause (Snowflake, BigQuery, DuckDB) // QUALIFY can appear before or after WINDOW clause let mut qualify = if self.match_token(TokenType::Qualify) { @@ -2962,6 +2975,13 @@ impl Parser { (None, None) }; + let limit_over = + if self.is_vertica() && limit.is_some() && self.match_token(TokenType::Over) { + Some(self.parse_over_clause()?) + } else { + None + }; + // WITH TIES after LIMIT (ClickHouse, DuckDB) if limit.is_some() { let _ = self.match_keywords(&[TokenType::With, TokenType::Ties]); @@ -3092,7 +3112,25 @@ impl Parser { into, } = *head; + if self.is_vertica() + && match_clause.is_some() + && (distinct || group_by.is_some() || having.is_some()) + { + return Err( + self.parse_error("MATCH cannot be combined with DISTINCT, GROUP BY or HAVING") + ); + } + let select = Select { + vertica: if timeseries.is_some() || match_clause.is_some() || limit_over.is_some() { + Some(Box::new(VerticaSelectExtensions { + timeseries, + match_clause, + limit_over, + })) + } else { + None + }, expressions, from, joins, @@ -4013,10 +4051,27 @@ impl Parser { })) } } else { + let column_aliases = if self.is_vertica() + && (matches!(expr, Expression::Struct(_)) + || matches!(&expr, Expression::Function(f) if f.name.eq_ignore_ascii_case("ROW"))) + && self.match_token(TokenType::LParen) + { + let mut columns = Vec::new(); + loop { + columns.push(self.expect_identifier_or_keyword_with_quoted()?); + if !self.match_token(TokenType::Comma) { + break; + } + } + self.expect(TokenType::RParen)?; + columns + } else { + Vec::new() + }; Expression::Alias(Box::new(Alias { this: expr, alias, - column_aliases: Vec::new(), + column_aliases, alias_explicit_as: true, alias_keyword, pre_alias_comments, @@ -4309,6 +4364,7 @@ impl Parser { query_hints: Vec::new(), result_serialization: None, + vertica: None, expressions, from: Some(from), joins, @@ -8829,13 +8885,17 @@ impl Parser { let expr = self.maybe_parse_clickhouse_order_collate(expr)?; + let mut nulls_auto = false; let nulls_first = if self.match_token(TokenType::Nulls) { if self.match_token(TokenType::First) { Some(true) } else if self.match_token(TokenType::Last) { Some(false) + } else if self.is_vertica() && self.match_text_seq(&["AUTO"]) { + nulls_auto = true; + None } else { - return Err(self.parse_error("Expected FIRST or LAST after NULLS")); + return Err(self.parse_error("Expected FIRST, LAST or AUTO after NULLS")); } } else { None @@ -8927,6 +8987,7 @@ impl Parser { }; expressions.push(Ordered { + nulls_auto, this: expr, desc, nulls_first, @@ -9113,19 +9174,24 @@ impl Parser { (false, false) }; + let mut nulls_auto = false; let nulls_first = if self.match_token(TokenType::Nulls) { if self.match_token(TokenType::First) { Some(true) } else if self.match_token(TokenType::Last) { Some(false) + } else if self.is_vertica() && self.match_text_seq(&["AUTO"]) { + nulls_auto = true; + None } else { - return Err(self.parse_error("Expected FIRST or LAST after NULLS")); + return Err(self.parse_error("Expected FIRST, LAST or AUTO after NULLS")); } } else { None }; expressions.push(Ordered { + nulls_auto, this: expr, desc, nulls_first, @@ -9171,6 +9237,7 @@ impl Parser { }; expressions.push(Ordered { + nulls_auto: false, this: expr, desc, nulls_first: None, @@ -9201,19 +9268,24 @@ impl Parser { (false, false) }; + let mut nulls_auto = false; let nulls_first = if self.match_token(TokenType::Nulls) { if self.match_token(TokenType::First) { Some(true) } else if self.match_token(TokenType::Last) { Some(false) + } else if self.is_vertica() && self.match_text_seq(&["AUTO"]) { + nulls_auto = true; + None } else { - return Err(self.parse_error("Expected FIRST or LAST after NULLS")); + return Err(self.parse_error("Expected FIRST, LAST or AUTO after NULLS")); } } else { None }; expressions.push(Ordered { + nulls_auto, this: expr, desc, nulls_first, @@ -17654,7 +17726,9 @@ impl Parser { col_def.visible = Some(true); } else if self.match_identifier("INVISIBLE") { col_def.visible = Some(false); - } else if self.match_identifier("ENCODE") { + } else if self.match_identifier("ENCODE") + || (self.is_vertica() && self.match_text_seq(&["ENCODING"])) + { // Redshift: ENCODE encoding_type (e.g., ZSTD, DELTA, LZO, etc.) let encoding = self.expect_identifier_or_keyword()?; col_def.encoding = Some(encoding); @@ -30532,6 +30606,26 @@ impl Parser { // the comments are returned to the caller by being accessible via the // `comparison_pre_left_comments` field, so they can be placed appropriately // (e.g., after an alias name, or after the expression in an AND chain). + if self.is_vertica() && self.match_text_seq(&["INTERPOLATE"]) { + let previous = if self.match_text_seq(&["PREVIOUS"]) { + true + } else if self.match_text_seq(&["NEXT"]) { + false + } else { + return Err(self.parse_error("Expected PREVIOUS or NEXT after INTERPOLATE")); + }; + if !self.match_text_seq(&["VALUE"]) { + return Err(self.parse_error("Expected VALUE after INTERPOLATE direction")); + } + let right = self.parse_bitwise_or()?; + return Ok(Expression::Vertica(Box::new( + VerticaExpression::Interpolate { + left, + right, + previous, + }, + ))); + } let has_comparison_op = !self.is_at_end() && matches!( self.peek().token_type, @@ -32127,7 +32221,7 @@ impl Parser { // Create the Cast expression Ok(Some(Expression::Cast(Box::new(Cast { this: value, - to: data_type, + to: self.normalize_dialect_data_type(data_type), trailing_comments: Vec::new(), double_colon_syntax: false, format: None, @@ -32247,8 +32341,22 @@ impl Parser { /// Parse unary expressions fn parse_unary(&mut self) -> Result { let mut prefixes = Vec::new(); + let is_vertica = self.config.dialect == Some(crate::dialects::DialectType::Vertica); while !self.is_at_end() { let token = self.peek().token_type; + // Vertica: `@ x` is ABS(x) and `!! x` is the prefix factorial + if is_vertica + && (token == TokenType::DAt + || (token == TokenType::Exclamation && self.check_next(TokenType::Exclamation))) + { + let _scope = self.enter_parser_depth(prefixes.len() + 1)?; + if token == TokenType::Exclamation { + self.skip(); + } + prefixes.push(token); + self.skip(); + continue; + } if !matches!( token, TokenType::Plus @@ -32284,6 +32392,8 @@ impl Parser { TokenType::PipeSlash => { Expression::Sqrt(Box::new(UnaryFunc::with_name(expr, "|/".to_string()))) } + TokenType::DAt => Expression::Abs(Box::new(UnaryFunc::new(expr))), + TokenType::Exclamation => factorial(expr), _ => unreachable!("collected prefix operator"), }; } @@ -32453,6 +32563,14 @@ impl Parser { } } + // Vertica: postfix `x!` is factorial + if self.config.dialect == Some(crate::dialects::DialectType::Vertica) { + while self.check(TokenType::Exclamation) && !self.check_next(TokenType::Exclamation) { + self.skip(); + expr = factorial(expr); + } + } + // Handle EXCLAMATION for Snowflake model attribute syntax: model!PREDICT(...) while self.match_token(TokenType::Exclamation) { // Parse the attribute/function after the exclamation mark @@ -32484,6 +32602,14 @@ impl Parser { // IMPORTANT: Use parse_data_type_for_cast to avoid consuming subscripts as array dimensions // e.g., ::VARIANT[0] should be cast to VARIANT followed by subscript [0] while self.match_token(TokenType::DColon) { + if self.is_vertica() && self.match_token(TokenType::Exclamation) { + let to = self.parse_data_type_for_cast()?; + expr = Expression::Vertica(Box::new(VerticaExpression::SafeCast { + this: expr, + to, + })); + continue; + } let data_type = self.parse_data_type_for_cast()?; expr = Expression::Cast(Box::new(Cast { this: expr, @@ -32597,6 +32723,10 @@ impl Parser { #[inline(never)] fn parse_colon_json_path_inner(&mut self, mut this: Expression) -> Result { + // Vertica uses colons for array slices, not Snowflake variant paths. + if self.is_vertica() { + return Ok(this); + } // DuckDB uses colon for prefix alias syntax (e.g., "alias: expr" means "expr AS alias") // Skip JSON path extraction for DuckDB - it's handled separately in parse_select_expressions if matches!( @@ -34153,6 +34283,12 @@ impl Parser { return Err(self.end_of_input_error()); } + if self.is_vertica() { + if let Some(expression) = self.parse_vertica_primary()? { + return self.maybe_parse_subscript(expression); + } + } + // Exasol-style IF expression: IF condition THEN true_value ELSE false_value ENDIF // Check for IF not followed by ( (which would be IF function call handled elsewhere) // This handles: IF age < 18 THEN 'minor' ELSE 'adult' ENDIF @@ -38900,6 +39036,9 @@ impl Parser { { return self.parse_hana_typed_function(name, upper_name, quoted); } + if self.is_vertica() && !quoted && self.is_vertica_parameter_function(upper_name) { + return self.parse_vertica_parameter_function(name); + } // ClickHouse bitOr/bitAnd/bitXor are scalar functions, not aggregate-family calls. // Routing them directly to the generic parser avoids aggregate-specific recursion // and matches the syntax used throughout the ClickHouse parser corpus. @@ -39761,13 +39900,63 @@ impl Parser { // Check for optional DISTINCT let distinct = self.match_token(TokenType::Distinct); let this = self.parse_expression()?; - let separator = if self.match_token(TokenType::Comma) { + let mut separator = if self.match_token(TokenType::Comma) { Some(self.parse_expression()?) } else { None }; + let mut max_length = None; + let mut vertica_overflow = None; + // Vertica: LISTAGG(expr USING PARAMETERS separator = ',', max_length = n, + // on_overflow = 'ERROR' | 'TRUNCATE') + if self.config.dialect == Some(crate::dialects::DialectType::Vertica) + && self.check(TokenType::Using) + && self.check_next_identifier("PARAMETERS") + { + self.skip(); + self.skip(); + loop { + let name = self.expect_identifier_or_keyword()?; + self.expect(TokenType::Eq)?; + let value = self.parse_expression()?; + match name.to_ascii_lowercase().as_str() { + "separator" => separator = Some(value), + "max_length" => max_length = Some(Box::new(value)), + "on_overflow" => { + let mode = match &value { + Expression::Literal(lit) => match lit.as_ref() { + Literal::String(mode) => mode.to_ascii_uppercase(), + _ => String::new(), + }, + _ => String::new(), + }; + vertica_overflow = Some(match mode.as_str() { + "ERROR" => ListAggOverflow::Error, + "TRUNCATE" => ListAggOverflow::Truncate { + filler: None, + with_count: false, + }, + _ => { + return Err(self.parse_error( + "LISTAGG on_overflow must be 'ERROR' or 'TRUNCATE'", + )) + } + }); + } + other => { + return Err(self + .parse_error(format!("Unknown LISTAGG parameter: {}", other))) + } + } + if !self.match_token(TokenType::Comma) { + break; + } + } + } // Parse optional ON OVERFLOW clause - let on_overflow = if self.match_token(TokenType::On) { + let on_overflow = if vertica_overflow.is_some() { + vertica_overflow + } else if self.match_token(TokenType::On) { if self.match_identifier("OVERFLOW") { if self.match_identifier("ERROR") { Some(ListAggOverflow::Error) @@ -39801,9 +39990,13 @@ impl Parser { self.expect(TokenType::RParen)?; // WITHIN GROUP (ORDER BY ...) is handled by maybe_parse_over Ok(Expression::ListAgg(Box::new(ListAggFunc { + source_dialect: self + .is_vertica() + .then_some(crate::dialects::DialectType::Vertica), this, separator, on_overflow, + max_length, order_by: None, distinct, filter: None, @@ -41971,16 +42164,14 @@ impl Parser { expression: end.map(Box::new), step: step.map(Box::new), })); - expr = Expression::Subscript(Box::new(Subscript { - this: expr, - index: slice, - })); + if self.is_vertica() { + return Err( + self.parse_error("Vertica array slices do not support a step") + ); + } + expr = self.make_subscript(expr, slice); } else { - expr = Expression::ArraySlice(Box::new(ArraySlice { - this: expr, - start: None, - end, - })); + expr = self.make_array_slice(expr, None, end); } } else { let start = self.parse_slice_element()?; @@ -42001,23 +42192,21 @@ impl Parser { expression: end.map(Box::new), step: step.map(Box::new), })); - expr = Expression::Subscript(Box::new(Subscript { - this: expr, - index: slice, - })); + if self.is_vertica() { + return Err( + self.parse_error("Vertica array slices do not support a step") + ); + } + expr = self.make_subscript(expr, slice); } else { - expr = Expression::ArraySlice(Box::new(ArraySlice { - this: expr, - start, - end, - })); + expr = self.make_array_slice(expr, start, end); } } else { self.expect(TokenType::RBracket)?; // Simple subscript access - start must be Some let index = start.unwrap_or_else(|| Expression::Null(crate::expressions::Null)); - expr = Expression::Subscript(Box::new(Subscript { this: expr, index })); + expr = self.make_subscript(expr, index); } } } else if self.match_token(TokenType::DotColon) { @@ -42356,7 +42545,15 @@ impl Parser { } else { // PostgreSQL :: cast operator: expr::type self.skip(); // consume DColon - // Use parse_data_type_for_cast to avoid consuming subscripts as array dimensions + if self.is_vertica() && self.match_token(TokenType::Exclamation) { + let to = self.parse_data_type_for_cast()?; + expr = Expression::Vertica(Box::new(VerticaExpression::SafeCast { + this: expr, + to, + })); + continue; + } + // Use parse_data_type_for_cast to avoid consuming subscripts as array dimensions let data_type = self.parse_data_type_for_cast()?; expr = Expression::Cast(Box::new(Cast { this: expr, @@ -42560,7 +42757,11 @@ impl Parser { }; // Parse PARTITION BY or DISTRIBUTE BY (Hive uses DISTRIBUTE BY in window specs) - let partition_by = if self.match_keywords(&[TokenType::Partition, TokenType::By]) { + let partition_by = if self.is_vertica() && self.match_text_seq(&["PARTITION", "BEST"]) { + vec![Expression::Vertica(Box::new( + VerticaExpression::PartitionBest, + ))] + } else if self.match_keywords(&[TokenType::Partition, TokenType::By]) { self.parse_expression_list()? } else if self.match_keywords(&[TokenType::Distribute, TokenType::By]) { // Hive: DISTRIBUTE BY is equivalent to PARTITION BY in window specs @@ -42594,13 +42795,17 @@ impl Parser { let _ = self.expect_identifier_or_keyword(); } } + let mut nulls_auto = false; let nulls_first = if self.match_token(TokenType::Nulls) { if self.match_token(TokenType::First) { Some(true) } else if self.match_token(TokenType::Last) { Some(false) + } else if self.is_vertica() && self.match_text_seq(&["AUTO"]) { + nulls_auto = true; + None } else { - return Err(self.parse_error("Expected FIRST or LAST after NULLS")); + return Err(self.parse_error("Expected FIRST, LAST or AUTO after NULLS")); } } else { None @@ -42665,6 +42870,7 @@ impl Parser { None }; exprs.push(Ordered { + nulls_auto, this: expr, desc, nulls_first, @@ -44055,13 +44261,67 @@ impl Parser { && name.eq_ignore_ascii_case("LARGEINT")) } + /// Vertica interval qualifiers may carry a seconds precision: `SECOND(3)`. + fn parse_interval_field_precision(&mut self, unit: String) -> Result { + if self.config.dialect == Some(crate::dialects::DialectType::Vertica) + && self.check(TokenType::LParen) + && self.check_next(TokenType::Number) + { + self.skip(); + let precision = self.expect_number()?; + self.expect(TokenType::RParen)?; + return Ok(format!("{}({})", unit, precision)); + } + Ok(unit) + } + /// Parse a data type. fn parse_data_type(&mut self) -> Result { - self.with_parser_depth(|parser| parser.parse_data_type_inner()) + let data_type = self.with_parser_depth(|parser| parser.parse_data_type_inner())?; + Ok(self.normalize_dialect_data_type(data_type)) + } + + /// Apply dialect-specific type aliasing after a data type has been parsed. + /// + /// Vertica stores every integer type as a signed 64-bit integer and every + /// floating-point type as an 8-byte double, so the narrower spellings are + /// aliases rather than distinct types. + fn normalize_dialect_data_type(&self, data_type: DataType) -> DataType { + if self.config.dialect != Some(crate::dialects::DialectType::Vertica) { + return data_type; + } + match data_type { + DataType::Int { .. } | DataType::SmallInt { .. } | DataType::TinyInt { .. } => { + DataType::BigInt { length: None } + } + DataType::Float { .. } => DataType::Double { + precision: None, + scale: None, + }, + DataType::Decimal { precision, scale } => DataType::Decimal { + precision: Some(precision.unwrap_or(37)), + scale: Some(scale.unwrap_or(15)), + }, + DataType::Custom { ref name } + if name.eq_ignore_ascii_case("INT8") || name.eq_ignore_ascii_case("INT4") => + { + DataType::BigInt { length: None } + } + DataType::Custom { ref name } if name.eq_ignore_ascii_case("FLOAT8") => { + DataType::Double { + precision: None, + scale: None, + } + } + other => other, + } } #[inline(never)] fn parse_data_type_inner(&mut self) -> Result { + if let Some(data_type) = self.parse_vertica_data_type()? { + return Ok(data_type); + } // Handle special token types that represent data type keywords // Teradata tokenizes ST_GEOMETRY as TokenType::Geometry if self.check(TokenType::Geometry) @@ -44692,7 +44952,10 @@ impl Parser { && !self.check(TokenType::RParen) && !self.check(TokenType::Comma) { - Some(self.advance_text()?.to_ascii_uppercase()) + { + let unit = self.advance_text()?.to_ascii_uppercase(); + Some(self.parse_interval_field_precision(unit)?) + } } else { None }; @@ -44702,7 +44965,10 @@ impl Parser { || self.check(TokenType::Var) || self.check_keyword() { - Some(self.advance_text()?.to_ascii_uppercase()) + { + let unit = self.advance_text()?.to_ascii_uppercase(); + Some(self.parse_interval_field_precision(unit)?) + } } else { None } @@ -45042,10 +45308,27 @@ impl Parser { }) } } - // LONG VARCHAR (Exasol) - same as TEXT + // LONG VARCHAR (Exasol, Vertica) - same as TEXT + // LONG VARBINARY (Vertica) - same as BLOB "LONG" => { if self.match_identifier("VARCHAR") { - Ok(DataType::Text) + if self.match_token(TokenType::LParen) { + let length = self.expect_number()? as u32; + self.expect(TokenType::RParen)?; + Ok(DataType::TextWithLength { length }) + } else { + Ok(DataType::Text) + } + } else if self.match_identifier("VARBINARY") { + if self.match_token(TokenType::LParen) { + let length = self.expect_number()?; + self.expect(TokenType::RParen)?; + Ok(DataType::Custom { + name: format!("LONG VARBINARY({})", length), + }) + } else { + Ok(DataType::Blob) + } } else { Ok(DataType::Custom { name: "LONG".to_string(), @@ -45352,11 +45635,15 @@ impl Parser { /// For other dialects (like Snowflake), brackets are subscript operations /// (e.g., x::VARIANT[0] means cast to VARIANT, then subscript with [0]). fn parse_data_type_for_cast(&mut self) -> Result { - self.with_parser_depth(|parser| parser.parse_data_type_for_cast_inner()) + let data_type = self.with_parser_depth(|parser| parser.parse_data_type_for_cast_inner())?; + Ok(self.normalize_dialect_data_type(data_type)) } #[inline(never)] fn parse_data_type_for_cast_inner(&mut self) -> Result { + if let Some(data_type) = self.parse_vertica_data_type()? { + return Ok(data_type); + } // Check if dialect supports array type suffixes (e.g., INT[], VARCHAR[3]) // PostgreSQL: INT[], TEXT[] (no fixed size) // DuckDB: INT[3] (fixed size arrays) @@ -45698,7 +45985,10 @@ impl Parser { && !self.check(TokenType::Not) && !self.check(TokenType::Null) { - Some(self.advance_text()?.to_ascii_uppercase()) + { + let unit = self.advance_text()?.to_ascii_uppercase(); + Some(self.parse_interval_field_precision(unit)?) + } } else { None }; @@ -45708,7 +45998,10 @@ impl Parser { || self.check(TokenType::Var) || self.check_keyword() { - Some(self.advance_text()?.to_ascii_uppercase()) + { + let unit = self.advance_text()?.to_ascii_uppercase(); + Some(self.parse_interval_field_precision(unit)?) + } } else { None } @@ -47802,6 +48095,14 @@ impl Parser { /// Check for an implicit relation alias while preserving clause and join boundaries. /// Shared by FROM and JOIN sources, including subqueries and table functions. fn can_parse_implicit_table_alias(&self) -> bool { + if self.is_vertica() + && matches!( + self.peek_text().to_ascii_uppercase().as_str(), + "TIMESERIES" | "MATCH" | "SEGMENTED" | "UNSEGMENTED" | "KSAFE" + ) + { + return false; + } if self.is_at_end() { return false; } @@ -51284,18 +51585,12 @@ impl Parser { } else if let Some(base_expr) = this { // Subscript access: base[index] if expressions.len() == 1 { - Ok(Some(Expression::Subscript(Box::new(Subscript { - this: base_expr, - index: expressions.remove(0), - })))) + Ok(Some(self.make_subscript(base_expr, expressions.remove(0)))) } else { // Multiple indices - create nested subscripts or array let mut result = base_expr; for expr in expressions { - result = Expression::Subscript(Box::new(Subscript { - this: result, - index: expr, - })); + result = self.make_subscript(result, expr); } Ok(Some(result)) } @@ -51952,10 +52247,7 @@ impl Parser { let index = self.parse_disjunction()?; self.match_token(TokenType::RBracket); if let Some(idx) = index { - Some(Expression::Subscript(Box::new(Subscript { - this: expr, - index: idx, - }))) + Some(self.make_subscript(expr, idx)) } else { Some(expr) } @@ -54654,6 +54946,7 @@ impl Parser { None }; orderings.push(Ordered { + nulls_auto: false, this: order_expr, desc, nulls_first, @@ -58148,6 +58441,7 @@ impl Parser { }; Ok(Some(Ordered { + nulls_auto: false, this: expr, desc, nulls_first, @@ -58902,6 +59196,7 @@ impl Parser { query_hints: Vec::new(), result_serialization: None, + vertica: None, expressions: Vec::new(), from: None, joins: Vec::new(), @@ -58985,6 +59280,7 @@ impl Parser { }; // Add modified Ordered to orders orders.push(Expression::Ordered(Box::new(Ordered { + nulls_auto: false, this: this.clone(), desc: ordered.desc, nulls_first: ordered.nulls_first, @@ -59033,6 +59329,7 @@ impl Parser { query_hints: Vec::new(), result_serialization: None, + vertica: None, expressions: Vec::new(), from: None, joins: Vec::new(), @@ -65628,6 +65925,739 @@ impl Parser { } } +// Vertica dialect support. +impl Parser { + fn is_vertica_parameter_function(&self, name: &str) -> bool { + // LISTAGG remains owned by the aggregate parser and its canonical AST. + name != "LISTAGG" && (name == "EXPLODE" || self.vertica_has_parameters()) + } + + fn parse_vertica_statement(&mut self) -> Result> { + let statement = if self.match_text_seq(&["CREATE", "PROJECTION"]) { + let name = self.parse_table_ref()?; + let mut columns = Vec::new(); + if self.match_token(TokenType::LParen) { + loop { + let name = self.expect_identifier_or_keyword_with_quoted()?; + let encoding = if self.match_text_seq(&["ENCODING"]) { + Some(self.expect_identifier_or_keyword_with_quoted()?) + } else { + None + }; + columns.push(VerticaProjectionColumn { name, encoding }); + if !self.match_token(TokenType::Comma) { + break; + } + } + self.expect(TokenType::RParen)?; + } + self.expect(TokenType::As)?; + let mut query = self.parse_select()?; + let mut physical = self.parse_vertica_physical()?; + // In CREATE PROJECTION the trailing ORDER BY describes storage order. + if let Expression::Select(select) = &mut query { + if let Some(order) = select.order_by.take() { + physical.order_by = order.expressions; + } + } + VerticaExpression::Projection { + name, + columns, + query, + physical, + } + } else if self.check_text_seq(&["CREATE", "FLEX"]) + || self.check_text_seq(&["CREATE", "FLEXIBLE"]) + { + self.skip(); + self.skip(); + let this = self.parse_create_table(false, false, Vec::new(), None)?; + let physical = self.parse_vertica_physical()?; + VerticaExpression::FlexTable { this, physical } + } else if self.check_text_seq(&["CREATE", "TABLE"]) + || self.check_text_seq(&["CREATE", "TEMP", "TABLE"]) + || self.check_text_seq(&["CREATE", "TEMPORARY", "TABLE"]) + { + let this = self.parse_statement_slow()?; + let physical = self.parse_vertica_physical()?; + if physical == VerticaPhysical::default() { + return Ok(Some(this)); + } + VerticaExpression::PhysicalTable { this, physical } + } else if self.match_token(TokenType::Copy) { + let table = self.parse_table_ref()?; + let columns = if self.match_token(TokenType::LParen) { + self.parse_vertica_identifiers()? + } else { + Vec::new() + }; + self.expect(TokenType::From)?; + let local = self.match_text_seq(&["LOCAL"]); + let mut sources = Vec::new(); + loop { + sources.push(if self.match_text_seq(&["STDIN"]) { + Expression::Identifier(Identifier::new("STDIN")) + } else { + Expression::string(&self.expect_string()?) + }); + if !self.match_token(TokenType::Comma) { + break; + } + } + let mut parser = None; + let mut options = Vec::new(); + loop { + if self.match_text_seq(&["PARSER"]) { + if parser.is_some() { + return Err(self.parse_error("Duplicate COPY PARSER")); + } + let name = self.parse_table_ref()?; + self.expect(TokenType::LParen)?; + parser = Some(VerticaParserCall { + name, + parameters: self.parse_vertica_named_parameters()?, + }); + } else if matches!( + self.peek_text().to_ascii_uppercase().as_str(), + "DELIMITER" | "NULL" | "ESCAPE" | "SKIP" | "REJECTMAX" + ) { + let name = Identifier::new(self.advance_text()?.to_ascii_uppercase()); + let value = self.parse_expression()?; + options.push(VerticaParameter { name, value }); + } else { + break; + } + } + VerticaExpression::Copy { + table, + columns, + local, + sources, + parser, + options, + } + } else if self.match_text_seq(&["EXPORT", "TO", "PARQUET"]) { + self.expect(TokenType::LParen)?; + let options = self.parse_vertica_named_parameters()?; + let over = if self.match_token(TokenType::Over) { + Some(self.parse_over_clause()?) + } else { + None + }; + self.expect(TokenType::As)?; + let query = self.parse_statement()?; + VerticaExpression::ExportParquet { + options, + over, + query, + } + } else { + return Ok(None); + }; + Ok(Some(Expression::Vertica(Box::new(statement)))) + } + + fn parse_vertica_identifiers(&mut self) -> Result> { + let mut names = Vec::new(); + loop { + names.push(self.expect_identifier_or_keyword_with_quoted()?); + if !self.match_token(TokenType::Comma) { + break; + } + } + self.expect(TokenType::RParen)?; + Ok(names) + } + + fn parse_vertica_named_parameters(&mut self) -> Result> { + let mut parameters = Vec::new(); + let mut names = HashSet::new(); + if !self.check(TokenType::RParen) { + loop { + let name = self.expect_identifier_or_keyword_with_quoted()?; + if !names.insert(name.name.to_ascii_lowercase()) { + return Err(self.parse_error("Duplicate parameter")); + } + self.expect(TokenType::Eq)?; + parameters.push(VerticaParameter { + name, + value: self.parse_expression()?, + }); + if !self.match_token(TokenType::Comma) { + break; + } + } + } + self.expect(TokenType::RParen)?; + Ok(parameters) + } + + fn parse_vertica_physical(&mut self) -> Result { + let mut physical = VerticaPhysical::default(); + if self.match_text_seq(&["ORDER", "BY"]) { + physical.order_by = self.parse_order_by_list()?; + } + if self.match_text_seq(&["SEGMENTED", "BY"]) { + let expression = self.parse_expression()?; + if !self.match_text_seq(&["ALL", "NODES"]) { + return Err(self.parse_error("SEGMENTED BY requires ALL NODES")); + } + let offset = if self.match_text_seq(&["OFFSET"]) { + Some(self.vertica_nonnegative_integer()?) + } else { + None + }; + physical.segmentation = Some(VerticaSegmentation::Segmented { expression, offset }); + } else if self.match_text_seq(&["UNSEGMENTED"]) { + let node = if self.match_text_seq(&["ALL", "NODES"]) { + None + } else if self.match_text_seq(&["NODE"]) { + Some(self.expect_identifier_or_keyword_with_quoted()?) + } else { + return Err(self.parse_error("UNSEGMENTED requires ALL NODES or NODE name")); + }; + physical.segmentation = Some(VerticaSegmentation::Unsegmented { node }); + } + if self.match_text_seq(&["KSAFE"]) { + physical.ksafe = Some(if self.check(TokenType::Number) { + VerticaKsafe::Level(self.vertica_nonnegative_integer()?) + } else { + VerticaKsafe::Default + }); + } + Ok(physical) + } + + fn vertica_nonnegative_integer(&mut self) -> Result { + self.advance_text()? + .parse::() + .map_err(|_| self.parse_error("Expected non-negative integer")) + } + + fn parse_vertica_timeseries(&mut self) -> Result> { + if !self.is_vertica() || !self.match_text_seq(&["TIMESERIES"]) { + return Ok(None); + } + let alias = self.expect_identifier_or_keyword_with_quoted()?; + self.expect(TokenType::As)?; + let interval = Expression::string(&self.expect_string()?); + self.expect(TokenType::Over)?; + let over = self.parse_over_clause()?; + if over.order_by.len() != 1 || over.frame.is_some() || over.window_name.is_some() { + return Err( + self.parse_error("TIMESERIES requires one ORDER BY expression and no frame") + ); + } + Ok(Some(VerticaTimeseries { + alias, + interval, + over, + })) + } + + fn parse_vertica_match(&mut self) -> Result> { + if !self.is_vertica() || !self.match_text_seq(&["MATCH"]) { + return Ok(None); + } + self.expect(TokenType::LParen)?; + let partition_by = if self.match_text_seq(&["PARTITION", "BY"]) { + self.parse_expression_list()? + } else { + Vec::new() + }; + if !self.match_text_seq(&["ORDER", "BY"]) { + return Err(self.parse_error("MATCH requires ORDER BY")); + } + let order_by = self.parse_order_by_list()?; + if !self.match_text_seq(&["DEFINE"]) { + return Err(self.parse_error("MATCH requires DEFINE")); + } + let mut definitions = Vec::new(); + let mut names = HashSet::new(); + loop { + let name = self.expect_identifier_or_keyword_with_quoted()?; + if !names.insert(name.name.to_ascii_lowercase()) { + return Err(self.parse_error("Duplicate MATCH event")); + } + self.expect(TokenType::As)?; + let value = self.parse_expression()?; + { + use crate::traversal::ExpressionWalk; + if value.dfs().any(|e| { + crate::traversal::is_aggregate(e) + || matches!( + e, + Expression::WindowFunction(_) + | Expression::Subquery(_) + | Expression::Select(_) + ) + }) { + return Err(self.parse_error( + "MATCH DEFINE cannot contain aggregates, windows or subqueries", + )); + } + } + definitions.push(VerticaParameter { name, value }); + if !self.match_token(TokenType::Comma) { + break; + } + } + if definitions.len() > 52 { + return Err(self.parse_error("MATCH supports at most 52 events")); + } + if !self.match_text_seq(&["PATTERN"]) { + return Err(self.parse_error("MATCH requires PATTERN")); + } + let name = self.expect_identifier_or_keyword_with_quoted()?; + self.expect(TokenType::As)?; + self.expect(TokenType::LParen)?; + let pattern = self.parse_vertica_pattern(&names)?; + self.expect(TokenType::RParen)?; + let first_event = if self.match_text_seq(&["ROWS", "MATCH"]) { + if self.match_text_seq(&["FIRST", "EVENT"]) { + Some(true) + } else if self.match_text_seq(&["ALL", "EVENTS"]) { + Some(false) + } else { + return Err(self.parse_error("Expected FIRST EVENT or ALL EVENTS")); + } + } else { + None + }; + self.expect(TokenType::RParen)?; + Ok(Some(VerticaMatch { + partition_by, + order_by, + definitions, + name, + pattern, + first_event, + })) + } + + fn parse_vertica_pattern(&mut self, events: &HashSet) -> Result { + self.with_parser_depth(|parser| { + let mut alternatives = Vec::new(); + loop { + let mut sequence = Vec::new(); + while !parser.is_at_end() + && !parser.check(TokenType::RParen) + && parser.peek_text() != "|" + { + let mut atom = if parser.match_token(TokenType::LParen) { + let group = parser.parse_vertica_pattern(events)?; + parser.expect(TokenType::RParen)?; + VerticaPattern::Group(Box::new(group)) + } else { + let name = parser.expect_identifier_or_keyword_with_quoted()?; + if !events.contains(&name.name.to_ascii_lowercase()) { + return Err( + parser.parse_error("Pattern references an undefined MATCH event") + ); + } + VerticaPattern::Event(name) + }; + if matches!(parser.peek_text(), "*" | "+" | "?" | "??") { + let mut quantifier = parser.advance_text()?; + if quantifier.len() == 1 && matches!(parser.peek_text(), "?" | "+") { + quantifier.push_str(&parser.advance_text()?); + } + atom = VerticaPattern::Repeat { + pattern: Box::new(atom), + quantifier, + }; + } + sequence.push(atom); + } + if sequence.is_empty() { + return Err(parser.parse_error("Empty MATCH pattern")); + } + alternatives.push(if sequence.len() == 1 { + sequence.pop().unwrap() + } else { + VerticaPattern::Sequence(sequence) + }); + if parser.peek_text() != "|" { + break; + } + parser.skip(); + } + Ok(if alternatives.len() == 1 { + alternatives.pop().unwrap() + } else { + VerticaPattern::Alternative(alternatives) + }) + }) + } + + fn is_vertica(&self) -> bool { + self.config.dialect == Some(crate::dialects::DialectType::Vertica) + } + + fn vertica_has_parameters(&self) -> bool { + let mut depth = 0; + for token in self.tokens.iter().skip(self.current) { + match token.token_type { + TokenType::LParen | TokenType::LBracket => depth += 1, + TokenType::RParen | TokenType::RBracket if depth == 0 => return false, + TokenType::RParen | TokenType::RBracket => depth -= 1, + TokenType::Using if depth == 0 => return true, + _ => {} + } + } + false + } + + fn parse_vertica_parameter_function(&mut self, name: &str) -> Result { + let mut args = Vec::new(); + if !self.check(TokenType::RParen) { + loop { + args.push(self.parse_expression()?); + if !self.match_token(TokenType::Comma) { + break; + } + } + } + let mut parameters = Vec::new(); + if self.match_token(TokenType::Using) { + if !self.match_text_seq(&["PARAMETERS"]) { + return Err(self.parse_error("Expected PARAMETERS after USING")); + } + let mut names = HashSet::new(); + loop { + let name = self.expect_identifier_or_keyword_with_quoted()?; + if !names.insert(name.name.to_ascii_lowercase()) { + return Err(self.parse_error("Duplicate USING PARAMETERS name")); + } + self.expect(TokenType::Eq)?; + let value = self.parse_expression()?; + parameters.push(VerticaParameter { name, value }); + if !self.match_token(TokenType::Comma) { + break; + } + } + } + self.expect(TokenType::RParen)?; + let this = if name.eq_ignore_ascii_case("APPROXIMATE_PERCENTILE") + || name.eq_ignore_ascii_case("APPROXIMATE_COUNT_DISTINCT") + { + Expression::AggregateFunction(Box::new(AggregateFunction { + name: name.to_string(), + args, + ..Default::default() + })) + } else { + Expression::Function(Box::new(Function::new(name.to_string(), args))) + }; + // Even without parameters EXPLODE has a Vertica-specific result shape. + if parameters.is_empty() && !name.eq_ignore_ascii_case("EXPLODE") { + return Ok(this); + } + Ok(Expression::Vertica(Box::new( + VerticaExpression::UsingParameters { this, parameters }, + ))) + } + + fn vertica_precision(&mut self) -> Result> { + if !self.match_token(TokenType::LParen) { + return Ok(None); + } + let text = self.advance_text()?; + let number = text + .parse::() + .map_err(|_| self.parse_error("Expected non-negative integer precision or bound"))?; + self.expect(TokenType::RParen)?; + Ok(Some(number)) + } + + fn vertica_interval_units(&mut self) -> Result<(Option, Option)> { + let unit = if matches!( + self.peek_text().to_ascii_uppercase().as_str(), + "YEAR" | "MONTH" | "DAY" | "HOUR" | "MINUTE" | "SECOND" + ) { + let unit = self.advance_text()?.to_ascii_uppercase(); + Some(self.parse_interval_field_precision(unit)?) + } else { + None + }; + let end = if self.match_token(TokenType::To) { + let unit = self.advance_text()?.to_ascii_uppercase(); + if !matches!(unit.as_str(), "MONTH" | "HOUR" | "MINUTE" | "SECOND") { + return Err(self.parse_error("Invalid interval end unit")); + } + Some(self.parse_interval_field_precision(unit)?) + } else { + None + }; + Ok((unit, end)) + } + + fn make_subscript(&self, this: Expression, index: Expression) -> Expression { + if self.is_vertica() { + Expression::Vertica(Box::new(VerticaExpression::ArrayAccess { + this, + indices: vec![index], + })) + } else { + Expression::Subscript(Box::new(Subscript { this, index })) + } + } + + fn make_array_slice( + &self, + this: Expression, + start: Option, + end: Option, + ) -> Expression { + if self.is_vertica() { + Expression::Vertica(Box::new(VerticaExpression::ArraySlice { this, start, end })) + } else { + Expression::ArraySlice(Box::new(ArraySlice { this, start, end })) + } + } + + fn parse_vertica_data_type(&mut self) -> Result> { + if !self.is_vertica() { + return Ok(None); + } + let name = self.peek_text().to_ascii_uppercase(); + // Resolve defaults before losing the alias spelling. In particular, + // NUMBER and MONEY have different defaults from NUMERIC/DECIMAL. + // https://docs.vertica.com/25.2.x/en/sql-reference/data-types/numeric-data-types/numeric/ + let defaults = match name.as_str() { + "DECIMAL" | "NUMERIC" => Some((37, 15)), + "NUMBER" => Some((38, 0)), + "MONEY" => Some((18, 4)), + _ => None, + }; + if let Some((default_precision, default_scale)) = defaults { + self.skip(); + let (precision, scale) = if self.match_token(TokenType::LParen) { + let precision = self.vertica_nonnegative_integer()?; + let scale = if self.match_token(TokenType::Comma) { + self.vertica_nonnegative_integer()? + } else { + default_scale + }; + self.expect(TokenType::RParen)?; + (precision, scale) + } else { + (default_precision, default_scale) + }; + if precision == 0 || precision > 1024 || scale > precision { + return Err(self.parse_error("Invalid Vertica numeric precision or scale")); + } + return Ok(Some(DataType::Decimal { + precision: Some(precision), + scale: Some(scale), + })); + } + let value = match name.as_str() { + "ARRAY" | "SET" if self.check_next(TokenType::LBracket) => { + self.skip(); + self.skip(); + let element_type = self.parse_data_type()?; + let mut bound = if self.match_token(TokenType::Comma) { + let text = self.advance_text()?; + let count = text + .parse::() + .map_err(|_| self.parse_error("Invalid collection bound"))?; + if count == 0 { + return Err(self.parse_error("Collection bound must be positive")); + } + Some(VerticaCollectionBound::Elements(count)) + } else { + None + }; + self.expect(TokenType::RBracket)?; + if let Some(bytes) = self.vertica_precision()? { + if bound.is_some() || bytes == 0 { + return Err(self.parse_error( + "Specify either an element bound or a positive byte size", + )); + } + bound = Some(VerticaCollectionBound::Bytes(bytes)); + } + if name == "ARRAY" { + VerticaDataType::Array { + element_type, + bound, + } + } else { + VerticaDataType::Set { + element_type, + bound, + } + } + } + "ROW" if self.check_next(TokenType::LParen) => { + self.skip(); + self.skip(); + let mut fields = Vec::new(); + loop { + let name = self.expect_identifier_or_keyword_with_quoted()?; + let data_type = self.parse_data_type()?; + let name = if name.quoted { + format!("\"{}\"", name.name.replace('"', "\"\"")) + } else { + name.name + }; + fields.push(StructField::new(name, data_type)); + if !self.match_token(TokenType::Comma) { + break; + } + } + self.expect(TokenType::RParen)?; + VerticaDataType::Row { fields } + } + "LONG" if self.check_next_identifier("VARBINARY") => { + self.skip(); + self.skip(); + VerticaDataType::LongVarBinary { + length: self.vertica_precision()?, + } + } + "INTERVAL" | "INTERVALYM" => { + self.skip(); + let precision = self.vertica_precision()?; + if precision.is_some_and(|p| p > 6) { + return Err(self.parse_error("Interval precision must be between 0 and 6")); + } + let (unit, end_unit) = self.vertica_interval_units()?; + VerticaDataType::Interval { + precision, + year_month: name == "INTERVALYM", + unit, + end_unit, + } + } + _ => return Ok(None), + }; + Ok(Some(DataType::Vertica { + vertica_type: Box::new(value), + })) + } + + fn parse_vertica_primary(&mut self) -> Result> { + let name = self.peek_text().to_ascii_uppercase(); + if matches!(name.as_str(), "NUMERIC" | "DECIMAL" | "NUMBER" | "MONEY") + && self.check_next(TokenType::String) + { + let to = self.parse_data_type()?; + let value = self.expect_string()?; + return Ok(Some(Expression::Cast(Box::new(Cast { + this: Expression::string(value), + to, + trailing_comments: Vec::new(), + double_colon_syntax: false, + format: None, + default: None, + inferred_type: None, + })))); + } + if name == "SET" && self.check_next(TokenType::LBracket) { + self.skip(); + self.skip(); + let values = if self.check(TokenType::RBracket) { + Vec::new() + } else { + self.parse_expression_list()? + }; + self.expect(TokenType::RBracket)?; + return Ok(Some(Expression::Vertica(Box::new( + VerticaExpression::Set { values }, + )))); + } + if matches!(name.as_str(), "INTERVAL" | "INTERVALYM") + && (self.check_next(TokenType::LParen) || self.check_next(TokenType::String)) + { + self.skip(); + let precision = self.vertica_precision()?; + if precision.is_some_and(|p| p > 6) { + return Err(self.parse_error("Interval precision must be between 0 and 6")); + } + let value = Expression::string(&self.expect_string()?); + let (unit, end_unit) = self.vertica_interval_units()?; + return Ok(Some(Expression::Vertica(Box::new( + VerticaExpression::Interval { + value, + precision, + year_month: name == "INTERVALYM", + unit, + end_unit, + }, + )))); + } + if matches!( + self.peek().token_type, + TokenType::HexString | TokenType::BitString + ) { + let binary = self.check(TokenType::BitString); + let value = self.advance_text()?; + let hex = if binary { + if !value.chars().all(|c| c == '0' || c == '1') { + return Err(self.parse_error("Invalid binary literal")); + } + let padded = format!("{}{}", "0".repeat((8 - value.len() % 8) % 8), value); + padded + .as_bytes() + .chunks(8) + .map(|bits| { + let byte = bits.iter().fold(0u8, |n, b| (n << 1) | (b - b'0')); + format!("{byte:02x}") + }) + .collect::() + } else { + if !value.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(self.parse_error("Invalid hexadecimal literal")); + } + format!( + "{}{}", + if value.len() % 2 == 1 { "0" } else { "" }, + value.to_ascii_lowercase() + ) + }; + return Ok(Some(Expression::Vertica(Box::new( + VerticaExpression::Binary { hex }, + )))); + } + Ok(None) + } + + fn parse_vertica_historical(&mut self) -> Result { + self.skip(); + let point = if self.match_text_seq(&["EPOCH"]) { + if self.match_text_seq(&["LATEST"]) { + VerticaHistoricalPoint::Latest + } else { + if !self.check(TokenType::Number) { + return Err(self.parse_error("Expected epoch number")); + } + VerticaHistoricalPoint::Epoch(Expression::Literal(Box::new(Literal::Number( + self.advance_text()?, + )))) + } + } else if self.match_text_seq(&["TIME"]) { + VerticaHistoricalPoint::Time(Expression::string(&self.expect_string()?)) + } else { + return Err(self.parse_error("Expected EPOCH or TIME after AT")); + }; + let query = self.parse_statement()?; + if !matches!( + query, + Expression::Select(_) + | Expression::Union(_) + | Expression::Intersect(_) + | Expression::Except(_) + ) { + return Err(self.parse_error("Historical prefix requires a query")); + } + Ok(Expression::Vertica(Box::new( + VerticaExpression::Historical { query, point }, + ))) + } +} + #[cfg(test)] mod tests { use super::*; @@ -69560,3 +70590,8 @@ mod explicit_eof_token_tests { ); } } + +/// FACTORIAL(x), produced by Vertica's `x!` and `!! x` operators. +fn factorial(expr: Expression) -> Expression { + Expression::Function(Box::new(Function::new("FACTORIAL".to_string(), vec![expr]))) +} diff --git a/crates/polyglot-sql/src/tokens.rs b/crates/polyglot-sql/src/tokens.rs index 9d78bde8..24a0ae9f 100644 --- a/crates/polyglot-sql/src/tokens.rs +++ b/crates/polyglot-sql/src/tokens.rs @@ -1551,6 +1551,8 @@ pub struct TokenizerConfig { /// end-of-input as the close. This is only enabled for ClickHouse fixture /// coverage, where some extracted corpus rows contain partial string probes. pub recover_unterminated_string: bool, + /// Whether `//` is the integer-division operator (Vertica). + pub double_slash_int_div: bool, } impl Default for TokenizerConfig { @@ -1584,6 +1586,7 @@ impl Default for TokenizerConfig { numbers_can_be_underscore_separated: false, recover_terminal_backslash_quote: false, recover_unterminated_string: false, + double_slash_int_div: false, } } } @@ -2644,6 +2647,7 @@ impl<'a, C: TokenizerCursor, T: TokenOutput> TokenizerState<'a, C, T> { ('>', '>') => Some(TokenType::GtGt), ('|', '|') => Some(TokenType::DPipe), ('|', '/') => Some(TokenType::PipeSlash), // Square root - PostgreSQL + ('/', '/') if self.config.double_slash_int_div => Some(TokenType::Div), // Vertica (':', ':') => Some(TokenType::DColon), (':', '=') => Some(TokenType::ColonEq), // := (assignment, named args) (':', '>') => Some(TokenType::ColonGt), // ::> (TSQL) diff --git a/crates/polyglot-sql/src/transforms.rs b/crates/polyglot-sql/src/transforms.rs index 24f548f0..e70d5681 100644 --- a/crates/polyglot-sql/src/transforms.rs +++ b/crates/polyglot-sql/src/transforms.rs @@ -1295,6 +1295,7 @@ pub fn eliminate_qualify(expr: Expression) -> Result { // Create the outer SELECT with alias-resolved expressions and WHERE _w value let outer_select = Select { + vertica: None, expressions: outer_exprs, from: Some(From { expressions: vec![Expression::Subquery(Box::new(subquery))], @@ -1340,6 +1341,7 @@ pub fn eliminate_qualify(expr: Expression) -> Result { }; let outer_select = Select { + vertica: None, expressions: original_exprs, from: Some(From { expressions: vec![Expression::Subquery(Box::new(subquery))], @@ -1728,6 +1730,7 @@ fn eliminate_distinct_on_select( inferred_type: None, })); new_exprs.push(crate::expressions::Ordered { + nulls_auto: false, this: null_check, desc: true, nulls_first: None, @@ -1746,6 +1749,7 @@ fn eliminate_distinct_on_select( distinct_cols .iter() .map(|e| crate::expressions::Ordered { + nulls_auto: false, this: e.clone(), desc: false, nulls_first: None, @@ -1870,6 +1874,7 @@ fn eliminate_distinct_on_select( // Create outer SELECT with WHERE _row_number = 1 // No ORDER BY on outer query let outer_select = Select { + vertica: None, expressions: outer_select_exprs, from: Some(From { expressions: vec![Expression::Subquery(Box::new(subquery))], @@ -1924,6 +1929,7 @@ pub fn eliminate_semi_and_anti_joins(expr: Expression) -> Result { if let Some(on_condition) = join.on { // Create: EXISTS (SELECT 1 FROM join_table WHERE on_condition) let subquery_select = Select { + vertica: None, expressions: vec![Expression::Literal(Box::new(Literal::Number( "1".to_string(), )))], @@ -1962,6 +1968,7 @@ pub fn eliminate_semi_and_anti_joins(expr: Expression) -> Result { if let Some(on_condition) = join.on { // Create: NOT EXISTS (SELECT 1 FROM join_table WHERE on_condition) let subquery_select = Select { + vertica: None, expressions: vec![Expression::Literal(Box::new(Literal::Number( "1".to_string(), )))], @@ -2094,6 +2101,7 @@ pub fn eliminate_full_outer_join(expr: Expression) -> Result { if let (Some(ref from), Some(ref join_cond)) = (&select.from, &join_condition) { if !from.expressions.is_empty() { let anti_subquery = Expression::Select(Box::new(Select { + vertica: None, expressions: vec![Expression::Literal(Box::new(Literal::Number( "1".to_string(), )))], @@ -3165,6 +3173,7 @@ fn unqualify_columns_recursive(expr: Expression) -> Expression { .expressions .iter() .map(|o| crate::expressions::Ordered { + nulls_auto: o.nulls_auto, this: unqualify_columns_recursive(o.this.clone()), desc: o.desc, nulls_first: o.nulls_first, @@ -3460,6 +3469,7 @@ fn try_convert_generate_date_array_with_name( // Build base case: SELECT CAST(start AS DATE) AS date_value let base_select = Select { + vertica: None, expressions: vec![Expression::Alias(Box::new(crate::expressions::Alias { this: cast_to_date(start), alias: column_name.clone(), @@ -3533,6 +3543,7 @@ fn try_convert_generate_date_array_with_name( })); let recursive_select = Select { + vertica: None, expressions: vec![cast_date_add.clone()], from: Some(From { expressions: vec![Expression::Table(Box::new( @@ -3586,6 +3597,7 @@ fn try_convert_generate_date_array_with_name( // Create replacement: SELECT date_value FROM cte_name let replacement_select = Select { + vertica: None, expressions: vec![Expression::Column(Box::new(crate::expressions::Column { name: column_name, table: None, @@ -6747,6 +6759,7 @@ mod tests { // Test that semi joins are converted to EXISTS let select = Expression::Select(Box::new(Select { + vertica: None, expressions: vec![Expression::boxed_column(Column { name: Identifier::new("a".to_string()), table: None, diff --git a/crates/polyglot-sql/src/validation.rs b/crates/polyglot-sql/src/validation.rs index 051b7f21..2b05818c 100644 --- a/crates/polyglot-sql/src/validation.rs +++ b/crates/polyglot-sql/src/validation.rs @@ -752,6 +752,13 @@ fn type_issue( fn data_type_family(data_type: &DataType) -> TypeFamily { match data_type { + DataType::Vertica { vertica_type } => match vertica_type.as_ref() { + crate::expressions::VerticaDataType::Array { .. } + | crate::expressions::VerticaDataType::Set { .. } => TypeFamily::Array, + crate::expressions::VerticaDataType::Row { .. } => TypeFamily::Struct, + crate::expressions::VerticaDataType::LongVarBinary { .. } => TypeFamily::Binary, + crate::expressions::VerticaDataType::Interval { .. } => TypeFamily::Interval, + }, DataType::Boolean => TypeFamily::Boolean, DataType::TinyInt { .. } | DataType::SmallInt { .. } diff --git a/crates/polyglot-sql/tests/common/test_runner.rs b/crates/polyglot-sql/tests/common/test_runner.rs index 8442c706..073e5ec4 100644 --- a/crates/polyglot-sql/tests/common/test_runner.rs +++ b/crates/polyglot-sql/tests/common/test_runner.rs @@ -311,6 +311,7 @@ pub fn parse_dialect(name: &str) -> Option { "fabric" => Some(DialectType::Fabric), "solr" => Some(DialectType::Solr), "datafusion" | "arrow-datafusion" | "arrow_datafusion" => Some(DialectType::DataFusion), + "vertica" => Some(DialectType::Vertica), _ => None, } } diff --git a/crates/polyglot-sql/tests/custom_dialect_tests.rs b/crates/polyglot-sql/tests/custom_dialect_tests.rs index a1963194..cfd1a143 100644 --- a/crates/polyglot-sql/tests/custom_dialect_tests.rs +++ b/crates/polyglot-sql/tests/custom_dialect_tests.rs @@ -46,6 +46,155 @@ fn load_dialect_fixtures(dir: &Path) -> Vec { /// Dialects with separate test runners (excluded from this auto-discovery). const EXCLUDED_DIALECTS: &[&str] = &["clickhouse"]; +#[cfg(all(feature = "dialect-hana", feature = "dialect-vertica"))] +#[test] +fn hana_and_vertica_preserve_each_others_source_semantics() { + use polyglot_sql::{transpile_with_by_name, TranspileOptions, UnsupportedLevel}; + + for (source, target) in [("hana", "vertica"), ("vertica", "hana")] { + assert_eq!( + transpile_with_by_name( + "SELECT id FROM t", + source, + target, + &TranspileOptions::strict(), + ) + .unwrap(), + ["SELECT id FROM t"], + ); + } + // A qualified UDF must not become Vertica's postfix factorial operator. + assert_eq!( + transpile_with_by_name( + "SELECT demo.FACTORIAL(3) FROM t", + "hana", + "vertica", + &TranspileOptions::strict(), + ) + .unwrap(), + ["SELECT demo.FACTORIAL(3) FROM t"], + ); + for level in [ + UnsupportedLevel::Ignore, + UnsupportedLevel::Warn, + UnsupportedLevel::Raise, + UnsupportedLevel::Immediate, + ] { + for (sql, source, target) in [ + ("SELECT * FROM t FOR JSON", "hana", "vertica"), + ( + "ALTER TABLE t ADD (x SMALLDECIMAL ARRAY)", + "hana", + "vertica", + ), + ("SELECT x::!INT FROM t", "vertica", "hana"), + ("SELECT LISTAGG(x) FROM t", "vertica", "hana"), + ("SELECT CAST(12.349 AS DECIMAL(5, 2))", "vertica", "hana"), + ] { + assert!( + transpile_with_by_name( + sql, + source, + target, + &TranspileOptions::default().with_unsupported_level(level), + ) + .is_err(), + "{source} -> {target}: {sql} ({level:?})", + ); + } + } +} + +#[test] +fn vertica_semantic_errors_are_independent_of_diagnostic_level() { + use polyglot_sql::{transpile_with_by_name, TranspileOptions, UnsupportedLevel}; + let cases = [ + ( + "SELECT LISTAGG(x) FROM t", + "vertica", + "postgresql", + "byte limit", + ), + ( + "SELECT LISTAGG(x USING PARAMETERS max_length=3, on_overflow='TRUNCATE') FROM t", + "vertica", + "mysql", + "byte limit", + ), + ( + "SELECT STRING_AGG(x, ',') FROM t", + "postgresql", + "vertica", + "byte limit", + ), + ( + "SELECT TRY_CAST(x AS INT) FROM t", + "snowflake", + "vertica", + "constant cast", + ), + ( + "SELECT SAFE_CAST(x AS INT64) FROM t", + "bigquery", + "vertica", + "constant cast", + ), + ]; + for level in [ + UnsupportedLevel::Ignore, + UnsupportedLevel::Warn, + UnsupportedLevel::Raise, + UnsupportedLevel::Immediate, + ] { + let mut options = TranspileOptions::default(); + options.unsupported_level = level; + for (sql, source, target, message) in cases { + let error = transpile_with_by_name(sql, source, target, &options).expect_err(sql); + assert!(error.to_string().contains(message), "{sql}: {error}"); + } + } +} + +#[test] +fn vertica_filtered_approximate_count_preserves_selected_rows() { + let output = polyglot_sql::transpile_with_by_name( + "SELECT APPROX_COUNT_DISTINCT(x) FILTER (WHERE keep) FROM t", + "duckdb", + "vertica", + &polyglot_sql::TranspileOptions::strict(), + ) + .unwrap(); + assert_eq!( + output, + ["SELECT APPROXIMATE_COUNT_DISTINCT(CASE WHEN keep THEN x END) FROM t"] + ); +} + +#[test] +fn vertica_parameter_expressions_and_nested_factorials_roundtrip() { + for sql in [ + "SELECT LISTAGG(x USING PARAMETERS max_length=1024*2) FROM t", + "SELECT !! !! 3", + "SELECT !! (1 + 2)", + ] { + let once = polyglot_sql::transpile_with_by_name( + sql, + "vertica", + "vertica", + &polyglot_sql::TranspileOptions::strict(), + ) + .unwrap(); + let twice = polyglot_sql::transpile_with_by_name( + &once[0], + "vertica", + "vertica", + &polyglot_sql::TranspileOptions::strict(), + ) + .unwrap(); + assert_eq!(once, twice, "{sql}"); + } +} + /// Auto-discover all dialect subdirectories and load their fixtures. static ALL_CUSTOM_FIXTURES: Lazy = Lazy::new(|| { let mut dialects = Vec::new(); @@ -272,3 +421,1219 @@ fn test_custom_dialect_transpilation_all() { println!("\nNo custom dialect transpilation tests found."); } } + +#[test] +fn vertica_structured_native_roundtrips() { + use polyglot_sql::{transpile_with_by_name, TranspileOptions}; + for sql in [ + "SELECT value::!INT FROM t", + "SELECT (value + 1)::!INT FROM t", + "SELECT INTERVAL(3) '1.2345 SECOND', INTERVALYM '2 YEARS'", + "CREATE TABLE t (a ARRAY[INT, 10], b ARRAY[VARCHAR(50)](32000), s SET[INT], r ROW(name VARCHAR, age INT), binary_value LONG VARBINARY(1000))", + "SELECT ARRAY['1', '2']::ARRAY[INT], ARRAY[2, 1, 2]::SET[INT]", + "SELECT SET[1, 2, 2]", + "SELECT EXPLODE(a) OVER() FROM t", + "SELECT EXPLODE(a) OVER(PARTITION BEST) FROM t", + "SELECT EXPLODE(a USING PARAMETERS skip_partitioning=true) FROM t", + "SELECT APPROXIMATE_PERCENTILE(x USING PARAMETERS percentiles='0.5,0.9') FROM t", + "SELECT ROW_NUMBER() OVER(ORDER BY x NULLS AUTO) FROM t", + "SELECT LISTAGG(x) WITHIN GROUP(ORDER BY y NULLS AUTO) FROM t", + "AT EPOCH LATEST SELECT * FROM t", + "AT EPOCH 42 WITH q AS (SELECT id FROM t) SELECT * FROM q", + "AT TIME '2026-01-01 00:00:00' SELECT * FROM t", + "SELECT id FROM t FOR UPDATE OF t", + "SELECT /*+LABEL('review')*/ id FROM t", + ] { + let first = transpile_with_by_name(sql, "vertica", "vertica", &TranspileOptions::strict()).unwrap_or_else(|e| panic!("{sql}: {e}")); + let second = transpile_with_by_name(&first[0], "vertica", "vertica", &TranspileOptions::strict()).unwrap_or_else(|e| panic!("{}: {e}", first[0])); + assert_eq!(first, second, "{sql}"); + } +} + +#[test] +fn vertica_reviewed_native_surface() { + use polyglot_sql::traversal::ExpressionWalk; + use polyglot_sql::{transpile_with_by_name, Dialect, DialectType, TranspileOptions}; + let mut errors = Vec::new(); + for (label, sql) in [ + ("plain_string", "SELECT 'a\\nb'"), + ("escape_string", "SELECT E'a\\nb'"), + ("dollar_string", "SELECT $tag$a'b$tag$"), + ("unicode_string", "SELECT U&'m\\00fcde'"), + ("unicode_escape", "SELECT U&'m!00fcde' UESCAPE '!'"), + ("hex_string", "SELECT X'abcd'"), + ("binary_string", "SELECT B'101100'"), + ("safe_cast_native", "SELECT value::!INT FROM t"), + ("array_literal", "SELECT ARRAY[10, 20]"), + ("array_index", "SELECT (ARRAY[10, 20])[0]"), + ("array_column_type", "CREATE TABLE t (a ARRAY[INT])"), + ("array_bound", "CREATE TABLE t (a ARRAY[INT, 10])"), + ("array_size", "CREATE TABLE t (a ARRAY[VARCHAR(50)](32000))"), + ("set_literal", "SELECT SET[1, 2, 2]"), + ("set_column_type", "CREATE TABLE t (id INT, s SET[INT])"), + ("set_cast", "SELECT ARRAY[2, 1, 2]::SET[INT]"), + ("row_named", "SELECT ROW('Amy' AS name, 2 AS id)"), + ("row_alias_names", "SELECT ROW('Amy', 2) AS student(name, id)"), + ("row_column_type", "CREATE TABLE t (id INT, r ROW(name VARCHAR, age INT))"), + ("percentile_params", "SELECT APPROXIMATE_PERCENTILE(x USING PARAMETERS percentiles='0.5,0.9') FROM t"), + ("explode_window", "SELECT EXPLODE(a) OVER() FROM t"), + ("explode_params", "SELECT EXPLODE(a USING PARAMETERS skip_partitioning=true) FROM t"), + ("explode_partition_best", "SELECT EXPLODE(a) OVER(PARTITION BEST) FROM t"), + ("nulls_auto_window", "SELECT ROW_NUMBER() OVER(ORDER BY x NULLS AUTO) FROM t"), + ("nulls_auto_aggregate", "SELECT LISTAGG(x) WITHIN GROUP(ORDER BY y NULLS AUTO) FROM t"), + ("epoch_latest", "AT EPOCH LATEST SELECT * FROM t"), + ("epoch_number", "AT EPOCH 42 SELECT * FROM t"), + ("epoch_time", "AT TIME '2026-01-01 00:00:00' SELECT * FROM t"), + ("for_update", "SELECT id FROM t FOR UPDATE"), + ("for_update_of", "SELECT id FROM t FOR UPDATE OF t"), + ("limit_partition", "SELECT k, v FROM t LIMIT 2 OVER(PARTITION BY k ORDER BY v DESC)"), + ("timeseries", "SELECT slice_time, TS_FIRST_VALUE(v) FROM t TIMESERIES slice_time AS '5 seconds' OVER(ORDER BY ts)"), + ("match_events", "SELECT * FROM t MATCH (PARTITION BY k ORDER BY ts DEFINE A AS v > 0 PATTERN P AS (A+))"), + ("interpolate", "SELECT t.ts FROM t LEFT JOIN u ON t.ts INTERPOLATE PREVIOUS VALUE u.ts"), + ("select_label_hint", "SELECT /*+LABEL('coverage_review')*/ id FROM t"), + ("recursive_cte", "WITH RECURSIVE r(n) AS (SELECT 1 UNION ALL SELECT n + 1 FROM r WHERE n < 3) SELECT * FROM r"), + ("grouping_sets", "SELECT a, b, COUNT(*) FROM t GROUP BY GROUPING SETS ((a), (b), ())"), + ("match_columns", "SELECT MATCH_COLUMNS('^a') FROM t"), + ("projection", "CREATE PROJECTION p AS SELECT id FROM t ORDER BY id UNSEGMENTED ALL NODES"), + ("table_segmentation", "CREATE TABLE t (id INT) ORDER BY id SEGMENTED BY HASH(id) ALL NODES"), + ("table_encoding", "CREATE TABLE t (id INT ENCODING RLE)"), + ("flex_table", "CREATE FLEX TABLE t()"), + ("copy_local", "COPY t FROM LOCAL '/tmp/data.csv' DELIMITER ','"), + ("copy_parser", "COPY t FROM '/tmp/data.json' PARSER FJSONPARSER()"), + ("export_parquet", "EXPORT TO PARQUET(directory='/tmp/out') AS SELECT * FROM t"), + ("conditional_event", "SELECT CONDITIONAL_TRUE_EVENT(x > 0) OVER(ORDER BY ts) FROM t"), + ("nullifzero", "SELECT NULLIFZERO(x) FROM t"), + ("regexp_extract", "SELECT REGEXP_SUBSTR(x, '(a)', 1, 1, '', 1) FROM t"), + ("time_slice", "SELECT TIME_SLICE(ts, 5, 'MINUTE', 'START') FROM t"), + ("array_column_index", "SELECT a[0] FROM t"), + ("array_slice", "SELECT (ARRAY[10, 20, 30])[0:2]"), + ("copy_parser_parameter", "COPY t FROM '/tmp/data.json' PARSER FJSONPARSER(flatten_maps=true)"), + ("array_cast", "SELECT ARRAY['1', '2']::ARRAY[INT]") + ] { + let check = || -> Result<(), String> { + let ast = Dialect::get(DialectType::Vertica).parse(sql).map_err(|e| e.to_string())?; + for root in &ast { + if root.dfs().any(|e| matches!(e, polyglot_sql::expressions::Expression::Raw(_) | polyglot_sql::expressions::Expression::Command(_))) { return Err("unstructured AST".into()); } + let json = serde_json::to_string(root).unwrap(); + let restored: polyglot_sql::expressions::Expression = serde_json::from_str(&json).map_err(|e| e.to_string())?; + assert_eq!(root, &restored); + } + let output = transpile_with_by_name(sql, "vertica", "vertica", &TranspileOptions::strict()).map_err(|e| e.to_string())?; + let again = transpile_with_by_name(&output[0], "vertica", "vertica", &TranspileOptions::strict()).map_err(|e| format!("{}: {e}", output[0]))?; + if output != again { return Err(format!("unstable: {output:?} -> {again:?}")); } + Ok(()) + }; + if let Err(error) = check() { errors.push(format!("{label}: {error}")); } + } + assert!(errors.is_empty(), "{}", errors.join("\n")); +} + +#[test] +fn vertica_unsafe_foreign_conversions_fail_in_every_mode() { + use polyglot_sql::{transpile_with_by_name, TranspileOptions, UnsupportedLevel}; + for sql in [ + "SELECT x::!INT FROM t", + "SELECT SET[1, 2]", + "SELECT ROW(1, 2)", + "SELECT INTERVAL(3) '1.2345 SECOND'", + "SELECT EXPLODE(a) OVER() FROM t", + "SELECT ROW_NUMBER() OVER(ORDER BY x NULLS AUTO) FROM t", + "SELECT APPROXIMATE_PERCENTILE(x USING PARAMETERS percentiles='0.5') FROM t", + "SELECT x FROM t ORDER BY x", + "SELECT DATEDIFF(day, a, b) FROM t", + "SELECT DATEDIFF(day, a::TIMESTAMPTZ, b::TIMESTAMPTZ) FROM t", + "SELECT a[lo:hi] FROM t", + "SELECT id FROM t FOR UPDATE", + "SELECT /*+LABEL('review')*/ id FROM t", + "AT EPOCH LATEST SELECT * FROM t", + "SELECT slice_time FROM t TIMESERIES slice_time AS '5 seconds' OVER(ORDER BY ts)", + "SELECT * FROM t MATCH(ORDER BY ts DEFINE A AS v > 0 PATTERN P AS(A+))", + "SELECT t.ts FROM t LEFT JOIN u ON t.ts INTERPOLATE NEXT VALUE u.ts", + "CREATE PROJECTION p AS SELECT id FROM t UNSEGMENTED ALL NODES", + "CREATE TABLE t(id INT ENCODING RLE)", + "CREATE TABLE t(a ARRAY[INT, 10])", + "CREATE TABLE t(b LONG VARBINARY(1000))", + "CREATE FLEX TABLE t()", + "COPY t FROM LOCAL '/tmp/file'", + "EXPORT TO PARQUET(directory='/tmp/out') AS SELECT * FROM t", + "SELECT * FROM t LIMIT 1 OVER(PARTITION BY k ORDER BY v)", + "SELECT TIME_SLICE(ts, 5, 'MINUTE', 'START') FROM t", + "SELECT MATCH_COLUMNS('^a') FROM t", + "SELECT REGEXP_SUBSTR(x, '(a)', 1, 1, '', 1) FROM t", + ] { + for target in ["postgresql", "duckdb"] { + for level in [ + UnsupportedLevel::Ignore, + UnsupportedLevel::Warn, + UnsupportedLevel::Raise, + UnsupportedLevel::Immediate, + ] { + let mut options = TranspileOptions::default(); + options.unsupported_level = level; + assert!( + transpile_with_by_name(sql, "vertica", target, &options).is_err(), + "{sql} -> {target}, {level:?}" + ); + } + } + } +} + +#[test] +fn vertica_foreign_values_and_types() { + use polyglot_sql::{transpile_with_by_name, Dialect, DialectType, TranspileOptions}; + // Set POLYGLOT_DUCKDB to a DuckDB CLI to execute the generated expressions too. + let engine = std::env::var("POLYGLOT_DUCKDB").ok(); + for (sql, expected) in [ + ("SELECT (ARRAY[10, 20])[0] AS result", "10"), + ("SELECT (ARRAY[10, 20])[-1] AS result", "NULL"), + ("SELECT (ARRAY[10, 20])[99] AS result", "NULL"), + ("SELECT (ARRAY[10, 20])[NULL] AS result", "NULL"), + ("SELECT (ARRAY[10, 20])[i] AS result FROM (SELECT 1 AS i) t", "20"), + ("SELECT (ARRAY[ARRAY[1, 2], ARRAY[3, 4]])[1][0] AS result", "3"), + ("SELECT (ARRAY[10, 20, 30])[0:2] AS result", "[10, 20]"), + ("SELECT (ARRAY[10, 20, 30])[2:1] AS result", "[]"), + ("SELECT (ARRAY[10, 20, 30])[:2] AS result", "[10, 20]"), + ("SELECT (ARRAY[10, 20, 30])[1:] AS result", "[20, 30]"), + ("SELECT NULLIFZERO(0) AS result", "NULL"), + ("SELECT NULLIFZERO(2) AS result", "2"), + ("SELECT DATEDIFF(day, TIMESTAMP '2026-01-01 23:59:00', TIMESTAMP '2026-01-02 00:01:00') AS result", "1"), + ("SELECT DATEDIFF(day, TIMESTAMP '2026-01-02 00:01:00', TIMESTAMP '2026-01-01 23:59:00') AS result", "-1"), + ("SELECT DATEDIFF(year, DATE '2025-12-31', DATE '2026-01-01') AS result", "1"), + ("SELECT DATEDIFF(quarter, DATE '2026-03-31', DATE '2026-04-01') AS result", "1"), + ("SELECT DATEDIFF(month, DATE '2026-01-31', DATE '2026-02-01') AS result", "1"), + ("SELECT DATEDIFF(hour, TIMESTAMP '2026-01-01 00:59:59', TIMESTAMP '2026-01-01 01:00:00') AS result", "1"), + ("SELECT DATEDIFF(minute, TIMESTAMP '2026-01-01 00:00:59', TIMESTAMP '2026-01-01 00:01:00') AS result", "1"), + ("SELECT DATEDIFF(second, TIMESTAMP '2026-01-01 00:00:00.999999', TIMESTAMP '2026-01-01 00:00:01') AS result", "1"), + ("SELECT DATEDIFF(millisecond, TIMESTAMP '2026-01-01 00:00:00.000999', TIMESTAMP '2026-01-01 00:00:00.001') AS result", "1"), + ("SELECT DATEDIFF(microsecond, TIMESTAMP '2026-01-01 00:00:00.000001', TIMESTAMP '2026-01-01 00:00:00.000002') AS result", "1"), + ] { + let output = transpile_with_by_name(sql, "vertica", "duckdb", &TranspileOptions::strict()).unwrap_or_else(|e| panic!("{sql}: {e}")); + Dialect::get(DialectType::DuckDB).parse(&output[0]).unwrap_or_else(|e| panic!("{}: {e}", output[0])); + let postgres = transpile_with_by_name(sql, "vertica", "postgresql", &TranspileOptions::strict()).unwrap_or_else(|e| panic!("{sql}: {e}")); + Dialect::get(DialectType::PostgreSQL).parse(&postgres[0]).unwrap_or_else(|e| panic!("{}: {e}", postgres[0])); + if let Some(engine) = &engine { + let query = format!("SELECT COALESCE(CAST(result AS VARCHAR), 'NULL') FROM ({}) q", output[0]); + let result = std::process::Command::new(engine).args(["-init", "/dev/null", "-noheader", "-list", ":memory:", &query]).output().unwrap(); + assert!(result.status.success(), "{}: {}", output[0], String::from_utf8_lossy(&result.stderr)); + assert_eq!(String::from_utf8_lossy(&result.stdout).trim(), expected, "{sql}\n{}", output[0]); + } + } + for (source, hex) in [ + ("B'101100'", "2c"), + ("B'000000001'", "0001"), + ("X'abc'", "0abc"), + ("B''", ""), + ] { + let sql = format!("SELECT {source}"); + let pg = transpile_with_by_name(&sql, "vertica", "postgresql", &TranspileOptions::strict()) + .unwrap(); + assert_eq!(pg[0], format!("SELECT DECODE('{hex}', 'hex')")); + let duck = + transpile_with_by_name(&sql, "vertica", "duckdb", &TranspileOptions::strict()).unwrap(); + assert_eq!(duck[0], format!("SELECT UNHEX('{hex}')")); + } +} + +#[test] +fn vertica_partitioned_limit_preserves_outputs_and_scopes() { + use polyglot_sql::expressions::Expression; + use polyglot_sql::{transpile_with_by_name, Dialect, DialectType, TranspileOptions}; + let sql = "SELECT k, v AS value FROM t LIMIT 2 OVER(PARTITION BY k ORDER BY v DESC)"; + let ast = Dialect::get(DialectType::Vertica).parse(sql).unwrap(); + let Expression::Select(select) = &ast[0] else { + panic!("expected SELECT") + }; + assert!(select.vertica.as_ref().unwrap().limit_over.is_some()); + for target in ["postgresql", "duckdb"] { + let result = + transpile_with_by_name(sql, "vertica", target, &TranspileOptions::strict()).unwrap(); + assert!(result[0].contains("ROW_NUMBER() OVER"), "{}", result[0]); + if target == "duckdb" { + assert!(result[0].contains("NULLS FIRST"), "{}", result[0]); + } + let ast = Dialect::get(if target == "duckdb" { + DialectType::DuckDB + } else { + DialectType::PostgreSQL + }) + .parse(&result[0]) + .unwrap(); + let Expression::Select(select) = &ast[0] else { + panic!("expected SELECT") + }; + assert_eq!(select.expressions.len(), 2); + assert!(matches!(&select.expressions[1], Expression::Alias(a) if a.alias.name == "value")); + if target == "duckdb" { + if let Ok(engine) = std::env::var("POLYGLOT_DUCKDB") { + let sql = format!("CREATE TABLE t(k INT, v INT); INSERT INTO t VALUES (1,1),(1,2),(1,3),(2,NULL),(2,4),(2,5); SELECT k, COALESCE(CAST(value AS VARCHAR), 'NULL') FROM ({}) q ORDER BY k, value NULLS FIRST", result[0]); + let output = std::process::Command::new(engine) + .args(["-init", "/dev/null", "-noheader", "-list", ":memory:", &sql]) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + assert_eq!( + String::from_utf8_lossy(&output.stdout).trim(), + "1|2\n1|3\n2|NULL\n2|5" + ); + } + } + } +} + +#[test] +fn vertica_structured_fields_and_invalid_native_syntax() { + use polyglot_sql::expressions::{Expression, VerticaExpression, VerticaKsafe}; + use polyglot_sql::traversal::{is_aggregate, ExpressionWalk}; + use polyglot_sql::{Dialect, DialectType}; + let dialect = Dialect::get(DialectType::Vertica); + let parsed = dialect.parse("CREATE PROJECTION p(id ENCODING RLE) AS SELECT id FROM t ORDER BY id SEGMENTED BY HASH(id) ALL NODES KSAFE 1").unwrap(); + let Expression::Vertica(node) = &parsed[0] else { + panic!("expected native projection") + }; + let VerticaExpression::Projection { + physical, columns, .. + } = node.as_ref() + else { + panic!("expected projection") + }; + assert_eq!(physical.order_by.len(), 1); + assert_eq!(physical.ksafe, Some(VerticaKsafe::Level(1))); + assert_eq!(columns[0].encoding.as_ref().unwrap().name, "RLE"); + assert!(parsed[0] + .dfs() + .any(|e| matches!(e, Expression::Column(c) if c.name.name == "id"))); + assert!(Dialect::get(DialectType::PostgreSQL) + .generate(&parsed[0]) + .is_err()); + let aggregate = dialect + .parse("SELECT APPROXIMATE_PERCENTILE(x USING PARAMETERS percentiles='0.5') FROM t") + .unwrap(); + assert!(aggregate[0].dfs().any(is_aggregate)); + for sql in [ + "CREATE TABLE t(r ROW(\"odd name\" INT, \"a\"\"b\" VARCHAR))", + "CREATE PROJECTION p AS SELECT id FROM t UNSEGMENTED ALL NODES KSAFE", + "SELECT k, v FROM t LIMIT 1 OVER(PARTITION BY k ORDER BY v) OFFSET 2", + ] { + let ast = dialect.parse(sql).unwrap(); + let restored: Vec = + serde_json::from_str(&serde_json::to_string(&ast).unwrap()).unwrap(); + assert_eq!(ast, restored, "{sql}"); + let output = dialect.generate(&ast[0]).unwrap(); + let again = dialect.parse(&output).unwrap(); + assert_eq!(output, dialect.generate(&again[0]).unwrap()); + } + for sql in [ + "CREATE TABLE t(a ARRAY[INT, 0])", + "CREATE TABLE t(a ARRAY[INT, 2](100))", + "SELECT INTERVAL(7) '1 SECOND'", + "SELECT EXPLODE(a USING PARAMETERS x=1, X=2) FROM t", + "SELECT * FROM t MATCH(ORDER BY ts DEFINE A AS SUM(v) > 0 PATTERN P AS(A+))", + "SELECT DISTINCT v FROM t MATCH(ORDER BY ts DEFINE A AS v > 0 PATTERN P AS(A+))", + "SELECT * FROM t MATCH(ORDER BY ts DEFINE A AS v > 0 PATTERN P AS(B+))", + ] { + assert!(dialect.parse(sql).is_err(), "{sql}"); + } +} + +#[test] +fn vertica_ordering_contexts_preserve_null_placement() { + use polyglot_sql::{transpile_with_by_name, TranspileOptions}; + for (sql, expected) in [ + ( + "SELECT ROW_NUMBER() OVER(ORDER BY x DESC) FROM t", + "x DESC NULLS FIRST", + ), + ("SELECT x::INT AS i FROM t ORDER BY i", "i NULLS FIRST"), + ("SELECT 1 AS i ORDER BY i", "i NULLS FIRST"), + ] { + let output = + transpile_with_by_name(sql, "vertica", "duckdb", &TranspileOptions::strict()).unwrap(); + assert!(output[0].contains(expected), "{}", output[0]); + } + assert!(transpile_with_by_name( + "SELECT RANDOM() AS x ORDER BY x NULLS FIRST", + "postgresql", + "vertica", + &TranspileOptions::default() + ) + .is_err()); +} + +#[test] +fn vertica_direct_generation_preserves_filters_and_safe_cast_failures() { + use polyglot_sql::{Dialect, DialectType}; + let source = Dialect::get(DialectType::DuckDB); + let target = Dialect::get(DialectType::Vertica); + let ast = source + .parse("SELECT APPROX_COUNT_DISTINCT(x) FILTER(WHERE keep) FROM t") + .unwrap(); + let sql = target.generate(&ast[0]).unwrap(); + assert!( + sql.contains("APPROXIMATE_COUNT_DISTINCT(CASE WHEN keep THEN x END)"), + "{sql}" + ); + let ast = source.parse("SELECT TRY_CAST(x AS INT) FROM t").unwrap(); + assert!(target.generate(&ast[0]).is_err()); +} + +fn execute_vertica_target(setup: &str, sql: &str) -> Option { + let engine = std::env::var("POLYGLOT_DUCKDB").ok()?; + let query = format!("{setup}; {sql}"); + let result = std::process::Command::new(engine) + .args([ + "-init", + "/dev/null", + "-noheader", + "-list", + "-nullvalue", + "NULL", + ":memory:", + &query, + ]) + .output() + .expect("run DuckDB"); + assert!( + result.status.success(), + "{query}\n{}", + String::from_utf8_lossy(&result.stderr) + ); + Some(String::from_utf8_lossy(&result.stdout).trim().to_string()) +} + +fn vertica_translate(sql: &str, read: &str, write: &str) -> String { + polyglot_sql::transpile_with_by_name( + sql, + read, + write, + &polyglot_sql::TranspileOptions::strict(), + ) + .unwrap_or_else(|e| panic!("{read} -> {write}: {sql}: {e}"))[0] + .clone() +} + +#[test] +fn vertica_numeric_defaults_survive_parsing_and_generation() { + use polyglot_sql::{expressions::DataType, Dialect, DialectType}; + let source = Dialect::get(DialectType::Vertica); + let target = Dialect::get(DialectType::DuckDB); + for (name, precision, scale, expected) in [ + ("DECIMAL", 37, 15, "1.123456000000000"), + ("NUMERIC", 37, 15, "1.123456000000000"), + ("NUMBER", 38, 0, "1"), + ("MONEY", 18, 4, "1.1235"), + ("NUMERIC(20)", 20, 15, "1.123456000000000"), + ("NUMBER(12,2)", 12, 2, "1.12"), + ("MONEY(12)", 12, 4, "1.1235"), + ("DECIMAL(20,6)", 20, 6, "1.123456"), + ] { + assert_eq!( + source.parse_data_type(name).unwrap(), + DataType::Decimal { + precision: Some(precision), + scale: Some(scale), + } + ); + for sql in [ + format!("SELECT CAST(1.123456 AS {name})"), + format!("SELECT 1.123456::{name}"), + ] { + let ast = source.parse(&sql).unwrap(); + let restored = serde_json::from_str(&serde_json::to_string(&ast[0]).unwrap()).unwrap(); + let generated = target.generate(&restored).unwrap(); + let transpiled = vertica_translate(&sql, "vertica", "duckdb"); + assert_eq!(generated, transpiled, "{sql}"); + if let Some(actual) = execute_vertica_target("", &generated) { + assert_eq!(actual, expected, "{sql}"); + } + assert_eq!( + source.generate(&ast[0]).unwrap(), + format!("SELECT CAST(1.123456 AS DECIMAL({precision}, {scale}))") + ); + } + } + assert_eq!( + vertica_translate( + "CREATE TABLE t(d DECIMAL, n NUMBER, m MONEY)", + "vertica", + "duckdb" + ), + "CREATE TABLE t (d DECIMAL(37, 15), n DECIMAL(38, 0), m DECIMAL(18, 4))" + ); + for (name, expected) in [ + ("NUMERIC", "1.123456000000000"), + ("DECIMAL", "1.123456000000000"), + ("MONEY", "1.1235"), + ("NUMBER", "1"), + ] { + let generated = + vertica_translate(&format!("SELECT {name} '1.123456'"), "vertica", "duckdb"); + if let Some(actual) = execute_vertica_target("", &generated) { + assert_eq!(actual, expected); + } + } + for name in ["DECIMAL(0,0)", "NUMERIC(1025,0)", "MONEY(3,4)"] { + assert!(source.parse_data_type(name).is_err(), "{name}"); + } +} + +#[test] +fn vertica_preserves_incoming_decimal_defaults() { + use polyglot_sql::{Dialect, DialectType, TranspileOptions, UnsupportedLevel}; + let target = Dialect::get(DialectType::Vertica); + for read in [DialectType::PostgreSQL, DialectType::DuckDB] { + let source = Dialect::get(read); + for (sql, expected) in [ + ( + "SELECT CAST(1.6 AS NUMERIC(20))", + "SELECT CAST(1.6 AS DECIMAL(20, 0))", + ), + ( + "CREATE TABLE t(amount NUMERIC(10))", + "CREATE TABLE t (amount DECIMAL(10, 0))", + ), + ] { + assert_eq!( + vertica_translate(sql, &read.to_string(), "vertica"), + expected + ); + let ast = source.parse(sql).unwrap(); + assert_eq!(target.generate(&ast[0]).unwrap(), expected); + assert_eq!( + target.generate_with_source(&ast[0], read).unwrap(), + expected + ); + // In particular, NUMERIC(10) must not inherit Vertica's invalid + // default scale of 15. Verify the generated declaration parses. + target.parse(expected).unwrap(); + } + } + if let Some(value) = execute_vertica_target( + "", + &vertica_translate("SELECT CAST(1.6 AS NUMERIC(20))", "postgres", "vertica"), + ) { + assert_eq!(value, "2"); + } + for (sql, expected) in [ + ( + "SELECT CAST(1.12345 AS DECIMAL)", + "SELECT CAST(1.12345 AS DECIMAL(18, 3))", + ), + ( + "CREATE TABLE t(amount DECIMAL)", + "CREATE TABLE t (amount DECIMAL(18, 3))", + ), + ] { + assert_eq!(vertica_translate(sql, "duckdb", "vertica"), expected); + let ast = Dialect::get(DialectType::DuckDB).parse(sql).unwrap(); + assert_eq!( + target + .generate_with_source(&ast[0], DialectType::DuckDB) + .unwrap(), + expected + ); + } + let source = Dialect::get(DialectType::PostgreSQL); + for (sql, diagnostic) in [ + ( + "SELECT CAST(1.1234567890123456789 AS NUMERIC)", + "unconstrained NUMERIC", + ), + ( + "SELECT 123456789012345678901234::NUMERIC", + "unconstrained NUMERIC", + ), + ("CREATE TABLE t(amount NUMERIC)", "unconstrained NUMERIC"), + ( + "SELECT CAST(x AS NUMERIC[]) FROM t", + "unconstrained NUMERIC", + ), + ("CREATE TABLE t(amount NUMERIC[])", "unconstrained NUMERIC"), + // PostgreSQL permits scale greater than precision; Vertica does not. + ("SELECT CAST(x AS NUMERIC(3,5)) FROM t", "DECIMAL scale"), + ("CREATE TABLE t(amount NUMERIC(3,5))", "DECIMAL scale"), + ] { + let ast = source.parse(sql).unwrap(); + assert!(target.generate(&ast[0]).is_err(), "{sql}"); + assert!( + target + .generate_with_source(&ast[0], DialectType::PostgreSQL) + .is_err(), + "{sql}" + ); + for level in [ + UnsupportedLevel::Ignore, + UnsupportedLevel::Warn, + UnsupportedLevel::Raise, + UnsupportedLevel::Immediate, + ] { + let error = source + .transpile_with( + sql, + DialectType::Vertica, + TranspileOptions::default().with_unsupported_level(level), + ) + .unwrap_err(); + assert!(error.to_string().contains(diagnostic), "{sql}: {error}"); + } + } +} + +#[test] +fn vertica_decimal_precision_must_fit_the_target() { + use polyglot_sql::{ + expressions::{DataType, Expression}, + Dialect, DialectType, TranspileOptions, UnsupportedLevel, + }; + let source = Dialect::get(DialectType::Vertica); + for (write, limit) in [(DialectType::DuckDB, 38), (DialectType::PostgreSQL, 1000)] { + let target = Dialect::get(write); + for precision in [limit, limit + 1, limit + 2, 1024] { + // Shared container ASTs must validate their decimal leaves, too. + // Native Vertica ARRAY[...] types are separately rejected because + // their collection constraints have no verified foreign mapping. + let nested = Expression::DataType(DataType::Array { + element_type: Box::new( + source + .parse_data_type(&format!("NUMERIC({precision}, 5)")) + .unwrap(), + ), + dimension: None, + }); + let generated = target.generate(&nested); + if precision == limit { + assert_eq!(generated.unwrap(), format!("DECIMAL({precision}, 5)[]")); + } else { + assert!(generated + .unwrap_err() + .to_string() + .contains("DECIMAL precision")); + } + for sql in [ + format!("SELECT 1::NUMERIC({precision}, 5)"), + format!("CREATE TABLE t(amount NUMERIC({precision}, 5))"), + ] { + let ast = source.parse(&sql).unwrap(); + let restored = + serde_json::from_str(&serde_json::to_string(&ast[0]).unwrap()).unwrap(); + let generated = target.generate(&restored); + if precision == limit { + let generated = generated.unwrap(); + assert!( + generated.contains(&format!("DECIMAL({precision}, 5)")), + "{generated}" + ); + if write == DialectType::DuckDB && sql.starts_with("SELECT 1") { + if let Some(value) = execute_vertica_target("", &generated) { + assert_eq!(value, "1.00000"); + } + } + } else { + assert!( + generated + .unwrap_err() + .to_string() + .contains("DECIMAL precision"), + "{sql}" + ); + } + for level in [ + UnsupportedLevel::Ignore, + UnsupportedLevel::Warn, + UnsupportedLevel::Raise, + UnsupportedLevel::Immediate, + ] { + let result = source.transpile_with( + &sql, + write, + TranspileOptions::default().with_unsupported_level(level), + ); + if precision == limit { + assert!(result.is_ok(), "{sql}: {result:?}"); + } else { + assert!( + result + .unwrap_err() + .to_string() + .contains("DECIMAL precision"), + "{sql}" + ); + } + } + } + } + } + // The source's full range remains valid for native generation. + assert_eq!( + vertica_translate("SELECT 1::NUMERIC(1024,5)", "vertica", "vertica"), + "SELECT CAST(1 AS DECIMAL(1024, 5))" + ); +} + +#[test] +fn vertica_rejects_mysql_elapsed_timestampdiff() { + use polyglot_sql::{Dialect, DialectType, TranspileOptions, UnsupportedLevel}; + for read in [DialectType::MySQL, DialectType::TiDB] { + let source = Dialect::get(read); + for sql in [ + "SELECT TIMESTAMPDIFF(MONTH, DATE '2026-01-31', DATE '2026-02-01')", + "SELECT TIMESTAMPDIFF(MONTH, DATE '2026-02-01', DATE '2026-01-31')", + "SELECT TIMESTAMPDIFF(HOUR, TIMESTAMP '2026-01-31 23:59:00', TIMESTAMP '2026-02-01 00:01:00')", + "SELECT TIMESTAMPDIFF(DAY, started_at, ended_at) FROM events", + "SELECT 1 + TIMESTAMPDIFF(MONTH, started_at, ended_at) FROM events", + ] { + let ast = source.parse(sql).unwrap(); + assert!(Dialect::get(DialectType::Vertica) + .generate_with_source(&ast[0], read) + .unwrap_err() + .to_string() + .contains("TIMESTAMPDIFF elapsed-unit semantics")); + for level in [ + UnsupportedLevel::Ignore, + UnsupportedLevel::Warn, + UnsupportedLevel::Raise, + UnsupportedLevel::Immediate, + ] { + let error = source + .transpile_with( + sql, + DialectType::Vertica, + TranspileOptions::default().with_unsupported_level(level), + ) + .unwrap_err(); + assert!( + error.to_string().contains("TIMESTAMPDIFF elapsed-unit semantics"), + "{sql}: {error}" + ); + } + } + } + assert_eq!( + vertica_translate( + "SELECT TIMESTAMPDIFF(hour, started_at, ended_at) FROM events", + "vertica", + "vertica" + ), + "SELECT DATEDIFF(HOUR, started_at, ended_at) FROM events" + ); + assert_eq!( + vertica_translate("SELECT DATEDIFF(b, a) FROM t", "mysql", "vertica"), + "SELECT DATEDIFF(DAY, a, b) FROM t" + ); +} + +#[test] +fn vertica_rejects_casts_that_widen_source_numeric_semantics() { + use polyglot_sql::{Dialect, DialectType, TranspileOptions, UnsupportedLevel}; + let source = Dialect::get(DialectType::DuckDB); + let target = Dialect::get(DialectType::Vertica); + for sql in [ + "SELECT CAST(16777217 AS REAL)", + "SELECT CAST(40000 AS SMALLINT)", + "SELECT CAST(x AS INTEGER) FROM t", + "SELECT CAST(x AS TINYINT) FROM t", + "SELECT CAST(x AS FLOAT[]) FROM t", + "SELECT CAST(x AS STRUCT(a SMALLINT)) FROM t", + ] { + let ast = source.parse(sql).unwrap(); + assert!( + target + .generate(&ast[0]) + .unwrap_err() + .to_string() + .contains("Narrow numeric CAST"), + "{sql}" + ); + assert!( + target + .generate_with_source(&ast[0], DialectType::DuckDB) + .is_err(), + "{sql}" + ); + for level in [ + UnsupportedLevel::Ignore, + UnsupportedLevel::Warn, + UnsupportedLevel::Raise, + UnsupportedLevel::Immediate, + ] { + let error = source + .transpile_with( + sql, + DialectType::Vertica, + TranspileOptions::default().with_unsupported_level(level), + ) + .unwrap_err(); + assert!( + error.to_string().contains("Narrow numeric CAST"), + "{sql}: {error}" + ); + } + } + for sql in [ + "SELECT CAST(x AS BIGINT) FROM t", + "SELECT CAST(x AS DOUBLE) FROM t", + ] { + assert!(source.transpile(sql, DialectType::Vertica).is_ok(), "{sql}"); + } + // Native aliases and storage declarations still use Vertica's wider types. + assert_eq!( + vertica_translate( + "SELECT CAST(x AS INT), CAST(x AS REAL) FROM t", + "vertica", + "vertica" + ), + "SELECT CAST(x AS BIGINT), CAST(x AS DOUBLE PRECISION) FROM t" + ); + assert_eq!( + vertica_translate("CREATE TABLE t(i SMALLINT, f REAL)", "duckdb", "vertica"), + "CREATE TABLE t (i BIGINT, f DOUBLE PRECISION)" + ); +} + +#[test] +fn vertica_serialized_arrays_preserve_bounds_in_direct_generation() { + use polyglot_sql::{Dialect, DialectType}; + let source = Dialect::get(DialectType::Vertica); + let target = Dialect::get(DialectType::DuckDB); + for (sql, expected) in [ + ("SELECT (ARRAY[10,20])[0]", "10"), + ("SELECT (ARRAY[10,20])[-1]", "NULL"), + ("SELECT (ARRAY[10,20])[99]", "NULL"), + ("SELECT (ARRAY[10,20])[NULL]", "NULL"), + ("SELECT (ARRAY[ARRAY[1,2],ARRAY[3,4]])[1][0]", "3"), + ("SELECT (ARRAY[10,20,30])[0:2]", "[10, 20]"), + ("SELECT (ARRAY[10,20,30])[:2]", "[10, 20]"), + ("SELECT (ARRAY[10,20,30])[1:]", "[20, 30]"), + ("SELECT (ARRAY[10,20])[i] FROM (SELECT 1 AS i) t", "20"), + ] { + let ast = source.parse(sql).unwrap(); + let restored = serde_json::from_str(&serde_json::to_string(&ast[0]).unwrap()).unwrap(); + for generated in [ + target.generate(&restored).unwrap(), + target + .generate_with_source(&restored, DialectType::Vertica) + .unwrap(), + vertica_translate(sql, "vertica", "duckdb"), + ] { + if let Some(actual) = execute_vertica_target("", &generated) { + assert_eq!(actual, expected, "{sql}: {generated}"); + } + } + let native = source.generate(&restored).unwrap(); + assert_eq!( + native, + source.generate(&source.parse(&native).unwrap()[0]).unwrap() + ); + assert!(Dialect::get(DialectType::MySQL) + .generate(&restored) + .is_err()); + } + for sql in ["SELECT a[1:3:2] FROM t", "SELECT a[:3:2] FROM t"] { + assert!(source.parse(sql).is_err()); + } + // Generic target-array nodes cannot silently acquire Vertica's zero base. + for sql in ["SELECT a[1] FROM t", "SELECT a[1:2] FROM t"] { + let ast = target.parse(sql).unwrap(); + assert!(source.generate(&ast[0]).is_err(), "{sql}"); + } + // The generator must evaluate a volatile index once per row, including + // direct generation where source normalization has not run. + let ast = source + .parse("SELECT (ARRAY[10,20])[NEXTVAL('s') % 2] FROM range(4)") + .unwrap(); + let generated = target.generate(&ast[0]).unwrap(); + assert_eq!(generated.matches("NEXTVAL").count(), 1); + if let Some(actual) = execute_vertica_target("CREATE SEQUENCE s MINVALUE 0 START 0", &generated) + { + assert_eq!(actual, "10\n20\n10\n20"); + } +} + +#[test] +fn vertica_listagg_source_semantics_survive_serialization() { + use polyglot_sql::{Dialect, DialectType}; + let source = Dialect::get(DialectType::Vertica); + for sql in ["SELECT LISTAGG(x) FROM t", "SELECT LISTAGG(x USING PARAMETERS max_length=8, on_overflow='TRUNCATE') WITHIN GROUP(ORDER BY x) FROM t"] { + let ast = source.parse(sql).unwrap(); + let restored = serde_json::from_str(&serde_json::to_string(&ast[0]).unwrap()).unwrap(); + assert!(source.generate(&restored).is_ok()); + for kind in [DialectType::DuckDB, DialectType::PostgreSQL, DialectType::Oracle] { + let target = Dialect::get(kind); + assert!(target.generate(&restored).is_err(), "{sql} -> {kind}"); + assert!(target.generate_with_source(&restored, DialectType::Vertica).is_err()); + } + } + let other = Dialect::get(DialectType::Oracle) + .parse("SELECT LISTAGG(x, ',') FROM t") + .unwrap(); + assert!(source.generate(&other[0]).is_err()); +} + +#[test] +fn vertica_aggregate_filters_preserve_values_and_empty_inputs() { + use polyglot_sql::{Dialect, DialectType}; + let source = Dialect::get(DialectType::PostgreSQL); + let target = Dialect::get(DialectType::Vertica); + let setup = "CREATE TABLE t(x INT, keep BOOLEAN); INSERT INTO t VALUES (1,TRUE),(1,TRUE),(2,FALSE),(NULL,TRUE),(3,NULL)"; + for sql in [ + "SELECT SUM(x) FILTER(WHERE keep), AVG(x) FILTER(WHERE keep), MIN(x) FILTER(WHERE keep), MAX(x) FILTER(WHERE keep) FROM t", + "SELECT COUNT(*) FILTER(WHERE keep), COUNT(x) FILTER(WHERE keep), COUNT(DISTINCT x) FILTER(WHERE keep), SUM(DISTINCT x) FILTER(WHERE keep) FROM t", + "SELECT COUNT(*) FILTER(WHERE keep), SUM(x) FILTER(WHERE keep) FROM t WHERE FALSE", + "SELECT COUNT(*) FILTER(WHERE FALSE), SUM(x) FILTER(WHERE FALSE) FROM t", + "SELECT SUM(x) FILTER(WHERE keep) OVER(), COUNT(*) FILTER(WHERE keep) OVER() FROM t", + ] { + let ast = source.parse(sql).unwrap(); + for output in [target.generate(&ast[0]).unwrap(), vertica_translate(sql, "postgresql", "vertica")] { + assert!(!output.contains("FILTER"), "{output}"); + assert!(output.contains("CASE WHEN"), "{output}"); + if let Some(actual) = execute_vertica_target(setup, &output) { + assert_eq!(actual, execute_vertica_target(setup, sql).unwrap(), "{sql}: {output}"); + } + } + } +} + +#[test] +fn vertica_unverified_aggregate_filters_fail_in_every_mode() { + use polyglot_sql::{Dialect, DialectType, TranspileOptions, UnsupportedLevel}; + let source = Dialect::get(DialectType::DuckDB); + for sql in [ + "SELECT ARRAY_AGG(x) FILTER(WHERE keep) FROM t", + "SELECT FIRST(x) FILTER(WHERE keep) FROM t", + "SELECT custom_aggregate(x) FILTER(WHERE keep) FROM t", + ] { + let ast = source.parse(sql).unwrap(); + assert!( + Dialect::get(DialectType::Vertica) + .generate(&ast[0]) + .is_err(), + "{sql}" + ); + for level in [ + UnsupportedLevel::Ignore, + UnsupportedLevel::Warn, + UnsupportedLevel::Raise, + UnsupportedLevel::Immediate, + ] { + assert!( + source + .transpile_with( + sql, + DialectType::Vertica, + TranspileOptions::default().with_unsupported_level(level) + ) + .is_err(), + "{sql}: {level:?}" + ); + } + } +} + +#[test] +fn vertica_array_access_uses_native_subscripts_without_name_capture() { + let setup = "CREATE TABLE t(a INT[], arr INT[], i0 BIGINT); INSERT INTO t VALUES ([10,20],[10,20],0),([10,20],[10,20],1),([10,20],[10,20],-1),([10,20],[10,20],NULL),([10,20],[10,20],9223372036854775807)"; + for (sql, expected) in [ + ("SELECT a[0] FROM t", "10\n10\n10\n10\n10"), + ("SELECT arr[i0] FROM t", "10\n20\nNULL\nNULL\nNULL"), + ("SELECT arr[i0 + 0] FROM t", "10\n20\nNULL\nNULL\nNULL"), + ( + "SELECT arr[COALESCE(i0, NULL)] FROM t", + "10\n20\nNULL\nNULL\nNULL", + ), + ( + "SELECT arr[2147483647] FROM t", + "NULL\nNULL\nNULL\nNULL\nNULL", + ), + ] { + for target in ["duckdb", "postgresql"] { + let output = vertica_translate(sql, "vertica", target); + assert!(!output.contains("(SELECT"), "{output}"); + polyglot_sql::Dialect::get(target.parse().unwrap()) + .parse(&output) + .unwrap(); + if target == "duckdb" { + if let Some(actual) = execute_vertica_target(setup, &output) { + assert_eq!(actual, expected, "{output}"); + } + } + } + } + assert_eq!( + vertica_translate("SELECT SUM(arr[0]) FROM t", "vertica", "duckdb"), + "SELECT SUM(arr[1]) FROM t" + ); + let bounds = "CREATE TABLE t(arr INT[], i BIGINT); INSERT INTO t VALUES ([10],-9223372036854775808),([10],2147483646),([10],2147483647),([10],NULL)"; + for index in ["i", "i + 0", "COALESCE(i, NULL)", "-9223372036854775808"] { + let sql = vertica_translate(&format!("SELECT arr[{index}] FROM t"), "vertica", "duckdb"); + if let Some(actual) = execute_vertica_target(bounds, &sql) { + assert_eq!(actual, "NULL\nNULL\nNULL\nNULL", "{sql}"); + } + } + let sql = vertica_translate("SELECT arr[NEXTVAL('s')] FROM t", "vertica", "duckdb"); + assert_eq!(sql.matches("NEXTVAL").count(), 1, "{sql}"); + let setup = "CREATE SEQUENCE s MINVALUE 0 START 0; CREATE TABLE t(arr INT[]); INSERT INTO t VALUES ([10,20,30]),([10,20,30]),([10,20,30])"; + if let Some(actual) = execute_vertica_target(setup, &sql) { + assert_eq!(actual, "10\n20\n30"); + } + if let Some(plan) = execute_vertica_target( + "CREATE TABLE t(arr INT[])", + "EXPLAIN SELECT SUM(arr[1]) FROM t", + ) { + assert!(!plan.contains("JOIN"), "{plan}"); + } +} + +#[test] +fn vertica_partitioned_limit_preserves_constants_and_base_aliases() { + let setup = "CREATE TABLE t(k INT, v INT); INSERT INTO t VALUES (1,1),(1,2),(2,3)"; + for (sql, expected) in [ + ("SELECT k, v FROM t LIMIT 1 OVER(PARTITION BY 1 ORDER BY v DESC)", "2|3"), + ("SELECT k AS d, SUM(v) AS s FROM t GROUP BY d LIMIT 1 OVER(PARTITION BY 1 ORDER BY d DESC)", "2|3"), + ("SELECT k AS d, v FROM t WHERE d > 1 LIMIT 1 OVER(PARTITION BY d ORDER BY v)", "2|3"), + ("SELECT k AS d, SUM(v) AS s FROM t GROUP BY k HAVING s > 1 LIMIT 1 OVER(PARTITION BY 1 ORDER BY d DESC)", "2|3"), + ("SELECT k AS \"odd name\", v FROM t LIMIT 1 OVER(PARTITION BY 1 ORDER BY v DESC)", "2|3"), + ] { + let output = vertica_translate(sql, "vertica", "duckdb"); + if let Some(actual) = execute_vertica_target(setup, &output) { assert_eq!(actual, expected, "{sql}\n{output}"); } + } + let sql = vertica_translate( + "SELECT k AS d, SUM(v) AS s FROM t GROUP BY d LIMIT 1 OVER(PARTITION BY d ORDER BY s)", + "vertica", + "postgresql", + ); + assert!( + sql.contains("k AS d") && sql.contains("GROUP BY d"), + "{sql}" + ); + let sql = vertica_translate( + "SELECT k, v FROM t LIMIT 1 OVER(PARTITION BY k ORDER BY 1)", + "vertica", + "duckdb", + ); + assert!( + sql.contains("ORDER BY 1"), + "window constants are not ordinals: {sql}" + ); + let sql = vertica_translate( + "SELECT k, v FROM t ORDER BY 2 NULLS LAST LIMIT 1 OVER(PARTITION BY 1 ORDER BY v DESC)", + "vertica", + "duckdb", + ); + if let Some(actual) = execute_vertica_target(setup, &sql) { + assert_eq!(actual, "2|3"); + } + let sql = vertica_translate( + "SELECT k FROM t LIMIT 1 OVER(PARTITION BY 1 ORDER BY _vertica_hidden DESC)", + "vertica", + "duckdb", + ); + if let Some(actual) = execute_vertica_target( + "CREATE TABLE t(k INT, _vertica_hidden INT); INSERT INTO t VALUES (1,1),(2,2)", + &sql, + ) { + assert_eq!(actual, "2", "{sql}"); + } +} + +#[test] +fn vertica_set_operation_ordering_preserves_nulls_and_limits() { + for operation in ["UNION ALL", "INTERSECT", "EXCEPT"] { + let branches = if operation == "EXCEPT" { + "SELECT CAST(1 AS INT) AS x UNION ALL SELECT CAST(NULL AS INT) AS x EXCEPT SELECT CAST(2 AS INT) AS x".to_string() + } else if operation == "INTERSECT" { + "(SELECT CAST(1 AS INT) AS x UNION ALL SELECT CAST(NULL AS INT) AS x) INTERSECT (SELECT CAST(1 AS INT) AS x UNION ALL SELECT CAST(NULL AS INT) AS x)".to_string() + } else { + "SELECT CAST(1 AS INT) AS x UNION ALL SELECT CAST(NULL AS INT) AS x".to_string() + }; + for key in ["x", "1"] { + let sql = format!("{branches} ORDER BY {key} LIMIT 1"); + let output = vertica_translate(&sql, "vertica", "duckdb"); + assert!(output.contains("NULLS FIRST"), "{output}"); + if let Some(actual) = execute_vertica_target("", &output) { + assert_eq!(actual, "NULL", "{sql}"); + } + } + } + for operation in ["UNION ALL", "INTERSECT", "EXCEPT"] { + let sql = + format!("SELECT x FROM t {operation} SELECT x FROM u ORDER BY x NULLS FIRST LIMIT 1"); + let output = vertica_translate(&sql, "postgresql", "vertica"); + assert!( + output.contains("CASE WHEN") && !output.contains("NULLS FIRST"), + "{output}" + ); + assert!(output.ends_with("LIMIT 1"), "{output}"); + polyglot_sql::Dialect::get(polyglot_sql::DialectType::Vertica) + .parse(&output) + .unwrap(); + let setup = "CREATE TABLE t(x INT); INSERT INTO t VALUES (NULL),(1),(2); CREATE TABLE u(x INT); INSERT INTO u VALUES (NULL),(3)"; + if let Some(actual) = execute_vertica_target(setup, &output) { + assert_eq!( + actual, + if operation == "EXCEPT" { "1" } else { "NULL" }, + "{output}" + ); + } + } +} + +#[test] +fn vertica_unverified_ordering_and_clocks_fail_in_every_mode() { + use polyglot_sql::{transpile_with_by_name, TranspileOptions, UnsupportedLevel}; + for (sql, target) in [ + ("SELECT CAST(x AS VARCHAR) AS x FROM t ORDER BY t.x", "duckdb"), + ("SELECT x FROM t UNION ALL SELECT x FROM u ORDER BY x", "duckdb"), + ("SELECT CAST(1 AS INT) AS x UNION ALL SELECT CAST(2 AS FLOAT) AS x ORDER BY x", "duckdb"), + ("SELECT GETDATE()", "duckdb"), + ("SELECT GETUTCDATE()", "duckdb"), + ("SELECT SYSDATE", "duckdb"), + ("SELECT k AS d, v FROM t WHERE d > 1 LIMIT 1 OVER(PARTITION BY d ORDER BY v)", "postgresql"), + ("SELECT k, SUM(v) AS s FROM t GROUP BY k HAVING s > 1 LIMIT 1 OVER(PARTITION BY k ORDER BY s)", "postgresql"), + ] { + for level in [UnsupportedLevel::Ignore, UnsupportedLevel::Warn, UnsupportedLevel::Raise, UnsupportedLevel::Immediate] { + let mut options = TranspileOptions::default(); + options.unsupported_level = level; + assert!(transpile_with_by_name(sql, "vertica", target, &options).is_err(), "{sql}, {level:?}"); + } + } + let output = vertica_translate("SELECT GETDATE(), GETUTCDATE()", "vertica", "postgresql"); + assert!(output.contains("STATEMENT_TIMESTAMP()")); +} + +#[test] +fn vertica_bigquery_countif_preserves_empty_inputs_and_frames() { + let setup = "CREATE TABLE t(x INT); INSERT INTO t VALUES (1),(2)"; + for (sql, expected) in [ + ("SELECT COUNTIF(x > 0) FROM t WHERE FALSE", "0"), + ("SELECT COUNTIF(x > 10) FROM t", "0"), + ("SELECT COUNTIF(x > 0) FROM t", "2"), + ("SELECT COUNTIF(x > 0) OVER(ORDER BY x ROWS BETWEEN 1 PRECEDING AND 1 PRECEDING) FROM t", "0\n1"), + ] { + let output = vertica_translate(sql, "bigquery", "vertica"); + assert!(output.contains("COUNT(CASE"), "{output}"); + if let Some(actual) = execute_vertica_target(setup, &output) { assert_eq!(actual, expected, "{output}"); } + } +} + +#[test] +fn vertica_distinct_ordering_sorts_outside_deduplication() { + for (sql, expected) in [ + ( + "SELECT DISTINCT x FROM t ORDER BY x NULLS FIRST LIMIT 2", + "NULL\n1", + ), + ( + "SELECT DISTINCT x AS value FROM t ORDER BY 1 NULLS FIRST LIMIT 2", + "NULL\n1", + ), + ( + "SELECT DISTINCT x AS \"odd name\" FROM t ORDER BY \"odd name\" NULLS FIRST LIMIT 2", + "NULL\n1", + ), + ("SELECT DISTINCT x FROM t ORDER BY x LIMIT 2", "1\n2"), + ] { + let output = vertica_translate(sql, "postgresql", "vertica"); + let ast = polyglot_sql::Dialect::get(polyglot_sql::DialectType::Vertica) + .parse(&output) + .unwrap(); + let polyglot_sql::expressions::Expression::Select(outer) = &ast[0] else { + panic!("expected SELECT") + }; + assert!(!outer.distinct && outer.expressions.len() == 1, "{output}"); + assert!( + output.contains("SELECT DISTINCT") && output.contains("CASE WHEN"), + "{output}" + ); + let setup = "CREATE TABLE t(x INT); INSERT INTO t VALUES (1),(1),(NULL),(2)"; + if let Some(actual) = execute_vertica_target(setup, &output) { + assert_eq!(actual, expected, "{output}"); + } + } +} + +#[test] +fn vertica_parenthesized_query_ordering_preserves_nulls_and_aliases() { + for sql in [ + "(SELECT x FROM t) ORDER BY x NULLS FIRST LIMIT 1", + "SELECT y FROM ((SELECT x FROM t UNION ALL SELECT x FROM t) ORDER BY x NULLS FIRST LIMIT 1) AS s(y)", + ] { + let output = vertica_translate(sql, "postgresql", "vertica"); + assert!(output.contains("CASE WHEN") && !output.contains("NULLS FIRST"), "{output}"); + if let Some(actual) = execute_vertica_target( + "CREATE TABLE t(x INT); INSERT INTO t VALUES (1),(NULL)", + &output, + ) { + assert_eq!(actual, "NULL", "{output}"); + } + } + let sql = + "(SELECT CAST(1 AS INT) AS x UNION ALL SELECT CAST(NULL AS INT) AS x) ORDER BY x LIMIT 1"; + let output = vertica_translate(sql, "vertica", "duckdb"); + if let Some(actual) = execute_vertica_target("", &output) { + assert_eq!(actual, "NULL", "{output}"); + } +} + +#[test] +#[ignore = "manual DuckDB execution benchmark; set POLYGLOT_DUCKDB and run with --ignored --nocapture"] +fn vertica_array_execution_benchmark() { + std::env::var("POLYGLOT_DUCKDB").expect("POLYGLOT_DUCKDB must name a DuckDB CLI"); + let previous = "SELECT SUM((SELECT _polyglot_v.a[CASE WHEN _polyglot_v.i0 < 0 OR _polyglot_v.i0 >= 2147483647 THEN NULL ELSE _polyglot_v.i0 + 1 END] FROM (SELECT arr AS a, 0 AS i0) AS _polyglot_v)) FROM t"; + let setup = "SET threads=1; CREATE TABLE t AS SELECT [i,i+1] AS arr, i%2 AS idx FROM range(1000000) r(i); CREATE SEQUENCE s MINVALUE 0 START 0"; + for (label, index) in [ + ("constant", "0"), + ("column", "idx"), + ("computed", "idx + 0"), + ("volatile", "NEXTVAL('s') % 2"), + ] { + let generated = vertica_translate( + &format!("SELECT SUM(arr[{index}]) FROM t"), + "vertica", + "duckdb", + ); + let native = if index == "0" { + let direct = "SELECT SUM(arr[1]) FROM t"; + assert_eq!(generated, direct); + direct.to_string() + } else { + format!("SELECT SUM(arr[({index}) + 1]) FROM t") + }; + let mut queries = vec![("native", native)]; + if index == "0" { + queries.push(("previous", previous.to_string())); + } + queries.push(("generated", generated)); + let mut sql = String::new(); + for _ in 0..7 { + for (_, query) in &queries { + sql.push_str(&format!("EXPLAIN ANALYZE {query};\n")); + } + } + let output = execute_vertica_target(setup, &sql).unwrap(); + let times = output + .lines() + .filter_map(|line| { + line.split_once("Total Time: ") + .and_then(|(_, t)| t.split_once('s')) + .and_then(|(t, _)| t.parse::().ok()) + }) + .collect::>(); + assert_eq!(times.len(), 7 * queries.len(), "{output}"); + for (index, (name, query)) in queries.iter().enumerate() { + let mut warm = times + .iter() + .skip(queries.len() + index) + .step_by(queries.len()) + .copied() + .collect::>(); + warm.sort_by(f64::total_cmp); + let median = (warm[2] + warm[3]) / 2.0; + println!("{label}/{name}: median={median:.6}s, warm_samples={warm:?}"); + println!("SQL: {query}"); + } + } +} diff --git a/crates/polyglot-sql/tests/custom_fixtures/vertica/identity.json b/crates/polyglot-sql/tests/custom_fixtures/vertica/identity.json new file mode 100644 index 00000000..2a933d5c --- /dev/null +++ b/crates/polyglot-sql/tests/custom_fixtures/vertica/identity.json @@ -0,0 +1,215 @@ +{ + "dialect": "vertica", + "category": "identity", + "identity": [ + { + "sql": "SELECT 1", + "description": "Simple literal select" + }, + { + "sql": "SELECT a, b FROM t WHERE a > 1 ORDER BY b LIMIT 10 OFFSET 5", + "description": "SELECT with LIMIT/OFFSET" + }, + { + "sql": "SELECT \"Mixed Case\" FROM \"My Table\"", + "description": "Double-quoted identifiers" + }, + { + "sql": "SELECT CAST(a AS VARCHAR(10)) FROM t", + "description": "CAST" + }, + { + "sql": "SELECT a::VARCHAR(10) FROM t", + "expected": "SELECT CAST(a AS VARCHAR(10)) FROM t", + "description": "Double-colon cast" + }, + { + "sql": "SELECT * FROM t WHERE name ILIKE 'a%'", + "description": "ILIKE" + }, + { + "sql": "SELECT * FROM t WHERE name NOT ILIKE 'a%'", + "description": "NOT ILIKE" + }, + { + "sql": "SELECT a || b FROM t", + "description": "String concatenation" + }, + { + "sql": "SELECT NVL(a, b) FROM t", + "description": "NVL is native" + }, + { + "sql": "SELECT NVL2(a, b, c) FROM t", + "description": "NVL2 is native" + }, + { + "sql": "SELECT DECODE(a, 1, 'one', 2, 'two', 'other') FROM t", + "description": "DECODE is native" + }, + { + "sql": "SELECT ZEROIFNULL(a) FROM t", + "description": "ZEROIFNULL is native" + }, + { + "sql": "SELECT COALESCE(a, b, c) FROM t", + "description": "COALESCE" + }, + { + "sql": "SELECT DATEDIFF(DAY, started_at, ended_at) FROM events", + "description": "DATEDIFF with unit" + }, + { + "sql": "SELECT DATEDIFF('day', started_at, ended_at) FROM events", + "description": "DATEDIFF with string unit" + }, + { + "sql": "SELECT TIMESTAMPADD(day, 3, occurred_at) FROM events", + "expected": "SELECT TIMESTAMPADD(DAY, 3, occurred_at) FROM events", + "description": "TIMESTAMPADD unit is upper-cased" + }, + { + "sql": "SELECT TIMESTAMPDIFF(hour, started_at, ended_at) FROM events", + "expected": "SELECT DATEDIFF(HOUR, started_at, ended_at) FROM events", + "description": "TIMESTAMPDIFF is DATEDIFF" + }, + { + "sql": "SELECT ADD_MONTHS(invoice_date, 2) FROM invoices", + "description": "ADD_MONTHS" + }, + { + "sql": "SELECT GETDATE(), GETUTCDATE(), CURRENT_TIMESTAMP(3)", + "description": "Statement and transaction timestamps" + }, + { + "sql": "SELECT SYSDATE, SYSDATE()", + "expected": "SELECT GETDATE(), GETDATE()", + "description": "SYSDATE is a synonym for GETDATE" + }, + { + "sql": "SELECT TIME_SLICE(ts, 5, 'MINUTE', 'START') FROM ticks", + "description": "TIME_SLICE" + }, + { + "sql": "SELECT DAYOFMONTH(ts), DAYOFWEEK(ts), DAYOFWEEK_ISO(ts), DAYOFYEAR(ts) FROM t", + "description": "DAYOF* functions" + }, + { + "sql": "SELECT TO_CHAR(ts, 'YYYY-MM-DD'), TO_DATE('2020-01-01', 'YYYY-MM-DD') FROM t", + "description": "TO_CHAR/TO_DATE" + }, + { + "sql": "SELECT REGEXP_LIKE(a, 'x.*') FROM t", + "description": "REGEXP_LIKE" + }, + { + "sql": "SELECT APPROXIMATE_COUNT_DISTINCT(a) FROM t", + "description": "APPROXIMATE_COUNT_DISTINCT" + }, + { + "sql": "SELECT MEDIAN(b) OVER (PARTITION BY c) FROM t", + "description": "MEDIAN analytic" + }, + { + "sql": "SELECT ROW_NUMBER() OVER (PARTITION BY k ORDER BY a DESC) FROM t", + "description": "Window function" + }, + { + "sql": "SELECT LISTAGG(city) FROM places", + "description": "LISTAGG default separator" + }, + { + "sql": "SELECT LISTAGG(DISTINCT city) FROM places", + "description": "LISTAGG DISTINCT" + }, + { + "sql": "SELECT LISTAGG(name, ',') WITHIN GROUP (ORDER BY ordinal) FROM names", + "expected": "SELECT LISTAGG(name USING PARAMETERS separator = ',') WITHIN GROUP (ORDER BY ordinal) FROM names", + "description": "Two-argument LISTAGG becomes USING PARAMETERS" + }, + { + "sql": "SELECT LISTAGG(city USING PARAMETERS separator=' | ', max_length=4096, on_overflow='TRUNCATE') WITHIN GROUP (ORDER BY city) FROM places", + "expected": "SELECT LISTAGG(city USING PARAMETERS separator = ' | ', max_length = 4096, on_overflow = 'TRUNCATE') WITHIN GROUP (ORDER BY city) FROM places", + "description": "LISTAGG USING PARAMETERS" + }, + { + "sql": "SELECT a FROM t ORDER BY a NULLS FIRST, b DESC NULLS LAST", + "description": "Explicit top-level NULL placement uses CASE sort keys", + "expected": "SELECT a FROM t ORDER BY CASE WHEN a IS NULL THEN 0 ELSE 1 END, a, CASE WHEN b IS NULL THEN 1 ELSE 0 END, b DESC" + }, + { + "sql": "SELECT 1 MINUS SELECT 2", + "expected": "SELECT 1 EXCEPT SELECT 2", + "description": "MINUS is EXCEPT" + }, + { + "sql": "SELECT 1 UNION ALL SELECT 2 INTERSECT SELECT 3", + "description": "Set operation chain" + }, + { + "sql": "SELECT 117.32 // 2.5, !! 5, 4.98!, @ -5.0", + "expected": "SELECT 117.32 // 2.5, 5!, 4.98!, ABS(-5.0)", + "description": "Integer division, factorial and absolute value operators" + }, + { + "sql": "SELECT -4!, (-4)!, 5! + 1", + "description": "Factorial precedence" + }, + { + "sql": "SELECT |/ 25.0, ||/ 27.0", + "expected": "SELECT SQRT(25.0), CBRT(27.0)", + "description": "Square and cube root operators" + }, + { + "sql": "SELECT a != b FROM t", + "expected": "SELECT a <> b FROM t", + "description": "Not-equal is not factorial" + }, + { + "sql": "SELECT INTERVAL '1 year 2 months' YEAR TO MONTH", + "description": "Interval YEAR TO MONTH" + }, + { + "sql": "SELECT INTERVAL '3 days' DAY TO SECOND(3)", + "description": "Interval with seconds precision" + }, + { + "sql": "SELECT INTERVAL '1.234' SECOND(3)", + "description": "Interval SECOND precision" + }, + { + "sql": "SELECT ARRAY[1, 2], ROW(1, 'x')", + "description": "Collection constructors" + }, + { + "sql": "SELECT * FROM t QUALIFY ROW_NUMBER() OVER (PARTITION BY a ORDER BY b) = 1", + "expected": "SELECT * FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY a ORDER BY b) AS _w FROM t) AS _t WHERE _w = 1", + "description": "QUALIFY is rewritten to a subquery" + }, + { + "sql": "CREATE LOCAL TEMPORARY TABLE t (id INT) ON COMMIT DELETE ROWS", + "description": "Local temporary table" + }, + { + "sql": "CREATE LOCAL TEMP TABLE t_local ON COMMIT PRESERVE ROWS AS SELECT 1 AS c", + "description": "Local temporary CTAS" + }, + { + "sql": "INSERT INTO t (a, b) VALUES (1, 'x')", + "description": "INSERT" + }, + { + "sql": "UPDATE t SET a = 1 WHERE b = 2", + "description": "UPDATE" + }, + { + "sql": "DELETE FROM t WHERE a = 1", + "description": "DELETE" + }, + { + "sql": "MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE SET a = s.a WHEN NOT MATCHED THEN INSERT (id, a) VALUES (s.id, s.a)", + "description": "MERGE" + } + ], + "transpilation": [] +} diff --git a/crates/polyglot-sql/tests/custom_fixtures/vertica/transpilation.json b/crates/polyglot-sql/tests/custom_fixtures/vertica/transpilation.json new file mode 100644 index 00000000..e12376d0 --- /dev/null +++ b/crates/polyglot-sql/tests/custom_fixtures/vertica/transpilation.json @@ -0,0 +1,188 @@ +{ + "dialect": "vertica", + "category": "transpilation", + "identity": [], + "transpilation": [ + { + "sql": "SELECT 1 EXCEPT SELECT 2", + "write": { + "postgresql": "SELECT 1 EXCEPT SELECT 2", + "duckdb": "SELECT 1 EXCEPT SELECT 2" + }, + "description": "MINUS/EXCEPT", + "read": {} + }, + { + "sql": "SELECT 1 FROM dual EXCEPT SELECT 2 FROM dual", + "read": { + "oracle": "SELECT 1 FROM dual MINUS SELECT 2 FROM dual" + }, + "description": "Oracle MINUS" + }, + { + "sql": "SELECT GETDATE(), GETUTCDATE()", + "write": { + "postgresql": "SELECT CAST(STATEMENT_TIMESTAMP() AS TIMESTAMP), CAST(STATEMENT_TIMESTAMP() AT TIME ZONE 'UTC' AS TIMESTAMP)", + "tsql": "SELECT GETDATE(), GETUTCDATE()" + }, + "description": "Statement-start timestamps lower to STATEMENT_TIMESTAMP in PostgreSQL", + "read": {} + }, + { + "sql": "SELECT GETDATE()", + "read": { + "redshift": "SELECT SYSDATE", + "tsql": "SELECT GETDATE()" + }, + "description": "SYSDATE / GETDATE into Vertica" + }, + { + "sql": "SELECT DATEDIFF(DAY, a, b) FROM t", + "write": {}, + "read": { + "mysql": "SELECT DATEDIFF(b, a) FROM t", + "snowflake": "SELECT DATEDIFF(day, a, b) FROM t" + }, + "description": "DATEDIFF" + }, + { + "sql": "SELECT TIMESTAMPADD(DAY, 3, ts) FROM t", + "write": { + "postgresql": "SELECT ts + INTERVAL '3 DAY' FROM t", + "mysql": "SELECT DATE_ADD(ts, INTERVAL 3 DAY) FROM t", + "snowflake": "SELECT DATEADD(DAY, 3, ts) FROM t", + "tsql": "SELECT DATEADD(DAY, 3, ts) FROM t", + "duckdb": "SELECT ts + INTERVAL 3 DAY FROM t" + }, + "read": { + "snowflake": "SELECT DATEADD(day, 3, ts) FROM t", + "tsql": "SELECT DATEADD(day, 3, ts) FROM t" + }, + "description": "TIMESTAMPADD and DATEADD" + }, + { + "sql": "SELECT TIMESTAMPADD(MONTH, n, ts) FROM t", + "write": { + "postgresql": "SELECT ts + INTERVAL '1 MONTH' * n FROM t" + }, + "description": "TIMESTAMPADD with a non-literal amount", + "read": {} + }, + { + "sql": "SELECT NVL(a, b), NVL2(a, b, c), DECODE(a, 1, 'one', 'other'), ZEROIFNULL(a) FROM t", + "write": { + "postgresql": "SELECT COALESCE(a, b), CASE WHEN NOT a IS NULL THEN b ELSE c END, CASE WHEN a = 1 THEN 'one' ELSE 'other' END, COALESCE(a, 0) FROM t", + "snowflake": "SELECT COALESCE(a, b), NVL2(a, b, c), DECODE(a, 1, 'one', 'other'), IFF(a IS NULL, 0, a) FROM t", + "oracle": "SELECT NVL(a, b), NVL2(a, b, c), DECODE(a, 1, 'one', 'other'), NVL(a, 0) FROM t" + }, + "description": "NULL-handling functions", + "read": {} + }, + { + "sql": "SELECT DECODE(a, 1, 'x', 'y') FROM t", + "read": { + "oracle": "SELECT DECODE(a, 1, 'x', 'y') FROM t" + }, + "description": "DECODE stays native" + }, + { + "sql": "SELECT APPROXIMATE_COUNT_DISTINCT(a) FROM t", + "write": { + "snowflake": "SELECT APPROX_COUNT_DISTINCT(a) FROM t", + "duckdb": "SELECT APPROX_COUNT_DISTINCT(a) FROM t" + }, + "read": { + "snowflake": "SELECT APPROX_COUNT_DISTINCT(a) FROM t" + }, + "description": "Approximate distinct count" + }, + { + "sql": "SELECT COALESCE(a, b) FROM t", + "read": { + "mysql": "SELECT IFNULL(a, b) FROM t", + "tsql": "SELECT ISNULL(a, b) FROM t" + }, + "description": "IFNULL/ISNULL to COALESCE" + }, + { + "sql": "SELECT CAST(a AS BIGINT) FROM t", + "write": { + "postgresql": "SELECT CAST(a AS BIGINT) FROM t", + "tsql": "SELECT CAST(a AS BIGINT) FROM t" + }, + "read": { + "postgresql": "SELECT a::BIGINT FROM t" + }, + "description": "Integers are BIGINT" + }, + { + "sql": "SELECT CASE WHEN a > 1 THEN 'x' ELSE 'y' END FROM t", + "read": { + "snowflake": "SELECT IFF(a > 1, 'x', 'y') FROM t", + "mysql": "SELECT IF(a > 1, 'x', 'y') FROM t" + }, + "description": "IF/IFF to CASE" + }, + { + "sql": "SELECT INSTR(a, 'x') FROM t", + "read": { + "tsql": "SELECT CHARINDEX('x', a) FROM t" + }, + "description": "CHARINDEX to INSTR" + }, + { + "sql": "SELECT SUM(CASE WHEN a > 1 THEN 1 ELSE 0 END) FROM t", + "read": { + "snowflake": "SELECT COUNT_IF(a > 1) FROM t" + }, + "description": "COUNT_IF expansion" + }, + { + "sql": "SELECT * FROM a WHERE EXISTS((SELECT 1 FROM b WHERE a.id = b.id))", + "read": { + "spark": "SELECT * FROM a LEFT SEMI JOIN b ON a.id = b.id" + }, + "description": "Semi join elimination" + }, + { + "sql": "SELECT a FROM t LIMIT 5", + "write": { + "tsql": "SELECT TOP 5 a FROM t" + }, + "read": { + "tsql": "SELECT TOP 5 a FROM t" + }, + "description": "TOP to LIMIT" + }, + { + "sql": "SELECT a FROM t ORDER BY CASE WHEN a IS NULL THEN 1 ELSE 0 END, a", + "read": { + "postgresql": "SELECT a FROM t ORDER BY a" + }, + "description": "Source NULL ordering is made explicit" + }, + { + "sql": "SELECT a FROM t ORDER BY CASE WHEN a IS NULL THEN 1 ELSE 0 END, a DESC", + "read": { + "duckdb": "SELECT a FROM t ORDER BY a DESC" + }, + "description": "DuckDB NULLS LAST default is made explicit" + }, + { + "sql": "SELECT 7 // 2, 5!, ABS(a) FROM t", + "write": { + "postgresql": "SELECT DIV(7, 2), FACTORIAL(5), ABS(a) FROM t", + "duckdb": "SELECT 7 // 2, FACTORIAL(5), ABS(a) FROM t" + }, + "description": "Vertica-only operators", + "read": {} + }, + { + "sql": "CREATE TABLE t (a BIGINT, b DOUBLE PRECISION, c LONG VARCHAR, d VARBINARY, e TIMESTAMPTZ)", + "read": { + "postgresql": "CREATE TABLE t (a INT, b REAL, c TEXT, d BYTEA, e TIMESTAMPTZ)" + }, + "description": "PostgreSQL types widen to Vertica types" + } + ] +} diff --git a/crates/polyglot-sql/tests/custom_fixtures/vertica/types.json b/crates/polyglot-sql/tests/custom_fixtures/vertica/types.json new file mode 100644 index 00000000..d7925adb --- /dev/null +++ b/crates/polyglot-sql/tests/custom_fixtures/vertica/types.json @@ -0,0 +1,50 @@ +{ + "dialect": "vertica", + "category": "types", + "identity": [ + { + "sql": "CREATE TABLE t (a INT, b INTEGER, c SMALLINT, d TINYINT, e INT8, f BIGINT)", + "expected": "CREATE TABLE t (a BIGINT, b BIGINT, c BIGINT, d BIGINT, e BIGINT, f BIGINT)", + "description": "All integers are 64-bit BIGINT" + }, + { + "sql": "CREATE TABLE t (a REAL, b FLOAT, c FLOAT8, d DOUBLE PRECISION)", + "expected": "CREATE TABLE t (a DOUBLE PRECISION, b DOUBLE PRECISION, c DOUBLE PRECISION, d DOUBLE PRECISION)", + "description": "All floats are DOUBLE PRECISION" + }, + { + "sql": "CREATE TABLE t (a LONG VARCHAR, b LONG VARCHAR(100000), c LONG VARBINARY, d LONG VARBINARY(200))", + "description": "LONG types" + }, + { + "sql": "CREATE TABLE t (a BINARY VARYING(32), b VARBINARY(10), c BINARY(4), d BYTEA)", + "expected": "CREATE TABLE t (a VARBINARY(32), b VARBINARY(10), c BINARY(4), d VARBINARY)", + "description": "Binary types" + }, + { + "sql": "CREATE TABLE t (a VARCHAR(80), b CHAR(3), c TEXT)", + "expected": "CREATE TABLE t (a VARCHAR(80), b CHAR(3), c LONG VARCHAR)", + "description": "Character types; TEXT is LONG VARCHAR" + }, + { + "sql": "CREATE TABLE t (a NUMERIC(10, 2), b DECIMAL(38, 0), c BOOLEAN, d UUID, e DATE)", + "expected": "CREATE TABLE t (a DECIMAL(10, 2), b DECIMAL(38, 0), c BOOLEAN, d UUID, e DATE)", + "description": "Numeric and misc types" + }, + { + "sql": "CREATE TABLE t (a TIMESTAMP, b TIMESTAMPTZ, c TIME, d TIMETZ, e TIMESTAMP WITH TIME ZONE)", + "expected": "CREATE TABLE t (a TIMESTAMP, b TIMESTAMPTZ, c TIME, d TIMETZ, e TIMESTAMPTZ)", + "description": "Temporal types" + }, + { + "sql": "CREATE TABLE t (a INTERVAL, b INTERVAL SECOND(3), c INTERVAL DAY TO SECOND(5), d INTERVAL YEAR TO MONTH)", + "description": "Interval column types" + }, + { + "sql": "SELECT CAST(a AS INT), a::FLOAT, CAST(b AS INTERVAL DAY TO SECOND(3)) FROM t", + "expected": "SELECT CAST(a AS BIGINT), CAST(a AS DOUBLE PRECISION), CAST(b AS INTERVAL DAY TO SECOND(3)) FROM t", + "description": "Cast target types" + } + ], + "transpilation": [] +} diff --git a/crates/polyglot-sql/tests/dialect_matrix.rs b/crates/polyglot-sql/tests/dialect_matrix.rs index a6959026..84c0b223 100644 --- a/crates/polyglot-sql/tests/dialect_matrix.rs +++ b/crates/polyglot-sql/tests/dialect_matrix.rs @@ -5702,6 +5702,7 @@ mod hana_regressions { DialectType::Dremio, DialectType::Exasol, DialectType::DataFusion, + DialectType::Vertica, ] { let ast = Dialect::get(DialectType::HANA).parse(sql).unwrap(); assert!( diff --git a/docs/set-operation-types.md b/docs/set-operation-types.md index ad13300a..2d998c36 100644 --- a/docs/set-operation-types.md +++ b/docs/set-operation-types.md @@ -40,7 +40,7 @@ every SQL expression is executable on a particular engine. ## Dialect rules and evidence -The policy covers all 33 named dialects and Generic. It is intentionally a +The policy covers all 34 named dialects and Generic. It is intentionally a partial type system: unsupported combinations return no hint, rather than claiming a complete implementation of an engine's implicit-cast rules. @@ -62,7 +62,7 @@ claiming a complete implementation of an engine's implicit-cast rules. | ClickHouse | Numeric representability, Nullable propagation, and compatible nested types. Int64/Float64 and Int64/UInt64 do not silently become lossy common types. | | Teradata | Compatible known operands retain the first SELECT's type, as specified by the engine. | | DataFusion | Numeric and decimal coercion, string/numeric union output, recursive nested types, and STRUCT matching by field name. Unsupported Arrow-specific types are unresolved. | -| Doris, StarRocks, Exasol, Generic | Same-category scalar widening and explicit decimal shapes. Engine-specific complex and cross-category coercions remain unresolved. | +| Doris, StarRocks, Exasol, Vertica, Generic | Same-category scalar widening and explicit decimal shapes. Engine-specific complex and cross-category coercions remain unresolved. | | Drill, Dremio, Druid, Solr, Tableau | Conservative scalar rules; mixed floating, decimal, temporal, and complex combinations without a supported rule remain unresolved. Druid DECIMAL/REAL normalize to DOUBLE. | Engine documentation and source used to distinguish these rules: diff --git a/packages/documentation/README.md b/packages/documentation/README.md index 9cbfa359..23958569 100644 --- a/packages/documentation/README.md +++ b/packages/documentation/README.md @@ -80,7 +80,7 @@ Default guard values: `maxInputBytes=16 MiB`, `maxTokens=1_000_000`, `maxAstNode ## Supported Dialects -Athena, BigQuery, ClickHouse, CockroachDB, DataFusion, Databricks, Doris, Dremio, Drill, Druid, DuckDB, Dune, Exasol, Fabric, Generic SQL, Hive, Materialize, MySQL, Oracle, PostgreSQL, Presto, Redshift, RisingWave, SingleStore, Snowflake, Solr, Spark, SQLite, StarRocks, Tableau, Teradata, TiDB, Trino, and TSQL (SQL Server). +Athena, BigQuery, ClickHouse, CockroachDB, DataFusion, Databricks, Doris, Dremio, Drill, Druid, DuckDB, Dune, Exasol, Fabric, Generic SQL, Hive, Materialize, MySQL, Oracle, PostgreSQL, Presto, Redshift, RisingWave, SingleStore, Snowflake, Solr, Spark, SQLite, StarRocks, Tableau, Teradata, TiDB, Trino, TSQL (SQL Server), and Vertica. ## Links diff --git a/packages/go/integration_test.go b/packages/go/integration_test.go index b951c3cd..03f2241e 100644 --- a/packages/go/integration_test.go +++ b/packages/go/integration_test.go @@ -1548,6 +1548,30 @@ func TestIntegrationPackageLevelAPI(t *testing.T) { assertValidJSON(t, "OpenLineageRunEvent wrapper", runEvent.Event) } +func TestIntegrationVerticaStructuredSyntaxAndErrors(t *testing.T) { + client := integrationClient(t) + sql := "COPY t FROM LOCAL '/tmp/data.json' PARSER FJSONPARSER(flatten_maps=TRUE)" + ast, err := client.Parse(sql, "vertica") + if err != nil || !strings.Contains(string(ast), `"kind":"copy"`) { + t.Fatalf("structured COPY: %s, %v", ast, err) + } + generated, err := client.Generate(ast, "vertica") + if err != nil || len(generated) != 1 || !strings.Contains(generated[0], "FJSONPARSER(flatten_maps = TRUE)") { + t.Fatalf("native COPY: %v, %v", generated, err) + } + for _, level := range []UnsupportedLevel{UnsupportedIgnore, UnsupportedWarn, UnsupportedRaise, UnsupportedImmediate} { + for _, sql := range []string{"SELECT x::!INT FROM t", "SELECT LISTAGG(x) FROM t", "SELECT id FROM t FOR UPDATE"} { + if _, err := client.Transpile(sql, "vertica", "postgres", TranspileOptions{UnsupportedLevel: level}); err == nil { + t.Fatalf("unsafe translation accepted at %s: %s", level, sql) + } + } + } + result, err := client.Transpile("SELECT B'101100'", "vertica", "duckdb") + if err != nil || len(result) != 1 || result[0] != "SELECT UNHEX('2c')" { + t.Fatalf("binary value: %v, %v", result, err) + } +} + func TestIntegrationHana(t *testing.T) { client := integrationClient(t) sql := "SELECT * FROM t FOR JSON ('arraywrap' = 'NO')" diff --git a/packages/playground/src/lib/constants.ts b/packages/playground/src/lib/constants.ts index 8cc206f9..47c30c93 100644 --- a/packages/playground/src/lib/constants.ts +++ b/packages/playground/src/lib/constants.ts @@ -34,6 +34,7 @@ export const DIALECT_DISPLAY_NAMES: Record = { tidb: "TiDB", trino: "Trino", tsql: "SQL Server (T-SQL)", + vertica: "Vertica", }; export const DEFAULT_TRANSPILE_SQL = `SELECT diff --git a/packages/sdk/README.md b/packages/sdk/README.md index 2ccaa4a5..1da31e58 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -1061,6 +1061,7 @@ This limit covers recursive parsing, not arbitrary programmatic AST construction | TiDB | `Dialect.TiDB` | | Trino | `Dialect.Trino` | | TSQL | `Dialect.TSQL` | +| Vertica | `Dialect.Vertica` | ## CDN Usage diff --git a/packages/sdk/src/index.test.ts b/packages/sdk/src/index.test.ts index 4221b27d..8a6ad842 100644 --- a/packages/sdk/src/index.test.ts +++ b/packages/sdk/src/index.test.ts @@ -2018,3 +2018,41 @@ describe('Edge cases', () => { }); }); }); + +describe('Vertica structured syntax and semantics', () => { + it('retains COPY parser arguments through the public AST API', () => { + const parsed = parse( + "COPY t FROM LOCAL '/tmp/data.json' PARSER FJSONPARSER(flatten_maps=TRUE)", + Dialect.Vertica, + ); + expect(parsed.success).toBe(true); + expect(JSON.stringify(parsed.ast)).toContain('"kind":"copy"'); + const output = generate(parsed.ast ?? [], Dialect.Vertica); + expect(output.success).toBe(true); + expect(output.sql?.[0]).toContain('FJSONPARSER(flatten_maps = TRUE)'); + }); + + it('preserves binary values and rejects unsafe conversions in every mode', () => { + expect( + transpile("SELECT B'101100'", Dialect.Vertica, Dialect.DuckDB).sql, + ).toEqual(["SELECT UNHEX('2c')"]); + for (const unsupportedLevel of [ + 'ignore', + 'warn', + 'raise', + 'immediate', + ] as const) { + for (const sql of [ + 'SELECT x::!INT FROM t', + 'SELECT LISTAGG(x) FROM t', + 'SELECT id FROM t FOR UPDATE', + ]) { + expect( + transpile(sql, Dialect.Vertica, Dialect.PostgreSQL, { + unsupportedLevel, + }).success, + ).toBe(false); + } + } + }); +}); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 94148e59..d9535ce8 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -50,6 +50,7 @@ export enum Dialect { Exasol = 'exasol', DataFusion = 'datafusion', HANA = 'hana', + Vertica = 'vertica', } /**