From 2102318b6bcdeaa9bcba6d58dd677b75b527cc5c Mon Sep 17 00:00:00 2001 From: ArjixWasTaken <53124886+ArjixWasTaken@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:39:02 +0300 Subject: [PATCH 1/7] feat: add Vertica dialect --- CHANGELOG.md | 16 + Makefile | 1 + README.md | 2 +- crates/polyglot-sql-ffi/src/dialects.rs | 3 +- crates/polyglot-sql-ffi/tests/ffi_tests.rs | 2 +- crates/polyglot-sql-python/README.md | 2 +- crates/polyglot-sql-python/src/dialects.rs | 1 + .../tests/test_dialects.py | 1 + crates/polyglot-sql-wasm/Cargo.toml | 2 + crates/polyglot-sql-wasm/src/lib.rs | 5 +- crates/polyglot-sql/Cargo.toml | 2 + crates/polyglot-sql/README.md | 2 +- crates/polyglot-sql/src/dialects/duckdb.rs | 2 + crates/polyglot-sql/src/dialects/exasol.rs | 1 + crates/polyglot-sql/src/dialects/mod.rs | 19 ++ .../src/dialects/normalization/aggregates.rs | 3 + .../src/dialects/normalization/mod.rs | 20 +- .../src/dialects/normalization/operators.rs | 7 +- .../src/dialects/normalization/scalar.rs | 18 +- .../src/dialects/normalization/vertica.rs | 236 ++++++++++++++ crates/polyglot-sql/src/dialects/snowflake.rs | 1 + crates/polyglot-sql/src/dialects/vertica.rs | 294 ++++++++++++++++++ crates/polyglot-sql/src/expressions.rs | 3 + crates/polyglot-sql/src/generator.rs | 143 ++++++++- .../src/optimizer/set_operation_types.rs | 3 +- crates/polyglot-sql/src/parser.rs | 177 ++++++++++- crates/polyglot-sql/src/tokens.rs | 4 + .../polyglot-sql/tests/common/test_runner.rs | 1 + .../custom_fixtures/vertica/identity.json | 214 +++++++++++++ .../vertica/transpilation.json | 248 +++++++++++++++ .../tests/custom_fixtures/vertica/types.json | 50 +++ docs/set-operation-types.md | 4 +- packages/documentation/README.md | 2 +- packages/playground/src/lib/constants.ts | 1 + packages/sdk/README.md | 1 + packages/sdk/src/index.ts | 1 + 36 files changed, 1457 insertions(+), 35 deletions(-) create mode 100644 crates/polyglot-sql/src/dialects/normalization/vertica.rs create mode 100644 crates/polyglot-sql/src/dialects/vertica.rs create mode 100644 crates/polyglot-sql/tests/custom_fixtures/vertica/identity.json create mode 100644 crates/polyglot-sql/tests/custom_fixtures/vertica/transpilation.json create mode 100644 crates/polyglot-sql/tests/custom_fixtures/vertica/types.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 05cf2c33..5956e2f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to this project are documented in this file. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. +## [Unreleased] + +### Added + +- Vertica dialect (`vertica`) across the Rust crate, FFI, Python, WASM, and + TypeScript SDK. Semantics follow the + [vertica-sqlglot-dialect](https://github.com/luisdelatorre012/vertica-sqlglot-dialect) + reference: 64-bit integer and double-precision type aliasing, `LONG VARCHAR` + / `LONG VARBINARY`, `MINUS`, the `//`, `!`, `!!`, and `@` operators, + `LISTAGG ... USING PARAMETERS`, native `NVL2`/`DECODE`/`ZEROIFNULL`, + `TIMESTAMPADD`/`DATEDIFF`, and statement-start `GETDATE()`/`SYSDATE`, which + lower to `STATEMENT_TIMESTAMP()` for PostgreSQL. Vertica's type-dependent + NULL ordering is never assumed when it is the source and is made explicit + when it is the target. Vertica-only DDL (projections, segmentation), + `TIMESERIES`, `MATCH`, and partitioned `LIMIT ... OVER` are not yet modeled. + ## [0.12.1] - 2026-09-21 ### Added diff --git a/Makefile b/Makefile index f1150570..f570d0b2 100644 --- a/Makefile +++ b/Makefile @@ -248,6 +248,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 12539a34..a9e626e0 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ 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 | Generic SQL | | +| Trino | TSQL | Vertica | DataFusion | Generic SQL | ## Quick Start diff --git a/crates/polyglot-sql-ffi/src/dialects.rs b/crates/polyglot-sql-ffi/src/dialects.rs index 87331aa4..3329b150 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; 34] = [ +const DIALECTS: [DialectType; 35] = [ DialectType::Generic, DialectType::PostgreSQL, DialectType::MySQL, @@ -38,6 +38,7 @@ const DIALECTS: [DialectType; 34] = [ DialectType::Dremio, DialectType::Exasol, DialectType::DataFusion, + 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 970c2935..6a2172ba 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, 34); + assert_eq!(count, 35); 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 1a6c33aa..d8a7ad0a 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`, `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`, `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/src/dialects.rs b/crates/polyglot-sql-python/src/dialects.rs index 028fbda3..7ee939c1 100644 --- a/crates/polyglot-sql-python/src/dialects.rs +++ b/crates/polyglot-sql-python/src/dialects.rs @@ -35,6 +35,7 @@ const DIALECT_NAMES: &[&str] = &[ "tidb", "trino", "tsql", + "vertica", ]; #[pyfunction] diff --git a/crates/polyglot-sql-python/tests/test_dialects.py b/crates/polyglot-sql-python/tests/test_dialects.py index 85cbfb48..a033d82f 100644 --- a/crates/polyglot-sql-python/tests/test_dialects.py +++ b/crates/polyglot-sql-python/tests/test_dialects.py @@ -36,6 +36,7 @@ "tidb", "trino", "tsql", + "vertica", } diff --git a/crates/polyglot-sql-wasm/Cargo.toml b/crates/polyglot-sql-wasm/Cargo.toml index 5669a150..b019a1c6 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-vertica", ] dialect-postgresql = ["polyglot-sql/dialect-postgresql"] dialect-mysql = ["polyglot-sql/dialect-mysql"] @@ -108,6 +109,7 @@ dialect-drill = ["polyglot-sql/dialect-drill"] dialect-dremio = ["polyglot-sql/dialect-dremio"] dialect-exasol = ["polyglot-sql/dialect-exasol"] dialect-datafusion = ["polyglot-sql/dialect-datafusion"] +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 0e5e3e8e..8f793cac 100644 --- a/crates/polyglot-sql-wasm/src/lib.rs +++ b/crates/polyglot-sql-wasm/src/lib.rs @@ -851,6 +851,8 @@ fn get_dialects_internal() -> Vec<&'static str> { dialects.push("exasol"); #[cfg(feature = "dialect-datafusion")] dialects.push("datafusion"); + #[cfg(feature = "dialect-vertica")] + dialects.push("vertica"); dialects } @@ -2927,7 +2929,7 @@ 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(), 34); + assert_eq!(dialects.len(), 35); assert_eq!(unique.len(), dialects.len()); assert!(unique.contains("generic")); assert!(unique.contains("postgresql")); @@ -3570,6 +3572,7 @@ mod tests { "dremio", "exasol", "datafusion", + "vertica", ]; for dialect in dialects { diff --git a/crates/polyglot-sql/Cargo.toml b/crates/polyglot-sql/Cargo.toml index 29cceb09..15766266 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-vertica", ] dialect-postgresql = [] dialect-mysql = [] @@ -82,6 +83,7 @@ dialect-drill = [] dialect-dremio = [] dialect-exasol = [] dialect-datafusion = [] +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 348eeb48..c3d42a7f 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, 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, 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/dialects/duckdb.rs b/crates/polyglot-sql/src/dialects/duckdb.rs index a49e5e78..6955f521 100644 --- a/crates/polyglot-sql/src/dialects/duckdb.rs +++ b/crates/polyglot-sql/src/dialects/duckdb.rs @@ -200,6 +200,7 @@ impl DialectImpl for DuckDBDialect { this: f.this, separator: f.separator, on_overflow: None, + max_length: None, order_by: f.order_by, distinct: f.distinct, filter: f.filter, @@ -216,6 +217,7 @@ impl DialectImpl for DuckDBDialect { 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/exasol.rs b/crates/polyglot-sql/src/dialects/exasol.rs index 8360a095..067d1f3c 100644 --- a/crates/polyglot-sql/src/dialects/exasol.rs +++ b/crates/polyglot-sql/src/dialects/exasol.rs @@ -130,6 +130,7 @@ impl DialectImpl for ExasolDialect { 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 61cdc84c..66354877 100644 --- a/crates/polyglot-sql/src/dialects/mod.rs +++ b/crates/polyglot-sql/src/dialects/mod.rs @@ -89,6 +89,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 @@ -158,6 +160,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")] @@ -274,6 +278,8 @@ pub enum DialectType { Exasol, /// Apache DataFusion -- Arrow-based query engine with modern SQL extensions. DataFusion, + /// Vertica (OpenText Analytics Database) -- columnar MPP analytic database. + Vertica, } impl DialectType { @@ -330,6 +336,7 @@ impl std::fmt::Display for DialectType { DialectType::Dremio => write!(f, "dremio"), DialectType::Exasol => write!(f, "exasol"), DialectType::DataFusion => write!(f, "datafusion"), + DialectType::Vertica => write!(f, "vertica"), } } } @@ -373,6 +380,7 @@ impl std::str::FromStr for DialectType { "dremio" => Ok(DialectType::Dremio), "exasol" => Ok(DialectType::Exasol), "datafusion" | "arrow-datafusion" | "arrow_datafusion" => Ok(DialectType::DataFusion), + "vertica" => Ok(DialectType::Vertica), _ => Err(crate::error::Error::parse( format!("Unknown dialect: {}", s), 0, @@ -2387,6 +2395,7 @@ cached_dialect!(CACHED_DRILL, DrillDialect, "dialect-drill"); cached_dialect!(CACHED_DREMIO, DremioDialect, "dialect-dremio"); cached_dialect!(CACHED_EXASOL, ExasolDialect, "dialect-exasol"); cached_dialect!(CACHED_DATAFUSION, DataFusionDialect, "dialect-datafusion"); +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. @@ -2469,6 +2478,8 @@ fn configs_for_dialect_type(dt: DialectType) -> DialectConfigs { DialectType::Exasol => from_cache!(CACHED_EXASOL, ExasolDialect), #[cfg(feature = "dialect-datafusion")] DialectType::DataFusion => from_cache!(CACHED_DATAFUSION, DataFusionDialect), + #[cfg(feature = "dialect-vertica")] + DialectType::Vertica => from_cache!(CACHED_VERTICA, VerticaDialect), _ => from_cache!(CACHED_GENERIC, GenericDialect), } } @@ -3211,6 +3222,7 @@ impl Dialect { feature = "dialect-oracle", feature = "dialect-clickhouse", feature = "dialect-fabric", + feature = "dialect-vertica", ))] use crate::transforms; @@ -3380,6 +3392,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 => { diff --git a/crates/polyglot-sql/src/dialects/normalization/aggregates.rs b/crates/polyglot-sql/src/dialects/normalization/aggregates.rs index ed93f23a..a6b8a085 100644 --- a/crates/polyglot-sql/src/dialects/normalization/aggregates.rs +++ b/crates/polyglot-sql/src/dialects/normalization/aggregates.rs @@ -421,6 +421,7 @@ pub(super) fn rewrite( this: sa.this, separator: sa.separator, on_overflow: None, + max_length: None, order_by: sa.order_by, distinct: sa.distinct, filter: None, @@ -541,6 +542,7 @@ pub(super) fn rewrite( this, separator: Some(sep), on_overflow: None, + max_length: None, order_by: gc.order_by, distinct: gc.distinct, filter: gc.filter, @@ -634,6 +636,7 @@ pub(super) fn rewrite( this: gc.this, separator: Some(sep), on_overflow: None, + max_length: None, order_by: gc.order_by, distinct: gc.distinct, filter: None, diff --git a/crates/polyglot-sql/src/dialects/normalization/mod.rs b/crates/polyglot-sql/src/dialects/normalization/mod.rs index 1ca6eb0e..f3f63f55 100644 --- a/crates/polyglot-sql/src/dialects/normalization/mod.rs +++ b/crates/polyglot-sql/src/dialects/normalization/mod.rs @@ -15,6 +15,7 @@ mod scalar; mod statements; pub(in crate::dialects) mod temporal; mod types; +mod vertica; #[derive(Debug, Clone, Copy)] struct NormalizationContext { @@ -86,6 +87,13 @@ pub(super) fn normalize( let expr = statements::normalize_root(expr, &context); transform_recursive(expr, &|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 { if function.name.eq_ignore_ascii_case("NOW") && function.args.is_empty() { @@ -2363,15 +2371,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), } } @@ -2728,8 +2739,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 ac339d69..2fe08cd8 100644 --- a/crates/polyglot-sql/src/dialects/normalization/scalar.rs +++ b/crates/polyglot-sql/src/dialects/normalization/scalar.rs @@ -5577,7 +5577,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(), @@ -6830,9 +6830,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, @@ -7096,6 +7099,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; @@ -10092,6 +10099,7 @@ pub(super) fn rewrite( | DialectType::Teradata | DialectType::Spark | DialectType::Databricks + | DialectType::Vertica ); if keep_as_decode { return Ok(Expression::Function(f)); @@ -11079,6 +11087,7 @@ pub(super) fn rewrite( this, separator, on_overflow: None, + max_length: None, order_by: None, distinct: false, filter: None, @@ -11674,6 +11683,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..c5e5f41e --- /dev/null +++ b/crates/polyglot-sql/src/dialects/normalization/vertica.rs @@ -0,0 +1,236 @@ +//! 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::expressions::{ + AggFunc, AtTimeZone, DateAddFunc, GroupConcatFunc, Interval, IntervalUnit, IntervalUnitSpec, + StringAggFunc, VarArgFunc, +}; + +/// 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::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()) { + // ZEROIFNULL(x) -> COALESCE(x, 0), except where it is native + ("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)), + } + } + Expression::ListAgg(f) => Ok(lower_listagg(*f, target)), + // LISTAGG(...) WITHIN GROUP (ORDER BY ...): fold the ordering into the aggregate + // when the target's native form carries it inline. + Expression::WithinGroup(wg) if matches!(wg.this, Expression::ListAgg(_)) => { + let crate::expressions::WithinGroup { this, order_by } = *wg; + let Expression::ListAgg(mut f) = this else { + unreachable!() + }; + match lower_listagg(*f.clone(), target) { + Expression::ListAgg(lowered) => Ok(Expression::WithinGroup(Box::new( + crate::expressions::WithinGroup { + this: Expression::ListAgg(lowered), + order_by, + }, + ))), + _ => { + f.order_by = Some(order_by); + Ok(lower_listagg(*f, target)) + } + } + } + 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, + } +} + +/// LISTAGG defaults to a ',' separator in Vertica; spell it out and pick the +/// target's native string-aggregation form. +fn lower_listagg(mut f: crate::expressions::ListAggFunc, target: DialectType) -> Expression { + if f.separator.is_none() { + f.separator = Some(Expression::string(",")); + } + match target { + DialectType::PostgreSQL + | DialectType::Materialize + | DialectType::RisingWave + | DialectType::CockroachDB + | DialectType::TSQL + | DialectType::Fabric + | DialectType::BigQuery => Expression::StringAgg(Box::new(StringAggFunc { + this: f.this, + separator: f.separator, + order_by: f.order_by, + distinct: f.distinct, + filter: f.filter, + limit: None, + inferred_type: None, + })), + DialectType::MySQL + | DialectType::TiDB + | DialectType::SingleStore + | DialectType::Doris + | DialectType::StarRocks + | DialectType::SQLite => Expression::GroupConcat(Box::new(GroupConcatFunc { + this: f.this, + separator: f.separator, + order_by: f.order_by, + distinct: f.distinct, + filter: f.filter, + limit: None, + inferred_type: None, + })), + _ => Expression::ListAgg(Box::new(f)), + } +} + +/// 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" => 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))) +} diff --git a/crates/polyglot-sql/src/dialects/snowflake.rs b/crates/polyglot-sql/src/dialects/snowflake.rs index 7deabe77..3553a419 100644 --- a/crates/polyglot-sql/src/dialects/snowflake.rs +++ b/crates/polyglot-sql/src/dialects/snowflake.rs @@ -203,6 +203,7 @@ impl DialectImpl for SnowflakeDialect { 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..6de3213b --- /dev/null +++ b/crates/polyglot-sql/src/dialects/vertica.rs @@ -0,0 +1,294 @@ +//! 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: false, + limit_fetch_style: LimitFetchStyle::Limit, + nvl2_supported: true, + supports_median: true, + ..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)) + } + + // Vertica has no TRY_CAST; fall back to CAST + Expression::TryCast(c) => Ok(Expression::Cast(c)), + Expression::SafeCast(c) => Ok(Expression::Cast(c)), + + // 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(f) | Expression::ApproxCountDistinct(f) => { + Ok(Expression::Function(Box::new(Function::new( + "APPROXIMATE_COUNT_DISTINCT".to_string(), + vec![f.this], + )))) + } + + // GROUP_CONCAT / STRING_AGG -> LISTAGG + Expression::GroupConcat(f) => Ok(Expression::ListAgg(Box::new(ListAggFunc { + 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 { + 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 c88b6b6b..66093257 100644 --- a/crates/polyglot-sql/src/expressions.rs +++ b/crates/polyglot-sql/src/expressions.rs @@ -6811,6 +6811,9 @@ pub struct ListAggFunc { 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, diff --git a/crates/polyglot-sql/src/generator.rs b/crates/polyglot-sql/src/generator.rs index cfb57ee1..d9b06e95 100644 --- a/crates/polyglot-sql/src/generator.rs +++ b/crates/polyglot-sql/src/generator.rs @@ -3248,8 +3248,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)?; @@ -18583,6 +18586,32 @@ impl Generator { } fn generate_function(&mut self, func: &Function) -> Result<()> { + // 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(_) + | Expression::Function(_) + ); + 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 @@ -20969,6 +20998,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 } @@ -21279,8 +21314,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)?; @@ -21769,6 +21807,43 @@ 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; let order_inside_args = matches!(self.config.dialect, Some(DialectType::DuckDB)); @@ -21779,7 +21854,12 @@ 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) { + self.unsupported("LISTAGG max_length is not supported in this 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!( @@ -21789,7 +21869,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(); @@ -25918,9 +26002,55 @@ 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<()> { 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 @@ -37685,6 +37815,7 @@ impl Generator { | Some(DialectType::Oracle) | Some(DialectType::BigQuery) | Some(DialectType::Teradata) + | Some(DialectType::Vertica) ) { self.write_keyword("INSTR"); self.write("("); diff --git a/crates/polyglot-sql/src/optimizer/set_operation_types.rs b/crates/polyglot-sql/src/optimizer/set_operation_types.rs index 45f1f095..894f4ece 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, Druid | Solr | Tableau => Family::Limited, } } @@ -680,6 +680,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 2bab9243..7f0403bf 100644 --- a/crates/polyglot-sql/src/parser.rs +++ b/crates/polyglot-sql/src/parser.rs @@ -30965,8 +30965,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 @@ -31002,6 +31016,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"), }; } @@ -31171,6 +31187,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 @@ -38076,13 +38100,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_primary()?; + 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) @@ -38119,6 +38193,7 @@ impl Parser { this, separator, on_overflow, + max_length, order_by: None, distinct, filter: None, @@ -42312,9 +42387,56 @@ 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::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)] @@ -42875,7 +42997,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 }; @@ -42885,7 +43010,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 } @@ -43225,10 +43353,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(), @@ -43535,7 +43680,8 @@ 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)] @@ -43881,7 +44027,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 }; @@ -43891,7 +44040,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 } @@ -67653,3 +67805,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/tests/common/test_runner.rs b/crates/polyglot-sql/tests/common/test_runner.rs index 1263ce8c..43a898cd 100644 --- a/crates/polyglot-sql/tests/common/test_runner.rs +++ b/crates/polyglot-sql/tests/common/test_runner.rs @@ -310,6 +310,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_fixtures/vertica/identity.json b/crates/polyglot-sql/tests/custom_fixtures/vertica/identity.json new file mode 100644 index 00000000..f81f856e --- /dev/null +++ b/crates/polyglot-sql/tests/custom_fixtures/vertica/identity.json @@ -0,0 +1,214 @@ +{ + "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 NULL placement" + }, + { + "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..477bc5f3 --- /dev/null +++ b/crates/polyglot-sql/tests/custom_fixtures/vertica/transpilation.json @@ -0,0 +1,248 @@ +{ + "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" + }, + { + "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()", + "duckdb": "SELECT CURRENT_TIMESTAMP, CAST(CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS TIMESTAMP)" + }, + "description": "Statement-start timestamps lower to STATEMENT_TIMESTAMP in PostgreSQL" + }, + { + "sql": "SELECT GETDATE()", + "read": { + "redshift": "SELECT SYSDATE", + "tsql": "SELECT GETDATE()" + }, + "description": "SYSDATE / GETDATE into Vertica" + }, + { + "sql": "SELECT DATEDIFF(DAY, a, b) FROM t", + "write": { + "postgresql": "SELECT CAST(EXTRACT(epoch FROM CAST(b AS TIMESTAMP) - CAST(a AS TIMESTAMP)) / 86400 AS BIGINT) FROM t", + "snowflake": "SELECT DATEDIFF(DAY, a, b) FROM t", + "tsql": "SELECT DATEDIFF(DAY, a, b) FROM t", + "duckdb": "SELECT DATE_DIFF('DAY', a, b) FROM t" + }, + "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" + }, + { + "sql": "SELECT LISTAGG(name USING PARAMETERS separator = ' | ') WITHIN GROUP (ORDER BY ordinal DESC) FROM names", + "write": { + "postgresql": "SELECT STRING_AGG(name, ' | ' ORDER BY ordinal DESC) FROM names", + "mysql": "SELECT GROUP_CONCAT(name ORDER BY ordinal DESC SEPARATOR ' | ') FROM names", + "snowflake": "SELECT LISTAGG(name, ' | ') WITHIN GROUP (ORDER BY ordinal DESC) FROM names", + "duckdb": "SELECT LISTAGG(name, ' | ' ORDER BY ordinal DESC) FROM names", + "tsql": "SELECT STRING_AGG(name, ' | ') WITHIN GROUP (ORDER BY ordinal DESC) FROM names" + }, + "description": "LISTAGG to native string aggregation" + }, + { + "sql": "SELECT LISTAGG(name USING PARAMETERS separator = ' | ') WITHIN GROUP (ORDER BY ordinal DESC NULLS FIRST) FROM names", + "read": { + "postgresql": "SELECT STRING_AGG(name, ' | ' ORDER BY ordinal DESC NULLS FIRST) FROM names" + }, + "description": "STRING_AGG to LISTAGG" + }, + { + "sql": "SELECT LISTAGG(name) FROM names", + "write": { + "postgresql": "SELECT STRING_AGG(name, ',') FROM names", + "snowflake": "SELECT LISTAGG(name, ',') FROM names" + }, + "description": "LISTAGG default separator is a comma" + }, + { + "sql": "SELECT LISTAGG(x USING PARAMETERS separator = ',') FROM t", + "read": { + "mysql": "SELECT GROUP_CONCAT(x SEPARATOR ',') FROM t" + }, + "description": "GROUP_CONCAT to LISTAGG" + }, + { + "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" + }, + { + "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": { + "snowflake": "SELECT TRY_CAST(a AS INT) FROM t", + "postgresql": "SELECT a::INTEGER FROM t" + }, + "description": "Integers are BIGINT; no TRY_CAST" + }, + { + "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 a", + "write": { + "postgresql": "SELECT a FROM t ORDER BY a", + "duckdb": "SELECT a FROM t ORDER BY a", + "mysql": "SELECT a FROM t ORDER BY a" + }, + "description": "Vertica NULL ordering is type-dependent, so none is assumed" + }, + { + "sql": "SELECT a FROM t ORDER BY a NULLS LAST", + "read": { + "postgresql": "SELECT a FROM t ORDER BY a" + }, + "description": "Source NULL ordering is made explicit" + }, + { + "sql": "SELECT a FROM t ORDER BY a DESC NULLS LAST", + "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" + }, + { + "sql": "CREATE TABLE t (a BIGINT, b DOUBLE PRECISION, c LONG VARCHAR, d LONG VARBINARY, e VARBINARY, f TIMESTAMPTZ)", + "write": { + "postgresql": "CREATE TABLE t (a BIGINT, b DOUBLE PRECISION, c TEXT, d BYTEA, e BYTEA, f TIMESTAMPTZ)", + "duckdb": "CREATE TABLE t (a BIGINT, b DOUBLE, c TEXT, d VARBINARY, e BLOB, f TIMESTAMPTZ)", + "snowflake": "CREATE TABLE t (a BIGINT, b DOUBLE, c VARCHAR, d BLOB, e VARBINARY, f TIMESTAMPTZ)" + }, + "description": "Type mapping" + }, + { + "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" + }, + { + "sql": "SELECT CAST(a AS BIGINT), CAST(b AS VARCHAR), CAST(c AS DOUBLE PRECISION), CAST(d AS VARBINARY) FROM t", + "read": { + "bigquery": "SELECT SAFE_CAST(a AS INT64), CAST(b AS STRING), CAST(c AS FLOAT64), CAST(d AS BYTES) FROM t" + }, + "description": "BigQuery 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/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/playground/src/lib/constants.ts b/packages/playground/src/lib/constants.ts index a2cf9587..4f8ba250 100644 --- a/packages/playground/src/lib/constants.ts +++ b/packages/playground/src/lib/constants.ts @@ -33,6 +33,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 cd98d04e..92d9e2ba 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -1060,6 +1060,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.ts b/packages/sdk/src/index.ts index 9d556f85..98695a55 100644 --- a/packages/sdk/src/index.ts +++ b/packages/sdk/src/index.ts @@ -49,6 +49,7 @@ export enum Dialect { Dremio = 'dremio', Exasol = 'exasol', DataFusion = 'datafusion', + Vertica = 'vertica', } /** From 78ec51f517a2c2d43d46f9026112816612901103 Mon Sep 17 00:00:00 2001 From: tobilg Date: Tue, 22 Sep 2026 12:26:37 +0200 Subject: [PATCH 2/7] Fix Vertica semantics and dialect coverage (#467) --- crates/polyglot-sql/src/builder/mod.rs | 3 + crates/polyglot-sql/src/dialects/bigquery.rs | 1 + crates/polyglot-sql/src/dialects/duckdb.rs | 1 + crates/polyglot-sql/src/dialects/mod.rs | 28 +- .../src/dialects/normalization/aggregates.rs | 1 + .../src/dialects/normalization/mod.rs | 2 +- .../src/dialects/normalization/scalar.rs | 3 + .../src/dialects/normalization/vertica.rs | 529 ++++++++++- crates/polyglot-sql/src/dialects/vertica.rs | 40 +- crates/polyglot-sql/src/expressions.rs | 227 +++++ crates/polyglot-sql/src/generator.rs | 829 ++++++++++++++++- crates/polyglot-sql/src/parser.rs | 832 +++++++++++++++++- crates/polyglot-sql/src/transforms.rs | 13 + crates/polyglot-sql/src/validation.rs | 7 + .../tests/custom_dialect_tests.rs | 445 ++++++++++ .../custom_fixtures/vertica/identity.json | 3 +- .../vertica/transpilation.json | 87 +- 17 files changed, 2945 insertions(+), 106 deletions(-) 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 b3ccaa47..c9ef7b88 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 6955f521..d06c4979 100644 --- a/crates/polyglot-sql/src/dialects/duckdb.rs +++ b/crates/polyglot-sql/src/dialects/duckdb.rs @@ -5789,6 +5789,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/mod.rs b/crates/polyglot-sql/src/dialects/mod.rs index 66354877..6f871c39 100644 --- a/crates/polyglot-sql/src/dialects/mod.rs +++ b/crates/polyglot-sql/src/dialects/mod.rs @@ -706,7 +706,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(_) @@ -1056,6 +1057,23 @@ where // First recursively transform children, then apply the transform function let expr = match expr { Expression::Select(mut select) => { + 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() @@ -3484,6 +3502,9 @@ impl Dialect { expressions .into_iter() .map(|expr| { + 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. @@ -4633,7 +4654,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 a6b8a085..4a164d50 100644 --- a/crates/polyglot-sql/src/dialects/normalization/aggregates.rs +++ b/crates/polyglot-sql/src/dialects/normalization/aggregates.rs @@ -1231,6 +1231,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 f3f63f55..622e552a 100644 --- a/crates/polyglot-sql/src/dialects/normalization/mod.rs +++ b/crates/polyglot-sql/src/dialects/normalization/mod.rs @@ -15,7 +15,7 @@ mod scalar; mod statements; pub(in crate::dialects) mod temporal; mod types; -mod vertica; +pub(in crate::dialects) mod vertica; #[derive(Debug, Clone, Copy)] struct NormalizationContext { diff --git a/crates/polyglot-sql/src/dialects/normalization/scalar.rs b/crates/polyglot-sql/src/dialects/normalization/scalar.rs index 2fe08cd8..5338ac6c 100644 --- a/crates/polyglot-sql/src/dialects/normalization/scalar.rs +++ b/crates/polyglot-sql/src/dialects/normalization/scalar.rs @@ -1126,6 +1126,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, @@ -1169,6 +1170,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, @@ -11238,6 +11240,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, diff --git a/crates/polyglot-sql/src/dialects/normalization/vertica.rs b/crates/polyglot-sql/src/dialects/normalization/vertica.rs index c5e5f41e..d51fa076 100644 --- a/crates/polyglot-sql/src/dialects/normalization/vertica.rs +++ b/crates/polyglot-sql/src/dialects/normalization/vertica.rs @@ -9,9 +9,325 @@ use crate::expressions::{ StringAggFunc, 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() { + 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 { .. } + ) => + { + 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::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 { + 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 { + for ordered in &mut order.expressions { + if ordered.nulls_auto { + return Err(crate::error::Error::unsupported( + "Vertica top-level NULLS AUTO", + target.to_string(), + )); + } + if ordered.nulls_first.is_some() { + continue; + } + let mut key = &ordered.this; + if let Expression::Literal(lit) = key { + if let Literal::Number(number) = lit.as_ref() { + if let Ok(index) = number.parse::() { + if let Some(expression) = + index.checked_sub(1).and_then(|i| select.expressions.get(i)) + { + key = expression; + } + } + } + } + if let Expression::Column(column) = key { + if let Some(Expression::Alias(alias)) = select.expressions.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; + } + let data_type = match key { + Expression::Cast(c) => Some(&c.to), + _ => key.inferred_type(), + }; + let low = match data_type { + Some( + DataType::Int { .. } + | DataType::BigInt { .. } + | DataType::SmallInt { .. } + | DataType::TinyInt { .. } + | DataType::Date + | DataType::Time { .. } + | DataType::Timestamp { .. }, + ) => true, + Some( + DataType::Float { .. } + | DataType::Double { .. } + | DataType::Boolean + | DataType::Char { .. } + | DataType::VarChar { .. } + | DataType::Text + | DataType::String { .. } + | DataType::Array { .. }, + ) => false, + _ if matches!(key, Expression::Literal(lit) if matches!(lit.as_ref(), Literal::Number(n) if n.parse::().is_ok())) => { + true + } + _ if matches!(key, Expression::Literal(lit) if matches!(lit.as_ref(), Literal::String(_) | Literal::Number(_))) + || matches!(key, Expression::Boolean(_) | Expression::Array(_)) => + { + false + } + _ => { + return Err(crate::error::Error::unsupported( + "Vertica ORDER BY requires a known sort-key type", + target.to_string(), + )) + } + }; + ordered.nulls_first = Some(low != ordered.desc); + } + } + } + _ => {} + } + Ok(node) + }) +} + /// 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::Subscript(sub) => { + let mut this = sub.this; + let mut indices = Vec::new(); + if let Expression::Vertica(node) = this { + match *node { + crate::expressions::VerticaExpression::ArrayAccess { + this: array, + indices: inner, + } => { + this = array; + indices = inner; + } + other => this = Expression::Vertica(Box::new(other)), + } + } + indices.push(sub.index); + Ok(Expression::Vertica(Box::new( + crate::expressions::VerticaExpression::ArrayAccess { this, indices }, + ))) + } + Expression::ArraySlice(slice) => Ok(Expression::Vertica(Box::new( + crate::expressions::VerticaExpression::ArraySlice { + this: slice.this, + start: slice.start, + end: slice.end, + }, + ))), + 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 @@ -30,7 +346,31 @@ pub(super) fn normalize_from_vertica(e: Expression, target: DialectType) -> Resu } 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)); @@ -197,7 +537,7 @@ fn timestamp_unit(unit: &Expression) -> Option { "MINUTE" | "MINUTES" | "MI" | "N" => IntervalUnit::Minute, "SECOND" | "SECONDS" | "SS" | "S" => IntervalUnit::Second, "MILLISECOND" | "MILLISECONDS" | "MS" => IntervalUnit::Millisecond, - "MICROSECOND" | "MICROSECONDS" | "US" => IntervalUnit::Microsecond, + "MICROSECOND" | "MICROSECONDS" | "US" | "MCS" => IntervalUnit::Microsecond, _ => return None, }) } @@ -234,3 +574,190 @@ fn postgres_interval_add(ts: Expression, amount: Expression, unit: IntervalUnit) }; 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, Subquery, 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 serialized = serde_json::to_string(&Expression::Select(Box::new(base.clone()))) + .map_err(|_| unsupported())?; + let mut used = std::collections::HashSet::new(); + let mut fresh = |stem: &str| { + let mut name = stem.to_string(); + let mut n = 0; + while serialized.contains(&name) || used.contains(&name) { + n += 1; + name = format!("{stem}_{n}"); + } + used.insert(name.clone()); + name + }; + let source_alias = fresh("_vertica_source"); + let ranked_alias = fresh("_vertica_ranked"); + let rank_name = fresh("_vertica_row_number"); + let mut output_names = Vec::new(); + let mut internal_names = Vec::new(); + let mut visible_names = std::collections::HashSet::new(); + base.expressions.clear(); + for (i, expression) in original.iter().enumerate() { + let (value, name) = match expression { + Expression::Alias(alias) if alias.column_aliases.is_empty() => { + (alias.this.clone(), alias.alias.clone()) + } + Expression::Column(column) => (expression.clone(), column.name.clone()), + _ => return Err(unsupported()), + }; + if !visible_names.insert(name.name.to_ascii_lowercase()) { + return Err(unsupported()); + } + let internal = fresh(&format!("_vertica_output_{i}")); + base.expressions.push(value.alias(&internal)); + output_names.push(name); + internal_names.push(internal); + } + let visible_count = internal_names.len(); + let mut resolve = |key: &Expression, table: &str| -> Result { + let projected = match key { + Expression::Literal(lit) => 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()); + } + let name = fresh("_vertica_hidden"); + base.expressions.push(key.clone().alias(&name)); + internal_names.push(name.clone()); + name + }; + Ok(Expression::qualified_column(table, name)) + }; + for key in &mut over.partition_by { + *key = resolve(key, &source_alias)?; + } + for key in &mut over.order_by { + key.this = resolve(&key.this, &source_alias)?; + 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)?; + } + } + let subquery = |select: Select, name: String| { + Expression::Subquery(Box::new(Subquery { + this: Expression::Select(Box::new(select)), + alias: Some(Identifier::new(name)), + column_aliases: Vec::new(), + alias_explicit_as: true, + alias_keyword: None, + order_by: None, + limit: None, + offset: None, + distribute_by: None, + sort_by: None, + cluster_by: None, + lateral: false, + modifiers_inside: true, + trailing_comments: Vec::new(), + inferred_type: None, + })) + }; + let mut ranked = Select::new(); + ranked.expressions = internal_names + .iter() + .map(|name| Expression::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( + Expression::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/vertica.rs b/crates/polyglot-sql/src/dialects/vertica.rs index 6de3213b..7e5d019b 100644 --- a/crates/polyglot-sql/src/dialects/vertica.rs +++ b/crates/polyglot-sql/src/dialects/vertica.rs @@ -60,7 +60,7 @@ impl DialectImpl for VerticaDialect { identifier_quote_style: IdentifierQuoteStyle::DOUBLE_QUOTE, dialect: Some(DialectType::Vertica), single_string_interval: true, - locking_reads_supported: false, + locking_reads_supported: true, limit_fetch_style: LimitFetchStyle::Limit, nvl2_supported: true, supports_median: true, @@ -84,9 +84,12 @@ impl DialectImpl for VerticaDialect { Ok(Expression::Coalesce(f)) } - // Vertica has no TRY_CAST; fall back to CAST - Expression::TryCast(c) => Ok(Expression::Cast(c)), - Expression::SafeCast(c) => Ok(Expression::Cast(c)), + 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) => { @@ -130,11 +133,30 @@ impl DialectImpl for VerticaDialect { )), // APPROX_COUNT_DISTINCT -> APPROXIMATE_COUNT_DISTINCT - Expression::ApproxDistinct(f) | Expression::ApproxCountDistinct(f) => { - Ok(Expression::Function(Box::new(Function::new( - "APPROXIMATE_COUNT_DISTINCT".to_string(), - vec![f.this], - )))) + 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 diff --git a/crates/polyglot-sql/src/expressions.rs b/crates/polyglot-sql/src/expressions.rs index 66093257..e201c9bf 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), @@ -1841,6 +1843,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", @@ -3643,6 +3646,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. @@ -3721,6 +3726,7 @@ pub struct Select { impl Select { pub fn new() -> Self { Self { + vertica: None, expressions: Vec::new(), from: None, joins: Vec::new(), @@ -5218,6 +5224,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). @@ -5235,6 +5244,7 @@ pub struct Ordered { impl Ordered { pub fn asc(expr: Expression) -> Self { Self { + nulls_auto: false, this: expr, desc: false, nulls_first: None, @@ -5245,6 +5255,7 @@ impl Ordered { pub fn desc(expr: Expression) -> Self { Self { + nulls_auto: false, this: expr, desc: true, nulls_first: None, @@ -5895,6 +5906,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 { @@ -15426,6 +15440,219 @@ 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, + }, + /// Lowered array access retains source bounds semantics until generation. + 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", 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 d9b06e95..b636c4de 100644 --- a/crates/polyglot-sql/src/generator.rs +++ b/crates/polyglot-sql/src/generator.rs @@ -2555,6 +2555,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(_) => { @@ -2712,6 +2713,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) @@ -2733,7 +2735,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), @@ -4607,6 +4613,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, @@ -4621,6 +4628,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, @@ -4668,6 +4676,12 @@ impl Generator { } fn generate_select(&mut self, select: &Select) -> Result<()> { + 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) { @@ -4734,6 +4748,7 @@ impl Generator { }); let outer_select = Select { + vertica: None, expressions: vec![star], from: Some(crate::expressions::From { expressions: vec![Expression::Subquery(Box::new(subquery))], @@ -5085,6 +5100,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 { @@ -5270,6 +5289,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)?; @@ -5305,7 +5332,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 @@ -5422,6 +5453,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 { @@ -7112,6 +7149,7 @@ impl Generator { }; let mut outer_select = Select { + vertica: None, expressions: vec![Expression::Star(Star { table: None, except: None, @@ -7146,12 +7184,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, @@ -9914,7 +9954,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); } @@ -10140,7 +10186,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); } @@ -17341,7 +17391,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() { @@ -18599,7 +18649,6 @@ impl Generator { | Expression::Column(_) | Expression::Identifier(_) | Expression::Paren(_) - | Expression::Function(_) ); if !atomic { self.write("("); @@ -19586,7 +19635,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 { @@ -21855,7 +21904,10 @@ impl Generator { } self.generate_expression(&f.this)?; if f.max_length.is_some() && self.config.dialect != Some(DialectType::Vertica) { - self.unsupported("LISTAGG max_length is not supported in this dialect")?; + 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)?; @@ -23753,6 +23805,12 @@ impl Generator { // Type conversion generators fn generate_try_cast(&mut self, cast: &Cast) -> Result<()> { + 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 @@ -23815,6 +23873,12 @@ impl Generator { } fn generate_safe_cast(&mut self, cast: &Cast) -> Result<()> { + 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)?; @@ -25451,6 +25515,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(()) } @@ -26322,6 +26395,7 @@ impl Generator { } } } + DataType::Vertica { vertica_type } => self.generate_vertica_type(vertica_type)?, DataType::Oracle { oracle_type } => { self.write_oracle_data_type(oracle_type)?; } @@ -31465,7 +31539,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 { @@ -31481,7 +31559,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() { @@ -41968,6 +42050,733 @@ 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 Some(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 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 } => { + self.write("(SELECT _polyglot_v.a"); + for i in 0..indices.len() { + self.write(&format!("[CASE WHEN _polyglot_v.i{i} < 0 OR _polyglot_v.i{i} >= 2147483647 THEN NULL ELSE _polyglot_v.i{i} + 1 END]")); + } + self.write(" FROM (SELECT "); + self.generate_expression(this)?; + self.write(" AS a"); + for (i, index) in indices.iter().enumerate() { + self.write(", "); + self.generate_expression(index)?; + self.write(&format!(" AS i{i}")); + } + self.write(") AS _polyglot_v)"); + 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 { .. } | V::ArraySlice { .. } | 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/parser.rs b/crates/polyglot-sql/src/parser.rs index 7f0403bf..5e2577a1 100644 --- a/crates/polyglot-sql/src/parser.rs +++ b/crates/polyglot-sql/src/parser.rs @@ -1200,6 +1200,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); } @@ -2704,6 +2713,8 @@ impl Parser { None }; + let timeseries = self.parse_vertica_timeseries()?; + // Parse CONNECT BY clause (Oracle hierarchical queries) let connect = self.parse_connect()?; @@ -2765,6 +2776,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) { @@ -2933,6 +2946,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]); @@ -3057,7 +3077,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, @@ -3966,10 +4004,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, @@ -4255,6 +4310,7 @@ impl Parser { // Build SELECT expression let select = Select { + vertica: None, expressions, from: Some(from), joins, @@ -8660,13 +8716,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 @@ -8758,6 +8818,7 @@ impl Parser { }; expressions.push(Ordered { + nulls_auto, this: expr, desc, nulls_first, @@ -8944,19 +9005,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, @@ -9002,6 +9068,7 @@ impl Parser { }; expressions.push(Ordered { + nulls_auto: false, this: expr, desc, nulls_first: None, @@ -9032,19 +9099,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, @@ -16447,7 +16519,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); @@ -29271,6 +29345,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, @@ -31226,6 +31320,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, @@ -31339,6 +31441,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!( @@ -32863,6 +32969,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 @@ -37279,6 +37391,9 @@ impl Parser { upper_name: &str, quoted: bool, ) -> Result { + 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. @@ -38118,7 +38233,7 @@ impl Parser { loop { let name = self.expect_identifier_or_keyword()?; self.expect(TokenType::Eq)?; - let value = self.parse_primary()?; + let value = self.parse_expression()?; match name.to_ascii_lowercase().as_str() { "separator" => separator = Some(value), "max_length" => max_length = Some(Box::new(value)), @@ -40697,7 +40812,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, @@ -40892,7 +41015,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 @@ -40926,13 +41053,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 @@ -40997,6 +41128,7 @@ impl Parser { None }; exprs.push(Ordered { + nulls_auto, this: expr, desc, nulls_first, @@ -42441,6 +42573,9 @@ impl Parser { #[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) { @@ -43686,6 +43821,9 @@ impl Parser { #[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) @@ -46120,6 +46258,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; } @@ -52958,6 +53104,7 @@ impl Parser { None }; orderings.push(Ordered { + nulls_auto: false, this: order_expr, desc, nulls_first, @@ -56416,6 +56563,7 @@ impl Parser { }; Ok(Some(Ordered { + nulls_auto: false, this: expr, desc, nulls_first, @@ -57163,6 +57311,7 @@ impl Parser { pub fn parse_pipe_syntax_aggregate(&mut self) -> Result> { if self.match_text_seq(&["AGGREGATE"]) { return Ok(Some(Expression::Select(Box::new(Select { + vertica: None, expressions: Vec::new(), from: None, joins: Vec::new(), @@ -57245,6 +57394,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, @@ -57286,6 +57436,7 @@ impl Parser { pub fn parse_pipe_syntax_extend(&mut self) -> Result> { if self.match_text_seq(&["EXTEND"]) { return Ok(Some(Expression::Select(Box::new(Select { + vertica: None, expressions: Vec::new(), from: None, joins: Vec::new(), @@ -63873,6 +64024,669 @@ 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) { + Some(self.vertica_nonnegative_integer()?) + } else { + None + }); + } + 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 parse_vertica_data_type(&mut self) -> Result> { + if !self.is_vertica() { + return Ok(None); + } + let name = self.peek_text().to_ascii_uppercase(); + 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 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::*; diff --git a/crates/polyglot-sql/src/transforms.rs b/crates/polyglot-sql/src/transforms.rs index 1ffb8489..0d5241ee 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, @@ -6739,6 +6751,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 ea6e9a3c..62444a86 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/custom_dialect_tests.rs b/crates/polyglot-sql/tests/custom_dialect_tests.rs index a1963194..fd0c2643 100644 --- a/crates/polyglot-sql/tests/custom_dialect_tests.rs +++ b/crates/polyglot-sql/tests/custom_dialect_tests.rs @@ -46,6 +46,96 @@ fn load_dialect_fixtures(dir: &Path) -> Vec { /// Dialects with separate test runners (excluded from this auto-discovery). const EXCLUDED_DIALECTS: &[&str] = &["clickhouse"]; +#[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 +362,358 @@ 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}; + 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(Some(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))", + "SELECT k, v FROM t LIMIT 1 OVER(PARTITION BY k ORDER BY v) OFFSET 2", + ] { + let ast = dialect.parse(sql).unwrap(); + 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()); +} diff --git a/crates/polyglot-sql/tests/custom_fixtures/vertica/identity.json b/crates/polyglot-sql/tests/custom_fixtures/vertica/identity.json index f81f856e..2a933d5c 100644 --- a/crates/polyglot-sql/tests/custom_fixtures/vertica/identity.json +++ b/crates/polyglot-sql/tests/custom_fixtures/vertica/identity.json @@ -134,7 +134,8 @@ }, { "sql": "SELECT a FROM t ORDER BY a NULLS FIRST, b DESC NULLS LAST", - "description": "Explicit NULL placement" + "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", diff --git a/crates/polyglot-sql/tests/custom_fixtures/vertica/transpilation.json b/crates/polyglot-sql/tests/custom_fixtures/vertica/transpilation.json index 477bc5f3..538c69bc 100644 --- a/crates/polyglot-sql/tests/custom_fixtures/vertica/transpilation.json +++ b/crates/polyglot-sql/tests/custom_fixtures/vertica/transpilation.json @@ -9,7 +9,8 @@ "postgresql": "SELECT 1 EXCEPT SELECT 2", "duckdb": "SELECT 1 EXCEPT SELECT 2" }, - "description": "MINUS/EXCEPT" + "description": "MINUS/EXCEPT", + "read": {} }, { "sql": "SELECT 1 FROM dual EXCEPT SELECT 2 FROM dual", @@ -25,7 +26,8 @@ "tsql": "SELECT GETDATE(), GETUTCDATE()", "duckdb": "SELECT CURRENT_TIMESTAMP, CAST(CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS TIMESTAMP)" }, - "description": "Statement-start timestamps lower to STATEMENT_TIMESTAMP in PostgreSQL" + "description": "Statement-start timestamps lower to STATEMENT_TIMESTAMP in PostgreSQL", + "read": {} }, { "sql": "SELECT GETDATE()", @@ -37,12 +39,7 @@ }, { "sql": "SELECT DATEDIFF(DAY, a, b) FROM t", - "write": { - "postgresql": "SELECT CAST(EXTRACT(epoch FROM CAST(b AS TIMESTAMP) - CAST(a AS TIMESTAMP)) / 86400 AS BIGINT) FROM t", - "snowflake": "SELECT DATEDIFF(DAY, a, b) FROM t", - "tsql": "SELECT DATEDIFF(DAY, a, b) FROM t", - "duckdb": "SELECT DATE_DIFF('DAY', a, b) FROM t" - }, + "write": {}, "read": { "mysql": "SELECT DATEDIFF(b, a) FROM t", "snowflake": "SELECT DATEDIFF(day, a, b) FROM t" @@ -69,40 +66,8 @@ "write": { "postgresql": "SELECT ts + INTERVAL '1 MONTH' * n FROM t" }, - "description": "TIMESTAMPADD with a non-literal amount" - }, - { - "sql": "SELECT LISTAGG(name USING PARAMETERS separator = ' | ') WITHIN GROUP (ORDER BY ordinal DESC) FROM names", - "write": { - "postgresql": "SELECT STRING_AGG(name, ' | ' ORDER BY ordinal DESC) FROM names", - "mysql": "SELECT GROUP_CONCAT(name ORDER BY ordinal DESC SEPARATOR ' | ') FROM names", - "snowflake": "SELECT LISTAGG(name, ' | ') WITHIN GROUP (ORDER BY ordinal DESC) FROM names", - "duckdb": "SELECT LISTAGG(name, ' | ' ORDER BY ordinal DESC) FROM names", - "tsql": "SELECT STRING_AGG(name, ' | ') WITHIN GROUP (ORDER BY ordinal DESC) FROM names" - }, - "description": "LISTAGG to native string aggregation" - }, - { - "sql": "SELECT LISTAGG(name USING PARAMETERS separator = ' | ') WITHIN GROUP (ORDER BY ordinal DESC NULLS FIRST) FROM names", - "read": { - "postgresql": "SELECT STRING_AGG(name, ' | ' ORDER BY ordinal DESC NULLS FIRST) FROM names" - }, - "description": "STRING_AGG to LISTAGG" - }, - { - "sql": "SELECT LISTAGG(name) FROM names", - "write": { - "postgresql": "SELECT STRING_AGG(name, ',') FROM names", - "snowflake": "SELECT LISTAGG(name, ',') FROM names" - }, - "description": "LISTAGG default separator is a comma" - }, - { - "sql": "SELECT LISTAGG(x USING PARAMETERS separator = ',') FROM t", - "read": { - "mysql": "SELECT GROUP_CONCAT(x SEPARATOR ',') FROM t" - }, - "description": "GROUP_CONCAT to LISTAGG" + "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", @@ -111,7 +76,8 @@ "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" + "description": "NULL-handling functions", + "read": {} }, { "sql": "SELECT DECODE(a, 1, 'x', 'y') FROM t", @@ -146,10 +112,9 @@ "tsql": "SELECT CAST(a AS BIGINT) FROM t" }, "read": { - "snowflake": "SELECT TRY_CAST(a AS INT) FROM t", "postgresql": "SELECT a::INTEGER FROM t" }, - "description": "Integers are BIGINT; no TRY_CAST" + "description": "Integers are BIGINT" }, { "sql": "SELECT CASE WHEN a > 1 THEN 'x' ELSE 'y' END FROM t", @@ -191,23 +156,14 @@ "description": "TOP to LIMIT" }, { - "sql": "SELECT a FROM t ORDER BY a", - "write": { - "postgresql": "SELECT a FROM t ORDER BY a", - "duckdb": "SELECT a FROM t ORDER BY a", - "mysql": "SELECT a FROM t ORDER BY a" - }, - "description": "Vertica NULL ordering is type-dependent, so none is assumed" - }, - { - "sql": "SELECT a FROM t ORDER BY a NULLS LAST", + "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 a DESC NULLS LAST", + "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" }, @@ -219,16 +175,8 @@ "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" - }, - { - "sql": "CREATE TABLE t (a BIGINT, b DOUBLE PRECISION, c LONG VARCHAR, d LONG VARBINARY, e VARBINARY, f TIMESTAMPTZ)", - "write": { - "postgresql": "CREATE TABLE t (a BIGINT, b DOUBLE PRECISION, c TEXT, d BYTEA, e BYTEA, f TIMESTAMPTZ)", - "duckdb": "CREATE TABLE t (a BIGINT, b DOUBLE, c TEXT, d VARBINARY, e BLOB, f TIMESTAMPTZ)", - "snowflake": "CREATE TABLE t (a BIGINT, b DOUBLE, c VARCHAR, d BLOB, e VARBINARY, f TIMESTAMPTZ)" - }, - "description": "Type mapping" + "description": "Vertica-only operators", + "read": {} }, { "sql": "CREATE TABLE t (a BIGINT, b DOUBLE PRECISION, c LONG VARCHAR, d VARBINARY, e TIMESTAMPTZ)", @@ -236,13 +184,6 @@ "postgresql": "CREATE TABLE t (a INT, b REAL, c TEXT, d BYTEA, e TIMESTAMPTZ)" }, "description": "PostgreSQL types widen to Vertica types" - }, - { - "sql": "SELECT CAST(a AS BIGINT), CAST(b AS VARCHAR), CAST(c AS DOUBLE PRECISION), CAST(d AS VARBINARY) FROM t", - "read": { - "bigquery": "SELECT SAFE_CAST(a AS INT64), CAST(b AS STRING), CAST(c AS FLOAT64), CAST(d AS BYTES) FROM t" - }, - "description": "BigQuery types" } ] } From c0675c67d9bfd7ad4ebcb75dc736283ed5ef90df Mon Sep 17 00:00:00 2001 From: tobilg Date: Tue, 22 Sep 2026 12:42:20 +0200 Subject: [PATCH 3/7] Preserve bare Vertica KSAFE through JSON (#467) --- crates/polyglot-sql/src/expressions.rs | 10 +++++++++- crates/polyglot-sql/src/generator.rs | 4 ++-- crates/polyglot-sql/src/parser.rs | 4 ++-- crates/polyglot-sql/tests/custom_dialect_tests.rs | 8 ++++++-- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/crates/polyglot-sql/src/expressions.rs b/crates/polyglot-sql/src/expressions.rs index e201c9bf..8a11d37c 100644 --- a/crates/polyglot-sql/src/expressions.rs +++ b/crates/polyglot-sql/src/expressions.rs @@ -15623,7 +15623,15 @@ pub enum VerticaPattern { pub struct VerticaPhysical { pub order_by: Vec, pub segmentation: Option, - pub ksafe: 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)] diff --git a/crates/polyglot-sql/src/generator.rs b/crates/polyglot-sql/src/generator.rs index b636c4de..127da29d 100644 --- a/crates/polyglot-sql/src/generator.rs +++ b/crates/polyglot-sql/src/generator.rs @@ -42195,9 +42195,9 @@ impl Generator { } } } - if let Some(ksafe) = physical.ksafe { + if let Some(ksafe) = &physical.ksafe { self.write(" KSAFE"); - if let Some(value) = ksafe { + if let VerticaKsafe::Level(value) = ksafe { self.write(&format!(" {value}")); } } diff --git a/crates/polyglot-sql/src/parser.rs b/crates/polyglot-sql/src/parser.rs index 5e2577a1..df8d72ce 100644 --- a/crates/polyglot-sql/src/parser.rs +++ b/crates/polyglot-sql/src/parser.rs @@ -64219,9 +64219,9 @@ impl Parser { } if self.match_text_seq(&["KSAFE"]) { physical.ksafe = Some(if self.check(TokenType::Number) { - Some(self.vertica_nonnegative_integer()?) + VerticaKsafe::Level(self.vertica_nonnegative_integer()?) } else { - None + VerticaKsafe::Default }); } Ok(physical) diff --git a/crates/polyglot-sql/tests/custom_dialect_tests.rs b/crates/polyglot-sql/tests/custom_dialect_tests.rs index fd0c2643..04e257b6 100644 --- a/crates/polyglot-sql/tests/custom_dialect_tests.rs +++ b/crates/polyglot-sql/tests/custom_dialect_tests.rs @@ -628,7 +628,7 @@ fn vertica_partitioned_limit_preserves_outputs_and_scopes() { #[test] fn vertica_structured_fields_and_invalid_native_syntax() { - use polyglot_sql::expressions::{Expression, VerticaExpression}; + 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); @@ -643,7 +643,7 @@ fn vertica_structured_fields_and_invalid_native_syntax() { panic!("expected projection") }; assert_eq!(physical.order_by.len(), 1); - assert_eq!(physical.ksafe, Some(Some(1))); + assert_eq!(physical.ksafe, Some(VerticaKsafe::Level(1))); assert_eq!(columns[0].encoding.as_ref().unwrap().name, "RLE"); assert!(parsed[0] .dfs() @@ -657,9 +657,13 @@ fn vertica_structured_fields_and_invalid_native_syntax() { 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()); From 21fe481e2bb167393a9a48d84eca9b301bbbddb9 Mon Sep 17 00:00:00 2001 From: tobilg Date: Tue, 22 Sep 2026 12:56:55 +0200 Subject: [PATCH 4/7] Verify Vertica bindings and document coverage (#467) --- CHANGELOG.md | 23 ++--- README.md | 2 + .../python/polyglot_sql/__init__.py | 2 + .../python/polyglot_sql/__init__.pyi | 1 + crates/polyglot-sql-python/src/expr_types.rs | 1 + .../tests/test_transpile.py | 12 +++ docs/vertica.md | 84 +++++++++++++++++++ packages/go/integration_test.go | 24 ++++++ packages/sdk/src/index.test.ts | 38 +++++++++ 9 files changed, 176 insertions(+), 11 deletions(-) create mode 100644 docs/vertica.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 5956e2f9..3c11fe67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,17 +8,18 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve ### Added -- Vertica dialect (`vertica`) across the Rust crate, FFI, Python, WASM, and - TypeScript SDK. Semantics follow the - [vertica-sqlglot-dialect](https://github.com/luisdelatorre012/vertica-sqlglot-dialect) - reference: 64-bit integer and double-precision type aliasing, `LONG VARCHAR` - / `LONG VARBINARY`, `MINUS`, the `//`, `!`, `!!`, and `@` operators, - `LISTAGG ... USING PARAMETERS`, native `NVL2`/`DECODE`/`ZEROIFNULL`, - `TIMESTAMPADD`/`DATEDIFF`, and statement-start `GETDATE()`/`SYSDATE`, which - lower to `STATEMENT_TIMESTAMP()` for PostgreSQL. Vertica's type-dependent - NULL ordering is never assumed when it is the source and is made explicit - when it is the target. Vertica-only DDL (projections, segmentation), - `TIMESERIES`, `MATCH`, and partitioned `LIMIT ... OVER` are not yet modeled. +- Vertica dialect (`vertica`) across Rust, FFI, Python, Go, WASM, and the + TypeScript SDK, including structured collection types and casts, function + parameters, historical queries, `TIMESERIES`, `MATCH`, `INTERPOLATE`, + partitioned `LIMIT`, projections, physical table clauses, FLEX TABLE, + COPY LOCAL/parser calls, and EXPORT TO PARQUET. + ([#467](https://github.com/tobilg/polyglot/pull/467)) +- Vertica translation preserves approximate-count filters, binary values, + zero-based array access, supported date-boundary differences, and analytic + null ordering. Unsafe conversions return errors at every diagnostic level. + Native locks, hints, interval precision, collection bounds, and nested + factorials are retained. See the [Vertica coverage matrix](docs/vertica.md) + for supported conversions and limits. ## [0.12.1] - 2026-09-21 diff --git a/README.md b/README.md index a9e626e0..c2762b00 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ Release notes are tracked in [`CHANGELOG.md`](CHANGELOG.md). | SQLite | StarRocks | Tableau | Teradata | TiDB | | Trino | TSQL | Vertica | DataFusion | Generic SQL | +See the [Vertica coverage matrix](docs/vertica.md) for native syntax and translation limits. + ## Quick Start ### Rust diff --git a/crates/polyglot-sql-python/python/polyglot_sql/__init__.py b/crates/polyglot-sql-python/python/polyglot_sql/__init__.py index b7860e97..2daf5a34 100644 --- a/crates/polyglot-sql-python/python/polyglot_sql/__init__.py +++ b/crates/polyglot-sql-python/python/polyglot_sql/__init__.py @@ -1165,6 +1165,7 @@ def dense_rank(): Variance, VectorSearch, Version, + Vertica, ViewAttributeProperty, VolatileProperty, WatermarkColumnConstraint, @@ -1296,6 +1297,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 67ea46da..4275357f 100644 --- a/crates/polyglot-sql-python/python/polyglot_sql/__init__.pyi +++ b/crates/polyglot-sql-python/python/polyglot_sql/__init__.pyi @@ -1424,6 +1424,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/expr_types.rs b/crates/polyglot-sql-python/src/expr_types.rs index 83dc2766..8c0f7886 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_transpile.py b/crates/polyglot-sql-python/tests/test_transpile.py index 4bfd5068..cd161424 100644 --- a/crates/polyglot-sql-python/tests/test_transpile.py +++ b/crates/polyglot-sql-python/tests/test_transpile.py @@ -188,3 +188,15 @@ def run(_): results = list(executor.map(run, range(32))) assert all(result == results[0] for result in results) + + +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/docs/vertica.md b/docs/vertica.md new file mode 100644 index 00000000..a4696db9 --- /dev/null +++ b/docs/vertica.md @@ -0,0 +1,84 @@ +# Vertica coverage + +The `vertica` dialect is available through Rust, Python, FFI/Go, and WASM/TypeScript. +Its implementation follows the [Vertica SQL reference](https://docs.vertica.com/26.2.x/en/sql-reference/). +The [SQLGlot Vertica plugin](https://github.com/luisdelatorre012/vertica-sqlglot-dialect) +was also checked, including its adjacent PostgreSQL and DuckDB conversions. +Reference-plugin output is not treated as a semantic specification. + +The following matrix describes the forms reviewed in +[PR #467](https://github.com/tobilg/polyglot/pull/467), not every option in the +Vertica language. “Native” means parsing, structured AST/JSON, traversal, and +Vertica generation. Unsupported foreign conversions fail even at `Ignore` +and `Warn`; those settings do not authorize these semantic losses. + +| Feature | Native | PostgreSQL / DuckDB conversion | +|---|---|---| +| `LISTAGG` parameters and integer expressions | Yes | Error: default/explicit byte limits and overflow behavior differ | +| Approximate distinct count | Yes, with aggregate metadata | Incoming filters become `CASE` inside the argument; unsupported modifiers error | +| `::!` safe casts | Yes | Error; incoming `TRY_CAST`/`SAFE_CAST` also error because constant-failure behavior differs | +| `DATEDIFF` | Yes | Boundary counts for known DATE/TIMESTAMP WITHOUT TIME ZONE operands and year, quarter, month, day, hour, minute, second, millisecond, microsecond units | +| Week, untyped, time-zone, TIME or INTERVAL date differences | Yes | Error; week cutoff requires engine verification | +| Binary `B'...'` / `X'...'` literals | Yes, byte value retained | PostgreSQL `DECODE(..., 'hex')`; DuckDB `UNHEX(...)` | +| Array element access | Yes, zero-based | Adjusts literal/dynamic indices; negative/out-of-range accesses return NULL; nested access supported | +| Array slices | Yes, exclusive end | Omitted or non-negative constant bounds supported; dynamic/negative bounds error | +| ARRAY/SET declarations, bounds, byte sizes and casts | Yes | Error: bounds, casts and set semantics are not equivalent | +| ROW fields and outer field aliases | Yes | Error without a verified type/field mapping | +| LONG VARBINARY size | Yes | Error rather than silently dropping capacity constraints | +| INTERVAL precision and INTERVALYM | Yes | Error rather than dropping precision/qualifiers | +| `USING PARAMETERS`, parameterized aggregates, `EXPLODE`, `PARTITION BEST` | Yes | Error without a verified parameter/result-shape mapping | +| Analytic and ordered-aggregate null ordering | Yes, including `NULLS AUTO` | Defined defaults retained; `AUTO` errors | +| Top-level null ordering | Type-dependent defaults; explicit placement uses CASE keys | Source defaults require a known key type; unknown types error | +| Historical `AT EPOCH` / `AT TIME` | Yes | Error | +| `FOR UPDATE [OF ...]` | Yes | Error: Vertica table locks differ from PostgreSQL row locks | +| SELECT optimizer hints | Yes | Error rather than discarding the hint | +| Partitioned `LIMIT ... OVER` | Yes | ROW_NUMBER rewrite for named, unambiguous outputs and resolvable keys; wildcard, OFFSET, locking or event-series combinations error | +| TIMESERIES, MATCH and INTERPOLATE | Yes | Error rather than treating them as ordinary queries/joins | +| CREATE PROJECTION, ENCODING, SEGMENTED/UNSEGMENTED and KSAFE | Yes | Error | +| CREATE FLEX/FLEXIBLE TABLE | Yes | Error | +| COPY FROM LOCAL and parser call arguments | Yes | Error | +| EXPORT TO PARQUET options and OVER | Yes | Error | +| NULLIFZERO | Yes | NULLIF(value, 0) | +| TIME_SLICE, MATCH_COLUMNS, conditional events, approximate percentiles, REGEXP_SUBSTR | Native calls retained | Error without a verified mapping | + +Additional restrictions: + +- Explicit top-level null placement uses an extra CASE sort key. Keys that might + be volatile are rejected rather than evaluated twice. Projection aliases and + ordinals are resolved before constructing the CASE expression. +- Partitioned LIMIT preserves output names and hides its helper columns. Internal + aliases avoid names already present in the query. Ambiguous output names and + non-projected keys in grouped/distinct queries are rejected conservatively. +- Array access assumes the target column represents a normal one-based array. + Migrating PostgreSQL columns with custom array lower bounds needs an explicit + data migration policy. Incoming array subscripts targeting Vertica are rejected. +- Generic native function acceptance is not a claim that every Vertica function + has a foreign mapping. The reviewed dialect-specific functions are guarded. +- Parsing does not replace database validation of catalog objects, permissions, + all parameter ranges, or table constraints. + +## Verification + +Regression cases live in the existing `custom_dialect_tests.rs`, Vertica custom +fixtures, and existing Python, Go, and TypeScript test files. No imported SQLGlot +fixture was excluded for this change. Corrections to PR-authored custom fixtures +replace unsafe expected translations with explicit error regressions. + +Run the complete Rust gate with `make test-rust-verify`. To additionally execute +the value regressions against an installed DuckDB CLI: + +```sh +POLYGLOT_DUCKDB=/path/to/duckdb cargo test -p polyglot-sql --test custom_dialect_tests +``` + +Native Vertica cases are checked against documentation and structured round trips; +these tests do not require or claim execution against a live Vertica server. + +Key semantic references: + +- [Array indexing, slices, bounds and casts](https://docs.vertica.com/26.2.x/en/sql-reference/data-types/complex-types/array/) +- [DATEDIFF boundaries](https://docs.vertica.com/24.2.x/en/sql-reference/functions/data-type-specific-functions/datetime-functions/datediff/) +- [SELECT and locking](https://docs.vertica.com/26.2.x/en/sql-reference/statements/select/) +- [Partitioned LIMIT](https://docs.vertica.com/26.2.x/en/sql-reference/statements/select/limit-clause/) +- [MATCH](https://docs.vertica.com/26.2.x/en/sql-reference/statements/select/match-clause/) +- [DuckDB date differences](https://duckdb.org/docs/lts/sql/functions/timestamp) diff --git a/packages/go/integration_test.go b/packages/go/integration_test.go index de234433..5eb484f5 100644 --- a/packages/go/integration_test.go +++ b/packages/go/integration_test.go @@ -1547,3 +1547,27 @@ 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) + } +} 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); + } + } + }); +}); From c7444c65c8b62f1829d196d119583f8219d3c227 Mon Sep 17 00:00:00 2001 From: tobilg Date: Wed, 23 Sep 2026 08:44:24 +0200 Subject: [PATCH 5/7] Fixes --- CHANGELOG.md | 17 - crates/polyglot-sql/src/dialects/mod.rs | 2 + .../src/dialects/normalization/vertica.rs | 509 +++++++++++------- .../polyglot-sql/src/dialects/vertica_ast.rs | 361 +++++++++++++ crates/polyglot-sql/src/generator.rs | 71 ++- .../tests/custom_dialect_tests.rs | 340 ++++++++++++ .../vertica/transpilation.json | 3 +- docs/vertica.md | 84 --- 8 files changed, 1084 insertions(+), 303 deletions(-) create mode 100644 crates/polyglot-sql/src/dialects/vertica_ast.rs delete mode 100644 docs/vertica.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c11fe67..05cf2c33 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,23 +4,6 @@ All notable changes to this project are documented in this file. The format is based on Keep a Changelog, and this project adheres to Semantic Versioning. -## [Unreleased] - -### Added - -- Vertica dialect (`vertica`) across Rust, FFI, Python, Go, WASM, and the - TypeScript SDK, including structured collection types and casts, function - parameters, historical queries, `TIMESERIES`, `MATCH`, `INTERPOLATE`, - partitioned `LIMIT`, projections, physical table clauses, FLEX TABLE, - COPY LOCAL/parser calls, and EXPORT TO PARQUET. - ([#467](https://github.com/tobilg/polyglot/pull/467)) -- Vertica translation preserves approximate-count filters, binary values, - zero-based array access, supported date-boundary differences, and analytic - null ordering. Unsafe conversions return errors at every diagnostic level. - Native locks, hints, interval precision, collection bounds, and nested - factorials are retained. See the [Vertica coverage matrix](docs/vertica.md) - for supported conversions and limits. - ## [0.12.1] - 2026-09-21 ### Added diff --git a/crates/polyglot-sql/src/dialects/mod.rs b/crates/polyglot-sql/src/dialects/mod.rs index 6f871c39..9005ce1c 100644 --- a/crates/polyglot-sql/src/dialects/mod.rs +++ b/crates/polyglot-sql/src/dialects/mod.rs @@ -22,6 +22,8 @@ mod generic; // Always compiled #[cfg(feature = "transpile")] mod normalization; +#[cfg(feature = "generate")] +pub(crate) mod vertica_ast; #[cfg(feature = "dialect-athena")] mod athena; diff --git a/crates/polyglot-sql/src/dialects/normalization/vertica.rs b/crates/polyglot-sql/src/dialects/normalization/vertica.rs index d51fa076..ce30a98d 100644 --- a/crates/polyglot-sql/src/dialects/normalization/vertica.rs +++ b/crates/polyglot-sql/src/dialects/normalization/vertica.rs @@ -5,8 +5,7 @@ use super::*; use crate::expressions::{ - AggFunc, AtTimeZone, DateAddFunc, GroupConcatFunc, Interval, IntervalUnit, IntervalUnitSpec, - StringAggFunc, VarArgFunc, + AggFunc, AtTimeZone, DateAddFunc, Interval, IntervalUnit, IntervalUnitSpec, VarArgFunc, }; /// Validate before source transforms erase parameters or failure semantics. @@ -88,6 +87,25 @@ pub(in crate::dialects) fn validate_conversion( { 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") } @@ -147,6 +165,45 @@ pub(in crate::dialects) fn prepare_conversion( 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); } @@ -186,75 +243,21 @@ pub(in crate::dialects) fn prepare_conversion( } } if let Some(order) = &mut select.order_by { - for ordered in &mut order.expressions { - if ordered.nulls_auto { - return Err(crate::error::Error::unsupported( - "Vertica top-level NULLS AUTO", - target.to_string(), - )); - } - if ordered.nulls_first.is_some() { - continue; - } - let mut key = &ordered.this; - if let Expression::Literal(lit) = key { - if let Literal::Number(number) = lit.as_ref() { - if let Ok(index) = number.parse::() { - if let Some(expression) = - index.checked_sub(1).and_then(|i| select.expressions.get(i)) - { - key = expression; - } - } - } - } - if let Expression::Column(column) = key { - if let Some(Expression::Alias(alias)) = select.expressions.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; - } - let data_type = match key { - Expression::Cast(c) => Some(&c.to), - _ => key.inferred_type(), - }; - let low = match data_type { - Some( - DataType::Int { .. } - | DataType::BigInt { .. } - | DataType::SmallInt { .. } - | DataType::TinyInt { .. } - | DataType::Date - | DataType::Time { .. } - | DataType::Timestamp { .. }, - ) => true, - Some( - DataType::Float { .. } - | DataType::Double { .. } - | DataType::Boolean - | DataType::Char { .. } - | DataType::VarChar { .. } - | DataType::Text - | DataType::String { .. } - | DataType::Array { .. }, - ) => false, - _ if matches!(key, Expression::Literal(lit) if matches!(lit.as_ref(), Literal::Number(n) if n.parse::().is_ok())) => { - true - } - _ if matches!(key, Expression::Literal(lit) if matches!(lit.as_ref(), Literal::String(_) | Literal::Number(_))) - || matches!(key, Expression::Boolean(_) | Expression::Array(_)) => - { - false - } - _ => { - return Err(crate::error::Error::unsupported( - "Vertica ORDER BY requires a known sort-key type", - target.to_string(), - )) - } - }; - ordered.nulls_first = Some(low != ordered.desc); - } + 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)?; } } _ => {} @@ -263,6 +266,171 @@ pub(in crate::dialects) fn prepare_conversion( }) } +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)| { + crate::dialects::vertica_ast::output_name(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 = crate::dialects::vertica_ast::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 { @@ -274,6 +442,14 @@ pub(super) fn normalize_from_vertica(e: Expression, target: DialectType) -> Resu { lower_partitioned_limit(*select, target) } + Expression::Subscript(sub) + if matches!(target, DialectType::PostgreSQL | DialectType::DuckDB) => + { + Ok(crate::dialects::vertica_ast::array_access( + sub.this, + vec![sub.index], + )) + } Expression::Subscript(sub) => { let mut this = sub.this; let mut indices = Vec::new(); @@ -415,27 +591,6 @@ pub(super) fn normalize_from_vertica(e: Expression, target: DialectType) -> Resu _ => Ok(Expression::Function(f)), } } - Expression::ListAgg(f) => Ok(lower_listagg(*f, target)), - // LISTAGG(...) WITHIN GROUP (ORDER BY ...): fold the ordering into the aggregate - // when the target's native form carries it inline. - Expression::WithinGroup(wg) if matches!(wg.this, Expression::ListAgg(_)) => { - let crate::expressions::WithinGroup { this, order_by } = *wg; - let Expression::ListAgg(mut f) = this else { - unreachable!() - }; - match lower_listagg(*f.clone(), target) { - Expression::ListAgg(lowered) => Ok(Expression::WithinGroup(Box::new( - crate::expressions::WithinGroup { - this: Expression::ListAgg(lowered), - order_by, - }, - ))), - _ => { - f.order_by = Some(order_by); - Ok(lower_listagg(*f, target)) - } - } - } other => Ok(other), } } @@ -484,46 +639,6 @@ fn statement_timestamp(target: DialectType, utc: bool) -> Option { } } -/// LISTAGG defaults to a ',' separator in Vertica; spell it out and pick the -/// target's native string-aggregation form. -fn lower_listagg(mut f: crate::expressions::ListAggFunc, target: DialectType) -> Expression { - if f.separator.is_none() { - f.separator = Some(Expression::string(",")); - } - match target { - DialectType::PostgreSQL - | DialectType::Materialize - | DialectType::RisingWave - | DialectType::CockroachDB - | DialectType::TSQL - | DialectType::Fabric - | DialectType::BigQuery => Expression::StringAgg(Box::new(StringAggFunc { - this: f.this, - separator: f.separator, - order_by: f.order_by, - distinct: f.distinct, - filter: f.filter, - limit: None, - inferred_type: None, - })), - DialectType::MySQL - | DialectType::TiDB - | DialectType::SingleStore - | DialectType::Doris - | DialectType::StarRocks - | DialectType::SQLite => Expression::GroupConcat(Box::new(GroupConcatFunc { - this: f.this, - separator: f.separator, - order_by: f.order_by, - distinct: f.distinct, - filter: f.filter, - limit: None, - inferred_type: None, - })), - _ => Expression::ListAgg(Box::new(f)), - } -} - /// 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); @@ -579,7 +694,7 @@ fn lower_partitioned_limit( mut base: crate::expressions::Select, target: DialectType, ) -> Result { - use crate::expressions::{Alias, From, Select, Subquery, Where, WindowFunction}; + 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()) }; @@ -612,46 +727,80 @@ fn lower_partitioned_limit( let outer_with = base.with.take(); let outer_comments = std::mem::take(&mut base.leading_comments); let original = base.expressions.clone(); - let serialized = serde_json::to_string(&Expression::Select(Box::new(base.clone()))) - .map_err(|_| unsupported())?; - let mut used = std::collections::HashSet::new(); - let mut fresh = |stem: &str| { - let mut name = stem.to_string(); - let mut n = 0; - while serialized.contains(&name) || used.contains(&name) { - n += 1; - name = format!("{stem}_{n}"); + let mut names = crate::dialects::vertica_ast::Names::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); } - used.insert(name.clone()); - name + } + let Expression::Select(base_box) = base_node else { + unreachable!() }; - let source_alias = fresh("_vertica_source"); - let ranked_alias = fresh("_vertica_ranked"); - let rank_name = fresh("_vertica_row_number"); + 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(); - base.expressions.clear(); - for (i, expression) in original.iter().enumerate() { - let (value, name) = match expression { - Expression::Alias(alias) if alias.column_aliases.is_empty() => { - (alias.this.clone(), alias.alias.clone()) - } - Expression::Column(column) => (expression.clone(), column.name.clone()), - _ => return Err(unsupported()), - }; + // 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 = crate::dialects::vertica_ast::output_name(expression) + .ok_or_else(unsupported)? + .clone(); if !visible_names.insert(name.name.to_ascii_lowercase()) { return Err(unsupported()); } - let internal = fresh(&format!("_vertica_output_{i}")); - base.expressions.push(value.alias(&internal)); - output_names.push(name); - internal_names.push(internal); + 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| -> Result { + 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) => match lit.as_ref() { + Expression::Literal(lit) if ordinal => match lit.as_ref() { Literal::Number(n) => n .parse::() .ok() @@ -680,48 +829,36 @@ fn lower_partitioned_limit( if base.distinct || base.group_by.is_some() || base.having.is_some() { return Err(unsupported()); } - let name = fresh("_vertica_hidden"); + 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(Expression::qualified_column(table, name)) + Ok(crate::dialects::vertica_ast::column(table, &name)) }; for key in &mut over.partition_by { - *key = resolve(key, &source_alias)?; + *key = resolve(key, &source_alias, false)?; } for key in &mut over.order_by { - key.this = resolve(&key.this, &source_alias)?; + 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)?; + key.this = resolve(&key.this, &ranked_alias, true)?; } } let subquery = |select: Select, name: String| { - Expression::Subquery(Box::new(Subquery { - this: Expression::Select(Box::new(select)), - alias: Some(Identifier::new(name)), - column_aliases: Vec::new(), - alias_explicit_as: true, - alias_keyword: None, - order_by: None, - limit: None, - offset: None, - distribute_by: None, - sort_by: None, - cluster_by: None, - lateral: false, - modifiers_inside: true, - trailing_comments: Vec::new(), - inferred_type: None, - })) + crate::dialects::vertica_ast::subquery(Expression::Select(Box::new(select)), Some(name)) }; let mut ranked = Select::new(); ranked.expressions = internal_names .iter() - .map(|name| Expression::qualified_column(&source_alias, name)) + .map(|name| crate::dialects::vertica_ast::column(&source_alias, name)) .collect(); ranked.expressions.push( Expression::WindowFunction(Box::new(WindowFunction { @@ -741,7 +878,7 @@ fn lower_partitioned_limit( .enumerate() .map(|(i, name)| { Expression::Alias(Box::new(Alias::new( - Expression::qualified_column(&ranked_alias, &internal_names[i]), + crate::dialects::vertica_ast::column(&ranked_alias, &internal_names[i]), name, ))) }) diff --git a/crates/polyglot-sql/src/dialects/vertica_ast.rs b/crates/polyglot-sql/src/dialects/vertica_ast.rs new file mode 100644 index 00000000..88cce64e --- /dev/null +++ b/crates/polyglot-sql/src/dialects/vertica_ast.rs @@ -0,0 +1,361 @@ +//! AST construction shared by Vertica normalization and generation. +//! Keep scope creation here so dialect generation never splices SQL subqueries. + +use crate::error::{Error, Result}; +use crate::expressions::*; +use crate::traversal::ExpressionWalk; +use std::collections::HashSet; + +#[derive(Default)] +pub(crate) struct Names(HashSet); + +impl Names { + 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 + } +} + +pub(crate) fn subquery(this: Expression, alias: Option) -> Expression { + Expression::Subquery(Box::new(Subquery { + this, + alias: alias.map(Identifier::new), + column_aliases: Vec::new(), + alias_explicit_as: true, + alias_keyword: None, + order_by: None, + limit: None, + offset: None, + distribute_by: None, + sort_by: None, + cluster_by: None, + lateral: false, + modifiers_inside: true, + trailing_comments: Vec::new(), + inferred_type: None, + })) +} + +pub(crate) fn output_name(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, + } +} + +pub(crate) fn 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, + }; + } +} + +pub(crate) fn 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) +} + +fn repeatable_index(index: &Expression) -> bool { + match index { + Expression::Column(_) + | Expression::Identifier(_) + | Expression::Literal(_) + | Expression::Null(_) => true, + Expression::Paren(p) => repeatable_index(&p.this), + Expression::Neg(n) => repeatable_index(&n.this), + Expression::Cast(c) => repeatable_index(&c.this), + Expression::Add(b) | Expression::Sub(b) | Expression::Mul(b) | Expression::Mod(b) => { + repeatable_index(&b.left) && repeatable_index(&b.right) + } + _ => false, + } +} + +fn adjusted_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 adjusted_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 zero-based access to ordinary subscript AST nodes. The base occurs once; +/// constants fold and dynamic indices preserve NULL/negative/overflow behavior. +pub(crate) fn array_access(mut array: Expression, indices: Vec) -> Expression { + if !matches!( + array, + Expression::Column(_) + | Expression::Identifier(_) + | Expression::Paren(_) + | Expression::Subscript(_) + ) { + array = Expression::Paren(Box::new(Paren { + this: array, + trailing_comments: Vec::new(), + })); + } + for index in indices { + let index = if repeatable_index(&index) { + adjusted_index(index) + } else { + adjusted_index_once(index) + }; + array = Expression::Subscript(Box::new(Subscript { this: array, index })); + } + array +} + +/// Sort outside DISTINCT/set operations so emulated NULL ordering may add CASE keys +/// without changing the result columns or the deduplication operation. +pub(crate) fn wrap_ordered_query(mut query: Expression) -> Result { + let unsupported = || { + Error::unsupported("Vertica ordered DISTINCT/set operation requires unique named outputs and resolvable sort keys", "vertica") + }; + let projections = projections(&query).ok_or_else(unsupported)?.to_vec(); + let output_names = projections + .iter() + .map(|p| output_name(p).cloned().ok_or_else(unsupported)) + .collect::>>()?; + let mut unique = HashSet::new(); + if output_names + .iter() + .any(|n| !unique.insert(n.name.to_ascii_lowercase())) + { + return Err(unsupported()); + } + let mut names = Names::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 = column(&table, &output_names[index]); + } + outer.expressions = output_names + .iter() + .map(|name| Expression::Alias(Box::new(Alias::new(column(&table, name), name.clone())))) + .collect(); + outer.from = Some(From { + expressions: vec![subquery(query, Some(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. +pub(crate) fn wrap_subquery_order(mut query: Subquery) -> Result { + if query.distribute_by.is_some() || query.sort_by.is_some() || query.cluster_by.is_some() { + return Err(Error::unsupported("ordered subquery modifiers", "vertica")); + } + let mut ordered = subquery( + 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 = wrap_ordered_query(ordered)?; + Ok(query) +} diff --git a/crates/polyglot-sql/src/generator.rs b/crates/polyglot-sql/src/generator.rs index 127da29d..947097f1 100644 --- a/crates/polyglot-sql/src/generator.rs +++ b/crates/polyglot-sql/src/generator.rs @@ -4676,6 +4676,18 @@ impl Generator { } fn generate_select(&mut self, select: &Select) -> Result<()> { + 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 = crate::dialects::vertica_ast::wrap_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", @@ -7220,6 +7232,17 @@ 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 = crate::dialects::vertica_ast::wrap_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, @@ -7399,6 +7422,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 = crate::dialects::vertica_ast::wrap_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, @@ -7573,6 +7607,17 @@ 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 = crate::dialects::vertica_ast::wrap_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, @@ -24850,6 +24895,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 = crate::dialects::vertica_ast::wrap_subquery_order(subquery.clone())?; + return self.generate_subquery(&wrapped); + } if subquery.lateral { self.write_keyword("LATERAL"); self.write_space(); @@ -42305,20 +42359,9 @@ impl Generator { ) { match node { V::ArrayAccess { this, indices } => { - self.write("(SELECT _polyglot_v.a"); - for i in 0..indices.len() { - self.write(&format!("[CASE WHEN _polyglot_v.i{i} < 0 OR _polyglot_v.i{i} >= 2147483647 THEN NULL ELSE _polyglot_v.i{i} + 1 END]")); - } - self.write(" FROM (SELECT "); - self.generate_expression(this)?; - self.write(" AS a"); - for (i, index) in indices.iter().enumerate() { - self.write(", "); - self.generate_expression(index)?; - self.write(&format!(" AS i{i}")); - } - self.write(") AS _polyglot_v)"); - return Ok(()); + let lowered = + crate::dialects::vertica_ast::array_access(this.clone(), indices.clone()); + return self.generate_expression(&lowered); } V::ArraySlice { this, start, end } => { let bound = |e: &Expression| match e { diff --git a/crates/polyglot-sql/tests/custom_dialect_tests.rs b/crates/polyglot-sql/tests/custom_dialect_tests.rs index 04e257b6..04ac33b0 100644 --- a/crates/polyglot-sql/tests/custom_dialect_tests.rs +++ b/crates/polyglot-sql/tests/custom_dialect_tests.rs @@ -721,3 +721,343 @@ fn vertica_direct_generation_preserves_filters_and_safe_cast_failures() { 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_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/transpilation.json b/crates/polyglot-sql/tests/custom_fixtures/vertica/transpilation.json index 538c69bc..2a07716b 100644 --- a/crates/polyglot-sql/tests/custom_fixtures/vertica/transpilation.json +++ b/crates/polyglot-sql/tests/custom_fixtures/vertica/transpilation.json @@ -23,8 +23,7 @@ "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()", - "duckdb": "SELECT CURRENT_TIMESTAMP, CAST(CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS TIMESTAMP)" + "tsql": "SELECT GETDATE(), GETUTCDATE()" }, "description": "Statement-start timestamps lower to STATEMENT_TIMESTAMP in PostgreSQL", "read": {} diff --git a/docs/vertica.md b/docs/vertica.md deleted file mode 100644 index a4696db9..00000000 --- a/docs/vertica.md +++ /dev/null @@ -1,84 +0,0 @@ -# Vertica coverage - -The `vertica` dialect is available through Rust, Python, FFI/Go, and WASM/TypeScript. -Its implementation follows the [Vertica SQL reference](https://docs.vertica.com/26.2.x/en/sql-reference/). -The [SQLGlot Vertica plugin](https://github.com/luisdelatorre012/vertica-sqlglot-dialect) -was also checked, including its adjacent PostgreSQL and DuckDB conversions. -Reference-plugin output is not treated as a semantic specification. - -The following matrix describes the forms reviewed in -[PR #467](https://github.com/tobilg/polyglot/pull/467), not every option in the -Vertica language. “Native” means parsing, structured AST/JSON, traversal, and -Vertica generation. Unsupported foreign conversions fail even at `Ignore` -and `Warn`; those settings do not authorize these semantic losses. - -| Feature | Native | PostgreSQL / DuckDB conversion | -|---|---|---| -| `LISTAGG` parameters and integer expressions | Yes | Error: default/explicit byte limits and overflow behavior differ | -| Approximate distinct count | Yes, with aggregate metadata | Incoming filters become `CASE` inside the argument; unsupported modifiers error | -| `::!` safe casts | Yes | Error; incoming `TRY_CAST`/`SAFE_CAST` also error because constant-failure behavior differs | -| `DATEDIFF` | Yes | Boundary counts for known DATE/TIMESTAMP WITHOUT TIME ZONE operands and year, quarter, month, day, hour, minute, second, millisecond, microsecond units | -| Week, untyped, time-zone, TIME or INTERVAL date differences | Yes | Error; week cutoff requires engine verification | -| Binary `B'...'` / `X'...'` literals | Yes, byte value retained | PostgreSQL `DECODE(..., 'hex')`; DuckDB `UNHEX(...)` | -| Array element access | Yes, zero-based | Adjusts literal/dynamic indices; negative/out-of-range accesses return NULL; nested access supported | -| Array slices | Yes, exclusive end | Omitted or non-negative constant bounds supported; dynamic/negative bounds error | -| ARRAY/SET declarations, bounds, byte sizes and casts | Yes | Error: bounds, casts and set semantics are not equivalent | -| ROW fields and outer field aliases | Yes | Error without a verified type/field mapping | -| LONG VARBINARY size | Yes | Error rather than silently dropping capacity constraints | -| INTERVAL precision and INTERVALYM | Yes | Error rather than dropping precision/qualifiers | -| `USING PARAMETERS`, parameterized aggregates, `EXPLODE`, `PARTITION BEST` | Yes | Error without a verified parameter/result-shape mapping | -| Analytic and ordered-aggregate null ordering | Yes, including `NULLS AUTO` | Defined defaults retained; `AUTO` errors | -| Top-level null ordering | Type-dependent defaults; explicit placement uses CASE keys | Source defaults require a known key type; unknown types error | -| Historical `AT EPOCH` / `AT TIME` | Yes | Error | -| `FOR UPDATE [OF ...]` | Yes | Error: Vertica table locks differ from PostgreSQL row locks | -| SELECT optimizer hints | Yes | Error rather than discarding the hint | -| Partitioned `LIMIT ... OVER` | Yes | ROW_NUMBER rewrite for named, unambiguous outputs and resolvable keys; wildcard, OFFSET, locking or event-series combinations error | -| TIMESERIES, MATCH and INTERPOLATE | Yes | Error rather than treating them as ordinary queries/joins | -| CREATE PROJECTION, ENCODING, SEGMENTED/UNSEGMENTED and KSAFE | Yes | Error | -| CREATE FLEX/FLEXIBLE TABLE | Yes | Error | -| COPY FROM LOCAL and parser call arguments | Yes | Error | -| EXPORT TO PARQUET options and OVER | Yes | Error | -| NULLIFZERO | Yes | NULLIF(value, 0) | -| TIME_SLICE, MATCH_COLUMNS, conditional events, approximate percentiles, REGEXP_SUBSTR | Native calls retained | Error without a verified mapping | - -Additional restrictions: - -- Explicit top-level null placement uses an extra CASE sort key. Keys that might - be volatile are rejected rather than evaluated twice. Projection aliases and - ordinals are resolved before constructing the CASE expression. -- Partitioned LIMIT preserves output names and hides its helper columns. Internal - aliases avoid names already present in the query. Ambiguous output names and - non-projected keys in grouped/distinct queries are rejected conservatively. -- Array access assumes the target column represents a normal one-based array. - Migrating PostgreSQL columns with custom array lower bounds needs an explicit - data migration policy. Incoming array subscripts targeting Vertica are rejected. -- Generic native function acceptance is not a claim that every Vertica function - has a foreign mapping. The reviewed dialect-specific functions are guarded. -- Parsing does not replace database validation of catalog objects, permissions, - all parameter ranges, or table constraints. - -## Verification - -Regression cases live in the existing `custom_dialect_tests.rs`, Vertica custom -fixtures, and existing Python, Go, and TypeScript test files. No imported SQLGlot -fixture was excluded for this change. Corrections to PR-authored custom fixtures -replace unsafe expected translations with explicit error regressions. - -Run the complete Rust gate with `make test-rust-verify`. To additionally execute -the value regressions against an installed DuckDB CLI: - -```sh -POLYGLOT_DUCKDB=/path/to/duckdb cargo test -p polyglot-sql --test custom_dialect_tests -``` - -Native Vertica cases are checked against documentation and structured round trips; -these tests do not require or claim execution against a live Vertica server. - -Key semantic references: - -- [Array indexing, slices, bounds and casts](https://docs.vertica.com/26.2.x/en/sql-reference/data-types/complex-types/array/) -- [DATEDIFF boundaries](https://docs.vertica.com/24.2.x/en/sql-reference/functions/data-type-specific-functions/datetime-functions/datediff/) -- [SELECT and locking](https://docs.vertica.com/26.2.x/en/sql-reference/statements/select/) -- [Partitioned LIMIT](https://docs.vertica.com/26.2.x/en/sql-reference/statements/select/limit-clause/) -- [MATCH](https://docs.vertica.com/26.2.x/en/sql-reference/statements/select/match-clause/) -- [DuckDB date differences](https://duckdb.org/docs/lts/sql/functions/timestamp) From 764c73e1a7a5fa4598b3ec6ba6e22233bcebe74c Mon Sep 17 00:00:00 2001 From: tobilg Date: Thu, 24 Sep 2026 08:23:27 +0200 Subject: [PATCH 6/7] Cleanup --- crates/polyglot-sql/src/ast_mutation.rs | 45 +- crates/polyglot-sql/src/ast_transforms.rs | 83 +++ crates/polyglot-sql/src/dialects/duckdb.rs | 2 + crates/polyglot-sql/src/dialects/exasol.rs | 1 + crates/polyglot-sql/src/dialects/mod.rs | 2 - .../src/dialects/normalization/aggregates.rs | 3 + .../src/dialects/normalization/scalar.rs | 1 + .../src/dialects/normalization/vertica.rs | 64 +- crates/polyglot-sql/src/dialects/snowflake.rs | 1 + crates/polyglot-sql/src/dialects/vertica.rs | 3 + .../polyglot-sql/src/dialects/vertica_ast.rs | 361 ----------- crates/polyglot-sql/src/expressions.rs | 7 +- crates/polyglot-sql/src/generator.rs | 570 +++++++++++++++--- crates/polyglot-sql/src/parser.rs | 128 +++- .../tests/custom_dialect_tests.rs | 272 +++++++++ .../vertica/transpilation.json | 2 +- 16 files changed, 992 insertions(+), 553 deletions(-) delete mode 100644 crates/polyglot-sql/src/dialects/vertica_ast.rs 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/dialects/duckdb.rs b/crates/polyglot-sql/src/dialects/duckdb.rs index e0422352..6062f468 100644 --- a/crates/polyglot-sql/src/dialects/duckdb.rs +++ b/crates/polyglot-sql/src/dialects/duckdb.rs @@ -197,6 +197,7 @@ 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, @@ -214,6 +215,7 @@ 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, diff --git a/crates/polyglot-sql/src/dialects/exasol.rs b/crates/polyglot-sql/src/dialects/exasol.rs index 067d1f3c..c6d19e9d 100644 --- a/crates/polyglot-sql/src/dialects/exasol.rs +++ b/crates/polyglot-sql/src/dialects/exasol.rs @@ -127,6 +127,7 @@ 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, diff --git a/crates/polyglot-sql/src/dialects/mod.rs b/crates/polyglot-sql/src/dialects/mod.rs index 40c4f8ab..98e11191 100644 --- a/crates/polyglot-sql/src/dialects/mod.rs +++ b/crates/polyglot-sql/src/dialects/mod.rs @@ -22,8 +22,6 @@ mod generic; // Always compiled #[cfg(feature = "transpile")] mod normalization; -#[cfg(feature = "generate")] -pub(crate) mod vertica_ast; #[cfg(feature = "dialect-athena")] mod athena; diff --git a/crates/polyglot-sql/src/dialects/normalization/aggregates.rs b/crates/polyglot-sql/src/dialects/normalization/aggregates.rs index 5f49f230..9f73b4aa 100644 --- a/crates/polyglot-sql/src/dialects/normalization/aggregates.rs +++ b/crates/polyglot-sql/src/dialects/normalization/aggregates.rs @@ -422,6 +422,7 @@ 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, @@ -543,6 +544,7 @@ 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, @@ -637,6 +639,7 @@ 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, diff --git a/crates/polyglot-sql/src/dialects/normalization/scalar.rs b/crates/polyglot-sql/src/dialects/normalization/scalar.rs index 5bcbe3f9..01592e1c 100644 --- a/crates/polyglot-sql/src/dialects/normalization/scalar.rs +++ b/crates/polyglot-sql/src/dialects/normalization/scalar.rs @@ -11118,6 +11118,7 @@ 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, diff --git a/crates/polyglot-sql/src/dialects/normalization/vertica.rs b/crates/polyglot-sql/src/dialects/normalization/vertica.rs index ce30a98d..b9235778 100644 --- a/crates/polyglot-sql/src/dialects/normalization/vertica.rs +++ b/crates/polyglot-sql/src/dialects/normalization/vertica.rs @@ -4,6 +4,7 @@ //! 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, }; @@ -41,6 +42,11 @@ pub(in crate::dialects) fn validate_conversion( return Ok(()); } for node in expression.dfs() { + if let Expression::Cast(cast) = node { + if target == DialectType::Vertica { + crate::generator::Generator::validate_vertica_cast_type(&cast.to)?; + } + } let feature = match node { Expression::Select(select) if source == DialectType::Vertica @@ -67,6 +73,8 @@ pub(in crate::dialects) fn validate_conversion( && !matches!( v.as_ref(), crate::expressions::VerticaExpression::Binary { .. } + | crate::expressions::VerticaExpression::ArrayAccess { .. } + | crate::expressions::VerticaExpression::ArraySlice { .. } ) => { Some("native Vertica expression") @@ -285,8 +293,7 @@ fn projection_index(key: &Expression, projections: &[Expression]) -> Option { let mut matches = projections.iter().enumerate().filter(|(_, p)| { - crate::dialects::vertica_ast::output_name(p) - .is_some_and(|n| n.name.eq_ignore_ascii_case(&c.name.name)) + 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) @@ -395,8 +402,7 @@ fn prepare_query_order( queries: &[&Expression], target: DialectType, ) -> Result<()> { - let projections = crate::dialects::vertica_ast::projections(queries[0]) - .ok_or_else(|| unknown_order(target))?; + 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)); @@ -442,41 +448,6 @@ pub(super) fn normalize_from_vertica(e: Expression, target: DialectType) -> Resu { lower_partitioned_limit(*select, target) } - Expression::Subscript(sub) - if matches!(target, DialectType::PostgreSQL | DialectType::DuckDB) => - { - Ok(crate::dialects::vertica_ast::array_access( - sub.this, - vec![sub.index], - )) - } - Expression::Subscript(sub) => { - let mut this = sub.this; - let mut indices = Vec::new(); - if let Expression::Vertica(node) = this { - match *node { - crate::expressions::VerticaExpression::ArrayAccess { - this: array, - indices: inner, - } => { - this = array; - indices = inner; - } - other => this = Expression::Vertica(Box::new(other)), - } - } - indices.push(sub.index); - Ok(Expression::Vertica(Box::new( - crate::expressions::VerticaExpression::ArrayAccess { this, indices }, - ))) - } - Expression::ArraySlice(slice) => Ok(Expression::Vertica(Box::new( - crate::expressions::VerticaExpression::ArraySlice { - this: slice.this, - start: slice.start, - end: slice.end, - }, - ))), Expression::DateDiff(diff) => { let unit = diff.unit.ok_or_else(|| { crate::error::Error::unsupported( @@ -727,7 +698,7 @@ fn lower_partitioned_limit( let outer_with = base.with.take(); let outer_comments = std::mem::take(&mut base.leading_comments); let original = base.expressions.clone(); - let mut names = crate::dialects::vertica_ast::Names::default(); + 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); @@ -755,7 +726,7 @@ fn lower_partitioned_limit( // 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 = crate::dialects::vertica_ast::output_name(expression) + let name = output_identifier(expression) .ok_or_else(unsupported)? .clone(); if !visible_names.insert(name.name.to_ascii_lowercase()) { @@ -838,7 +809,7 @@ fn lower_partitioned_limit( internal_names.push(name.clone()); name }; - Ok(crate::dialects::vertica_ast::column(table, &name)) + Ok(crate::ast_mutation::qualified_column(table, &name)) }; for key in &mut over.partition_by { *key = resolve(key, &source_alias, false)?; @@ -853,12 +824,15 @@ fn lower_partitioned_limit( } } let subquery = |select: Select, name: String| { - crate::dialects::vertica_ast::subquery(Expression::Select(Box::new(select)), Some(name)) + 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::dialects::vertica_ast::column(&source_alias, name)) + .map(|name| crate::ast_mutation::qualified_column(&source_alias, name)) .collect(); ranked.expressions.push( Expression::WindowFunction(Box::new(WindowFunction { @@ -878,7 +852,7 @@ fn lower_partitioned_limit( .enumerate() .map(|(i, name)| { Expression::Alias(Box::new(Alias::new( - crate::dialects::vertica_ast::column(&ranked_alias, &internal_names[i]), + crate::ast_mutation::qualified_column(&ranked_alias, &internal_names[i]), name, ))) }) diff --git a/crates/polyglot-sql/src/dialects/snowflake.rs b/crates/polyglot-sql/src/dialects/snowflake.rs index 59ae730d..f0e9016d 100644 --- a/crates/polyglot-sql/src/dialects/snowflake.rs +++ b/crates/polyglot-sql/src/dialects/snowflake.rs @@ -200,6 +200,7 @@ 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, diff --git a/crates/polyglot-sql/src/dialects/vertica.rs b/crates/polyglot-sql/src/dialects/vertica.rs index 7e5d019b..a2de0c56 100644 --- a/crates/polyglot-sql/src/dialects/vertica.rs +++ b/crates/polyglot-sql/src/dialects/vertica.rs @@ -64,6 +64,7 @@ impl DialectImpl for VerticaDialect { limit_fetch_style: LimitFetchStyle::Limit, nvl2_supported: true, supports_median: true, + aggregate_filter_supported: false, ..Default::default() } } @@ -161,6 +162,7 @@ impl DialectImpl for VerticaDialect { // 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, @@ -171,6 +173,7 @@ impl DialectImpl for VerticaDialect { inferred_type: None, }))), Expression::StringAgg(f) => Ok(Expression::ListAgg(Box::new(ListAggFunc { + source_dialect: None, this: f.this, separator: f.separator, on_overflow: None, diff --git a/crates/polyglot-sql/src/dialects/vertica_ast.rs b/crates/polyglot-sql/src/dialects/vertica_ast.rs deleted file mode 100644 index 88cce64e..00000000 --- a/crates/polyglot-sql/src/dialects/vertica_ast.rs +++ /dev/null @@ -1,361 +0,0 @@ -//! AST construction shared by Vertica normalization and generation. -//! Keep scope creation here so dialect generation never splices SQL subqueries. - -use crate::error::{Error, Result}; -use crate::expressions::*; -use crate::traversal::ExpressionWalk; -use std::collections::HashSet; - -#[derive(Default)] -pub(crate) struct Names(HashSet); - -impl Names { - 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 - } -} - -pub(crate) fn subquery(this: Expression, alias: Option) -> Expression { - Expression::Subquery(Box::new(Subquery { - this, - alias: alias.map(Identifier::new), - column_aliases: Vec::new(), - alias_explicit_as: true, - alias_keyword: None, - order_by: None, - limit: None, - offset: None, - distribute_by: None, - sort_by: None, - cluster_by: None, - lateral: false, - modifiers_inside: true, - trailing_comments: Vec::new(), - inferred_type: None, - })) -} - -pub(crate) fn output_name(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, - } -} - -pub(crate) fn 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, - }; - } -} - -pub(crate) fn 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) -} - -fn repeatable_index(index: &Expression) -> bool { - match index { - Expression::Column(_) - | Expression::Identifier(_) - | Expression::Literal(_) - | Expression::Null(_) => true, - Expression::Paren(p) => repeatable_index(&p.this), - Expression::Neg(n) => repeatable_index(&n.this), - Expression::Cast(c) => repeatable_index(&c.this), - Expression::Add(b) | Expression::Sub(b) | Expression::Mul(b) | Expression::Mod(b) => { - repeatable_index(&b.left) && repeatable_index(&b.right) - } - _ => false, - } -} - -fn adjusted_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 adjusted_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 zero-based access to ordinary subscript AST nodes. The base occurs once; -/// constants fold and dynamic indices preserve NULL/negative/overflow behavior. -pub(crate) fn array_access(mut array: Expression, indices: Vec) -> Expression { - if !matches!( - array, - Expression::Column(_) - | Expression::Identifier(_) - | Expression::Paren(_) - | Expression::Subscript(_) - ) { - array = Expression::Paren(Box::new(Paren { - this: array, - trailing_comments: Vec::new(), - })); - } - for index in indices { - let index = if repeatable_index(&index) { - adjusted_index(index) - } else { - adjusted_index_once(index) - }; - array = Expression::Subscript(Box::new(Subscript { this: array, index })); - } - array -} - -/// Sort outside DISTINCT/set operations so emulated NULL ordering may add CASE keys -/// without changing the result columns or the deduplication operation. -pub(crate) fn wrap_ordered_query(mut query: Expression) -> Result { - let unsupported = || { - Error::unsupported("Vertica ordered DISTINCT/set operation requires unique named outputs and resolvable sort keys", "vertica") - }; - let projections = projections(&query).ok_or_else(unsupported)?.to_vec(); - let output_names = projections - .iter() - .map(|p| output_name(p).cloned().ok_or_else(unsupported)) - .collect::>>()?; - let mut unique = HashSet::new(); - if output_names - .iter() - .any(|n| !unique.insert(n.name.to_ascii_lowercase())) - { - return Err(unsupported()); - } - let mut names = Names::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 = column(&table, &output_names[index]); - } - outer.expressions = output_names - .iter() - .map(|name| Expression::Alias(Box::new(Alias::new(column(&table, name), name.clone())))) - .collect(); - outer.from = Some(From { - expressions: vec![subquery(query, Some(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. -pub(crate) fn wrap_subquery_order(mut query: Subquery) -> Result { - if query.distribute_by.is_some() || query.sort_by.is_some() || query.cluster_by.is_some() { - return Err(Error::unsupported("ordered subquery modifiers", "vertica")); - } - let mut ordered = subquery( - 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 = wrap_ordered_query(ordered)?; - Ok(query) -} diff --git a/crates/polyglot-sql/src/expressions.rs b/crates/polyglot-sql/src/expressions.rs index 01ffb25a..8a32b5fc 100644 --- a/crates/polyglot-sql/src/expressions.rs +++ b/crates/polyglot-sql/src/expressions.rs @@ -1131,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, @@ -7135,6 +7136,10 @@ 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, @@ -15850,7 +15855,7 @@ pub enum VerticaExpression { right: Expression, previous: bool, }, - /// Lowered array access retains source bounds semantics until generation. + /// Zero-based array access retains source bounds semantics from parsing. ArrayAccess { this: Expression, indices: Vec, diff --git a/crates/polyglot-sql/src/generator.rs b/crates/polyglot-sql/src/generator.rs index d6d656d5..6a7406bf 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, @@ -4880,6 +4889,139 @@ 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())?; @@ -4890,9 +5032,8 @@ impl Generator { .as_ref() .is_some_and(|o| o.expressions.iter().any(|o| o.nulls_first.is_some())) { - let wrapped = crate::dialects::vertica_ast::wrap_ordered_query(Expression::Select( - Box::new(select.clone()), - ))?; + 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) { @@ -7493,9 +7634,8 @@ impl Generator { .as_ref() .is_some_and(|o| o.expressions.iter().any(|o| o.nulls_first.is_some())) { - let wrapped = crate::dialects::vertica_ast::wrap_ordered_query(Expression::Union( - Box::new(outermost.clone()), - ))?; + 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( @@ -7683,9 +7823,9 @@ impl Generator { .as_ref() .is_some_and(|o| o.expressions.iter().any(|o| o.nulls_first.is_some())) { - let wrapped = crate::dialects::vertica_ast::wrap_ordered_query(Expression::Intersect( - Box::new(outermost.clone()), - ))?; + 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( @@ -7868,9 +8008,8 @@ impl Generator { .as_ref() .is_some_and(|o| o.expressions.iter().any(|o| o.nulls_first.is_some())) { - let wrapped = crate::dialects::vertica_ast::wrap_ordered_query(Expression::Except( - Box::new(outermost.clone()), - ))?; + 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( @@ -18498,8 +18637,62 @@ impl Generator { 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!( @@ -20974,13 +21167,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(()) @@ -22888,7 +23075,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(), @@ -22904,7 +23135,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 @@ -22969,19 +23208,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()), @@ -23006,7 +23255,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) @@ -23088,14 +23339,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(()) } @@ -23177,13 +23422,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(()) } @@ -23242,13 +23481,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(()) } @@ -23342,6 +23575,14 @@ impl Generator { 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("("); @@ -23425,13 +23666,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(()) } @@ -23444,13 +23679,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(()) } @@ -23467,13 +23696,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(()) } @@ -23499,13 +23722,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(()) } @@ -24908,13 +25125,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(()) } @@ -24934,13 +25145,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(()) } @@ -25372,6 +25577,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] @@ -25429,6 +25640,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!( @@ -26326,7 +26543,7 @@ impl Generator { .as_ref() .is_some_and(|o| o.expressions.iter().any(|o| o.nulls_first.is_some())) { - let wrapped = crate::dialects::vertica_ast::wrap_subquery_order(subquery.clone())?; + let wrapped = Self::wrap_vertica_subquery_order(subquery.clone())?; return self.generate_subquery(&wrapped); } if subquery.lateral { @@ -33400,6 +33617,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"); @@ -43842,6 +44065,120 @@ impl Generator { 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!( @@ -43850,9 +44187,26 @@ impl Generator { ) { match node { V::ArrayAccess { this, indices } => { - let lowered = - crate::dialects::vertica_ast::array_access(this.clone(), indices.clone()); - return self.generate_expression(&lowered); + 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 { @@ -44041,7 +44395,27 @@ impl Generator { }); self.generate_expression(right)?; } - V::ArrayAccess { .. } | V::ArraySlice { .. } | V::BoundaryDateDiff { .. } => { + 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", diff --git a/crates/polyglot-sql/src/parser.rs b/crates/polyglot-sql/src/parser.rs index 96ed878d..ad7f6076 100644 --- a/crates/polyglot-sql/src/parser.rs +++ b/crates/polyglot-sql/src/parser.rs @@ -32221,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, @@ -39990,6 +39990,9 @@ 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, @@ -42161,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()?; @@ -42191,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) { @@ -44299,6 +44298,10 @@ impl Parser { 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") => { @@ -51582,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)) } @@ -52250,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) } @@ -66398,11 +66392,66 @@ impl Parser { 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(); @@ -66491,6 +66540,21 @@ impl Parser { 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(); diff --git a/crates/polyglot-sql/tests/custom_dialect_tests.rs b/crates/polyglot-sql/tests/custom_dialect_tests.rs index f6f5e41e..76a79e9b 100644 --- a/crates/polyglot-sql/tests/custom_dialect_tests.rs +++ b/crates/polyglot-sql/tests/custom_dialect_tests.rs @@ -816,6 +816,278 @@ fn vertica_translate(sql: &str, read: &str, write: &str) -> String { .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_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)"; diff --git a/crates/polyglot-sql/tests/custom_fixtures/vertica/transpilation.json b/crates/polyglot-sql/tests/custom_fixtures/vertica/transpilation.json index 2a07716b..e12376d0 100644 --- a/crates/polyglot-sql/tests/custom_fixtures/vertica/transpilation.json +++ b/crates/polyglot-sql/tests/custom_fixtures/vertica/transpilation.json @@ -111,7 +111,7 @@ "tsql": "SELECT CAST(a AS BIGINT) FROM t" }, "read": { - "postgresql": "SELECT a::INTEGER FROM t" + "postgresql": "SELECT a::BIGINT FROM t" }, "description": "Integers are BIGINT" }, From 0af14a52a47c0e256120416d8e57714cd06918e2 Mon Sep 17 00:00:00 2001 From: tobilg Date: Thu, 24 Sep 2026 08:28:11 +0200 Subject: [PATCH 7/7] Cleanup --- .../src/dialects/normalization/vertica.rs | 12 +- crates/polyglot-sql/src/generator.rs | 70 +++++ .../tests/custom_dialect_tests.rs | 245 ++++++++++++++++++ 3 files changed, 324 insertions(+), 3 deletions(-) diff --git a/crates/polyglot-sql/src/dialects/normalization/vertica.rs b/crates/polyglot-sql/src/dialects/normalization/vertica.rs index b9235778..2ed91aa2 100644 --- a/crates/polyglot-sql/src/dialects/normalization/vertica.rs +++ b/crates/polyglot-sql/src/dialects/normalization/vertica.rs @@ -42,9 +42,15 @@ pub(in crate::dialects) fn validate_conversion( return Ok(()); } for node in expression.dfs() { - if let Expression::Cast(cast) = node { - if target == DialectType::Vertica { - crate::generator::Generator::validate_vertica_cast_type(&cast.to)?; + 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 { diff --git a/crates/polyglot-sql/src/generator.rs b/crates/polyglot-sql/src/generator.rs index 6a7406bf..2e778883 100644 --- a/crates/polyglot-sql/src/generator.rs +++ b/crates/polyglot-sql/src/generator.rs @@ -18637,6 +18637,25 @@ 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<()> { @@ -20195,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) { @@ -28053,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) => { diff --git a/crates/polyglot-sql/tests/custom_dialect_tests.rs b/crates/polyglot-sql/tests/custom_dialect_tests.rs index 76a79e9b..cfd1a143 100644 --- a/crates/polyglot-sql/tests/custom_dialect_tests.rs +++ b/crates/polyglot-sql/tests/custom_dialect_tests.rs @@ -881,6 +881,251 @@ fn vertica_numeric_defaults_survive_parsing_and_generation() { } } +#[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};