diff --git a/README.md b/README.md index 5a523efd..3e8f6daa 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Polyglot -Rust/Wasm-powered SQL transpiler for 32+ dialects, inspired by [sqlglot](https://github.com/tobymao/sqlglot). +Rust/Wasm-powered SQL transpiler for 33+ dialects, inspired by [sqlglot](https://github.com/tobymao/sqlglot). -Polyglot parses, generates, transpiles, and formats SQL across 32+ database dialects. It ships as: +Polyglot parses, generates, transpiles, and formats SQL across 33+ database dialects. It ships as: - a Rust crate ([`polyglot-sql`](https://crates.io/crates/polyglot-sql/)) - a TypeScript/WASM SDK ([`@polyglot-sql/sdk`](https://www.npmjs.com/package/@polyglot-sql/sdk)) - a Python package ([`polyglot-sql`](https://pypi.org/project/polyglot-sql/)) @@ -14,7 +14,7 @@ Release notes are tracked in [`CHANGELOG.md`](CHANGELOG.md). ## Features -- **Transpile** SQL between any pair of 32 dialects +- **Transpile** SQL between any pair of 33 dialects - **Parse** SQL into a fully-typed AST - **Generate** SQL back from AST nodes - **Format** / pretty-print SQL @@ -27,17 +27,17 @@ Release notes are tracked in [`CHANGELOG.md`](CHANGELOG.md). - **C FFI** shared/static library for multi-language bindings (`polyglot-sql-ffi`) - **Python bindings** powered by PyO3 (`polyglot-sql` on PyPI) -## Supported Dialects (32) +## Supported Dialects (33) | | | | | | |---|---|---|---|---| | Athena | BigQuery | ClickHouse | CockroachDB | Databricks | | Doris | Dremio | Drill | Druid | DuckDB | -| Dune | Exasol | Fabric | Hive | Materialize | -| MySQL | Oracle | PostgreSQL | Presto | Redshift | -| RisingWave | SingleStore | Snowflake | Solr | Spark | -| SQLite | StarRocks | Tableau | Teradata | TiDB | -| Trino | TSQL | | | | +| Dune | Exasol | Fabric | HANA | Hive | +| Materialize | MySQL | Oracle | PostgreSQL | Presto | +| Redshift | RisingWave | SingleStore | Snowflake | Solr | +| Spark | SQLite | StarRocks | Tableau | Teradata | +| TiDB | Trino | TSQL | | | ## Quick Start diff --git a/crates/polyglot-sql-ffi/README.md b/crates/polyglot-sql-ffi/README.md index e646556c..b6ac4513 100644 --- a/crates/polyglot-sql-ffi/README.md +++ b/crates/polyglot-sql-ffi/README.md @@ -275,6 +275,7 @@ Dialect identifiers are string names used in core `polyglot-sql`, for example: - `snowflake` - `duckdb` - `clickhouse` +- `hana` For a complete runtime list, call `polyglot_dialect_list()`. diff --git a/crates/polyglot-sql-ffi/src/dialects.rs b/crates/polyglot-sql-ffi/src/dialects.rs index 87331aa4..de46a4f5 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::HANA, ]; /// 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 6bd5ccb9..c9ef6a07 100644 --- a/crates/polyglot-sql-ffi/tests/ffi_tests.rs +++ b/crates/polyglot-sql-ffi/tests/ffi_tests.rs @@ -1257,8 +1257,9 @@ 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!(count >= 32); + assert!(count >= 35); assert!(list.iter().any(|d| d == "generic")); + assert!(list.iter().any(|d| d == "hana")); } #[test] diff --git a/crates/polyglot-sql-python/README.md b/crates/polyglot-sql-python/README.md index 56d84cd9..2ecc36c6 100644 --- a/crates/polyglot-sql-python/README.md +++ b/crates/polyglot-sql-python/README.md @@ -1,6 +1,6 @@ # polyglot-sql (Python) -Rust-powered SQL transpiler for 30+ dialects. +Rust-powered SQL transpiler for 33+ dialects. The `polyglot-sql` Python package exposes an API backed by the Rust `polyglot-sql` engine for fast parse/transpile/generate/format/validate workflows. @@ -203,7 +203,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`, `hana`, `hive`, `materialize`, `mysql`, `oracle`, `postgres`, `presto`, `redshift`, `risingwave`, `singlestore`, `snowflake`, `solr`, `spark`, `sqlite`, `starrocks`, `tableau`, `teradata`, `tidb`, `trino`, `tsql`. ## Error Handling diff --git a/crates/polyglot-sql-python/docs/index.md b/crates/polyglot-sql-python/docs/index.md index 8d4a0868..91aecac5 100644 --- a/crates/polyglot-sql-python/docs/index.md +++ b/crates/polyglot-sql-python/docs/index.md @@ -186,7 +186,7 @@ Use `polyglot_sql.dialects()` to retrieve supported dialect names at runtime. ```python polyglot_sql.dialects() # ["athena", "bigquery", "clickhouse", "databricks", "doris", "drill", -# "duckdb", "generic", "hive", "materialize", "mysql", "oracle", +# "duckdb", "generic", "hana", "hive", "materialize", "mysql", "oracle", # "postgres", "presto", "redshift", "snowflake", "spark", "sqlite", # "starrocks", "tableau", "teradata", "trino", "tsql", ...] ``` diff --git a/crates/polyglot-sql-python/pyproject.toml b/crates/polyglot-sql-python/pyproject.toml index 87f313a4..97fbd5f9 100644 --- a/crates/polyglot-sql-python/pyproject.toml +++ b/crates/polyglot-sql-python/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "maturin" [project] name = "polyglot-sql" dynamic = ["version"] -description = "Rust-powered SQL transpiler for 32+ dialects. Parse, generate, transpile, format, and validate SQL." +description = "Rust-powered SQL transpiler for 33+ dialects. Parse, generate, transpile, format, and validate SQL." readme = "README.md" license = { text = "MIT" } requires-python = ">=3.9" diff --git a/crates/polyglot-sql-python/python/polyglot_sql/__init__.py b/crates/polyglot-sql-python/python/polyglot_sql/__init__.py index da3e28f9..cff766ba 100644 --- a/crates/polyglot-sql-python/python/polyglot_sql/__init__.py +++ b/crates/polyglot-sql-python/python/polyglot_sql/__init__.py @@ -1,4 +1,4 @@ -"""Polyglot SQL — Rust-powered SQL transpiler for 32+ dialects.""" +"""Polyglot SQL — Rust-powered SQL transpiler for 33+ dialects.""" from polyglot_sql._polyglot_sql import ( Expression, diff --git a/crates/polyglot-sql-python/src/dialects.rs b/crates/polyglot-sql-python/src/dialects.rs index 028fbda3..97840f0e 100644 --- a/crates/polyglot-sql-python/src/dialects.rs +++ b/crates/polyglot-sql-python/src/dialects.rs @@ -16,6 +16,7 @@ const DIALECT_NAMES: &[&str] = &[ "exasol", "fabric", "generic", + "hana", "hive", "materialize", "mysql", diff --git a/crates/polyglot-sql-python/tests/test_dialects.py b/crates/polyglot-sql-python/tests/test_dialects.py index de8342cb..0dc817b9 100644 --- a/crates/polyglot-sql-python/tests/test_dialects.py +++ b/crates/polyglot-sql-python/tests/test_dialects.py @@ -10,6 +10,7 @@ def test_dialects_contains_known_values(): assert "snowflake" in values assert "bigquery" in values assert "duckdb" in values + assert "hana" in values def test_version_is_exposed(): diff --git a/crates/polyglot-sql-wasm/Cargo.toml b/crates/polyglot-sql-wasm/Cargo.toml index 143ec511..9f5f9490 100644 --- a/crates/polyglot-sql-wasm/Cargo.toml +++ b/crates/polyglot-sql-wasm/Cargo.toml @@ -73,6 +73,7 @@ all-dialects = [ "dialect-druid", "dialect-solr", "dialect-tableau", "dialect-dune", "dialect-fabric", "dialect-drill", "dialect-dremio", "dialect-exasol", "dialect-datafusion", + "dialect-hana", ] dialect-postgresql = ["polyglot-sql/dialect-postgresql"] dialect-mysql = ["polyglot-sql/dialect-mysql"] @@ -107,6 +108,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-hana = ["polyglot-sql/dialect-hana"] function-catalog-clickhouse = [ "dialect-clickhouse", "semantic", diff --git a/crates/polyglot-sql/Cargo.toml b/crates/polyglot-sql/Cargo.toml index f946dbb3..5bc7e597 100644 --- a/crates/polyglot-sql/Cargo.toml +++ b/crates/polyglot-sql/Cargo.toml @@ -48,6 +48,7 @@ all-dialects = [ "dialect-druid", "dialect-solr", "dialect-tableau", "dialect-dune", "dialect-fabric", "dialect-drill", "dialect-dremio", "dialect-exasol", "dialect-datafusion", + "dialect-hana", ] dialect-postgresql = [] dialect-mysql = [] @@ -82,6 +83,7 @@ dialect-drill = [] dialect-dremio = [] dialect-exasol = [] dialect-datafusion = [] +dialect-hana = [] function-catalog-clickhouse = [ "semantic", "dep:polyglot-sql-function-catalogs", diff --git a/crates/polyglot-sql/README.md b/crates/polyglot-sql/README.md index 4b81a16a..465041cb 100644 --- a/crates/polyglot-sql/README.md +++ b/crates/polyglot-sql/README.md @@ -9,7 +9,7 @@ Part of the [Polyglot](https://github.com/tobilg/polyglot) project. - **Parse** SQL into a fully-typed AST with 200+ expression types - **Parse standalone data types** such as `DECIMAL(10, 2)` without a statement wrapper - **Generate** SQL from AST nodes for any target dialect -- **Transpile** between any pair of 32 dialects in one call +- **Transpile** between any pair of 33 dialects in one call - **Format** / pretty-print SQL - **Fluent builder API** for constructing queries programmatically - **AST traversal** utilities (DFS/BFS iterators, transform, walk) @@ -448,7 +448,7 @@ assert_eq!(err.line(), None); ## Supported Dialects -Athena, BigQuery, ClickHouse, CockroachDB, Databricks, Doris, Dremio, Drill, Druid, DuckDB, Dune, Exasol, Fabric, Hive, Materialize, MySQL, Oracle, PostgreSQL, Presto, Redshift, RisingWave, SingleStore, Snowflake, Solr, Spark, SQLite, StarRocks, Tableau, Teradata, TiDB, Trino, TSQL +Athena, BigQuery, ClickHouse, CockroachDB, Databricks, Doris, Dremio, Drill, Druid, DuckDB, Dune, Exasol, Fabric, HANA, Hive, Materialize, MySQL, Oracle, PostgreSQL, Presto, Redshift, RisingWave, SingleStore, Snowflake, Solr, Spark, SQLite, StarRocks, Tableau, Teradata, TiDB, Trino, TSQL ## Feature Flags diff --git a/crates/polyglot-sql/src/dialects/hana.rs b/crates/polyglot-sql/src/dialects/hana.rs new file mode 100644 index 00000000..2d2ac200 --- /dev/null +++ b/crates/polyglot-sql/src/dialects/hana.rs @@ -0,0 +1,489 @@ +//! SAP HANA Cloud SQL Dialect +//! +//! SAP HANA is an in-memory, column-oriented, relational database management system. +//! Reference: SAP HANA Cloud SQL Reference Guide. +//! +//! Key characteristics: +//! - Double-quote identifiers (case preserved, like Oracle) +//! - Standard SQL string quoting with single-quote escaping (`''`) +//! - No nested comment support +//! - Uppercase keyword generation +//! - Phase 2: ~40 HANA-specific function transforms for cross-dialect transpilation + +use super::{DialectImpl, DialectType}; +use crate::error::Result; +#[cfg(feature = "transpile")] +use crate::expressions::{ + Case, Cast, CurrentDate, CurrentTime, CurrentTimestamp, DataType, Expression, Function, LikeOp, + UnaryFunc, VarArgFunc, +}; +#[cfg(feature = "generate")] +use crate::generator::GeneratorConfig; +use crate::tokens::TokenizerConfig; + +/// SAP HANA Cloud dialect +pub struct HanaDialect; + +impl DialectImpl for HanaDialect { + fn dialect_type(&self) -> DialectType { + DialectType::HANA + } + + fn tokenizer_config(&self) -> TokenizerConfig { + let mut config = TokenizerConfig::default(); + // HANA uses double quotes for identifiers + config.identifiers.insert('"', '"'); + // HANA does not support nested comments + config.nested_comments = false; + config + } + + #[cfg(feature = "generate")] + fn generator_config(&self) -> GeneratorConfig { + use crate::generator::{IdentifierQuoteStyle, NormalizeFunctions}; + GeneratorConfig { + identifier_quote: '"', + identifier_quote_style: IdentifierQuoteStyle::DOUBLE_QUOTE, + dialect: Some(DialectType::HANA), + // HANA uses the COLUMN keyword in ALTER TABLE ADD + alter_table_include_column_keyword: true, + // Preserve function name casing for identity round-trip + normalize_functions: NormalizeFunctions::None, + ..Default::default() + } + } + + #[cfg(feature = "transpile")] + fn transform_expr(&self, expr: Expression) -> Result { + match expr { + // Typed date arithmetic: ADD_MONTHS → DATE_ADD('month', val, date) + Expression::AddMonths(f) => Ok(Expression::Function(Box::new(Function::new( + "DATE_ADD".to_string(), + vec![Expression::string("month"), f.expression, f.this], + )))), + + // Typed date diff: MONTHS_BETWEEN → DATE_DIFF('month', d1, d2) + Expression::MonthsBetween(f) => Ok(Expression::Function(Box::new(Function::new( + "DATE_DIFF".to_string(), + vec![Expression::string("month"), f.this, f.expression], + )))), + + // NVL → COALESCE + Expression::Nvl(f) => Ok(Expression::Coalesce(Box::new(VarArgFunc { + original_name: None, + expressions: vec![f.this, f.expression], + inferred_type: None, + }))), + + // IFNULL → COALESCE + Expression::IfNull(f) => Ok(Expression::Coalesce(Box::new(VarArgFunc { + original_name: None, + expressions: vec![f.this, f.expression], + inferred_type: None, + }))), + + // IF(cond, a, b) → CASE WHEN cond THEN a ELSE b END + Expression::IfFunc(f) => Ok(Expression::Case(Box::new(Case { + operand: None, + whens: vec![(f.condition, f.true_value)], + else_: f.false_value, + comments: Vec::new(), + inferred_type: None, + }))), + + // ILIKE → LOWER() LIKE LOWER() (HANA doesn't support ILIKE natively) + Expression::ILike(op) => { + let lower_left = Expression::Lower(Box::new(UnaryFunc::new(op.left.clone()))); + let lower_right = Expression::Lower(Box::new(UnaryFunc::new(op.right.clone()))); + Ok(Expression::Like(Box::new(LikeOp { + left: lower_left, + right: lower_right, + escape: op.escape, + quantifier: op.quantifier.clone(), + inferred_type: None, + }))) + } + + // HANA FLOAT (64-bit) → DOUBLE in CAST expressions + // HANA's FLOAT is equivalent to DOUBLE, not single-precision REAL + Expression::Cast(mut c) => { + if let DataType::Float { + real_spelling: false, + .. + } = c.to + { + c.to = DataType::Double { + precision: None, + scale: None, + }; + } + Ok(Expression::Cast(c)) + } + + // IFNULL is parsed as Coalesce with original_name "IFNULL" — clear it + Expression::Coalesce(mut f) => { + f.original_name = None; + Ok(Expression::Coalesce(f)) + } + + // TO_DECIMAL/TO_NUMBER is parsed as Expression::ToNumber → CAST AS DECIMAL + // (intercept before cross_dialect_normalize converts it to CAST AS DOUBLE) + Expression::ToNumber(f) => Ok(Expression::Cast(Box::new(Cast { + this: *f.this, + to: DataType::Decimal { + precision: None, + scale: None, + }, + trailing_comments: Vec::new(), + double_colon_syntax: false, + format: None, + default: None, + inferred_type: None, + }))), + + // CURRENT_UTCTIMESTAMP/CURRENT_UTCDATE/CURRENT_UTCTIME without parens + // are parsed as Column references — convert to proper datetime expressions + Expression::Column(c) => { + let name_upper = c.name.name.to_ascii_uppercase(); + match name_upper.as_str() { + "CURRENT_UTCTIMESTAMP" => Ok(Expression::CurrentTimestamp(CurrentTimestamp { + precision: None, + sysdate: false, + })), + "CURRENT_UTCDATE" => Ok(Expression::CurrentDate(CurrentDate)), + "CURRENT_UTCTIME" => { + Ok(Expression::CurrentTime(CurrentTime { precision: None })) + } + _ => Ok(Expression::Column(c)), + } + } + + // Generic function transformations + Expression::Function(f) => self.transform_function(*f), + + // Pass through everything else + _ => Ok(expr), + } + } +} + +#[cfg(feature = "transpile")] +impl HanaDialect { + fn transform_function(&self, f: Function) -> Result { + let name_upper = f.name.to_uppercase(); + match name_upper.as_str() { + // Date arithmetic: ADD_DAYS(d, n) → DATE_ADD('day', n, d) + "ADD_DAYS" if f.args.len() == 2 => { + let mut args = f.args; + let date = args.remove(0); + let val = args.remove(0); + Ok(Expression::Function(Box::new(Function::new( + "DATE_ADD".to_string(), + vec![Expression::string("day"), val, date], + )))) + } + + // ADD_SECONDS(ts, n) → DATE_ADD('second', n, ts) + "ADD_SECONDS" if f.args.len() == 2 => { + let mut args = f.args; + let date = args.remove(0); + let val = args.remove(0); + Ok(Expression::Function(Box::new(Function::new( + "DATE_ADD".to_string(), + vec![Expression::string("second"), val, date], + )))) + } + + // ADD_YEARS(d, n) → DATE_ADD('year', n, d) + "ADD_YEARS" if f.args.len() == 2 => { + let mut args = f.args; + let date = args.remove(0); + let val = args.remove(0); + Ok(Expression::Function(Box::new(Function::new( + "DATE_ADD".to_string(), + vec![Expression::string("year"), val, date], + )))) + } + + // Date diff: DAYS_BETWEEN(d1, d2) → DATE_DIFF('day', d1, d2) + "DAYS_BETWEEN" if f.args.len() == 2 => { + Ok(Expression::Function(Box::new(Function::new( + "DATE_DIFF".to_string(), + vec![ + Expression::string("day"), + f.args[0].clone(), + f.args[1].clone(), + ], + )))) + } + + "SECONDS_BETWEEN" if f.args.len() == 2 => { + Ok(Expression::Function(Box::new(Function::new( + "DATE_DIFF".to_string(), + vec![ + Expression::string("second"), + f.args[0].clone(), + f.args[1].clone(), + ], + )))) + } + + "YEARS_BETWEEN" if f.args.len() == 2 => { + Ok(Expression::Function(Box::new(Function::new( + "DATE_DIFF".to_string(), + vec![ + Expression::string("year"), + f.args[0].clone(), + f.args[1].clone(), + ], + )))) + } + + // Conversion: TO_VARCHAR(x) without format → CAST(x AS VARCHAR) + "TO_VARCHAR" if f.args.len() == 1 => Ok(Expression::Cast(Box::new(Cast { + this: f.args.into_iter().next().unwrap(), + to: DataType::VarChar { + length: None, + parenthesized_length: false, + }, + trailing_comments: Vec::new(), + double_colon_syntax: false, + format: None, + default: None, + inferred_type: None, + }))), + + // TO_VARCHAR(d, fmt) with format → DATE_FORMAT(d, converted_fmt) + "TO_VARCHAR" if f.args.len() == 2 => { + let mut args = f.args; + let date = args.remove(0); + let fmt = args.remove(0); + let converted_fmt = convert_format_arg(&fmt); + Ok(Expression::Function(Box::new(Function::new( + "DATE_FORMAT".to_string(), + vec![date, converted_fmt], + )))) + } + + // TO_INTEGER(x) → CAST(x AS INTEGER) + "TO_INTEGER" if f.args.len() == 1 => Ok(Expression::Cast(Box::new(Cast { + this: f.args.into_iter().next().unwrap(), + to: DataType::Int { + length: None, + integer_spelling: true, + }, + trailing_comments: Vec::new(), + double_colon_syntax: false, + format: None, + default: None, + inferred_type: None, + }))), + + // TO_NUMBER/TO_DECIMAL(x) → CAST(x AS DECIMAL) + // (Parser canonicalizes TO_DECIMAL to TO_NUMBER; intercept before + // cross_dialect_normalize converts it to CAST AS DOUBLE) + "TO_NUMBER" if f.args.len() == 1 => Ok(Expression::Cast(Box::new(Cast { + this: f.args.into_iter().next().unwrap(), + to: DataType::Decimal { + precision: None, + scale: None, + }, + trailing_comments: Vec::new(), + double_colon_syntax: false, + format: None, + default: None, + inferred_type: None, + }))), + + // TO_REAL(x) → CAST(x AS REAL) + "TO_REAL" if f.args.len() == 1 => Ok(Expression::Cast(Box::new(Cast { + this: f.args.into_iter().next().unwrap(), + to: DataType::Float { + precision: None, + scale: None, + real_spelling: true, + }, + trailing_comments: Vec::new(), + double_colon_syntax: false, + format: None, + default: None, + inferred_type: None, + }))), + + // TO_DOUBLE(x) → CAST(x AS DOUBLE) + "TO_DOUBLE" if f.args.len() == 1 => Ok(Expression::Cast(Box::new(Cast { + this: f.args.into_iter().next().unwrap(), + to: DataType::Double { + precision: None, + scale: None, + }, + trailing_comments: Vec::new(), + double_colon_syntax: false, + format: None, + default: None, + inferred_type: None, + }))), + + // TO_DATE(s, fmt) with format → keep as TO_DATE but convert format + // (Trino's transform_function handles TO_DATE → DATE_PARSE) + "TO_DATE" if f.args.len() == 2 => { + let mut args = f.args; + let date = args.remove(0); + let fmt = args.remove(0); + let converted_fmt = convert_format_arg(&fmt); + Ok(Expression::Function(Box::new(Function::new( + "TO_DATE".to_string(), + vec![date, converted_fmt], + )))) + } + + // TO_TIMESTAMP(s, fmt) with format → keep as TO_TIMESTAMP but convert format + // (Trino's transform_function handles TO_TIMESTAMP → DATE_PARSE) + "TO_TIMESTAMP" if f.args.len() == 2 => { + let mut args = f.args; + let date = args.remove(0); + let fmt = args.remove(0); + let converted_fmt = convert_format_arg(&fmt); + Ok(Expression::Function(Box::new(Function::new( + "TO_TIMESTAMP".to_string(), + vec![date, converted_fmt], + )))) + } + + // Datetime constants: CURRENT_UTCTIMESTAMP → CURRENT_TIMESTAMP + "CURRENT_UTCTIMESTAMP" => Ok(Expression::CurrentTimestamp(CurrentTimestamp { + precision: None, + sysdate: false, + })), + + // CURRENT_UTCDATE → CURRENT_DATE + "CURRENT_UTCDATE" => Ok(Expression::CurrentDate(CurrentDate)), + + // CURRENT_UTCTIME → CURRENT_TIME + "CURRENT_UTCTIME" => Ok(Expression::CurrentTime(CurrentTime { precision: None })), + + // NOW() → CURRENT_TIMESTAMP + "NOW" => Ok(Expression::CurrentTimestamp(CurrentTimestamp { + precision: None, + sysdate: false, + })), + + // SYSDATE → CURRENT_TIMESTAMP + "SYSDATE" => Ok(Expression::CurrentTimestamp(CurrentTimestamp { + precision: None, + sysdate: false, + })), + + // TRUNC(x, n) → TRUNCATE(x, n) + "TRUNC" => Ok(Expression::Function(Box::new(Function::new( + "TRUNCATE".to_string(), + f.args, + )))), + + // Bitwise: BITAND → BITWISE_AND + "BITAND" => Ok(Expression::Function(Box::new(Function::new( + "BITWISE_AND".to_string(), + f.args, + )))), + + // BITOR → BITWISE_OR + "BITOR" => Ok(Expression::Function(Box::new(Function::new( + "BITWISE_OR".to_string(), + f.args, + )))), + + // BITNOT → BITWISE_NOT + "BITNOT" => Ok(Expression::Function(Box::new(Function::new( + "BITWISE_NOT".to_string(), + f.args, + )))), + + // HEX_TO_VARCHAR → FROM_HEX + "HEX_TO_VARCHAR" => Ok(Expression::Function(Box::new(Function::new( + "FROM_HEX".to_string(), + f.args, + )))), + + // Pass through everything else + _ => Ok(Expression::Function(Box::new(f))), + } + } +} + +/// Convert a HANA Oracle-style date format string argument to Java SimpleDateFormat. +/// If the argument is a string literal, converts the format tokens in-place. +/// Otherwise, returns the expression unchanged. +#[cfg(feature = "transpile")] +fn convert_format_arg(fmt: &Expression) -> Expression { + if let Expression::Literal(ref lit) = fmt { + if let crate::expressions::Literal::String(ref s) = lit.as_ref() { + return Expression::string(convert_hana_to_java_format(s)); + } + } + fmt.clone() +} + +/// Convert HANA Oracle-style date format tokens to Java SimpleDateFormat tokens. +/// +/// Token mapping (case-insensitive match on HANA tokens): +/// - YYYY→yyyy, YY→yy, MM→MM, DD→dd +/// - HH24→HH, HH12→hh, MI→mm, SS→ss +/// - FF3→SSS, FF6→SSSSSS +/// - DAY→EEEE, DY→EEE, MONTH→MMMM, MON→MMM +/// - AM→a +/// +/// Unknown tokens and literal text pass through unchanged. +#[cfg(feature = "transpile")] +fn convert_hana_to_java_format(hana_fmt: &str) -> String { + // Token mapping sorted by length (longest first) to ensure correct matching. + // (hana_token_uppercase, java_equivalent) + const TOKENS: &[(&str, &str)] = &[ + ("MONTH", "MMMM"), + ("YYYY", "yyyy"), + ("HH24", "HH"), + ("HH12", "hh"), + ("FF3", "SSS"), + ("FF6", "SSSSSS"), + ("DAY", "EEEE"), + ("MON", "MMM"), + ("YY", "yy"), + ("MM", "MM"), + ("DD", "dd"), + ("MI", "mm"), + ("SS", "ss"), + ("DY", "EEE"), + ("AM", "a"), + ("PM", "a"), + ]; + + let chars: Vec = hana_fmt.chars().collect(); + let mut result = String::new(); + let mut i = 0; + + while i < chars.len() { + let remaining = &chars[i..]; + + // Try to match the longest token at the current position (case-insensitive) + let mut matched = false; + for (hana_token, java_eq) in TOKENS { + let token_len = hana_token.chars().count(); + if remaining.len() >= token_len { + let candidate: String = remaining[..token_len].iter().collect(); + if candidate.eq_ignore_ascii_case(hana_token) { + result.push_str(java_eq); + i += token_len; + matched = true; + break; + } + } + } + + // No token matched — copy the character as-is (literal text) + if !matched { + result.push(chars[i]); + i += 1; + } + } + + result +} diff --git a/crates/polyglot-sql/src/dialects/mod.rs b/crates/polyglot-sql/src/dialects/mod.rs index 8b566990..0c21a530 100644 --- a/crates/polyglot-sql/src/dialects/mod.rs +++ b/crates/polyglot-sql/src/dialects/mod.rs @@ -49,6 +49,8 @@ mod dune; mod exasol; #[cfg(feature = "dialect-fabric")] mod fabric; +#[cfg(feature = "dialect-hana")] +mod hana; #[cfg(feature = "dialect-hive")] mod hive; #[cfg(feature = "dialect-materialize")] @@ -118,6 +120,8 @@ pub use dune::DuneDialect; pub use exasol::ExasolDialect; #[cfg(feature = "dialect-fabric")] pub use fabric::FabricDialect; +#[cfg(feature = "dialect-hana")] +pub use hana::HanaDialect; #[cfg(feature = "dialect-hive")] pub use hive::HiveDialect; #[cfg(feature = "dialect-materialize")] @@ -264,6 +268,8 @@ pub enum DialectType { Exasol, /// Apache DataFusion -- Arrow-based query engine with modern SQL extensions. DataFusion, + /// SAP HANA Cloud -- enterprise in-memory database (also accepts "saphana", "sap_hana"). + HANA, } impl Default for DialectType { @@ -309,6 +315,7 @@ impl std::fmt::Display for DialectType { DialectType::Dremio => write!(f, "dremio"), DialectType::Exasol => write!(f, "exasol"), DialectType::DataFusion => write!(f, "datafusion"), + DialectType::HANA => write!(f, "hana"), } } } @@ -352,6 +359,7 @@ impl std::str::FromStr for DialectType { "dremio" => Ok(DialectType::Dremio), "exasol" => Ok(DialectType::Exasol), "datafusion" | "arrow-datafusion" | "arrow_datafusion" => Ok(DialectType::DataFusion), + "hana" | "saphana" | "sap_hana" => Ok(DialectType::HANA), _ => Err(crate::error::Error::parse( format!("Unknown dialect: {}", s), 0, @@ -3400,6 +3408,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_HANA, HanaDialect, "dialect-hana"); fn configs_for_dialect_type(dt: DialectType) -> DialectConfigs { /// Clone configs from a cached static and pair with a fresh transform closure. @@ -3482,6 +3491,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-hana")] + DialectType::HANA => from_cache!(CACHED_HANA, HanaDialect), _ => from_cache!(CACHED_GENERIC, GenericDialect), } } @@ -18623,7 +18634,11 @@ impl Dialect { | DialectType::Athena => { Ok(Expression::Function(Box::new(Function::new( "DATE_DIFF".to_string(), - vec![Expression::string(&unit_str), arg1, arg2], + vec![ + Expression::string(&unit_str.to_ascii_lowercase()), + arg1, + arg2, + ], )))) } DialectType::ClickHouse => { @@ -19120,7 +19135,11 @@ impl Dialect { | DialectType::Athena => { Ok(Expression::Function(Box::new(Function::new( "DATE_ADD".to_string(), - vec![Expression::string(&unit_str), arg1, arg2], + vec![ + Expression::string(&unit_str.to_ascii_lowercase()), + arg1, + arg2, + ], )))) } DialectType::DuckDB => { @@ -39553,6 +39572,26 @@ mod tests { ); } + #[test] + fn test_hana_dialect_type_from_str_aliases() { + assert_eq!("hana".parse::().unwrap(), DialectType::HANA); + assert_eq!("saphana".parse::().unwrap(), DialectType::HANA); + assert_eq!( + "sap_hana".parse::().unwrap(), + DialectType::HANA + ); + // Case-insensitive lookup + assert_eq!("HANA".parse::().unwrap(), DialectType::HANA); + assert_eq!("SapHANA".parse::().unwrap(), DialectType::HANA); + // Unknown names still error + assert!("hanacloud".parse::().is_err()); + } + + #[test] + fn test_hana_dialect_type_display() { + assert_eq!(DialectType::HANA.to_string(), "hana"); + } + #[test] fn test_basic_transpile() { let dialect = Dialect::get(DialectType::Generic); diff --git a/crates/polyglot-sql/src/generator.rs b/crates/polyglot-sql/src/generator.rs index d0039790..5c403751 100644 --- a/crates/polyglot-sql/src/generator.rs +++ b/crates/polyglot-sql/src/generator.rs @@ -19525,15 +19525,10 @@ impl Generator { } fn generate_substring(&mut self, f: &SubstringFunc) -> Result<()> { - // Oracle and Presto-family dialects use SUBSTR; most others use SUBSTRING + // Oracle and Presto-family dialects use SUBSTR; Trino uses SUBSTRING let use_substr = matches!( self.config.dialect, - Some( - DialectType::Oracle - | DialectType::Presto - | DialectType::Trino - | DialectType::Athena - ) + Some(DialectType::Oracle | DialectType::Presto | DialectType::Athena) ); if use_substr { self.write_keyword("SUBSTR"); @@ -24273,7 +24268,8 @@ impl Generator { | Some(DialectType::Presto) | Some(DialectType::Trino) | Some(DialectType::SQLite) - | Some(DialectType::Redshift) => true, + | Some(DialectType::Redshift) + | Some(DialectType::HANA) => true, _ => false, }; if use_integer { @@ -25679,6 +25675,12 @@ impl Generator { self.write_keyword("STRING"); } } + Some(DialectType::HANA) => { + self.write_keyword("NVARCHAR"); + if let Some(args) = _args_str { + self.write(args); + } + } _ => { self.write_keyword("VARCHAR"); if let Some(args) = _args_str { @@ -25852,6 +25854,38 @@ impl Generator { } } } + // HANA SMALLDECIMAL → DECIMAL (with args preserved) + "SMALLDECIMAL" + if !matches!(self.config.dialect, Some(DialectType::HANA)) => + { + self.write_keyword("DECIMAL"); + if let Some(args) = _args_str { + self.write(args); + } + } + // HANA SECONDDATE → TIMESTAMP + "SECONDDATE" + if !matches!(self.config.dialect, Some(DialectType::HANA)) => + { + self.write_keyword("TIMESTAMP"); + } + // HANA ALPHANUM → VARCHAR (with length preserved) + "ALPHANUM" + if !matches!(self.config.dialect, Some(DialectType::HANA)) => + { + self.write_keyword("VARCHAR"); + if let Some(args) = _args_str { + self.write(args); + } + } + // HANA CLOB → VARCHAR + "CLOB" if !matches!(self.config.dialect, Some(DialectType::HANA)) => { + self.write_keyword("VARCHAR"); + } + // HANA NCLOB → VARCHAR + "NCLOB" if !matches!(self.config.dialect, Some(DialectType::HANA)) => { + self.write_keyword("VARCHAR"); + } _ => self.write(name), } } diff --git a/crates/polyglot-sql/tests/common/test_runner.rs b/crates/polyglot-sql/tests/common/test_runner.rs index 7d8d0224..8ad0e837 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), + "hana" | "saphana" | "sap_hana" => Some(DialectType::HANA), _ => None, } } diff --git a/crates/polyglot-sql/tests/custom_fixtures/hana/ddl.json b/crates/polyglot-sql/tests/custom_fixtures/hana/ddl.json new file mode 100644 index 00000000..6f0c4f46 --- /dev/null +++ b/crates/polyglot-sql/tests/custom_fixtures/hana/ddl.json @@ -0,0 +1,59 @@ +{ + "dialect": "hana", + "category": "ddl", + "identity": [ + { + "sql": "CREATE TABLE t (id INTEGER, name NVARCHAR(100), salary SMALLDECIMAL, created SECONDDATE)", + "description": "CREATE TABLE with HANA types" + }, + { + "sql": "CREATE TABLE t (id INTEGER NOT NULL PRIMARY KEY, name VARCHAR(100) NOT NULL)", + "description": "CREATE TABLE with constraints" + }, + { + "sql": "CREATE TABLE t (a NVARCHAR(255), b SMALLDECIMAL(10, 2))", + "description": "Types with precision and length args" + }, + { + "sql": "CREATE TABLE t (a ALPHANUM(20), b CLOB)", + "description": "ALPHANUM and CLOB types" + }, + { + "sql": "CREATE TABLE t (id BIGINT, name TEXT, val DECIMAL(15, 2), ts TIMESTAMP)", + "description": "Various HANA column types" + }, + { + "sql": "CREATE TABLE t (a INTEGER, b NVARCHAR(50) NOT NULL, c DECIMAL(10, 2))", + "description": "Mixed constraints and types" + }, + { + "sql": "CREATE TABLE IF NOT EXISTS t (a INTEGER)", + "description": "CREATE TABLE IF NOT EXISTS" + }, + { + "sql": "DROP TABLE t", + "description": "DROP TABLE" + }, + { + "sql": "DROP TABLE IF EXISTS t", + "description": "DROP TABLE IF EXISTS" + }, + { + "sql": "ALTER TABLE t ADD COLUMN x INTEGER", + "description": "ALTER TABLE ADD COLUMN" + }, + { + "sql": "CREATE TABLE t (a INTEGER, b INTEGER, PRIMARY KEY (a))", + "description": "CREATE TABLE with table-level PRIMARY KEY" + }, + { + "sql": "CREATE VIEW v AS SELECT a, b FROM t WHERE x > 1", + "description": "CREATE VIEW" + }, + { + "sql": "CREATE TABLE t AS SELECT a, b FROM s", + "description": "CREATE TABLE AS SELECT" + } + ], + "transpilation": [] +} diff --git a/crates/polyglot-sql/tests/custom_fixtures/hana/dml.json b/crates/polyglot-sql/tests/custom_fixtures/hana/dml.json new file mode 100644 index 00000000..04ea5b85 --- /dev/null +++ b/crates/polyglot-sql/tests/custom_fixtures/hana/dml.json @@ -0,0 +1,51 @@ +{ + "dialect": "hana", + "category": "dml", + "identity": [ + { + "sql": "INSERT INTO t (a, b) VALUES (1, 'x')", + "description": "INSERT with VALUES and explicit column list" + }, + { + "sql": "INSERT INTO t VALUES (1, 'a'), (2, 'b'), (3, 'c')", + "description": "INSERT with multiple rows" + }, + { + "sql": "INSERT INTO t SELECT a, b FROM s", + "description": "INSERT with SELECT" + }, + { + "sql": "INSERT INTO t (a, b) SELECT x, y FROM s", + "description": "INSERT with explicit column list and SELECT" + }, + { + "sql": "INSERT INTO t SELECT * FROM s", + "description": "INSERT SELECT star" + }, + { + "sql": "INSERT INTO t VALUES (1, NULL)", + "description": "INSERT with NULL value" + }, + { + "sql": "UPDATE t SET x = 1 WHERE y = 2", + "description": "UPDATE with WHERE" + }, + { + "sql": "UPDATE t SET x = 1, y = 2 WHERE z = 3", + "description": "UPDATE multiple columns with WHERE" + }, + { + "sql": "DELETE FROM t WHERE x = 1", + "description": "DELETE with WHERE" + }, + { + "sql": "DELETE FROM t", + "description": "DELETE all rows" + }, + { + "sql": "INSERT INTO t (a, b, c) VALUES (1, 'hello', 3.14)", + "description": "INSERT with mixed type values" + } + ], + "transpilation": [] +} diff --git a/crates/polyglot-sql/tests/custom_fixtures/hana/functions.json b/crates/polyglot-sql/tests/custom_fixtures/hana/functions.json new file mode 100644 index 00000000..073947fe --- /dev/null +++ b/crates/polyglot-sql/tests/custom_fixtures/hana/functions.json @@ -0,0 +1,166 @@ +{ + "dialect": "hana", + "category": "functions", + "identity": [ + { + "sql": "SELECT ADD_DAYS(d, 7) FROM t", + "expected": "SELECT DATE_ADD('day', 7, d) FROM t", + "description": "ADD_DAYS transformed to DATE_ADD" + }, + { + "sql": "SELECT ADD_MONTHS(d, 3) FROM t", + "expected": "SELECT DATE_ADD('month', 3, d) FROM t", + "description": "ADD_MONTHS transformed to DATE_ADD" + }, + { + "sql": "SELECT ADD_SECONDS(ts, 30) FROM t", + "expected": "SELECT DATE_ADD('second', 30, ts) FROM t", + "description": "ADD_SECONDS transformed to DATE_ADD" + }, + { + "sql": "SELECT ADD_YEARS(d, 1) FROM t", + "expected": "SELECT DATE_ADD('year', 1, d) FROM t", + "description": "ADD_YEARS transformed to DATE_ADD" + }, + { + "sql": "SELECT DAYS_BETWEEN(d1, d2) FROM t", + "expected": "SELECT DATE_DIFF('day', d1, d2) FROM t", + "description": "DAYS_BETWEEN transformed to DATE_DIFF" + }, + { + "sql": "SELECT MONTHS_BETWEEN(d1, d2) FROM t", + "expected": "SELECT DATE_DIFF('month', d1, d2) FROM t", + "description": "MONTHS_BETWEEN transformed to DATE_DIFF" + }, + { + "sql": "SELECT SECONDS_BETWEEN(d1, d2) FROM t", + "expected": "SELECT DATE_DIFF('second', d1, d2) FROM t", + "description": "SECONDS_BETWEEN transformed to DATE_DIFF" + }, + { + "sql": "SELECT YEARS_BETWEEN(d1, d2) FROM t", + "expected": "SELECT DATE_DIFF('year', d1, d2) FROM t", + "description": "YEARS_BETWEEN transformed to DATE_DIFF" + }, + { + "sql": "SELECT TO_VARCHAR(x) FROM t", + "expected": "SELECT CAST(x AS VARCHAR) FROM t", + "description": "TO_VARCHAR without format transformed to CAST" + }, + { + "sql": "SELECT TO_VARCHAR(d, 'YYYY-MM-DD') FROM t", + "expected": "SELECT DATE_FORMAT(d, 'yyyy-MM-dd') FROM t", + "description": "TO_VARCHAR with format transformed to DATE_FORMAT" + }, + { + "sql": "SELECT TO_INTEGER(s) FROM t", + "expected": "SELECT CAST(s AS INTEGER) FROM t", + "description": "TO_INTEGER transformed to CAST" + }, + { + "sql": "SELECT TO_DECIMAL(s) FROM t", + "expected": "SELECT CAST(s AS DECIMAL) FROM t", + "description": "TO_DECIMAL/TO_NUMBER transformed to CAST AS DECIMAL" + }, + { + "sql": "SELECT NVL(a, b) FROM t", + "expected": "SELECT COALESCE(a, b) FROM t", + "description": "NVL transformed to COALESCE" + }, + { + "sql": "SELECT SUBSTR(s, 1, 3) FROM t", + "expected": "SELECT SUBSTRING(s, 1, 3) FROM t", + "description": "SUBSTR canonicalized to SUBSTRING" + }, + { + "sql": "SELECT LCASE(s) FROM t", + "expected": "SELECT LOWER(s) FROM t", + "description": "LCASE canonicalized to LOWER by parser" + }, + { + "sql": "SELECT UCASE(s) FROM t", + "expected": "SELECT UPPER(s) FROM t", + "description": "UCASE canonicalized to UPPER by parser" + }, + { + "sql": "SELECT LOCATE('abc', s) FROM t", + "expected": "SELECT STRPOS(s, 'abc') FROM t", + "description": "LOCATE canonicalized to STRPOS with arg swap by parser" + }, + { + "sql": "SELECT CURRENT_UTCTIMESTAMP FROM t", + "expected": "SELECT CURRENT_TIMESTAMP FROM t", + "description": "CURRENT_UTCTIMESTAMP transformed to CURRENT_TIMESTAMP" + }, + { + "sql": "SELECT CURRENT_UTCDATE FROM t", + "expected": "SELECT CURRENT_DATE FROM t", + "description": "CURRENT_UTCDATE transformed to CURRENT_DATE" + }, + { + "sql": "SELECT CURRENT_UTCTIME FROM t", + "expected": "SELECT CURRENT_TIME FROM t", + "description": "CURRENT_UTCTIME transformed to CURRENT_TIME" + }, + { + "sql": "SELECT TRUNC(x, 2) FROM t", + "expected": "SELECT TRUNCATE(x, 2) FROM t", + "description": "TRUNC transformed to TRUNCATE" + }, + { + "sql": "SELECT BITAND(a, b) FROM t", + "expected": "SELECT BITWISE_AND(a, b) FROM t", + "description": "BITAND transformed to BITWISE_AND" + }, + { + "sql": "SELECT BITOR(a, b) FROM t", + "expected": "SELECT BITWISE_OR(a, b) FROM t", + "description": "BITOR transformed to BITWISE_OR" + }, + { + "sql": "SELECT BITNOT(a) FROM t", + "expected": "SELECT BITWISE_NOT(a) FROM t", + "description": "BITNOT transformed to BITWISE_NOT" + }, + { + "sql": "SELECT HEX_TO_VARCHAR('414243') FROM t", + "expected": "SELECT FROM_HEX('414243') FROM t", + "description": "HEX_TO_VARCHAR transformed to FROM_HEX" + }, + { + "sql": "SELECT IFNULL(a, b) FROM t", + "expected": "SELECT COALESCE(a, b) FROM t", + "description": "IFNULL transformed to COALESCE" + }, + { + "sql": "SELECT IF(cond, a, b) FROM t", + "expected": "SELECT CASE WHEN cond THEN a ELSE b END FROM t", + "description": "IF transformed to CASE WHEN" + }, + { + "sql": "SELECT add_days(d, 7) FROM t", + "expected": "SELECT DATE_ADD('day', 7, d) FROM t", + "description": "Mixed-case add_days transformed to DATE_ADD" + }, + { + "sql": "SELECT Add_Months(d, 3) FROM t", + "expected": "SELECT DATE_ADD('month', 3, d) FROM t", + "description": "Mixed-case Add_Months transformed to DATE_ADD" + }, + { + "sql": "SELECT lcase(s) FROM t", + "expected": "SELECT LOWER(s) FROM t", + "description": "Mixed-case lcase canonicalized to LOWER by parser" + }, + { + "sql": "SELECT TRY_CAST(x AS INTEGER) FROM t", + "description": "TRY_CAST identity (pass-through)" + }, + { + "sql": "SELECT * FROM t WHERE name ILIKE '%john%'", + "expected": "SELECT * FROM t WHERE LOWER(name) LIKE LOWER('%john%')", + "description": "ILIKE transformed to LOWER LIKE" + } + ], + "transpilation": [] +} diff --git a/crates/polyglot-sql/tests/custom_fixtures/hana/identity.json b/crates/polyglot-sql/tests/custom_fixtures/hana/identity.json new file mode 100644 index 00000000..2507c20a --- /dev/null +++ b/crates/polyglot-sql/tests/custom_fixtures/hana/identity.json @@ -0,0 +1,166 @@ +{ + "dialect": "hana", + "category": "identity", + "identity": [ + { + "sql": "SELECT a, b FROM t WHERE x > 1", + "description": "Simple SELECT with WHERE" + }, + { + "sql": "SELECT \"MyColumn\" FROM \"MyTable\"", + "description": "Double-quoted mixed-case identifiers" + }, + { + "sql": "SELECT \"SELECT\" FROM \"ORDER\"", + "description": "Double-quoted reserved words as identifiers" + }, + { + "sql": "SELECT 'it''s' AS val FROM t", + "description": "Escaped single quote in string literal" + }, + { + "sql": "SELECT NULL AS x FROM t", + "description": "NULL literal in SELECT" + }, + { + "sql": "SELECT dept, COUNT(*) FROM employees GROUP BY dept ORDER BY dept", + "description": "Aggregate with GROUP BY" + }, + { + "sql": "SELECT a.x, b.y FROM a INNER JOIN b ON a.id = b.id", + "description": "INNER JOIN" + }, + { + "sql": "SELECT a.x, b.y FROM a LEFT JOIN b ON a.id = b.id WHERE b.y IS NULL", + "description": "LEFT JOIN with IS NULL" + }, + { + "sql": "SELECT * FROM a JOIN b ON a.id = b.id JOIN c ON b.id = c.id", + "description": "Multiple JOINs" + }, + { + "sql": "SELECT * FROM (SELECT x FROM t WHERE x > 0) AS sub", + "description": "Subquery in FROM" + }, + { + "sql": "SELECT * FROM t WHERE x IN (SELECT y FROM s)", + "description": "Nested subquery in WHERE" + }, + { + "sql": "WITH cte AS (SELECT x FROM t) SELECT * FROM cte", + "description": "CTE" + }, + { + "sql": "SELECT x, ROW_NUMBER() OVER (PARTITION BY y ORDER BY z) AS rn FROM t", + "description": "Window function with PARTITION BY and ORDER BY" + }, + { + "sql": "SELECT 1 UNION ALL SELECT 2", + "description": "UNION ALL" + }, + { + "sql": "SELECT * FROM t LIMIT 10 OFFSET 5", + "description": "LIMIT and OFFSET" + }, + { + "sql": "SELECT CASE WHEN x > 1 THEN 'a' ELSE 'b' END FROM t", + "description": "CASE WHEN" + }, + { + "sql": "SELECT COALESCE(a, b, c) FROM t", + "description": "COALESCE" + }, + { + "sql": "SELECT ADD_DAYS(d, 7), NVL(a, b) FROM t", + "expected": "SELECT DATE_ADD('day', 7, d), COALESCE(a, b) FROM t", + "description": "HANA functions transformed (ADD_DAYS→DATE_ADD, NVL→COALESCE)" + }, + { + "sql": "SELECT SUBSTR(s, 1, 3) FROM t", + "expected": "SELECT SUBSTRING(s, 1, 3) FROM t", + "description": "SUBSTR canonicalized to SUBSTRING by parser (Phase 2 transform will restore SUBSTR)" + }, + { + "sql": "SELECT add_days(d, 7) FROM t", + "expected": "SELECT DATE_ADD('day', 7, d) FROM t", + "description": "Mixed-case function name transformed to DATE_ADD" + }, + { + "sql": "SELECT * FROM t WHERE x IS NULL", + "description": "IS NULL in WHERE" + }, + { + "sql": "SELECT * FROM t WHERE x IS NOT NULL", + "description": "IS NOT NULL in WHERE" + }, + { + "sql": "SELECT a, b FROM t ORDER BY a, b DESC", + "description": "ORDER BY multiple columns" + }, + { + "sql": "SELECT a, b FROM t WHERE x = 1 AND y = 2 OR z = 3", + "description": "Boolean logic in WHERE" + }, + { + "sql": "SELECT a, COUNT(*) AS cnt FROM t GROUP BY a HAVING COUNT(*) > 1", + "description": "GROUP BY with HAVING" + }, + { + "sql": "SELECT * FROM t WHERE x BETWEEN 1 AND 10", + "description": "BETWEEN" + }, + { + "sql": "SELECT * FROM t WHERE x IN (1, 2, 3)", + "description": "IN list" + }, + { + "sql": "SELECT * FROM t WHERE x LIKE '%foo%'", + "description": "LIKE pattern" + }, + { + "sql": "SELECT a || b FROM t", + "description": "String concatenation" + }, + { + "sql": "SELECT COUNT(DISTINCT a) FROM t", + "description": "COUNT DISTINCT" + }, + { + "sql": "SELECT SUM(a), AVG(a), MIN(a), MAX(a) FROM t", + "description": "Aggregate functions" + }, + { + "sql": "WITH cte1 AS (SELECT 1 AS x), cte2 AS (SELECT 2 AS y) SELECT * FROM cte1, cte2", + "description": "Multiple CTEs" + }, + { + "sql": "SELECT * FROM t WHERE EXISTS (SELECT 1 FROM s WHERE s.id = t.id)", + "expected": "SELECT * FROM t WHERE EXISTS(SELECT 1 FROM s WHERE s.id = t.id)", + "description": "EXISTS subquery" + }, + { + "sql": "SELECT a, ROW_NUMBER() OVER (PARTITION BY b ORDER BY c DESC) AS rn FROM t", + "description": "Window function with DESC ordering" + }, + { + "sql": "SELECT a FROM t -- trailing comment", + "expected": "SELECT a FROM t /* trailing comment */", + "description": "Single-line comment preserved as block comment" + }, + { + "sql": "SELECT a /* inline comment */ FROM t", + "description": "Multi-line comment preserved in identity round-trip" + }, + { + "sql": "SELECT a FROM t WHERE /* outer /* inner */ x = 1", + "expected": "SELECT a FROM t WHERE x = 1", + "description": "No nested comments: first */ closes the comment" + }, + { + "sql": "select a, b from t where x > 1", + "expected": "SELECT a, b FROM t WHERE x > 1", + "description": "Lowercase keywords normalized to uppercase" + } + ], + "transpilation": [] +} diff --git a/crates/polyglot-sql/tests/custom_fixtures/hana/select.json b/crates/polyglot-sql/tests/custom_fixtures/hana/select.json new file mode 100644 index 00000000..e7916137 --- /dev/null +++ b/crates/polyglot-sql/tests/custom_fixtures/hana/select.json @@ -0,0 +1,128 @@ +{ + "dialect": "hana", + "category": "select", + "identity": [ + { + "sql": "SELECT DISTINCT x FROM t", + "description": "SELECT DISTINCT" + }, + { + "sql": "SELECT ALL x FROM t", + "expected": "SELECT x FROM t", + "description": "SELECT ALL" + }, + { + "sql": "SELECT x AS alias_name FROM t", + "description": "Column alias" + }, + { + "sql": "SELECT * FROM t", + "description": "Star select" + }, + { + "sql": "SELECT a + b * c FROM t", + "description": "Arithmetic expression" + }, + { + "sql": "SELECT a, b, a + b AS sum FROM t", + "description": "Multiple columns with expression alias" + }, + { + "sql": "SELECT CASE WHEN x > 1 THEN 'a' WHEN x > 2 THEN 'b' ELSE 'c' END FROM t", + "description": "CASE WHEN with multiple branches" + }, + { + "sql": "SELECT CASE x WHEN 1 THEN 'a' WHEN 2 THEN 'b' ELSE 'c' END FROM t", + "description": "Simple CASE expression" + }, + { + "sql": "SELECT COALESCE(a, b) FROM t", + "description": "COALESCE in SELECT" + }, + { + "sql": "SELECT 'hello world' FROM t", + "description": "String literal" + }, + { + "sql": "SELECT 'it''s a test' FROM t", + "description": "Escaped quote in string literal" + }, + { + "sql": "SELECT 1, 2, 3 FROM t", + "description": "Multiple literal select" + }, + { + "sql": "SELECT 1.5, 2.5 FROM t", + "description": "Float literals" + }, + { + "sql": "SELECT -1 FROM t", + "description": "Negative literal" + }, + { + "sql": "SELECT TRUE FROM t", + "description": "Boolean TRUE literal" + }, + { + "sql": "SELECT FALSE FROM t", + "description": "Boolean FALSE literal" + }, + { + "sql": "SELECT t.* FROM t", + "description": "Qualified star" + }, + { + "sql": "SELECT a, b FROM t AS t1", + "description": "Table alias" + }, + { + "sql": "SELECT a FROM t WHERE a IS NOT NULL", + "description": "IS NOT NULL filter" + }, + { + "sql": "SELECT a FROM t WHERE a BETWEEN 1 AND 100", + "description": "BETWEEN filter" + }, + { + "sql": "SELECT a FROM t WHERE a NOT BETWEEN 1 AND 100", + "description": "NOT BETWEEN filter" + }, + { + "sql": "SELECT a FROM t WHERE a NOT IN (1, 2, 3)", + "description": "NOT IN list" + }, + { + "sql": "SELECT a FROM t WHERE NOT (a = 1)", + "description": "NOT with parenthesized expression" + }, + { + "sql": "SELECT COUNT(*) FROM t", + "description": "COUNT star" + }, + { + "sql": "SELECT LENGTH(s) FROM t", + "description": "LENGTH function" + }, + { + "sql": "SELECT UPPER(s), LOWER(s) FROM t", + "description": "UPPER and LOWER functions" + }, + { + "sql": "SELECT TRIM(s) FROM t", + "description": "TRIM function" + }, + { + "sql": "SELECT a, b FROM t ORDER BY a ASC, b DESC", + "description": "ORDER BY with mixed ASC/DESC" + }, + { + "sql": "SELECT a FROM t GROUP BY a", + "description": "GROUP BY single column" + }, + { + "sql": "SELECT a, COUNT(*) FROM t GROUP BY a HAVING COUNT(*) > 5", + "description": "GROUP BY with HAVING" + } + ], + "transpilation": [] +} diff --git a/crates/polyglot-sql/tests/custom_fixtures/hana/transpilation.json b/crates/polyglot-sql/tests/custom_fixtures/hana/transpilation.json new file mode 100644 index 00000000..bcb7fd05 --- /dev/null +++ b/crates/polyglot-sql/tests/custom_fixtures/hana/transpilation.json @@ -0,0 +1,350 @@ +{ + "dialect": "hana", + "category": "transpilation", + "identity": [], + "transpilation": [ + { + "sql": "SELECT ADD_DAYS(CURRENT_DATE, 7) FROM t", + "write": { "trino": "SELECT DATE_ADD('day', 7, CURRENT_DATE) FROM t" }, + "description": "ADD_DAYS to DATE_ADD" + }, + { + "sql": "SELECT ADD_DAYS(d, -1) FROM t", + "write": { "trino": "SELECT DATE_ADD('day', -1, d) FROM t" }, + "description": "ADD_DAYS with negative value" + }, + { + "sql": "SELECT ADD_DAYS(d, 0) FROM t", + "write": { "trino": "SELECT DATE_ADD('day', 0, d) FROM t" }, + "description": "ADD_DAYS with zero" + }, + { + "sql": "SELECT UPPER(ADD_DAYS(d, 7)) FROM t", + "write": { "trino": "SELECT UPPER(DATE_ADD('day', 7, d)) FROM t" }, + "description": "ADD_DAYS nested in another function" + }, + { + "sql": "SELECT * FROM t WHERE d > ADD_DAYS(CURRENT_DATE, -30)", + "write": { "trino": "SELECT * FROM t WHERE d > DATE_ADD('day', -30, CURRENT_DATE)" }, + "description": "ADD_DAYS in WHERE clause" + }, + { + "sql": "SELECT ADD_MONTHS(CURRENT_DATE, 3) FROM t", + "write": { "trino": "SELECT DATE_ADD('month', 3, CURRENT_DATE) FROM t" }, + "description": "ADD_MONTHS to DATE_ADD" + }, + { + "sql": "SELECT ADD_SECONDS(ts, 30) FROM t", + "write": { "trino": "SELECT DATE_ADD('second', 30, ts) FROM t" }, + "description": "ADD_SECONDS to DATE_ADD" + }, + { + "sql": "SELECT ADD_YEARS(d, 1) FROM t", + "write": { "trino": "SELECT DATE_ADD('year', 1, d) FROM t" }, + "description": "ADD_YEARS to DATE_ADD" + }, + { + "sql": "SELECT add_days(d, 7) FROM t", + "write": { "trino": "SELECT DATE_ADD('day', 7, d) FROM t" }, + "description": "Mixed case function name add_days" + }, + { + "sql": "SELECT DAYS_BETWEEN(d1, d2) FROM t", + "write": { "trino": "SELECT DATE_DIFF('day', d1, d2) FROM t" }, + "description": "DAYS_BETWEEN to DATE_DIFF" + }, + { + "sql": "SELECT MONTHS_BETWEEN(d1, d2) FROM t", + "write": { "trino": "SELECT DATE_DIFF('month', d1, d2) FROM t" }, + "description": "MONTHS_BETWEEN to DATE_DIFF" + }, + { + "sql": "SELECT SECONDS_BETWEEN(d1, d2) FROM t", + "write": { "trino": "SELECT DATE_DIFF('second', d1, d2) FROM t" }, + "description": "SECONDS_BETWEEN to DATE_DIFF" + }, + { + "sql": "SELECT YEARS_BETWEEN(d1, d2) FROM t", + "write": { "trino": "SELECT DATE_DIFF('year', d1, d2) FROM t" }, + "description": "YEARS_BETWEEN to DATE_DIFF" + }, + { + "sql": "SELECT DAYS_BETWEEN(start_date, end_date) AS duration FROM events", + "write": { "trino": "SELECT DATE_DIFF('day', start_date, end_date) AS duration FROM events" }, + "description": "DAYS_BETWEEN with column expressions" + }, + + { + "sql": "SELECT SUBSTR(s, 1, 5) FROM t", + "write": { "trino": "SELECT SUBSTRING(s, 1, 5) FROM t" }, + "description": "SUBSTR to SUBSTRING" + }, + { + "sql": "SELECT SUBSTR(s, 3) FROM t", + "write": { "trino": "SELECT SUBSTRING(s, 3) FROM t" }, + "description": "SUBSTR with two args (no length)" + }, + { + "sql": "SELECT LCASE(s) FROM t", + "write": { "trino": "SELECT LOWER(s) FROM t" }, + "description": "LCASE to LOWER" + }, + { + "sql": "SELECT UCASE(s) FROM t", + "write": { "trino": "SELECT UPPER(s) FROM t" }, + "description": "UCASE to UPPER" + }, + { + "sql": "SELECT LOCATE('abc', s) FROM t", + "write": { "trino": "SELECT STRPOS(s, 'abc') FROM t" }, + "description": "LOCATE arg swap to STRPOS" + }, + { + "sql": "SELECT UCASE(LCASE(s)) FROM t", + "write": { "trino": "SELECT UPPER(LOWER(s)) FROM t" }, + "description": "LCASE nested in UCASE" + }, + + { + "sql": "SELECT NVL(a, b) FROM t", + "write": { "trino": "SELECT COALESCE(a, b) FROM t" }, + "description": "NVL to COALESCE" + }, + { + "sql": "SELECT IFNULL(a, b) FROM t", + "write": { "trino": "SELECT COALESCE(a, b) FROM t" }, + "description": "IFNULL to COALESCE" + }, + { + "sql": "SELECT NVL(NULL, b) FROM t", + "write": { "trino": "SELECT COALESCE(NULL, b) FROM t" }, + "description": "NVL with NULL literal as first arg" + }, + { + "sql": "SELECT NVL(name, 'unknown') FROM employees", + "write": { "trino": "SELECT COALESCE(name, 'unknown') FROM employees" }, + "description": "NVL with column and literal" + }, + { + "sql": "SELECT IF(cond, a, b) FROM t", + "write": { "trino": "SELECT CASE WHEN cond THEN a ELSE b END FROM t" }, + "description": "IF to CASE WHEN" + }, + { + "sql": "SELECT NVL(NVL(a, b), c) FROM t", + "write": { "trino": "SELECT COALESCE(COALESCE(a, b), c) FROM t" }, + "description": "Nested NVL" + }, + { + "sql": "SELECT * FROM t WHERE NVL(status, 0) = 1", + "write": { "trino": "SELECT * FROM t WHERE COALESCE(status, 0) = 1" }, + "description": "NVL in WHERE clause" + }, + + { + "sql": "SELECT TO_VARCHAR(x) FROM t", + "write": { "trino": "SELECT CAST(x AS VARCHAR) FROM t" }, + "description": "TO_VARCHAR without format" + }, + { + "sql": "SELECT TO_VARCHAR(123) FROM t", + "write": { "trino": "SELECT CAST(123 AS VARCHAR) FROM t" }, + "description": "TO_VARCHAR with numeric value (no format)" + }, + { + "sql": "SELECT TO_VARCHAR(d, 'YYYY-MM-DD') FROM t", + "write": { "trino": "SELECT DATE_FORMAT(d, 'yyyy-MM-dd') FROM t" }, + "description": "TO_VARCHAR with date format" + }, + { + "sql": "SELECT TO_DATE('2024-01-15', 'YYYY-MM-DD') FROM t", + "write": { "trino": "SELECT DATE_PARSE('2024-01-15', 'yyyy-MM-dd') FROM t" }, + "description": "TO_DATE with format" + }, + { + "sql": "SELECT TO_DATE('2024-01-15') FROM t", + "write": { "trino": "SELECT CAST('2024-01-15' AS DATE) FROM t" }, + "description": "TO_DATE without format (single arg)" + }, + { + "sql": "SELECT TO_TIMESTAMP('2024-01-15 10:30:00', 'YYYY-MM-DD HH24:MI:SS') FROM t", + "write": { "trino": "SELECT DATE_PARSE('2024-01-15 10:30:00', 'yyyy-MM-dd HH:mm:ss') FROM t" }, + "description": "TO_TIMESTAMP with format" + }, + { + "sql": "SELECT TO_TIMESTAMP('2024-01-15 10:30:00') FROM t", + "write": { "trino": "SELECT CAST('2024-01-15 10:30:00' AS TIMESTAMP) FROM t" }, + "description": "TO_TIMESTAMP without format (single arg)" + }, + { + "sql": "SELECT TO_INTEGER(s) FROM t", + "write": { "trino": "SELECT CAST(s AS INTEGER) FROM t" }, + "description": "TO_INTEGER to CAST" + }, + { + "sql": "SELECT TO_DECIMAL(s) FROM t", + "write": { "trino": "SELECT CAST(s AS DECIMAL) FROM t" }, + "description": "TO_DECIMAL to CAST" + }, + { + "sql": "SELECT TO_REAL(s) FROM t", + "write": { "trino": "SELECT CAST(s AS REAL) FROM t" }, + "description": "TO_REAL to CAST" + }, + { + "sql": "SELECT TO_DOUBLE(s) FROM t", + "write": { "trino": "SELECT CAST(s AS DOUBLE) FROM t" }, + "description": "TO_DOUBLE to CAST" + }, + { + "sql": "SELECT * FROM t WHERE TO_VARCHAR(d, 'YYYY-MM') = '2024-01'", + "write": { "trino": "SELECT * FROM t WHERE DATE_FORMAT(d, 'yyyy-MM') = '2024-01'" }, + "description": "TO_VARCHAR with format in WHERE clause" + }, + { + "sql": "SELECT ADD_DAYS(TO_DATE('2024-01-15', 'YYYY-MM-DD'), 7) FROM t", + "write": { "trino": "SELECT DATE_ADD('day', 7, DATE_PARSE('2024-01-15', 'yyyy-MM-dd')) FROM t" }, + "description": "Nested TO_DATE inside ADD_DAYS" + }, + + { + "sql": "SELECT CURRENT_UTCTIMESTAMP FROM t", + "write": { "trino": "SELECT CURRENT_TIMESTAMP FROM t" }, + "description": "CURRENT_UTCTIMESTAMP" + }, + { + "sql": "SELECT CURRENT_UTCTIMESTAMP() FROM t", + "write": { "trino": "SELECT CURRENT_TIMESTAMP FROM t" }, + "description": "CURRENT_UTCTIMESTAMP with parentheses" + }, + { + "sql": "SELECT CURRENT_UTCDATE FROM t", + "write": { "trino": "SELECT CURRENT_DATE FROM t" }, + "description": "CURRENT_UTCDATE" + }, + { + "sql": "SELECT CURRENT_UTCTIME FROM t", + "write": { "trino": "SELECT CURRENT_TIME FROM t" }, + "description": "CURRENT_UTCTIME" + }, + { + "sql": "SELECT NOW() FROM t", + "write": { "trino": "SELECT CURRENT_TIMESTAMP FROM t" }, + "description": "NOW" + }, + { + "sql": "SELECT SYSDATE FROM t", + "write": { "trino": "SELECT CURRENT_TIMESTAMP FROM t" }, + "description": "SYSDATE" + }, + { + "sql": "SELECT EXTRACT(YEAR FROM CURRENT_UTCTIMESTAMP) FROM t", + "write": { "trino": "SELECT EXTRACT(YEAR FROM CURRENT_TIMESTAMP) FROM t" }, + "description": "CURRENT_UTCTIMESTAMP used in expression" + }, + + { + "sql": "SELECT * FROM t WHERE name ILIKE '%john%'", + "write": { "trino": "SELECT * FROM t WHERE LOWER(name) LIKE LOWER('%john%')" }, + "description": "ILIKE to LOWER LIKE" + }, + { + "sql": "SELECT * FROM t WHERE name NOT ILIKE '%test%'", + "write": { "trino": "SELECT * FROM t WHERE LOWER(name) NOT LIKE LOWER('%test%')" }, + "description": "NOT ILIKE" + }, + + { + "sql": "SELECT TRUNC(x, 2) FROM t", + "write": { "trino": "SELECT TRUNCATE(x, 2) FROM t" }, + "description": "TRUNC to TRUNCATE" + }, + { + "sql": "SELECT BITAND(a, b) FROM t", + "write": { "trino": "SELECT BITWISE_AND(a, b) FROM t" }, + "description": "BITAND to BITWISE_AND" + }, + { + "sql": "SELECT BITOR(a, b) FROM t", + "write": { "trino": "SELECT BITWISE_OR(a, b) FROM t" }, + "description": "BITOR to BITWISE_OR" + }, + { + "sql": "SELECT BITNOT(a) FROM t", + "write": { "trino": "SELECT BITWISE_NOT(a) FROM t" }, + "description": "BITNOT to BITWISE_NOT" + }, + { + "sql": "SELECT HEX_TO_VARCHAR('414243') FROM t", + "write": { "trino": "SELECT FROM_HEX('414243') FROM t" }, + "description": "HEX_TO_VARCHAR to FROM_HEX" + }, + + { + "sql": "SELECT TRY_CAST(x AS INTEGER) FROM t", + "write": { "trino": "SELECT TRY_CAST(x AS INTEGER) FROM t" }, + "description": "TRY_CAST to Trino (pass-through)" + }, + { + "sql": "SELECT TRY_CAST(x AS VARCHAR) FROM t", + "write": { "trino": "SELECT TRY_CAST(x AS VARCHAR) FROM t" }, + "description": "TRY_CAST with VARCHAR type" + }, + + { + "sql": "SELECT TO_VARCHAR(d, 'YYYY-MM-DD HH24:MI:SS') FROM t", + "write": { "trino": "SELECT DATE_FORMAT(d, 'yyyy-MM-dd HH:mm:ss') FROM t" }, + "description": "Format with 24-hour time" + }, + { + "sql": "SELECT TO_VARCHAR(d, 'HH12:MI:SS AM') FROM t", + "write": { "trino": "SELECT DATE_FORMAT(d, 'hh:mm:ss a') FROM t" }, + "description": "Format with 12-hour time and AM/PM" + }, + { + "sql": "SELECT TO_VARCHAR(d, 'DD MON YYYY') FROM t", + "write": { "trino": "SELECT DATE_FORMAT(d, 'dd MMM yyyy') FROM t" }, + "description": "Format with month name" + }, + { + "sql": "SELECT TO_VARCHAR(d, 'DAY, DD MONTH YYYY') FROM t", + "write": { "trino": "SELECT DATE_FORMAT(d, 'EEEE, dd MMMM yyyy') FROM t" }, + "description": "Format with full day name" + }, + { + "sql": "SELECT TO_VARCHAR(d, 'YYYY-MM-DD HH24:MI:SS.FF3') FROM t", + "write": { "trino": "SELECT DATE_FORMAT(d, 'yyyy-MM-dd HH:mm:ss.SSS') FROM t" }, + "description": "Format with fractional seconds (millisecond)" + }, + { + "sql": "SELECT TO_VARCHAR(d, 'DD-MM-YY') FROM t", + "write": { "trino": "SELECT DATE_FORMAT(d, 'dd-MM-yy') FROM t" }, + "description": "Format with two-digit year" + }, + { + "sql": "SELECT TO_VARCHAR(d, 'YYYY/MM/DD HH24:MI:SS') FROM t", + "write": { "trino": "SELECT DATE_FORMAT(d, 'yyyy/MM/dd HH:mm:ss') FROM t" }, + "description": "Format with slash separators preserved" + }, + { + "sql": "SELECT TO_VARCHAR(d, 'YYYY-FOO-DD') FROM t", + "write": { "trino": "SELECT DATE_FORMAT(d, 'yyyy-FOO-dd') FROM t" }, + "description": "Unknown format token passes through" + }, + { + "sql": "SELECT TO_VARCHAR(d, 'YYYY-MM-DD HH24:MI:SS.FF6') FROM t", + "write": { "trino": "SELECT DATE_FORMAT(d, 'yyyy-MM-dd HH:mm:ss.SSSSSS') FROM t" }, + "description": "Format with fractional seconds (microsecond)" + }, + { + "sql": "SELECT TO_VARCHAR(d, 'DY') FROM t", + "write": { "trino": "SELECT DATE_FORMAT(d, 'EEE') FROM t" }, + "description": "Format with abbreviated day name" + }, + { + "sql": "SELECT TO_VARCHAR(d, 'hello world') FROM t", + "write": { "trino": "SELECT DATE_FORMAT(d, 'hello world') FROM t" }, + "description": "Format with no tokens (literal only)" + } + ] +} diff --git a/crates/polyglot-sql/tests/custom_fixtures/hana/types.json b/crates/polyglot-sql/tests/custom_fixtures/hana/types.json new file mode 100644 index 00000000..571a5450 --- /dev/null +++ b/crates/polyglot-sql/tests/custom_fixtures/hana/types.json @@ -0,0 +1,106 @@ +{ + "dialect": "hana", + "category": "types", + "identity": [ + { + "sql": "SELECT CAST(x AS SMALLDECIMAL) FROM t", + "description": "CAST to SMALLDECIMAL (HANA identity)" + }, + { + "sql": "SELECT CAST(x AS SECONDDATE) FROM t", + "description": "CAST to SECONDDATE (HANA identity)" + }, + { + "sql": "SELECT CAST(x AS ALPHANUM) FROM t", + "description": "CAST to ALPHANUM (HANA identity)" + }, + { + "sql": "SELECT CAST(x AS NVARCHAR(255)) FROM t", + "description": "CAST to NVARCHAR(255) (HANA identity)" + }, + { + "sql": "SELECT CAST(x AS CLOB) FROM t", + "description": "CAST to CLOB (HANA identity)" + }, + { + "sql": "SELECT CAST(x AS NCLOB) FROM t", + "description": "CAST to NCLOB (HANA identity)" + }, + { + "sql": "SELECT CAST(x AS BINARY) FROM t", + "description": "CAST to BINARY (HANA identity)" + }, + { + "sql": "SELECT CAST(x AS FLOAT) FROM t", + "expected": "SELECT CAST(x AS DOUBLE) FROM t", + "description": "CAST to FLOAT transformed to DOUBLE (HANA FLOAT is 64-bit)" + } + ], + "transpilation": [ + { + "sql": "SELECT CAST(x AS SMALLDECIMAL) FROM t", + "write": { "trino": "SELECT CAST(x AS DECIMAL) FROM t" }, + "description": "SMALLDECIMAL to DECIMAL" + }, + { + "sql": "SELECT CAST(x AS SMALLDECIMAL(10,2)) FROM t", + "write": { "trino": "SELECT CAST(x AS DECIMAL(10, 2)) FROM t" }, + "description": "SMALLDECIMAL with precision args" + }, + { + "sql": "SELECT CAST(x AS SECONDDATE) FROM t", + "write": { "trino": "SELECT CAST(x AS TIMESTAMP) FROM t" }, + "description": "SECONDDATE to TIMESTAMP" + }, + { + "sql": "SELECT CAST(x AS ALPHANUM) FROM t", + "write": { "trino": "SELECT CAST(x AS VARCHAR) FROM t" }, + "description": "ALPHANUM to VARCHAR" + }, + { + "sql": "SELECT CAST(x AS ALPHANUM(10)) FROM t", + "write": { "trino": "SELECT CAST(x AS VARCHAR(10)) FROM t" }, + "description": "ALPHANUM with length" + }, + { + "sql": "SELECT CAST(x AS NVARCHAR) FROM t", + "write": { "trino": "SELECT CAST(x AS VARCHAR) FROM t" }, + "description": "NVARCHAR to VARCHAR" + }, + { + "sql": "SELECT CAST(x AS NVARCHAR(255)) FROM t", + "write": { "trino": "SELECT CAST(x AS VARCHAR(255)) FROM t" }, + "description": "NVARCHAR with length" + }, + { + "sql": "SELECT CAST(x AS CLOB) FROM t", + "write": { "trino": "SELECT CAST(x AS VARCHAR) FROM t" }, + "description": "CLOB to VARCHAR" + }, + { + "sql": "SELECT CAST(x AS NCLOB) FROM t", + "write": { "trino": "SELECT CAST(x AS VARCHAR) FROM t" }, + "description": "NCLOB to VARCHAR" + }, + { + "sql": "SELECT CAST(x AS BINARY) FROM t", + "write": { "trino": "SELECT CAST(x AS VARBINARY) FROM t" }, + "description": "BINARY to VARBINARY" + }, + { + "sql": "SELECT CAST(x AS FLOAT) FROM t", + "write": { "trino": "SELECT CAST(x AS DOUBLE) FROM t" }, + "description": "FLOAT to DOUBLE" + }, + { + "sql": "SELECT CAST(x AS INTEGER) FROM t", + "write": { "trino": "SELECT CAST(x AS INTEGER) FROM t" }, + "description": "Standard type INTEGER passes through" + }, + { + "sql": "CREATE TABLE t (id INTEGER, name NVARCHAR(100), salary SMALLDECIMAL, created SECONDDATE)", + "write": { "trino": "CREATE TABLE t (id INTEGER, name VARCHAR(100), salary DECIMAL, created TIMESTAMP)" }, + "description": "HANA types in CREATE TABLE transpilation" + } + ] +} diff --git a/mise.toml b/mise.toml new file mode 100644 index 00000000..0a168d9a --- /dev/null +++ b/mise.toml @@ -0,0 +1,5 @@ +[tools] +bun = "latest" +gh = "latest" +opencode = "latest" +rust = { version = "latest", components = ["rustfmt", "clippy"] } diff --git a/openspec/changes/archive/2026-07-10-add-sap-hana-dialect/.openspec.yaml b/openspec/changes/archive/2026-07-10-add-sap-hana-dialect/.openspec.yaml new file mode 100644 index 00000000..eb5fa80e --- /dev/null +++ b/openspec/changes/archive/2026-07-10-add-sap-hana-dialect/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-10 diff --git a/openspec/changes/archive/2026-07-10-add-sap-hana-dialect/design.md b/openspec/changes/archive/2026-07-10-add-sap-hana-dialect/design.md new file mode 100644 index 00000000..cacfe38b --- /dev/null +++ b/openspec/changes/archive/2026-07-10-add-sap-hana-dialect/design.md @@ -0,0 +1,78 @@ +## Context + +Polyglot has 33 dialects following a uniform pattern: each dialect implements `DialectImpl` with `tokenizer_config`, `generator_config`, and optionally `transform_expr`. This is **Phase 1** of a two-phase effort to add SAP HANA Cloud SQL support. + +Phase 1 delivers the dialect scaffold: registration, tokenizer config, generator config, and identity round-trip tests. The HANA dialect will parse and regenerate HANA SQL correctly but will not yet transpile to other dialects (no function transforms or type mappings). Phase 2 (`add-sap-hana-transpilation`) adds the ~40 function transforms, date format conversion, and generator type mappings needed for HANA→Trino transpilation. + +The split exists because: +1. Phase 1 is low-risk, mechanical work (proven pattern across 33 dialects) with immediate value (HANA parsing) +2. Phase 2 contains novel logic (date format conversion, ~40 transforms) that benefits from isolated review attention +3. Each phase has a clean RED→GREEN TDD cycle where all tests pass at the end + +HANA's tokenizer/generator config is modeled after Oracle (double-quote identifiers, uppercase folding, no nested comments) and verified against the SAP HANA Cloud SQL Reference Guide. + +## Goals / Non-Goals + +**Goals:** +- Register `HANA` as a `DialectType` with feature gate `dialect-hana` +- Configure tokenizer and generator for HANA Cloud SQL conventions +- Parse standard HANA Cloud SQL (SELECT, INSERT, UPDATE, DELETE, DDL) correctly +- Round-trip HANA SQL as identity (HANA→HANA) for all supported standard SQL constructs +- Expose HANA in all language bindings (FFI, TypeScript SDK) +- Comprehensive identity test fixtures including edge cases + +**Non-Goals:** +- Function transforms (ADD_DAYS, NVL, TO_VARCHAR, etc.) — Phase 2 +- Date format string conversion — Phase 2 +- Generator type mapping for HANA-specific types — Phase 2 +- HANA→Trino transpilation — Phase 2 +- SQLScript, WITH HINT, CONTAINS/FUZZY, spatial methods + +## Decisions + +### D1: HANA tokenizer modeled after Oracle + +**Decision:** Configure tokenizer with double-quote identifiers, no nested comments, standard SQL string escapes (`''`). + +**Rationale:** HANA shares Oracle's identifier quoting and comment conventions. This is a proven config in the existing Oracle dialect. + +### D2: HANA generator modeled after Oracle + +**Decision:** Configure generator with double-quote identifier quoting, uppercase keywords, and HANA-compatible null ordering/limit/interval settings. + +**Rationale:** HANA generates SQL in uppercase with double-quoted identifiers, matching Oracle conventions. Specific config flags (null ordering, limit style) are verified against HANA Cloud docs. + +### D3: No transform_expr in Phase 1 + +**Decision:** `HanaDialect` uses the default `transform_expr` (no-op pass-through) in Phase 1. + +**Rationale:** Identity round-trip requires no transforms — the parser produces a generic AST and the HANA generator renders it back. Transforms are only needed for cross-dialect transpilation, which is Phase 2. This keeps Phase 1 focused and low-risk. + +### D4: Four fixture files in Phase 1 (identity, select, ddl, dml) + +**Decision:** Create only identity/select/ddl/dml fixture files in Phase 1. Transpilation/types/functions fixtures come in Phase 2. + +**Rationale:** Identity tests are the only tests that pass without transforms. Including transpilation fixtures that fail would violate the principle that each phase's tests all pass. + +### D5: Minimal HANA arms in shared generator.rs + +**Decision:** Add two HANA-specific match arms in `generator.rs`: canonicalize `INT` to `INTEGER`, and preserve `NVARCHAR` instead of the default fold to `VARCHAR`. + +**Rationale:** These are required for identity round-trip, not cross-dialect type mapping (which remains Phase 2). Without the `NVARCHAR` arm, `NVARCHAR(100)` in HANA DDL would regenerate as `VARCHAR(100)`. Both arms follow the existing per-dialect pattern in these match blocks and only affect output when `dialect == Some(HANA)`. + +### D6: normalize_functions = None in Phase 1 + +**Decision:** Set `normalize_functions: NormalizeFunctions::None` (default is `Upper`) so mixed-case function names round-trip as-is. + +**Rationale:** Identity fidelity in Phase 1. Note for Phase 2: function transforms often key off normalized names, so this setting may need to be revisited (or transforms must match case-insensitively) when transforms are added. + +### D7: HANA aliases in FromStr — "hana", "saphana", "sap_hana" + +**Decision:** Accept all three as aliases for `DialectType::HANA`. + +**Rationale:** Following the precedent of TSQL accepting "mssql"/"sqlserver" and CockroachDB accepting "cockroach". + +## Risks / Trade-offs + +- **[Generator config flags may need tuning]** → Some HANA-specific generator settings (null ordering, interval syntax) may not be exactly right without testing against real HANA. Mitigation: identity fixtures will catch obvious mismatches; fine-tuning can happen in Phase 2 when transpilation tests provide cross-dialect validation. +- **[No transpilation value yet]** → Phase 1 alone doesn't enable HANA→Trino. Mitigation: Phase 1 delivers HANA parsing/identity which is useful for tooling; Phase 2 follows quickly. diff --git a/openspec/changes/archive/2026-07-10-add-sap-hana-dialect/proposal.md b/openspec/changes/archive/2026-07-10-add-sap-hana-dialect/proposal.md new file mode 100644 index 00000000..eb656391 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-add-sap-hana-dialect/proposal.md @@ -0,0 +1,51 @@ +## Why + +Polyglot supports 33 SQL dialects but lacks SAP HANA, an enterprise database used by many large organizations. Users need to parse and round-trip HANA Cloud SQL as a first step toward transpiling to Trino for data lakehouse federation and migration scenarios. There is no existing HANA dialect in polyglot or in SQLGlot (polyglot's primary reference), so this requires building from the SAP HANA Cloud SQL Reference Guide. + +This is **Phase 1** of a two-phase effort. Phase 1 delivers a functional HANA dialect that can parse and round-trip HANA SQL (identity tests). Phase 2 (`add-sap-hana-transpilation`) will add the ~40 function transforms, date format conversion, and type mappings needed for HANA→Trino transpilation. + +## What Changes + +- Add `HANA` as a new `DialectType` variant with feature gate `dialect-hana` +- Create `crates/polyglot-sql/src/dialects/hana.rs` implementing `DialectImpl` + - Tokenizer: double-quote identifiers, standard SQL string quoting (`''` escape), no nested comment support + - Generator config: double-quote identifiers, uppercase keywords, HANA-compatible settings (null ordering, limit style, interval syntax) + - No `transform_expr` overrides in Phase 1 (all defaults — identity round-trip only) +- Register HANA in all 7 touch points within `dialects/mod.rs` (module, re-export, enum, Display, FromStr, cached_dialect, configs_for_dialect_type) +- Add `dialect-hana` feature to `crates/polyglot-sql/Cargo.toml` `all-dialects` list +- Add `dialect-hana` feature passthrough to `crates/polyglot-sql-wasm/Cargo.toml` +- Add `DialectType::HANA` to FFI `DIALECTS` array in `crates/polyglot-sql-ffi/src/dialects.rs` (count 34→35) +- Add `HANA = 'hana'` to TypeScript `DialectType` enum in `packages/sdk/src/index.ts` +- Add `"hana"` to the Python binding's hardcoded `DIALECT_NAMES` list in `crates/polyglot-sql-python/src/dialects.rs` +- Add HANA generator arms in `generator.rs` needed for identity: `INT`→`INTEGER` canonical form and `NVARCHAR` preservation +- Create custom test fixtures in `crates/polyglot-sql/tests/custom_fixtures/hana/` for identity, select, DDL, and DML categories (no transpilation fixtures yet — those come in Phase 2) + +### Non-Goals (Phase 1) + +- Function transforms (ADD_DAYS, NVL, TO_VARCHAR, etc.) — Phase 2 +- Date format string conversion — Phase 2 +- Generator type mapping for HANA-specific types (SMALLDECIMAL, SECONDDATE, etc.) — Phase 2 +- HANA→Trino transpilation verification — Phase 2 +- SQLScript procedural language +- WITH HINT, CONTAINS/FUZZY, graph, spatial methods + +## Capabilities + +### New Capabilities +- `hana-dialect`: SAP HANA Cloud SQL dialect — tokenizer, generator config, identifier handling, and dialect registration for parsing HANA Cloud SQL and round-tripping it as identity. Transform and transpilation support is added in a follow-up change. + +### Modified Capabilities +- (none — no existing specs are changing) + +## Impact + +- **Rust core** (`crates/polyglot-sql`): new dialect module, `Cargo.toml` feature, `dialects/mod.rs` registration +- **WASM crate** (`crates/polyglot-sql-wasm`): `Cargo.toml` feature passthrough +- **FFI crate** (`crates/polyglot-sql-ffi`): `DIALECTS` array size change (34→35), no API breaking change +- **TypeScript SDK** (`packages/sdk`): new enum variant in `DialectType`, no breaking change +- **Go package**: no changes (dialects fetched dynamically via FFI) +- **Python package** (`crates/polyglot-sql-python`): add `"hana"` to the hardcoded `DIALECT_NAMES` list in `src/dialects.rs` and assert it in `tests/test_dialects.py` +- **Generator** (`crates/polyglot-sql/src/generator.rs`): two HANA-specific arms required for identity round-trip — canonicalize `INT` to `INTEGER`, and preserve `NVARCHAR` (the default arm folds it to `VARCHAR`) +- **Docs/playground**: dialect lists and counts updated in READMEs, Python docs, and playground `DIALECT_DISPLAY_NAMES` +- **Tests**: new `tests/custom_fixtures/hana/` directory with 4 JSON fixture files (identity, select, ddl, dml); auto-discovered by existing `custom_dialect_tests.rs` runner +- **No breaking changes**: all additions are additive; existing dialects and APIs remain unchanged diff --git a/openspec/changes/archive/2026-07-10-add-sap-hana-dialect/specs/hana-dialect/spec.md b/openspec/changes/archive/2026-07-10-add-sap-hana-dialect/specs/hana-dialect/spec.md new file mode 100644 index 00000000..58a0f83a --- /dev/null +++ b/openspec/changes/archive/2026-07-10-add-sap-hana-dialect/specs/hana-dialect/spec.md @@ -0,0 +1,261 @@ +## ADDED Requirements + +### Requirement: HANA dialect registration +The system SHALL register `HANA` as a `DialectType` enum variant with feature gate `dialect-hana`, accepting `"hana"`, `"saphana"`, and `"sap_hana"` as string aliases via `FromStr`. + +#### Scenario: Dialect lookup by name +- **WHEN** a user calls `DialectType::from_str("hana")` +- **THEN** the system returns `Ok(DialectType::HANA)` + +#### Scenario: Dialect lookup by alias saphana +- **WHEN** a user calls `DialectType::from_str("saphana")` +- **THEN** the system returns `Ok(DialectType::HANA)` + +#### Scenario: Dialect lookup by alias sap_hana +- **WHEN** a user calls `DialectType::from_str("sap_hana")` +- **THEN** the system returns `Ok(DialectType::HANA)` + +#### Scenario: Unknown dialect still errors +- **WHEN** a user calls `DialectType::from_str("hanacloud")` +- **THEN** the system returns an error (not a valid alias) + +#### Scenario: Display name +- **WHEN** `DialectType::HANA` is rendered as a string +- **THEN** the output is `"hana"` + +#### Scenario: Case-insensitive lookup +- **WHEN** a user calls `DialectType::from_str("HANA")` or `DialectType::from_str("SapHANA")` +- **THEN** the system returns `Ok(DialectType::HANA)` (FromStr is case-insensitive) + +### Requirement: HANA tokenizer configuration +The system SHALL configure the HANA tokenizer with double-quote (`"`) identifier quoting, standard SQL string quoting with single-quote escaping (`''`), and no nested comment support. + +#### Scenario: Double-quoted identifier with spaces +- **WHEN** the tokenizer encounters `"my column"` in HANA SQL +- **THEN** it produces a single identifier token with value `my column` (case preserved) + +#### Scenario: Double-quoted identifier preserves case +- **WHEN** the tokenizer encounters `"MyCamelCase"` in HANA SQL +- **THEN** it produces an identifier token with value `MyCamelCase` (case preserved, not folded) + +#### Scenario: Double-quoted reserved word as identifier +- **WHEN** the tokenizer encounters `"SELECT"` or `"ORDER"` in HANA SQL +- **THEN** it produces an identifier token, not a keyword token + +#### Scenario: Single-quote string escaping +- **WHEN** the tokenizer encounters `'it''s'` in HANA SQL +- **THEN** it produces a string literal token with value `it's` + +#### Scenario: Single-line comment +- **WHEN** the tokenizer encounters `-- this is a comment` in HANA SQL +- **THEN** the comment is captured as trivia (not a SQL token) and re-emitted as a block comment (`/* this is a comment */`) in generated output + +#### Scenario: Multi-line comment +- **WHEN** the tokenizer encounters `/* comment */` in HANA SQL +- **THEN** the comment is captured as trivia (not a SQL token) and preserved in generated output + +#### Scenario: No nested comment support +- **WHEN** the tokenizer encounters `/* outer /* inner */ still comment */` in HANA SQL +- **THEN** the first `*/` closes the comment (non-nested behavior); `still comment */` is tokenized as SQL + +### Requirement: HANA generator configuration +The system SHALL configure the HANA generator with double-quote identifier quoting, uppercase keywords, and HANA-compatible settings for null ordering, limit style, and interval syntax. + +#### Scenario: Identifier quoting in generated SQL +- **WHEN** generating SQL for the HANA dialect with an identifier `order` (a reserved word) +- **THEN** the output uses double quotes: `"order"` + +#### Scenario: Keyword casing +- **WHEN** generating SQL for the HANA dialect +- **THEN** keywords (SELECT, FROM, WHERE, etc.) are rendered in uppercase + +#### Scenario: Non-reserved identifier not quoted +- **WHEN** generating SQL for the HANA dialect with an identifier `my_column` (not a reserved word) +- **THEN** the output does not add quotes: `my_column` + +### Requirement: HANA identity round-trip +The system SHALL parse and regenerate HANA SQL preserving identity for all supported standard SQL constructs. + +#### Scenario: Simple SELECT identity +- **WHEN** parsing `SELECT a, b FROM t WHERE x > 1` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: Double-quoted identifier identity +- **WHEN** parsing `SELECT "MyColumn" FROM "MyTable"` as HANA and generating as HANA +- **THEN** the output preserves the double-quoted identifiers with case intact + +#### Scenario: Double-quoted reserved word identity +- **WHEN** parsing `SELECT "SELECT" FROM "ORDER"` as HANA and generating as HANA +- **THEN** the output preserves the double-quoted reserved words as identifiers + +#### Scenario: Aggregate with GROUP BY identity +- **WHEN** parsing `SELECT dept, COUNT(*) FROM employees GROUP BY dept ORDER BY dept` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: JOIN identity +- **WHEN** parsing `SELECT a.x, b.y FROM a INNER JOIN b ON a.id = b.id` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: LEFT JOIN identity +- **WHEN** parsing `SELECT a.x, b.y FROM a LEFT JOIN b ON a.id = b.id WHERE b.y IS NULL` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: Multiple JOINs identity +- **WHEN** parsing `SELECT * FROM a JOIN b ON a.id = b.id JOIN c ON b.id = c.id` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: Subquery identity +- **WHEN** parsing `SELECT * FROM (SELECT x FROM t WHERE x > 0) AS sub` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: Nested subquery in WHERE identity +- **WHEN** parsing `SELECT * FROM t WHERE x IN (SELECT y FROM s)` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: CTE identity +- **WHEN** parsing `WITH cte AS (SELECT x FROM t) SELECT * FROM cte` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: Window function identity +- **WHEN** parsing `SELECT x, ROW_NUMBER() OVER (PARTITION BY y ORDER BY z) AS rn FROM t` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: UNION ALL identity +- **WHEN** parsing `SELECT 1 UNION ALL SELECT 2` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: LIMIT and OFFSET identity +- **WHEN** parsing `SELECT * FROM t LIMIT 10 OFFSET 5` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: CASE WHEN identity +- **WHEN** parsing `SELECT CASE WHEN x > 1 THEN 'a' ELSE 'b' END FROM t` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: String literal with escaped quote identity +- **WHEN** parsing `SELECT 'it''s' AS val FROM t` as HANA and generating as HANA +- **THEN** the output preserves the escaped single quote + +#### Scenario: NULL in SELECT identity +- **WHEN** parsing `SELECT NULL AS x FROM t` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: DISTINCT identity +- **WHEN** parsing `SELECT DISTINCT x FROM t` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: Column alias identity +- **WHEN** parsing `SELECT x AS alias_name FROM t` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: Star expression identity +- **WHEN** parsing `SELECT * FROM t` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: COALESCE identity +- **WHEN** parsing `SELECT COALESCE(a, b, c) FROM t` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: HANA native function identity +- **WHEN** parsing `SELECT ADD_DAYS(d, 7), NVL(a, b) FROM t` as HANA and generating as HANA +- **THEN** the output is `SELECT ADD_DAYS(d, 7), NVL(a, b) FROM t` (HANA functions preserved in identity round-trip, no transforms applied in Phase 1) + +#### Scenario: SUBSTR canonicalized to SUBSTRING +- **WHEN** parsing `SELECT SUBSTR(s, 1, 3) FROM t` as HANA and generating as HANA +- **THEN** the output is `SELECT SUBSTRING(s, 1, 3) FROM t` (the parser canonicalizes SUBSTR to the generic SUBSTRING AST node; rendering SUBSTR for HANA output is a Phase 2 transform) + +#### Scenario: Mixed-case function name identity +- **WHEN** parsing `SELECT add_days(d, 7) FROM t` as HANA and generating as HANA +- **THEN** the output preserves the function name as-is (no case normalization in Phase 1) + +### Requirement: HANA DDL identity +The system SHALL parse and regenerate HANA DDL statements preserving identity. + +#### Scenario: CREATE TABLE with HANA types identity +- **WHEN** parsing `CREATE TABLE t (id INTEGER, name NVARCHAR(100), salary SMALLDECIMAL, created SECONDDATE)` as HANA and generating as HANA +- **THEN** the output preserves HANA-specific type names (SMALLDECIMAL, SECONDDATE, NVARCHAR) as-is + +#### Scenario: CREATE TABLE with constraints identity +- **WHEN** parsing `CREATE TABLE t (id INTEGER NOT NULL PRIMARY KEY, name VARCHAR(100) NOT NULL)` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: DROP TABLE identity +- **WHEN** parsing `DROP TABLE t` as HANA and generating as HANA +- **THEN** the output matches the input + +#### Scenario: ALTER TABLE ADD COLUMN identity +- **WHEN** parsing `ALTER TABLE t ADD COLUMN x INTEGER` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +### Requirement: HANA DML identity +The system SHALL parse and regenerate HANA DML statements preserving identity. + +#### Scenario: INSERT with VALUES identity +- **WHEN** parsing `INSERT INTO t (a, b) VALUES (1, 'x')` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: INSERT with SELECT identity +- **WHEN** parsing `INSERT INTO t SELECT a, b FROM s` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: UPDATE with WHERE identity +- **WHEN** parsing `UPDATE t SET x = 1 WHERE y = 2` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: DELETE with WHERE identity +- **WHEN** parsing `DELETE FROM t WHERE x = 1` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: INSERT multiple rows identity +- **WHEN** parsing `INSERT INTO t VALUES (1, 'a'), (2, 'b'), (3, 'c')` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +### Requirement: HANA test fixtures +The system SHALL include custom test fixtures in `tests/custom_fixtures/hana/` covering identity, select, DDL, and DML categories, auto-discovered by the `custom_dialect_tests.rs` runner. Fixtures SHALL include both common cases and edge cases. + +#### Scenario: Identity fixture file +- **WHEN** the test runner discovers `tests/custom_fixtures/hana/identity.json` +- **THEN** it runs all identity tests and reports pass/fail per test case + +#### Scenario: Select fixture file +- **WHEN** the test runner discovers `tests/custom_fixtures/hana/select.json` +- **THEN** it runs all identity tests for SELECT constructs and reports pass/fail per test case + +#### Scenario: DDL fixture file +- **WHEN** the test runner discovers `tests/custom_fixtures/hana/ddl.json` +- **THEN** it runs all identity tests for DDL constructs including HANA-specific data types + +#### Scenario: DML fixture file +- **WHEN** the test runner discovers `tests/custom_fixtures/hana/dml.json` +- **THEN** it runs all identity tests for DML constructs and reports pass/fail per test case + +#### Scenario: All fixture categories present +- **WHEN** the test runner scans `tests/custom_fixtures/hana/` +- **THEN** it finds fixture files for: identity, select, ddl, dml + +#### Scenario: Edge case coverage in identity fixtures +- **WHEN** the identity fixture file is loaded +- **THEN** it includes test cases for: escaped single quotes in strings, NULL literal, reserved words as double-quoted identifiers, multiple JOINs, nested subqueries, window functions with PARTITION BY and ORDER BY, UNION ALL, CTEs, CASE WHEN, LEFT JOIN with IS NULL + +#### Scenario: Edge case coverage in DDL fixtures +- **WHEN** the DDL fixture file is loaded +- **THEN** it includes test cases for: HANA-specific types (SMALLDECIMAL, SECONDDATE, NVARCHAR, ALPHANUM), types with precision/length args, CREATE TABLE with constraints, ALTER TABLE + +#### Scenario: Edge case coverage in DML fixtures +- **WHEN** the DML fixture file is loaded +- **THEN** it includes test cases for: INSERT with multiple rows, INSERT with SELECT, UPDATE with WHERE, DELETE with WHERE, INSERT with explicit column list + +### Requirement: HANA dialect available in all bindings +The system SHALL expose the HANA dialect through FFI, WASM/TypeScript SDK, and all language bindings that enumerate dialects. + +#### Scenario: FFI dialect list includes HANA +- **WHEN** calling `polyglot_dialect_list()` via FFI +- **THEN** the returned JSON array includes `"hana"` + +#### Scenario: FFI dialect count +- **WHEN** calling `polyglot_dialect_count()` via FFI +- **THEN** the returned count is 35 (34 existing + 1 HANA) + +#### Scenario: TypeScript SDK DialectType enum +- **WHEN** a TypeScript user imports `DialectType` +- **THEN** the enum includes `HANA = 'hana'` diff --git a/openspec/changes/archive/2026-07-10-add-sap-hana-dialect/tasks.md b/openspec/changes/archive/2026-07-10-add-sap-hana-dialect/tasks.md new file mode 100644 index 00000000..96e5c306 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-add-sap-hana-dialect/tasks.md @@ -0,0 +1,50 @@ +## 1. Scaffold and Feature Registration + +- [x] 1.1 Add `dialect-hana = []` feature to `crates/polyglot-sql/Cargo.toml` and add `dialect-hana` to `all-dialects` list +- [x] 1.2 Add `dialect-hana` passthrough and `all-dialects` entry to `crates/polyglot-sql-wasm/Cargo.toml` +- [x] 1.3 Add `HANA` variant to `DialectType` enum, `Display` impl (`"hana"`), `FromStr` impl (`"hana" | "saphana" | "sap_hana"`) in `crates/polyglot-sql/src/dialects/mod.rs` +- [x] 1.4 Add `#[cfg(feature = "dialect-hana")] mod hana;` and `pub use hana::HanaDialect;` to `mod.rs` +- [x] 1.5 Add `cached_dialect!(CACHED_HANA, HanaDialect, "dialect-hana");` and `configs_for_dialect_type` match arm to `mod.rs` +- [x] 1.6 Create minimal `crates/polyglot-sql/src/dialects/hana.rs` — empty `HanaDialect` struct, `DialectImpl` impl with only `dialect_type()` returning `DialectType::HANA` (no transforms, all defaults) +- [x] 1.7 Verify `cargo test -p polyglot-sql --lib` compiles and passes (no regressions from scaffold) + +## 2. Write Test Fixtures (RED phase — tests define expected behavior) + +Write all test fixtures BEFORE implementing tokenizer/generator config. Some identity tests may fail initially if config is wrong; this confirms the tests are exercising the right behavior. + +- [x] 2.1 Create `tests/custom_fixtures/hana/identity.json` — identity round-trip tests with edge cases: escaped single quotes (`'it''s'`), NULL literal, double-quoted reserved words (`"SELECT"`, `"ORDER"`), double-quoted mixed-case identifiers (`"MyColumn"`), multiple JOINs, nested subqueries, CTEs, window functions with PARTITION BY + ORDER BY, UNION ALL, LIMIT/OFFSET, CASE WHEN, LEFT JOIN with IS NULL, COALESCE, HANA native function pass-through (ADD_DAYS, NVL, SUBSTR — identity only, no transforms) +- [x] 2.2 Create `tests/custom_fixtures/hana/select.json` — SELECT-specific identity: DISTINCT, ALL, column aliases, star, expressions, CASE WHEN with multiple branches, COALESCE in SELECT, string literals +- [x] 2.3 Create `tests/custom_fixtures/hana/ddl.json` — CREATE TABLE identity with HANA types (SMALLDECIMAL, SECONDDATE, NVARCHAR, ALPHANUM, CLOB), types with precision/length args (NVARCHAR(255), SMALLDECIMAL(10,2)), CREATE TABLE with constraints (NOT NULL, PRIMARY KEY), DROP TABLE, ALTER TABLE ADD COLUMN +- [x] 2.3a Add unit tests in `dialects/mod.rs` for HANA `FromStr` (aliases `saphana`/`sap_hana`, case-insensitivity, `hanacloud` error) and `Display` +- [x] 2.3b Add identity fixture cases covering comments (single-line, multi-line, non-nested) and lowercase-keyword input (uppercase keyword output) +- [x] 2.4 Create `tests/custom_fixtures/hana/dml.json` — INSERT with VALUES, INSERT with multiple rows, INSERT with SELECT, INSERT with explicit column list, UPDATE with WHERE, DELETE with WHERE +- [x] 2.5 Run `cargo test -p polyglot-sql --test custom_dialect_tests -- --nocapture` and confirm HANA tests are discovered; note which identity tests fail (expected — tokenizer/generator config not yet implemented) + +## 3. Implement Tokenizer and Generator Config (GREEN phase) + +- [x] 3.1 Implement `tokenizer_config()` in `hana.rs`: double-quote identifiers, no nested comments, standard SQL string escapes (`''`) +- [x] 3.2 Implement `generator_config()` in `hana.rs`: double-quote identifier quoting, uppercase keywords, HANA-compatible null ordering, limit style, interval syntax (verify against HANA Cloud docs) +- [x] 3.2a Add HANA arm in `generator.rs` `DataType::Int` rendering: canonicalize `INT` to `INTEGER` (HANA's canonical integer type) +- [x] 3.2b Add HANA arm in `generator.rs` `NVARCHAR` rendering: preserve `NVARCHAR` (default arm folds it to `VARCHAR`, breaking DDL identity) +- [x] 3.2c Register `"hana" | "saphana" | "sap_hana"` in `tests/common/test_runner.rs` `parse_dialect` so fixtures are discovered +- [x] 3.3 Run `cargo test -p polyglot-sql --test custom_dialect_tests -- --nocapture` — all HANA identity/select/ddl/dml tests must pass +- [x] 3.4 Run `cargo test -p polyglot-sql --lib` — no regressions + +## 4. FFI and SDK Registration + +- [x] 4.1 Add `DialectType::HANA` to `DIALECTS` array in `crates/polyglot-sql-ffi/src/dialects.rs`, update array size 34→35 +- [x] 4.2 Add `HANA = 'hana'` to TypeScript `DialectType` enum in `packages/sdk/src/index.ts` +- [x] 4.2a Add `"hana"` to Python `DIALECT_NAMES` in `crates/polyglot-sql-python/src/dialects.rs` and assert it in `tests/test_dialects.py` +- [x] 4.2b Assert `"hana"` in FFI `test_dialect_list_and_count` in `crates/polyglot-sql-ffi/tests/ffi_tests.rs` +- [x] 4.2c Add `hana` display name to playground `DIALECT_DISPLAY_NAMES` and update dialect lists/counts in READMEs and Python docs +- [x] 4.3 Verify FFI build: `cargo build -p polyglot-sql-ffi --profile ffi_release` +- [x] 4.4 Verify SDK typecheck: `cd packages/sdk && pnpm typecheck` + +## 5. Full Verification and Refactor + +- [x] 5.1 Run `cargo test -p polyglot-sql --test custom_dialect_tests -- --nocapture` — all HANA fixtures pass (identity + select + ddl + dml) +- [x] 5.2 Run `cargo test -p polyglot-sql --lib` — no regressions +- [x] 5.3 Run `cargo test -p polyglot-sql --features all-dialects` — full test suite passes +- [x] 5.4 Run `cargo clippy --all` — no warnings in hana.rs or related changes +- [x] 5.5 Run `make fmt` — formatting clean +- [x] 5.6 Final review: verify every spec scenario has a corresponding test case in fixtures, no gaps between spec and tests diff --git a/openspec/changes/archive/2026-07-10-add-sap-hana-transpilation/.openspec.yaml b/openspec/changes/archive/2026-07-10-add-sap-hana-transpilation/.openspec.yaml new file mode 100644 index 00000000..eb5fa80e --- /dev/null +++ b/openspec/changes/archive/2026-07-10-add-sap-hana-transpilation/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-10 diff --git a/openspec/changes/archive/2026-07-10-add-sap-hana-transpilation/design.md b/openspec/changes/archive/2026-07-10-add-sap-hana-transpilation/design.md new file mode 100644 index 00000000..d14cc041 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-add-sap-hana-transpilation/design.md @@ -0,0 +1,65 @@ +## Context + +Phase 1 (`add-sap-hana-dialect`) established the HANA dialect with tokenizer config, generator config, and identity round-trip. The `HanaDialect` struct currently has no `transform_expr` override — it uses the default no-op pass-through. + +Phase 2 adds the transpilation layer: ~40 function transforms, a date format converter, and generator type mappings. The primary target is Trino, which is already well-supported in polyglot with an existing `TrinoDialect` that maps many generic functions to Trino equivalents. + +The transpilation pipeline is: HANA SQL → parse (generic AST) → HANA `transform_expr` (normalizes HANA functions to generic forms) → Trino `transform_expr` (adapts to Trino) → Trino generator (emits SQL). Much of the pipeline already works because the existing Trino dialect handles many generic→Trino mappings (SUBSTR→SUBSTRING, NVL→COALESCE, NOW→CURRENT_TIMESTAMP). The HANA dialect only needs to normalize HANA-proprietary functions that no other dialect recognizes. + +## Goals / Non-Goals + +**Goals:** +- Normalize ~40 HANA-specific functions to generic AST forms during `transform_expr` +- Convert HANA Oracle-style date format strings to Java SimpleDateFormat for Trino compatibility +- Map HANA-specific data types to Trino-compatible types in the generator +- Achieve correct HANA→Trino transpilation for common analytical SQL queries +- Comprehensive transpilation test fixtures with edge cases (negative values, nested functions, WHERE-clause usage, single-arg vs two-arg variants, NULL args, mixed-case names) +- All existing Phase 1 identity tests continue to pass + +**Non-Goals:** +- SQLScript procedural language +- WITH HINT, CONTAINS/FUZZY, graph, spatial methods +- Bi-directional Trino→HANA transpilation (only HANA→Trino is verified) +- Performance optimization + +## Decisions + +### D1: Function normalization in HANA dialect's transform_expr + +**Decision:** HANA-specific function transforms live in `HanaDialect::transform_expr`, normalizing to generic AST forms. The existing Trino dialect and generator then handle rendering. + +**Rationale:** The polyglot architecture places function normalization in the source dialect's `transform_expr`. For example, HANA `ADD_DAYS(d, n)` becomes `Function("DATE_ADD", [Literal("day"), n, d])` in the generic AST, which the Trino generator renders as `DATE_ADD('day', n, d)`. Many HANA functions (SUBSTR, NVL, NOW) are already handled by the existing Trino dialect, so no new code is needed for those — they pass through and Trino's existing transforms handle them. + +### D2: HANA date functions → DATE_ADD/DATE_DIFF with string unit argument + +**Decision:** Map HANA's typed date arithmetic functions to generic `DATE_ADD`/`DATE_DIFF` with a string literal unit: +- `ADD_DAYS(d, n)` → `Function("DATE_ADD", [Literal("day"), n, d])` +- `DAYS_BETWEEN(d1, d2)` → `Function("DATE_DIFF", [Literal("day"), d1, d2])` + +**Rationale:** This matches Trino's `DATE_ADD`/`DATE_DIFF` signature (first arg is unit string). The Trino generator already handles these natively. The arg order follows Trino convention: `DATE_ADD(unit, value, date)`. + +### D3: Date format string conversion — HANA Oracle-style → Java SimpleDateFormat + +**Decision:** Implement `convert_hana_to_java_format()` in `hana.rs` with token-by-token conversion: YYYY→yyyy, MM→MM, DD→dd, HH24→HH, MI→mm, SS→ss, FF3→SSS, DAY→EEEE, MON→MMM, etc. Applied during `transform_expr` when processing TO_VARCHAR/TO_DATE/TO_TIMESTAMP with format arguments. + +**Rationale:** Trino's DATE_FORMAT and DATE_PARSE use Java SimpleDateFormat. HANA uses Oracle-style tokens. Unknown tokens pass through unchanged (best-effort). + +### D4: Data type mapping in generator's DataType::Custom branch + +**Decision:** HANA-specific types (SMALLDECIMAL, SECONDDATE, ALPHANUM, etc.) that parse as `DataType::Custom` get mapped in the generator's existing `DataType::Custom` match arm, following the same pattern as ClickHouse and Dremio custom types. + +**Rationale:** The generator already has a well-structured per-dialect type mapping branch. Adding HANA there follows the established pattern. Precision/length args are preserved (e.g., NVARCHAR(255) → VARCHAR(255)). + +### D5: LOCATE argument swap + +**Decision:** HANA's `LOCATE(substr, str)` has reversed argument order compared to Trino's `STRPOS(str, substr)`. The HANA transform swaps the args. + +**Rationale:** HANA LOCATE takes substring first, string second. Trino STRPOS takes string first, substring second. Without swapping, transpilation would produce incorrect results. This is a semantic difference, not just a naming difference. + +## Risks / Trade-offs + +- **[HANA function semantics differ from Trino in edge cases]** → Mitigation: edge case fixtures (negative values, NULL args, nested calls); document assumptions in fixture descriptions +- **[SMALLDECIMAL precision loss]** → HANA SMALLDECIMAL has 38 significant digits with flexible decimal places; Trino DECIMAL has fixed precision. Mitigation: map to DECIMAL as best-effort; document limitation +- **[Date format conversion edge cases]** → Unknown HANA tokens pass through. Mitigation: comprehensive format fixture covering all documented HANA tokens plus unknown-token edge case +- **[LOCATE with 3 args]** → HANA LOCATE supports a start position (third arg); Trino STRPOS does not. Mitigation: map to SUBSTRING + STRPOS + offset arithmetic, or pass through if too complex for Phase 2 +- **[TRUNC overload]** → HANA TRUNC serves both numeric truncation (TRUNC(x, n)) and date truncation (TRUNC(d)). Mitigation: detect context (numeric vs temporal arg) and map accordingly diff --git a/openspec/changes/archive/2026-07-10-add-sap-hana-transpilation/proposal.md b/openspec/changes/archive/2026-07-10-add-sap-hana-transpilation/proposal.md new file mode 100644 index 00000000..abddfd6f --- /dev/null +++ b/openspec/changes/archive/2026-07-10-add-sap-hana-transpilation/proposal.md @@ -0,0 +1,37 @@ +## Why + +Phase 1 (`add-sap-hana-dialect`) established the HANA dialect scaffold with tokenizer, generator config, and identity round-trip support. However, HANA SQL cannot yet be transpiled to other dialects because no function transforms or type mappings are implemented. The primary use case is transpiling HANA Cloud SQL to Trino for data lakehouse federation and migration. + +## What Changes + +- Implement `transform_expr()` in `crates/polyglot-sql/src/dialects/hana.rs` with ~40 HANA-specific function transforms: + - Date arithmetic: ADD_DAYS, ADD_MONTHS, ADD_SECONDS, ADD_YEARS → DATE_ADD with string unit + - Date diff: DAYS_BETWEEN, MONTHS_BETWEEN, SECONDS_BETWEEN, YEARS_BETWEEN → DATE_DIFF with string unit + - String: SUBSTR (pass-through, Trino handles), LCASE→LOWER, UCASE→UPPER, LOCATE (arg swap)→STRPOS + - Null/conditional: NVL→COALESCE, IFNULL→COALESCE, IF(cond,a,b)→CASE WHEN + - Conversion: TO_VARCHAR(no fmt)→CAST, TO_INTEGER→CAST, TO_DECIMAL→CAST, TO_REAL→CAST, TO_DOUBLE→CAST + - Conversion with format: TO_VARCHAR(d,fmt)→DATE_FORMAT, TO_DATE(s,fmt)→DATE_PARSE, TO_TIMESTAMP(s,fmt)→DATE_PARSE + - Datetime constants: CURRENT_UTCTIMESTAMP→CURRENT_TIMESTAMP, CURRENT_UTCDATE→CURRENT_DATE, CURRENT_UTCTIME→CURRENT_TIME, NOW→CURRENT_TIMESTAMP, SYSDATE→CURRENT_TIMESTAMP + - Numeric: TRUNC(x,n)→TRUNCATE, BITAND→BITWISE_AND, BITOR→BITWISE_OR, BITNOT→BITWISE_NOT + - Hex: HEX_TO_VARCHAR→FROM_HEX + - ILIKE→LOWER() LIKE LOWER() (HANA doesn't support ILIKE) + - TRY_CAST pass-through (both HANA and Trino support it) +- Implement `convert_hana_to_java_format()` for Oracle-style → Java SimpleDateFormat token conversion +- Add HANA-specific type mappings to `crates/polyglot-sql/src/generator.rs` `DataType::Custom` branch: SMALLDECIMAL→DECIMAL, SECONDDATE→TIMESTAMP, ALPHANUM→VARCHAR, NVARCHAR→VARCHAR, CLOB→VARCHAR, NCLOB→VARCHAR, BINARY→VARBINARY, FLOAT→DOUBLE (with precision/length args preserved) +- Create additional test fixtures: `transpilation.json` (HANA→Trino with edge cases), `types.json` (type mapping tests), `functions.json` (function identity tests) + +## Capabilities + +### New Capabilities +- `hana-transpilation`: HANA Cloud SQL function transforms, date format conversion, and type mappings that enable transpiling HANA SQL to Trino and other supported dialects. + +### Modified Capabilities +- `hana-dialect`: The `HanaDialect` struct gains `transform_expr()` overrides (previously no-op in Phase 1). This changes the dialect's behavior from identity-only to supporting cross-dialect transpilation. + +## Impact + +- **Rust core** (`crates/polyglot-sql`): `hana.rs` grows from ~30 lines (scaffold) to ~500-700 lines (transforms + format conversion); `generator.rs` gets ~20-30 lines added to `DataType::Custom` branch +- **No new files**: all changes are additions to existing files from Phase 1 +- **Tests**: 3 new fixture files in `tests/custom_fixtures/hana/` (transpilation, types, functions) +- **No breaking changes**: existing identity tests continue to pass; new transpilation tests verify HANA→Trino correctness +- **Depends on**: `add-sap-hana-dialect` (Phase 1) must be completed first diff --git a/openspec/changes/archive/2026-07-10-add-sap-hana-transpilation/specs/hana-transpilation/spec.md b/openspec/changes/archive/2026-07-10-add-sap-hana-transpilation/specs/hana-transpilation/spec.md new file mode 100644 index 00000000..b37436fd --- /dev/null +++ b/openspec/changes/archive/2026-07-10-add-sap-hana-transpilation/specs/hana-transpilation/spec.md @@ -0,0 +1,388 @@ +## ADDED Requirements + +### Requirement: HANA date arithmetic function transforms +The system SHALL transform HANA date arithmetic functions to generic `DATE_ADD` / `DATE_DIFF` functions with string unit arguments during `transform_expr`. + +#### Scenario: ADD_DAYS to DATE_ADD +- **WHEN** transpiling `SELECT ADD_DAYS(CURRENT_DATE, 7) FROM t` from HANA to Trino +- **THEN** the output is `SELECT DATE_ADD('day', 7, CURRENT_DATE) FROM t` + +#### Scenario: ADD_DAYS with negative value +- **WHEN** transpiling `SELECT ADD_DAYS(d, -1) FROM t` from HANA to Trino +- **THEN** the output is `SELECT DATE_ADD('day', -1, d) FROM t` + +#### Scenario: ADD_DAYS with zero +- **WHEN** transpiling `SELECT ADD_DAYS(d, 0) FROM t` from HANA to Trino +- **THEN** the output is `SELECT DATE_ADD('day', 0, d) FROM t` + +#### Scenario: ADD_DAYS nested in another function +- **WHEN** transpiling `SELECT UPPER(ADD_DAYS(d, 7)) FROM t` from HANA to Trino +- **THEN** the output is `SELECT UPPER(DATE_ADD('day', 7, d)) FROM t` + +#### Scenario: ADD_DAYS in WHERE clause +- **WHEN** transpiling `SELECT * FROM t WHERE d > ADD_DAYS(CURRENT_DATE, -30)` from HANA to Trino +- **THEN** the output is `SELECT * FROM t WHERE d > DATE_ADD('day', -30, CURRENT_DATE)` + +#### Scenario: ADD_MONTHS to DATE_ADD +- **WHEN** transpiling `SELECT ADD_MONTHS(CURRENT_DATE, 3) FROM t` from HANA to Trino +- **THEN** the output is `SELECT DATE_ADD('month', 3, CURRENT_DATE) FROM t` + +#### Scenario: ADD_SECONDS to DATE_ADD +- **WHEN** transpiling `SELECT ADD_SECONDS(ts, 30) FROM t` from HANA to Trino +- **THEN** the output is `SELECT DATE_ADD('second', 30, ts) FROM t` + +#### Scenario: ADD_YEARS to DATE_ADD +- **WHEN** transpiling `SELECT ADD_YEARS(d, 1) FROM t` from HANA to Trino +- **THEN** the output is `SELECT DATE_ADD('year', 1, d) FROM t` + +#### Scenario: Mixed case function name add_days +- **WHEN** transpiling `SELECT add_days(d, 7) FROM t` from HANA to Trino +- **THEN** the output is `SELECT DATE_ADD('day', 7, d) FROM t` (case-insensitive match on function name) + +#### Scenario: DAYS_BETWEEN to DATE_DIFF +- **WHEN** transpiling `SELECT DAYS_BETWEEN(d1, d2) FROM t` from HANA to Trino +- **THEN** the output is `SELECT DATE_DIFF('day', d1, d2) FROM t` + +#### Scenario: MONTHS_BETWEEN to DATE_DIFF +- **WHEN** transpiling `SELECT MONTHS_BETWEEN(d1, d2) FROM t` from HANA to Trino +- **THEN** the output is `SELECT DATE_DIFF('month', d1, d2) FROM t` + +#### Scenario: SECONDS_BETWEEN to DATE_DIFF +- **WHEN** transpiling `SELECT SECONDS_BETWEEN(d1, d2) FROM t` from HANA to Trino +- **THEN** the output is `SELECT DATE_DIFF('second', d1, d2) FROM t` + +#### Scenario: YEARS_BETWEEN to DATE_DIFF +- **WHEN** transpiling `SELECT YEARS_BETWEEN(d1, d2) FROM t` from HANA to Trino +- **THEN** the output is `SELECT DATE_DIFF('year', d1, d2) FROM t` + +#### Scenario: DAYS_BETWEEN with column expressions +- **WHEN** transpiling `SELECT DAYS_BETWEEN(start_date, end_date) AS duration FROM events` from HANA to Trino +- **THEN** the output is `SELECT DATE_DIFF('day', start_date, end_date) AS duration FROM events` + +### Requirement: HANA string function transforms +The system SHALL transform HANA string functions to their generic or Trino-compatible equivalents during `transform_expr`. + +#### Scenario: SUBSTR to SUBSTRING +- **WHEN** transpiling `SELECT SUBSTR(s, 1, 5) FROM t` from HANA to Trino +- **THEN** the output is `SELECT SUBSTRING(s, 1, 5) FROM t` + +#### Scenario: SUBSTR with two args (no length) +- **WHEN** transpiling `SELECT SUBSTR(s, 3) FROM t` from HANA to Trino +- **THEN** the output is `SELECT SUBSTRING(s, 3) FROM t` + +#### Scenario: LCASE to LOWER +- **WHEN** transpiling `SELECT LCASE(s) FROM t` from HANA to Trino +- **THEN** the output is `SELECT LOWER(s) FROM t` + +#### Scenario: UCASE to UPPER +- **WHEN** transpiling `SELECT UCASE(s) FROM t` from HANA to Trino +- **THEN** the output is `SELECT UPPER(s) FROM t` + +#### Scenario: LOCATE arg swap to STRPOS +- **WHEN** transpiling `SELECT LOCATE('abc', s) FROM t` from HANA to Trino (HANA LOCATE takes substring first, string second) +- **THEN** the output is `SELECT STRPOS(s, 'abc') FROM t` + +#### Scenario: LCASE nested in UCASE +- **WHEN** transpiling `SELECT UCASE(LCASE(s)) FROM t` from HANA to Trino +- **THEN** the output is `SELECT UPPER(LOWER(s)) FROM t` + +### Requirement: HANA null/conditional function transforms +The system SHALL transform HANA null-handling and conditional functions to generic AST equivalents during `transform_expr`. + +#### Scenario: NVL to COALESCE +- **WHEN** transpiling `SELECT NVL(a, b) FROM t` from HANA to Trino +- **THEN** the output is `SELECT COALESCE(a, b) FROM t` + +#### Scenario: IFNULL to COALESCE +- **WHEN** transpiling `SELECT IFNULL(a, b) FROM t` from HANA to Trino +- **THEN** the output is `SELECT COALESCE(a, b) FROM t` + +#### Scenario: NVL with NULL literal as first arg +- **WHEN** transpiling `SELECT NVL(NULL, b) FROM t` from HANA to Trino +- **THEN** the output is `SELECT COALESCE(NULL, b) FROM t` + +#### Scenario: NVL with column and literal +- **WHEN** transpiling `SELECT NVL(name, 'unknown') FROM employees` from HANA to Trino +- **THEN** the output is `SELECT COALESCE(name, 'unknown') FROM employees` + +#### Scenario: IF to CASE WHEN +- **WHEN** transpiling `SELECT IF(cond, a, b) FROM t` from HANA to Trino +- **THEN** the output is `SELECT CASE WHEN cond THEN a ELSE b END FROM t` + +#### Scenario: Nested NVL +- **WHEN** transpiling `SELECT NVL(NVL(a, b), c) FROM t` from HANA to Trino +- **THEN** the output is `SELECT COALESCE(COALESCE(a, b), c) FROM t` + +#### Scenario: NVL in WHERE clause +- **WHEN** transpiling `SELECT * FROM t WHERE NVL(status, 0) = 1` from HANA to Trino +- **THEN** the output is `SELECT * FROM t WHERE COALESCE(status, 0) = 1` + +### Requirement: HANA conversion function transforms +The system SHALL transform HANA type conversion functions to CAST or Trino-compatible functions during `transform_expr`. + +#### Scenario: TO_VARCHAR without format +- **WHEN** transpiling `SELECT TO_VARCHAR(x) FROM t` from HANA to Trino +- **THEN** the output is `SELECT CAST(x AS VARCHAR) FROM t` + +#### Scenario: TO_VARCHAR with numeric value (no format) +- **WHEN** transpiling `SELECT TO_VARCHAR(123) FROM t` from HANA to Trino +- **THEN** the output is `SELECT CAST(123 AS VARCHAR) FROM t` + +#### Scenario: TO_VARCHAR with date format +- **WHEN** transpiling `SELECT TO_VARCHAR(d, 'YYYY-MM-DD') FROM t` from HANA to Trino +- **THEN** the output is `SELECT DATE_FORMAT(d, 'yyyy-MM-dd') FROM t` (format converted to Java style) + +#### Scenario: TO_DATE with format +- **WHEN** transpiling `SELECT TO_DATE('2024-01-15', 'YYYY-MM-DD') FROM t` from HANA to Trino +- **THEN** the output is `SELECT DATE_PARSE('2024-01-15', 'yyyy-MM-dd') FROM t` + +#### Scenario: TO_DATE without format (single arg) +- **WHEN** transpiling `SELECT TO_DATE('2024-01-15') FROM t` from HANA to Trino +- **THEN** the output is `SELECT CAST('2024-01-15' AS DATE) FROM t` + +#### Scenario: TO_TIMESTAMP with format +- **WHEN** transpiling `SELECT TO_TIMESTAMP('2024-01-15 10:30:00', 'YYYY-MM-DD HH24:MI:SS') FROM t` from HANA to Trino +- **THEN** the output is `SELECT DATE_PARSE('2024-01-15 10:30:00', 'yyyy-MM-dd HH:mm:ss') FROM t` + +#### Scenario: TO_TIMESTAMP without format (single arg) +- **WHEN** transpiling `SELECT TO_TIMESTAMP('2024-01-15 10:30:00') FROM t` from HANA to Trino +- **THEN** the output is `SELECT CAST('2024-01-15 10:30:00' AS TIMESTAMP) FROM t` + +#### Scenario: TO_INTEGER to CAST +- **WHEN** transpiling `SELECT TO_INTEGER(s) FROM t` from HANA to Trino +- **THEN** the output is `SELECT CAST(s AS INTEGER) FROM t` + +#### Scenario: TO_DECIMAL to CAST +- **WHEN** transpiling `SELECT TO_DECIMAL(s) FROM t` from HANA to Trino +- **THEN** the output is `SELECT CAST(s AS DECIMAL) FROM t` + +#### Scenario: TO_REAL to CAST +- **WHEN** transpiling `SELECT TO_REAL(s) FROM t` from HANA to Trino +- **THEN** the output is `SELECT CAST(s AS REAL) FROM t` + +#### Scenario: TO_DOUBLE to CAST +- **WHEN** transpiling `SELECT TO_DOUBLE(s) FROM t` from HANA to Trino +- **THEN** the output is `SELECT CAST(s AS DOUBLE) FROM t` + +#### Scenario: TO_VARCHAR with format in WHERE clause +- **WHEN** transpiling `SELECT * FROM t WHERE TO_VARCHAR(d, 'YYYY-MM') = '2024-01'` from HANA to Trino +- **THEN** the output is `SELECT * FROM t WHERE DATE_FORMAT(d, 'yyyy-MM') = '2024-01'` + +#### Scenario: Nested TO_DATE inside ADD_DAYS +- **WHEN** transpiling `SELECT ADD_DAYS(TO_DATE('2024-01-15', 'YYYY-MM-DD'), 7) FROM t` from HANA to Trino +- **THEN** the output is `SELECT DATE_ADD('day', 7, DATE_PARSE('2024-01-15', 'yyyy-MM-dd')) FROM t` + +### Requirement: HANA datetime constant transforms +The system SHALL transform HANA datetime constants to their generic equivalents during `transform_expr`. + +#### Scenario: CURRENT_UTCTIMESTAMP +- **WHEN** transpiling `SELECT CURRENT_UTCTIMESTAMP FROM t` from HANA to Trino +- **THEN** the output is `SELECT CURRENT_TIMESTAMP FROM t` + +#### Scenario: CURRENT_UTCTIMESTAMP with parentheses +- **WHEN** transpiling `SELECT CURRENT_UTCTIMESTAMP() FROM t` from HANA to Trino +- **THEN** the output is `SELECT CURRENT_TIMESTAMP FROM t` + +#### Scenario: CURRENT_UTCDATE +- **WHEN** transpiling `SELECT CURRENT_UTCDATE FROM t` from HANA to Trino +- **THEN** the output is `SELECT CURRENT_DATE FROM t` + +#### Scenario: CURRENT_UTCTIME +- **WHEN** transpiling `SELECT CURRENT_UTCTIME FROM t` from HANA to Trino +- **THEN** the output is `SELECT CURRENT_TIME FROM t` + +#### Scenario: NOW +- **WHEN** transpiling `SELECT NOW() FROM t` from HANA to Trino +- **THEN** the output is `SELECT CURRENT_TIMESTAMP FROM t` + +#### Scenario: SYSDATE +- **WHEN** transpiling `SELECT SYSDATE FROM t` from HANA to Trino +- **THEN** the output is `SELECT CURRENT_TIMESTAMP FROM t` + +#### Scenario: CURRENT_UTCTIMESTAMP used in expression +- **WHEN** transpiling `SELECT EXTRACT(YEAR FROM CURRENT_UTCTIMESTAMP) FROM t` from HANA to Trino +- **THEN** the output is `SELECT EXTRACT(YEAR FROM CURRENT_TIMESTAMP) FROM t` + +### Requirement: HANA data type mapping +The system SHALL map HANA-specific data types to Trino-compatible types during generation. + +#### Scenario: SMALLDECIMAL to DECIMAL +- **WHEN** generating Trino SQL from an AST containing `DataType::Custom { name: "SMALLDECIMAL" }` +- **THEN** the output type is `DECIMAL` + +#### Scenario: SMALLDECIMAL with precision args +- **WHEN** generating Trino SQL from an AST containing `DataType::Custom { name: "SMALLDECIMAL(10,2)" }` +- **THEN** the output type is `DECIMAL(10,2)` (precision args preserved) + +#### Scenario: SECONDDATE to TIMESTAMP +- **WHEN** generating Trino SQL from an AST containing `DataType::Custom { name: "SECONDDATE" }` +- **THEN** the output type is `TIMESTAMP` + +#### Scenario: ALPHANUM to VARCHAR +- **WHEN** generating Trino SQL from an AST containing `DataType::Custom { name: "ALPHANUM" }` +- **THEN** the output type is `VARCHAR` + +#### Scenario: ALPHANUM with length +- **WHEN** generating Trino SQL from an AST containing `DataType::Custom { name: "ALPHANUM(10)" }` +- **THEN** the output type is `VARCHAR(10)` (length preserved) + +#### Scenario: NVARCHAR to VARCHAR +- **WHEN** generating Trino SQL from an AST containing `DataType::Custom { name: "NVARCHAR" }` +- **THEN** the output type is `VARCHAR` + +#### Scenario: NVARCHAR with length +- **WHEN** generating Trino SQL from an AST containing `DataType::Custom { name: "NVARCHAR(255)" }` +- **THEN** the output type is `VARCHAR(255)` (length preserved) + +#### Scenario: CLOB to VARCHAR +- **WHEN** generating Trino SQL from an AST containing `DataType::Custom { name: "CLOB" }` +- **THEN** the output type is `VARCHAR` + +#### Scenario: NCLOB to VARCHAR +- **WHEN** generating Trino SQL from an AST containing `DataType::Custom { name: "NCLOB" }` +- **THEN** the output type is `VARCHAR` + +#### Scenario: BINARY to VARBINARY +- **WHEN** generating Trino SQL from an AST containing `DataType::Custom { name: "BINARY" }` +- **THEN** the output type is `VARBINARY` + +#### Scenario: FLOAT to DOUBLE +- **WHEN** generating Trino SQL from an AST containing `DataType::Custom { name: "FLOAT" }` +- **THEN** the output type is `DOUBLE` + +#### Scenario: Standard types pass through unchanged +- **WHEN** generating Trino SQL from an AST containing `DataType::Integer` +- **THEN** the output type is `INTEGER` (no HANA-specific mapping needed) + +#### Scenario: HANA types in CREATE TABLE transpilation +- **WHEN** transpiling `CREATE TABLE t (id INTEGER, name NVARCHAR(100), salary SMALLDECIMAL, created SECONDDATE)` from HANA to Trino +- **THEN** the output is `CREATE TABLE t (id INTEGER, name VARCHAR(100), salary DECIMAL, created TIMESTAMP)` + +### Requirement: HANA date format string conversion +The system SHALL convert HANA Oracle-style date format tokens to Java SimpleDateFormat tokens when transforming `TO_VARCHAR`, `TO_DATE`, and `TO_TIMESTAMP` functions. + +#### Scenario: Basic date format +- **WHEN** converting HANA format `'YYYY-MM-DD'` +- **THEN** the Java format is `'yyyy-MM-dd'` + +#### Scenario: Timestamp format with 24-hour time +- **WHEN** converting HANA format `'YYYY-MM-DD HH24:MI:SS'` +- **THEN** the Java format is `'yyyy-MM-dd HH:mm:ss'` + +#### Scenario: Format with 12-hour time and AM/PM +- **WHEN** converting HANA format `'HH12:MI:SS AM'` +- **THEN** the Java format is `'hh:mm:ss a'` + +#### Scenario: Format with month name +- **WHEN** converting HANA format `'DD MON YYYY'` +- **THEN** the Java format is `'dd MMM yyyy'` + +#### Scenario: Format with full day name +- **WHEN** converting HANA format `'DAY, DD MONTH YYYY'` +- **THEN** the Java format is `'EEEE, dd MMMM yyyy'` + +#### Scenario: Format with fractional seconds (millisecond) +- **WHEN** converting HANA format `'YYYY-MM-DD HH24:MI:SS.FF3'` +- **THEN** the Java format is `'yyyy-MM-dd HH:mm:ss.SSS'` + +#### Scenario: Format with two-digit year +- **WHEN** converting HANA format `'DD-MM-YY'` +- **THEN** the Java format is `'dd-MM-yy'` + +#### Scenario: Format with literal separators preserved +- **WHEN** converting HANA format `'YYYY/MM/DD HH24:MI:SS'` +- **THEN** the Java format is `'yyyy/MM/dd HH:mm:ss'` (slash separators preserved) + +#### Scenario: Unknown format token passes through +- **WHEN** converting HANA format `'YYYY-FOO-DD'` where `FOO` is not a known HANA token +- **THEN** the Java format preserves the unknown token as-is: `'yyyy-FOO-dd'` + +#### Scenario: Empty format string +- **WHEN** converting an empty HANA format `''` +- **THEN** the Java format is `''` (empty string returned as-is) + +#### Scenario: Format with no tokens (literal only) +- **WHEN** converting HANA format `'hello world'` (no format tokens) +- **THEN** the Java format is `'hello world'` (preserved as-is) + +### Requirement: HANA ILIKE handling +The system SHALL transform `ILIKE` to `LOWER() LIKE LOWER()` since HANA does not support `ILIKE` natively. + +#### Scenario: ILIKE to LOWER LIKE +- **WHEN** transpiling `SELECT * FROM t WHERE name ILIKE '%john%'` from HANA to Trino +- **THEN** the output is `SELECT * FROM t WHERE LOWER(name) LIKE LOWER('%john%')` + +#### Scenario: NOT ILIKE +- **WHEN** transpiling `SELECT * FROM t WHERE name NOT ILIKE '%test%'` from HANA to Trino +- **THEN** the output is `SELECT * FROM t WHERE NOT LOWER(name) LIKE LOWER('%test%')` + +### Requirement: HANA TRY_CAST handling +The system SHALL handle `TRY_CAST` in HANA SQL by passing it through, as HANA Cloud supports `TRY_CAST`. + +#### Scenario: TRY_CAST identity +- **WHEN** parsing `SELECT TRY_CAST(x AS INTEGER) FROM t` as HANA and generating as HANA +- **THEN** the output preserves `TRY_CAST` + +#### Scenario: TRY_CAST to Trino +- **WHEN** transpiling `SELECT TRY_CAST(x AS INTEGER) FROM t` from HANA to Trino +- **THEN** the output preserves `TRY_CAST(x AS INTEGER)` (Trino supports TRY_CAST) + +#### Scenario: TRY_CAST with VARCHAR type +- **WHEN** transpiling `SELECT TRY_CAST(x AS VARCHAR) FROM t` from HANA to Trino +- **THEN** the output is `SELECT TRY_CAST(x AS VARCHAR) FROM t` + +### Requirement: HANA numeric function transforms +The system SHALL transform HANA numeric and bitwise functions to Trino-compatible equivalents during `transform_expr`. + +#### Scenario: TRUNC to TRUNCATE +- **WHEN** transpiling `SELECT TRUNC(x, 2) FROM t` from HANA to Trino +- **THEN** the output is `SELECT TRUNCATE(x, 2) FROM t` + +#### Scenario: BITAND to BITWISE_AND +- **WHEN** transpiling `SELECT BITAND(a, b) FROM t` from HANA to Trino +- **THEN** the output is `SELECT BITWISE_AND(a, b) FROM t` + +#### Scenario: BITOR to BITWISE_OR +- **WHEN** transpiling `SELECT BITOR(a, b) FROM t` from HANA to Trino +- **THEN** the output is `SELECT BITWISE_OR(a, b) FROM t` + +#### Scenario: BITNOT to BITWISE_NOT +- **WHEN** transpiling `SELECT BITNOT(a) FROM t` from HANA to Trino +- **THEN** the output is `SELECT BITWISE_NOT(a) FROM t` + +### Requirement: HANA hex function transform +The system SHALL transform HANA hex conversion functions to Trino-compatible equivalents. + +#### Scenario: HEX_TO_VARCHAR to FROM_HEX +- **WHEN** transpiling `SELECT HEX_TO_VARCHAR('414243') FROM t` from HANA to Trino +- **THEN** the output is `SELECT FROM_HEX('414243') FROM t` + +### Requirement: HANA transpilation test fixtures +The system SHALL include transpilation, type, and function test fixtures in `tests/custom_fixtures/hana/` with edge case coverage, auto-discovered by the `custom_dialect_tests.rs` runner. + +#### Scenario: Transpilation fixture with Trino target +- **WHEN** the test runner discovers `tests/custom_fixtures/hana/transpilation.json` containing `write` entries for `trino` +- **THEN** it transpiles each HANA SQL to Trino and compares against expected output + +#### Scenario: Types fixture with Trino target +- **WHEN** the test runner discovers `tests/custom_fixtures/hana/types.json` containing `write` entries for `trino` +- **THEN** it transpiles HANA type declarations to Trino and compares against expected output + +#### Scenario: Functions fixture identity +- **WHEN** the test runner discovers `tests/custom_fixtures/hana/functions.json` +- **THEN** it runs HANA function identity tests (HANA→HANA round-trip) + +#### Scenario: Edge case coverage in transpilation fixtures +- **WHEN** the transpilation fixture file is loaded +- **THEN** it includes test cases for: negative date arithmetic values, nested function calls, functions in WHERE clauses, single-arg conversion functions (no format), mixed-case function names, NULL literal arguments, nested NVL, TRUNC with numeric and date contexts + +#### Scenario: Edge case coverage in type fixtures +- **WHEN** the types fixture file is loaded +- **THEN** it includes test cases for: types with precision/scale args (SMALLDECIMAL(10,2), NVARCHAR(255), ALPHANUM(10)), types without args, types in CAST expressions, types in CREATE TABLE + +### Requirement: Phase 1 identity tests continue to pass +The system SHALL maintain all Phase 1 identity test fixtures passing after Phase 2 transforms are added. + +#### Scenario: Identity tests not broken by transforms +- **WHEN** running `cargo test -p polyglot-sql --test custom_dialect_tests` after Phase 2 implementation +- **THEN** all Phase 1 identity/select/ddl/dml tests still pass (transforms only apply when target dialect differs from source) diff --git a/openspec/changes/archive/2026-07-10-add-sap-hana-transpilation/tasks.md b/openspec/changes/archive/2026-07-10-add-sap-hana-transpilation/tasks.md new file mode 100644 index 00000000..c4899d73 --- /dev/null +++ b/openspec/changes/archive/2026-07-10-add-sap-hana-transpilation/tasks.md @@ -0,0 +1,58 @@ +## 1. Write Test Fixtures (RED phase — tests will fail) + +Write all transpilation/type/function test fixtures BEFORE implementing transforms. These define the expected HANA→Trino behavior and will initially fail because `hana.rs` has no `transform_expr` yet. + +- [x] 1.1 Create `tests/custom_fixtures/hana/transpilation.json` — HANA→Trino transpilation tests covering ALL function transforms with edge cases: negative date values (`ADD_DAYS(d, -1)`), zero values (`ADD_DAYS(d, 0)`), nested functions (`UPPER(ADD_DAYS(d,7))`, `ADD_DAYS(TO_DATE(s,fmt),7)`), functions in WHERE clauses, single-arg conversions (`TO_DATE(s)` without format), NULL literal args (`NVL(NULL, b)`), nested NVL, mixed-case function names (`add_days`), `write` targets for `trino` +- [x] 1.2 Create `tests/custom_fixtures/hana/types.json` — data type transpilation tests including edge cases: SMALLDECIMAL with and without precision args, NVARCHAR(255) vs NVARCHAR, ALPHANUM(10), SECONDDATE, CLOB, NCLOB, BINARY, FLOAT, standard types, types in CAST and CREATE TABLE contexts, `write` targets for `trino` +- [x] 1.3 Create `tests/custom_fixtures/hana/functions.json` — HANA function identity tests (HANA→HANA round-trip): ADD_DAYS, ADD_MONTHS, DAYS_BETWEEN, TO_VARCHAR, NVL, SUBSTR, LCASE, UCASE, LOCATE, CURRENT_UTCTIMESTAMP, TRUNC, BITAND, HEX_TO_VARCHAR, including mixed-case function names +- [x] 1.4 Run `cargo test -p polyglot-sql --test custom_dialect_tests -- --nocapture` and confirm new transpilation tests fail (expected — transforms not implemented), Phase 1 identity tests still pass + +## 2. Implement Date Arithmetic Transforms (GREEN — incremental) + +- [x] 2.1 Implement `transform_expr()` entry point and `transform_function()` helper for generic Function matching in `hana.rs` +- [x] 2.2 Add ADD_DAYS→`DATE_ADD('day', n, d)`, ADD_MONTHS→`DATE_ADD('month', n, d)`, ADD_SECONDS→`DATE_ADD('second', n, d)`, ADD_YEARS→`DATE_ADD('year', n, d)` +- [x] 2.3 Add DAYS_BETWEEN→`DATE_DIFF('day', d1, d2)`, MONTHS_BETWEEN→`DATE_DIFF('month', d1, d2)`, SECONDS_BETWEEN→`DATE_DIFF('second', d1, d2)`, YEARS_BETWEEN→`DATE_DIFF('year', d1, d2)` +- [x] 2.4 Run transpilation tests for date arithmetic — confirm all date-related transpilation tests pass including edge cases (negative, zero, nested, WHERE clause, mixed-case) + +## 3. Implement String and Null/Conditional Transforms (GREEN — incremental) + +- [x] 3.1 Add LCASE→`LOWER()`, UCASE→`UPPER()`, LOCATE (arg swap)→`STRPOS()` in `transform_function()` +- [x] 3.2 Add NVL→`COALESCE()`, IFNULL→`COALESCE()`, IF(cond,a,b)→`CASE WHEN cond THEN a ELSE b END` in `transform_expr()` +- [x] 3.3 Run transpilation tests for string/null/conditional — confirm all pass including edge cases (nested NVL, NULL literal arg, NVL in WHERE, SUBSTR with 2 args, LCASE nested in UCASE) + +## 4. Implement Conversion and Datetime Constant Transforms (GREEN — incremental) + +- [x] 4.1 Add TO_VARCHAR(no fmt)→`CAST AS VARCHAR`, TO_INTEGER→`CAST AS INTEGER`, TO_DECIMAL→`CAST AS DECIMAL`, TO_REAL→`CAST AS REAL`, TO_DOUBLE→`CAST AS DOUBLE` in `transform_function()` +- [x] 4.2 Add TO_VARCHAR(d,fmt)→`DATE_FORMAT(d, )`, TO_DATE(s,fmt)→`DATE_PARSE(s, )`, TO_TIMESTAMP(s,fmt)→`DATE_PARSE(s, )` — depends on date format converter (step 5) +- [x] 4.3 Add TO_DATE(single arg)→`CAST AS DATE`, TO_TIMESTAMP(single arg)→`CAST AS TIMESTAMP` +- [x] 4.4 Add CURRENT_UTCTIMESTAMP→`CurrentTimestamp`, CURRENT_UTCDATE→`CurrentDate`, CURRENT_UTCTIME→`CurrentTime`, NOW→`CurrentTimestamp`, SYSDATE→`CurrentTimestamp` in `transform_expr()` and `transform_function()` +- [x] 4.5 Run transpilation tests for conversions/datetime — confirm all pass including edge cases (single-arg vs two-arg, nested TO_DATE inside ADD_DAYS, CURRENT_UTCTIMESTAMP in expressions, CURRENT_UTCTIMESTAMP with parens) + +## 5. Implement Date Format Conversion (GREEN — incremental) + +- [x] 5.1 Implement `convert_hana_to_java_format()` function in `hana.rs`: YYYY→yyyy, YY→yy, MM→MM, DD→dd, HH24→HH, HH12→hh, MI→mm, SS→ss, FF3→SSS, FF6→SSSSSS, DAY→EEEE, DY→EEE, MONTH→MMMM, MON→MMM, AM/PM→a +- [x] 5.2 Handle edge cases: unknown tokens pass through, literal text between tokens preserved, empty string, no-token strings +- [x] 5.3 Run transpilation tests for format conversion — confirm all pass including edge cases (12-hour with AM/PM, fractional seconds, two-digit year, slash separators, unknown tokens, empty format, literal-only format) + +## 6. Implement Remaining Transforms (GREEN — incremental) + +- [x] 6.1 Add ILIKE→`LOWER() LIKE LOWER()` in `transform_expr()` (handle NOT ILIKE) +- [x] 6.2 Add TRUNC(x,n)→`TRUNCATE(x,n)`, BITAND→`BITWISE_AND`, BITOR→`BITWISE_OR`, BITNOT→`BITWISE_NOT` in `transform_function()` +- [x] 6.3 Add HEX_TO_VARCHAR→`FROM_HEX` in `transform_function()` +- [x] 6.4 Add TRY_CAST pass-through (HANA supports it, no transform needed — verify it works) +- [x] 6.5 Run ALL transpilation tests — confirm 100% pass rate + +## 7. Implement Generator Type Mapping (GREEN — incremental) + +- [x] 7.1 Add HANA-specific type mappings to `crates/polyglot-sql/src/generator.rs` `DataType::Custom` branch: SMALLDECIMAL→DECIMAL, SECONDDATE→TIMESTAMP, ALPHANUM→VARCHAR, NVARCHAR→VARCHAR, CLOB→VARCHAR, NCLOB→VARCHAR, BINARY→VARBINARY, FLOAT→DOUBLE (with precision/length args preserved) +- [x] 7.2 Run type transpilation tests — confirm all pass including edge cases (types with args, types in CREATE TABLE, types in CAST) + +## 8. Full Verification and Refactor + +- [x] 8.1 Run `cargo test -p polyglot-sql --test custom_dialect_tests -- --nocapture` — ALL HANA fixtures pass (Phase 1 identity/select/ddl/dml + Phase 2 transpilation/types/functions) +- [x] 8.2 Run `cargo test -p polyglot-sql --lib` — no regressions in existing tests +- [x] 8.3 Run `cargo test -p polyglot-sql --features all-dialects` — full test suite passes +- [x] 8.4 Run `cargo clippy --all` — no warnings in hana.rs or generator.rs changes +- [x] 8.5 Run `make fmt` — formatting clean +- [x] 8.6 Review hana.rs for code quality: consistent match arm ordering, no dead code, all transforms have corresponding test coverage, edge case comments where logic is non-obvious +- [x] 8.7 Final review: verify every Phase 2 spec scenario has a corresponding test case in fixtures, no gaps between spec and tests diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 00000000..392946c6 --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/openspec/specs/hana-dialect/spec.md b/openspec/specs/hana-dialect/spec.md new file mode 100644 index 00000000..8ac78ef3 --- /dev/null +++ b/openspec/specs/hana-dialect/spec.md @@ -0,0 +1,265 @@ +# hana-dialect Specification + +## Purpose +TBD - created by archiving change add-sap-hana-dialect. Update Purpose after archive. +## Requirements +### Requirement: HANA dialect registration +The system SHALL register `HANA` as a `DialectType` enum variant with feature gate `dialect-hana`, accepting `"hana"`, `"saphana"`, and `"sap_hana"` as string aliases via `FromStr`. + +#### Scenario: Dialect lookup by name +- **WHEN** a user calls `DialectType::from_str("hana")` +- **THEN** the system returns `Ok(DialectType::HANA)` + +#### Scenario: Dialect lookup by alias saphana +- **WHEN** a user calls `DialectType::from_str("saphana")` +- **THEN** the system returns `Ok(DialectType::HANA)` + +#### Scenario: Dialect lookup by alias sap_hana +- **WHEN** a user calls `DialectType::from_str("sap_hana")` +- **THEN** the system returns `Ok(DialectType::HANA)` + +#### Scenario: Unknown dialect still errors +- **WHEN** a user calls `DialectType::from_str("hanacloud")` +- **THEN** the system returns an error (not a valid alias) + +#### Scenario: Display name +- **WHEN** `DialectType::HANA` is rendered as a string +- **THEN** the output is `"hana"` + +#### Scenario: Case-insensitive lookup +- **WHEN** a user calls `DialectType::from_str("HANA")` or `DialectType::from_str("SapHANA")` +- **THEN** the system returns `Ok(DialectType::HANA)` (FromStr is case-insensitive) + +### Requirement: HANA tokenizer configuration +The system SHALL configure the HANA tokenizer with double-quote (`"`) identifier quoting, standard SQL string quoting with single-quote escaping (`''`), and no nested comment support. + +#### Scenario: Double-quoted identifier with spaces +- **WHEN** the tokenizer encounters `"my column"` in HANA SQL +- **THEN** it produces a single identifier token with value `my column` (case preserved) + +#### Scenario: Double-quoted identifier preserves case +- **WHEN** the tokenizer encounters `"MyCamelCase"` in HANA SQL +- **THEN** it produces an identifier token with value `MyCamelCase` (case preserved, not folded) + +#### Scenario: Double-quoted reserved word as identifier +- **WHEN** the tokenizer encounters `"SELECT"` or `"ORDER"` in HANA SQL +- **THEN** it produces an identifier token, not a keyword token + +#### Scenario: Single-quote string escaping +- **WHEN** the tokenizer encounters `'it''s'` in HANA SQL +- **THEN** it produces a string literal token with value `it's` + +#### Scenario: Single-line comment +- **WHEN** the tokenizer encounters `-- this is a comment` in HANA SQL +- **THEN** the comment is captured as trivia (not a SQL token) and re-emitted as a block comment (`/* this is a comment */`) in generated output + +#### Scenario: Multi-line comment +- **WHEN** the tokenizer encounters `/* comment */` in HANA SQL +- **THEN** the comment is captured as trivia (not a SQL token) and preserved in generated output + +#### Scenario: No nested comment support +- **WHEN** the tokenizer encounters `/* outer /* inner */ still comment */` in HANA SQL +- **THEN** the first `*/` closes the comment (non-nested behavior); `still comment */` is tokenized as SQL + +### Requirement: HANA generator configuration +The system SHALL configure the HANA generator with double-quote identifier quoting, uppercase keywords, and HANA-compatible settings for null ordering, limit style, and interval syntax. + +#### Scenario: Identifier quoting in generated SQL +- **WHEN** generating SQL for the HANA dialect with an identifier `order` (a reserved word) +- **THEN** the output uses double quotes: `"order"` + +#### Scenario: Keyword casing +- **WHEN** generating SQL for the HANA dialect +- **THEN** keywords (SELECT, FROM, WHERE, etc.) are rendered in uppercase + +#### Scenario: Non-reserved identifier not quoted +- **WHEN** generating SQL for the HANA dialect with an identifier `my_column` (not a reserved word) +- **THEN** the output does not add quotes: `my_column` + +### Requirement: HANA identity round-trip +The system SHALL parse and regenerate HANA SQL preserving identity for all supported standard SQL constructs. + +#### Scenario: Simple SELECT identity +- **WHEN** parsing `SELECT a, b FROM t WHERE x > 1` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: Double-quoted identifier identity +- **WHEN** parsing `SELECT "MyColumn" FROM "MyTable"` as HANA and generating as HANA +- **THEN** the output preserves the double-quoted identifiers with case intact + +#### Scenario: Double-quoted reserved word identity +- **WHEN** parsing `SELECT "SELECT" FROM "ORDER"` as HANA and generating as HANA +- **THEN** the output preserves the double-quoted reserved words as identifiers + +#### Scenario: Aggregate with GROUP BY identity +- **WHEN** parsing `SELECT dept, COUNT(*) FROM employees GROUP BY dept ORDER BY dept` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: JOIN identity +- **WHEN** parsing `SELECT a.x, b.y FROM a INNER JOIN b ON a.id = b.id` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: LEFT JOIN identity +- **WHEN** parsing `SELECT a.x, b.y FROM a LEFT JOIN b ON a.id = b.id WHERE b.y IS NULL` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: Multiple JOINs identity +- **WHEN** parsing `SELECT * FROM a JOIN b ON a.id = b.id JOIN c ON b.id = c.id` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: Subquery identity +- **WHEN** parsing `SELECT * FROM (SELECT x FROM t WHERE x > 0) AS sub` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: Nested subquery in WHERE identity +- **WHEN** parsing `SELECT * FROM t WHERE x IN (SELECT y FROM s)` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: CTE identity +- **WHEN** parsing `WITH cte AS (SELECT x FROM t) SELECT * FROM cte` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: Window function identity +- **WHEN** parsing `SELECT x, ROW_NUMBER() OVER (PARTITION BY y ORDER BY z) AS rn FROM t` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: UNION ALL identity +- **WHEN** parsing `SELECT 1 UNION ALL SELECT 2` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: LIMIT and OFFSET identity +- **WHEN** parsing `SELECT * FROM t LIMIT 10 OFFSET 5` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: CASE WHEN identity +- **WHEN** parsing `SELECT CASE WHEN x > 1 THEN 'a' ELSE 'b' END FROM t` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: String literal with escaped quote identity +- **WHEN** parsing `SELECT 'it''s' AS val FROM t` as HANA and generating as HANA +- **THEN** the output preserves the escaped single quote + +#### Scenario: NULL in SELECT identity +- **WHEN** parsing `SELECT NULL AS x FROM t` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: DISTINCT identity +- **WHEN** parsing `SELECT DISTINCT x FROM t` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: Column alias identity +- **WHEN** parsing `SELECT x AS alias_name FROM t` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: Star expression identity +- **WHEN** parsing `SELECT * FROM t` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: COALESCE identity +- **WHEN** parsing `SELECT COALESCE(a, b, c) FROM t` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: HANA native function identity +- **WHEN** parsing `SELECT ADD_DAYS(d, 7), NVL(a, b) FROM t` as HANA and generating as HANA +- **THEN** the output is `SELECT ADD_DAYS(d, 7), NVL(a, b) FROM t` (HANA functions preserved in identity round-trip, no transforms applied in Phase 1) + +#### Scenario: SUBSTR canonicalized to SUBSTRING +- **WHEN** parsing `SELECT SUBSTR(s, 1, 3) FROM t` as HANA and generating as HANA +- **THEN** the output is `SELECT SUBSTRING(s, 1, 3) FROM t` (the parser canonicalizes SUBSTR to the generic SUBSTRING AST node; rendering SUBSTR for HANA output is a Phase 2 transform) + +#### Scenario: Mixed-case function name identity +- **WHEN** parsing `SELECT add_days(d, 7) FROM t` as HANA and generating as HANA +- **THEN** the output preserves the function name as-is (no case normalization in Phase 1) + +### Requirement: HANA DDL identity +The system SHALL parse and regenerate HANA DDL statements preserving identity. + +#### Scenario: CREATE TABLE with HANA types identity +- **WHEN** parsing `CREATE TABLE t (id INTEGER, name NVARCHAR(100), salary SMALLDECIMAL, created SECONDDATE)` as HANA and generating as HANA +- **THEN** the output preserves HANA-specific type names (SMALLDECIMAL, SECONDDATE, NVARCHAR) as-is + +#### Scenario: CREATE TABLE with constraints identity +- **WHEN** parsing `CREATE TABLE t (id INTEGER NOT NULL PRIMARY KEY, name VARCHAR(100) NOT NULL)` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: DROP TABLE identity +- **WHEN** parsing `DROP TABLE t` as HANA and generating as HANA +- **THEN** the output matches the input + +#### Scenario: ALTER TABLE ADD COLUMN identity +- **WHEN** parsing `ALTER TABLE t ADD COLUMN x INTEGER` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +### Requirement: HANA DML identity +The system SHALL parse and regenerate HANA DML statements preserving identity. + +#### Scenario: INSERT with VALUES identity +- **WHEN** parsing `INSERT INTO t (a, b) VALUES (1, 'x')` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: INSERT with SELECT identity +- **WHEN** parsing `INSERT INTO t SELECT a, b FROM s` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: UPDATE with WHERE identity +- **WHEN** parsing `UPDATE t SET x = 1 WHERE y = 2` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: DELETE with WHERE identity +- **WHEN** parsing `DELETE FROM t WHERE x = 1` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +#### Scenario: INSERT multiple rows identity +- **WHEN** parsing `INSERT INTO t VALUES (1, 'a'), (2, 'b'), (3, 'c')` as HANA and generating as HANA +- **THEN** the output matches the input (modulo whitespace normalization) + +### Requirement: HANA test fixtures +The system SHALL include custom test fixtures in `tests/custom_fixtures/hana/` covering identity, select, DDL, and DML categories, auto-discovered by the `custom_dialect_tests.rs` runner. Fixtures SHALL include both common cases and edge cases. + +#### Scenario: Identity fixture file +- **WHEN** the test runner discovers `tests/custom_fixtures/hana/identity.json` +- **THEN** it runs all identity tests and reports pass/fail per test case + +#### Scenario: Select fixture file +- **WHEN** the test runner discovers `tests/custom_fixtures/hana/select.json` +- **THEN** it runs all identity tests for SELECT constructs and reports pass/fail per test case + +#### Scenario: DDL fixture file +- **WHEN** the test runner discovers `tests/custom_fixtures/hana/ddl.json` +- **THEN** it runs all identity tests for DDL constructs including HANA-specific data types + +#### Scenario: DML fixture file +- **WHEN** the test runner discovers `tests/custom_fixtures/hana/dml.json` +- **THEN** it runs all identity tests for DML constructs and reports pass/fail per test case + +#### Scenario: All fixture categories present +- **WHEN** the test runner scans `tests/custom_fixtures/hana/` +- **THEN** it finds fixture files for: identity, select, ddl, dml + +#### Scenario: Edge case coverage in identity fixtures +- **WHEN** the identity fixture file is loaded +- **THEN** it includes test cases for: escaped single quotes in strings, NULL literal, reserved words as double-quoted identifiers, multiple JOINs, nested subqueries, window functions with PARTITION BY and ORDER BY, UNION ALL, CTEs, CASE WHEN, LEFT JOIN with IS NULL + +#### Scenario: Edge case coverage in DDL fixtures +- **WHEN** the DDL fixture file is loaded +- **THEN** it includes test cases for: HANA-specific types (SMALLDECIMAL, SECONDDATE, NVARCHAR, ALPHANUM), types with precision/length args, CREATE TABLE with constraints, ALTER TABLE + +#### Scenario: Edge case coverage in DML fixtures +- **WHEN** the DML fixture file is loaded +- **THEN** it includes test cases for: INSERT with multiple rows, INSERT with SELECT, UPDATE with WHERE, DELETE with WHERE, INSERT with explicit column list + +### Requirement: HANA dialect available in all bindings +The system SHALL expose the HANA dialect through FFI, WASM/TypeScript SDK, and all language bindings that enumerate dialects. + +#### Scenario: FFI dialect list includes HANA +- **WHEN** calling `polyglot_dialect_list()` via FFI +- **THEN** the returned JSON array includes `"hana"` + +#### Scenario: FFI dialect count +- **WHEN** calling `polyglot_dialect_count()` via FFI +- **THEN** the returned count is 35 (34 existing + 1 HANA) + +#### Scenario: TypeScript SDK DialectType enum +- **WHEN** a TypeScript user imports `DialectType` +- **THEN** the enum includes `HANA = 'hana'` + diff --git a/packages/documentation/README.md b/packages/documentation/README.md index 11d8e334..d02d1eb0 100644 --- a/packages/documentation/README.md +++ b/packages/documentation/README.md @@ -76,7 +76,7 @@ Default guard values: `maxInputBytes=16 MiB`, `maxTokens=1_000_000`, `maxAstNode ## Supported Dialects -Athena, BigQuery, ClickHouse, CockroachDB, Databricks, Dremio, Drill, Druid, DuckDB, Dune, Exasol, Fabric, Hive, Materialize, MySQL, Oracle, PostgreSQL, Presto, Redshift, RisingWave, SingleStore, Snowflake, Solr, Spark, SQLite, StarRocks, Tableau, Teradata, TiDB, Trino, TSQL (SQL Server), and Doris. +Athena, BigQuery, ClickHouse, CockroachDB, Databricks, Dremio, Drill, Druid, DuckDB, Dune, Exasol, Fabric, HANA, Hive, Materialize, MySQL, Oracle, PostgreSQL, Presto, Redshift, RisingWave, SingleStore, Snowflake, Solr, Spark, SQLite, StarRocks, Tableau, Teradata, TiDB, Trino, TSQL (SQL Server), and Doris. ## Links diff --git a/packages/playground/src/lib/constants.ts b/packages/playground/src/lib/constants.ts index a2cf9587..8cc206f9 100644 --- a/packages/playground/src/lib/constants.ts +++ b/packages/playground/src/lib/constants.ts @@ -13,6 +13,7 @@ export const DIALECT_DISPLAY_NAMES: Record = { dune: "Dune SQL", exasol: "Exasol", fabric: "Microsoft Fabric", + hana: "SAP HANA", hive: "Apache Hive", materialize: "Materialize", mysql: "MySQL", diff --git a/packages/sdk/README.md b/packages/sdk/README.md index adfbe717..69e2b16d 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -910,6 +910,7 @@ const formattedSafe = pg.formatWithOptions('SELECT a,b FROM t', Dialect.Generic, | Dune | `Dialect.Dune` | | Exasol | `Dialect.Exasol` | | Fabric | `Dialect.Fabric` | +| HANA | `Dialect.HANA` | | Hive | `Dialect.Hive` | | Materialize | `Dialect.Materialize` | | MySQL | `Dialect.MySQL` | diff --git a/packages/sdk/src/ast/visitor/visitor.test.ts b/packages/sdk/src/ast/visitor/visitor.test.ts index 6f708cad..0f228058 100644 --- a/packages/sdk/src/ast/visitor/visitor.test.ts +++ b/packages/sdk/src/ast/visitor/visitor.test.ts @@ -291,10 +291,9 @@ describe('Convenience Finder Functions', () => { const tables = getTables(ast); expect(tables).toHaveLength(2); - expect(tables.map((table) => (getExprData(table) as any).name.name)).toEqual([ - 'ticket', - 'team', - ]); + expect( + tables.map((table) => (getExprData(table) as any).name.name), + ).toEqual(['ticket', 'team']); }); }); @@ -834,7 +833,9 @@ describe('Limit/Offset Manipulation', () => { const newAst = setOrderBy(ast, col('id').toJSON() as Expression); const sql = toSql(newAst); - expect(sql).toBe('SELECT id FROM a UNION ALL SELECT id FROM b ORDER BY id'); + expect(sql).toBe( + 'SELECT id FROM a UNION ALL SELECT id FROM b ORDER BY id', + ); }); }); diff --git a/packages/sdk/src/builders.test.ts b/packages/sdk/src/builders.test.ts index af336413..7b7e9549 100644 --- a/packages/sdk/src/builders.test.ts +++ b/packages/sdk/src/builders.test.ts @@ -296,7 +296,9 @@ describe('Expr operators', () => { it('toJSON serializes NULL as canonical AST JSON', () => { expect(JSON.stringify(sqlNull().toJSON())).toBe('{"null":null}'); - expect(JSON.stringify(col('x').eq(sqlNull()).toJSON())).toContain('"right":{"null":null}'); + expect(JSON.stringify(col('x').eq(sqlNull()).toJSON())).toContain( + '"right":{"null":null}', + ); }); }); @@ -582,9 +584,7 @@ describe('CaseBuilder', () => { .when(col('kind').eq(lit('n')), cast(col('x'), 'NVARCHAR(MAX)')) .toSql('fabric'); - expect(sql).toBe( - "CASE WHEN kind = 'n' THEN CAST(x AS VARCHAR(MAX)) END", - ); + expect(sql).toBe("CASE WHEN kind = 'n' THEN CAST(x AS VARCHAR(MAX)) END"); }); it('build() returns Expr', () => { diff --git a/packages/sdk/src/index.test.ts b/packages/sdk/src/index.test.ts index 9c3287dc..1c449904 100644 --- a/packages/sdk/src/index.test.ts +++ b/packages/sdk/src/index.test.ts @@ -170,14 +170,17 @@ describe('Polyglot SDK', () => { expect(parsed.success).toBe(true); expect(parsed.dataType?.data_type).toBe('array'); - expect(polyglot.generateDataType(parsed.dataType!, Dialect.DuckDB).sql).toBe( - 'INT[]', - ); + expect( + polyglot.generateDataType(parsed.dataType!, Dialect.DuckDB).sql, + ).toBe('INT[]'); }); }); describe('lineage helpers', () => { - const collectNames = (node: { name?: string; downstream?: unknown[] }): string[] => [ + const collectNames = (node: { + name?: string; + downstream?: unknown[]; + }): string[] => [ node.name ?? '', ...(node.downstream ?? []).flatMap((child) => collectNames(child as { name?: string; downstream?: unknown[] }), @@ -240,9 +243,7 @@ describe('Polyglot SDK', () => { 'amount', 'SELECT order_id, amount FROM t', { - tables: [ - { name: 't', columns: [{ name: 'amount', type: 'INT' }] }, - ], + tables: [{ name: 't', columns: [{ name: 'amount', type: 'INT' }] }], }, Dialect.DuckDB, ); @@ -433,9 +434,7 @@ describe('Polyglot SDK', () => { const result = analyzeQuery('SELECT i FROM t, UNNEST(t.arr) AS i', { dialect: Dialect.DuckDB, schema: { - tables: [ - { name: 't', columns: [{ name: 'arr', type: 'INT' }] }, - ], + tables: [{ name: 't', columns: [{ name: 'arr', type: 'INT' }] }], }, }); @@ -451,17 +450,14 @@ describe('Polyglot SDK', () => { const result = analyzeQuery('SELECT order_id, amount FROM t', { dialect: Dialect.DuckDB, schema: { - tables: [ - { name: 't', columns: [{ name: 'amount', type: 'INT' }] }, - ], + tables: [{ name: 't', columns: [{ name: 'amount', type: 'INT' }] }], }, }); expect(result.success).toBe(true); - expect(result.analysis?.projections.map((projection) => projection.name)).toEqual([ - 'order_id', - 'amount', - ]); + expect( + result.analysis?.projections.map((projection) => projection.name), + ).toEqual(['order_id', 'amount']); expect(result.analysis?.projections[0].upstream).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index dd7df9c7..48042990 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', + HANA = 'hana', } /** @@ -501,7 +502,12 @@ export function transpile( } if (typeof wasm.transpile_with_options === 'function') { return JSON.parse( - wasm.transpile_with_options(sql, read, write, JSON.stringify(options)), + wasm.transpile_with_options( + sql, + read, + write, + JSON.stringify(options), + ), ) as TranspileResult; } return {