diff --git a/Makefile b/Makefile index f1150570..0bd5cde8 100644 --- a/Makefile +++ b/Makefile @@ -238,6 +238,9 @@ test-rust-lib: # Check capabilities separately so Cargo feature unification cannot mask dependencies. test-rust-feature-gates: + cargo check -p polyglot-sql --no-default-features --features dialect-hana + cargo check -p polyglot-sql --no-default-features --features generate,dialect-hana + cargo check -p polyglot-sql --no-default-features --features transpile,dialect-hana cargo check -p polyglot-sql --no-default-features @for feature in generate transpile builder ast-tools semantic openlineage diff planner time \ function-catalog-clickhouse function-catalog-duckdb function-catalog-all-dialects; do \ @@ -282,6 +285,7 @@ test-rust-verify-core: @echo "=== Lib unit tests ===" @cargo test --lib -p polyglot-sql @cargo test -p polyglot-sql --test deep_nesting_regression + @cargo test -p polyglot-sql --test dialect_matrix @echo "" @echo "=== Generic identity tests ===" @cargo test --test sqlglot_identity test_sqlglot_identity_all -p polyglot-sql -- --nocapture diff --git a/README.md b/README.md index 12539a34..647bbf29 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ Release notes are tracked in [`CHANGELOG.md`](CHANGELOG.md). | MySQL | Oracle | PostgreSQL | Presto | Redshift | | RisingWave | SingleStore | Snowflake | Solr | Spark | | SQLite | StarRocks | Tableau | Teradata | TiDB | -| Trino | TSQL | DataFusion | Generic SQL | | +| Trino | TSQL | DataFusion | SAP HANA | Generic SQL | ## Quick Start diff --git a/crates/polyglot-sql-ast-derive/src/lib.rs b/crates/polyglot-sql-ast-derive/src/lib.rs index 25eea077..ebde3e0d 100644 --- a/crates/polyglot-sql-ast-derive/src/lib.rs +++ b/crates/polyglot-sql-ast-derive/src/lib.rs @@ -19,32 +19,9 @@ pub fn derive_ast_node(input: TokenStream) -> TokenStream { fn expand_ast_node(input: &DeriveInput) -> proc_macro2::TokenStream { let name = &input.ident; - let immutable = match &input.data { - Data::Struct(data) => visit_fields(&data.fields, false), - Data::Enum(data) => { - let arms = data.variants.iter().map(|variant| { - let variant_name = &variant.ident; - let (pattern, body) = - visit_variant_fields(name, variant_name, &variant.fields, false); - quote!(#pattern => { #body }) - }); - quote!(match self { #(#arms),* }) - } - Data::Union(_) => quote!(), - }; - let mutable = match &input.data { - Data::Struct(data) => visit_fields(&data.fields, true), - Data::Enum(data) => { - let arms = data.variants.iter().map(|variant| { - let variant_name = &variant.ident; - let (pattern, body) = - visit_variant_fields(name, variant_name, &variant.fields, true); - quote!(#pattern => { #body }) - }); - quote!(match self { #(#arms),* }) - } - Data::Union(_) => quote!(), - }; + let immutable = node_visitor(input, false, true); + let untracked = node_visitor(input, false, false); + let mutable = node_visitor(input, true, false); let serialized_variant_names = if name == "Expression" { if let Data::Enum(data) = &input.data { let names = data.variants.iter().map(|variant| { @@ -81,6 +58,14 @@ fn expand_ast_node(input: &DeriveInput) -> proc_macro2::TokenStream { #immutable } + fn visit_syntax_untracked<'ast, F, T>(&'ast self, visitor: &mut F, type_visitor: &mut T) + where + F: FnMut(&'ast crate::expressions::Expression), + T: FnMut(&'ast crate::expressions::DataType), + { + #untracked + } + fn visit_expressions_mut( &mut self, visitor: &mut F, @@ -96,6 +81,22 @@ fn expand_ast_node(input: &DeriveInput) -> proc_macro2::TokenStream { } } +fn node_visitor(input: &DeriveInput, mutable: bool, paths: bool) -> proc_macro2::TokenStream { + let name = &input.ident; + match &input.data { + Data::Struct(data) => visit_fields(&data.fields, mutable, paths), + Data::Enum(data) => { + let arms = data.variants.iter().map(|variant| { + let (pattern, body) = + visit_variant_fields(name, &variant.ident, &variant.fields, mutable, paths); + quote!(#pattern => { #body }) + }); + quote!(match self { #(#arms),* }) + } + Data::Union(_) => quote!(), + } +} + /// Match serde's `rename_all = "snake_case"` behavior for Rust enum variants. fn serde_snake_case(name: &str) -> String { let mut snake_case = String::with_capacity(name.len()); @@ -108,7 +109,7 @@ fn serde_snake_case(name: &str) -> String { snake_case } -fn visit_fields(fields: &Fields, mutable: bool) -> proc_macro2::TokenStream { +fn visit_fields(fields: &Fields, mutable: bool, paths: bool) -> proc_macro2::TokenStream { match fields { Fields::Named(fields) => { let visits = fields.named.iter().filter_map(|field| { @@ -122,11 +123,15 @@ fn visit_fields(fields: &Fields, mutable: bool) -> proc_macro2::TokenStream { Some(if mutable { mutable_visit(&field.ty, access) } else { - let visit = immutable_visit(&field.ty, immutable_access); - quote! { - path.push(crate::ast_children::ChildPathSegment::Field(#field_name)); - #visit - path.pop(); + let visit = immutable_visit(&field.ty, immutable_access, paths); + if paths { + quote! { + path.push(crate::ast_children::ChildPathSegment::Field(#field_name)); + #visit + path.pop(); + } + } else { + visit } }) }); @@ -147,11 +152,15 @@ fn visit_fields(fields: &Fields, mutable: bool) -> proc_macro2::TokenStream { Some(if mutable { mutable_visit(&field.ty, access) } else { - let visit = immutable_visit(&field.ty, immutable_access); - quote! { - path.push(crate::ast_children::ChildPathSegment::Index(#index)); - #visit - path.pop(); + let visit = immutable_visit(&field.ty, immutable_access, paths); + if paths { + quote! { + path.push(crate::ast_children::ChildPathSegment::Index(#index)); + #visit + path.pop(); + } + } else { + visit } }) }); @@ -166,6 +175,7 @@ fn visit_variant_fields( variant_name: &syn::Ident, fields: &Fields, mutable: bool, + paths: bool, ) -> (proc_macro2::TokenStream, proc_macro2::TokenStream) { match fields { Fields::Named(fields) => { @@ -190,11 +200,15 @@ fn visit_variant_fields( Some(if mutable { mutable_visit(&field.ty, quote!(#ident)) } else { - let visit = immutable_visit(&field.ty, quote!(#ident)); - quote! { - path.push(crate::ast_children::ChildPathSegment::Field(#field_name)); - #visit - path.pop(); + let visit = immutable_visit(&field.ty, quote!(#ident), paths); + if paths { + quote! { + path.push(crate::ast_children::ChildPathSegment::Field(#field_name)); + #visit + path.pop(); + } + } else { + visit } }) }); @@ -220,8 +234,8 @@ fn visit_variant_fields( Some(if mutable { mutable_visit(&field.ty, quote!(#binding)) } else { - let visit = immutable_visit(&field.ty, quote!(#binding)); - if single_expression_payload { + let visit = immutable_visit(&field.ty, quote!(#binding), paths); + if single_expression_payload || !paths { visit } else { quote! { @@ -241,19 +255,30 @@ fn visit_variant_fields( } } -fn immutable_visit(ty: &Type, access: proc_macro2::TokenStream) -> proc_macro2::TokenStream { +fn immutable_visit( + ty: &Type, + access: proc_macro2::TokenStream, + paths: bool, +) -> proc_macro2::TokenStream { if is_expression(ty) { - return quote!(visitor(path, #access);); + return if paths { + quote!(visitor(path, #access);) + } else { + quote!(visitor(#access);) + }; } if let Some(inner) = container_inner(ty, "Option") { - let visit = immutable_visit(inner, quote!(value)); + let visit = immutable_visit(inner, quote!(value), paths); return quote!(if let Some(value) = (#access).as_ref() { #visit }); } if let Some(inner) = container_inner(ty, "Box") { - return immutable_visit(inner, quote!((#access).as_ref())); + return immutable_visit(inner, quote!((#access).as_ref()), paths); } if let Some(inner) = container_inner(ty, "Vec") { - let visit = immutable_visit(inner, quote!(value)); + let visit = immutable_visit(inner, quote!(value), paths); + if !paths { + return quote!(for value in (#access).iter() { #visit }); + } return quote! { for (index, value) in (#access).iter().enumerate() { path.push(crate::ast_children::ChildPathSegment::Index(index)); @@ -265,11 +290,15 @@ fn immutable_visit(ty: &Type, access: proc_macro2::TokenStream) -> proc_macro2:: if let Type::Tuple(tuple) = ty { let visits = tuple.elems.iter().enumerate().map(|(index, element)| { let tuple_index = syn::Index::from(index); - let visit = immutable_visit(element, quote!(&(#access).#tuple_index)); - quote! { - path.push(crate::ast_children::ChildPathSegment::Index(#index)); - #visit - path.pop(); + let visit = immutable_visit(element, quote!(&(#access).#tuple_index), paths); + if paths { + quote! { + path.push(crate::ast_children::ChildPathSegment::Index(#index)); + #visit + path.pop(); + } + } else { + visit } }); return quote!(#(#visits)*); @@ -277,7 +306,20 @@ fn immutable_visit(ty: &Type, access: proc_macro2::TokenStream) -> proc_macro2:: if is_scalar(ty) { return quote!(); } - quote!(crate::ast_children::AstNode::visit_expressions(#access, path, visitor);) + if paths { + quote!(crate::ast_children::AstNode::visit_expressions(#access, path, visitor);) + } else { + let visit_type = if matches!(ty, Type::Path(path) if path.path.segments.last().is_some_and(|segment| segment.ident == "DataType")) + { + quote!(type_visitor(#access);) + } else { + quote!() + }; + quote! { + #visit_type + crate::ast_children::AstNode::visit_syntax_untracked(#access, visitor, type_visitor); + } + } } fn mutable_visit(ty: &Type, access: proc_macro2::TokenStream) -> proc_macro2::TokenStream { 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 970c2935..08db2506 100644 --- a/crates/polyglot-sql-ffi/tests/ffi_tests.rs +++ b/crates/polyglot-sql-ffi/tests/ffi_tests.rs @@ -2050,7 +2050,7 @@ fn test_dialect_list_and_count() { let list: Vec = serde_json::from_str(&json).expect("invalid dialect list json"); let count = polyglot_dialect_count(); assert_eq!(list.len() as i32, count); - assert_eq!(count, 34); + assert_eq!(count, 35); let unique: BTreeSet<&str> = list.iter().map(String::as_str).collect(); assert_eq!(unique.len(), list.len()); assert!(list.iter().any(|d| d == "generic")); @@ -2176,3 +2176,24 @@ fn test_public_api_matches_capability_contract() { declared_available ); } + +#[test] +fn test_hana_round_trip_and_unsupported_conversion() { + let sql = c("SELECT * FROM t FOR JSON ('arraywrap' = 'NO')"); + let hana = c("hana"); + let duckdb = c("duckdb"); + let (status, output, error) = consume_result(polyglot_transpile( + sql.as_ptr(), + hana.as_ptr(), + hana.as_ptr(), + )); + assert_eq!(status, 0, "{error:?}"); + assert!(output.unwrap().contains("FOR JSON")); + let (status, _, error) = consume_result(polyglot_transpile( + sql.as_ptr(), + hana.as_ptr(), + duckdb.as_ptr(), + )); + assert_ne!(status, 0); + assert!(error.unwrap().contains("HANA")); +} diff --git a/crates/polyglot-sql-python/README.md b/crates/polyglot-sql-python/README.md index 1a6c33aa..aafe8159 100644 --- a/crates/polyglot-sql-python/README.md +++ b/crates/polyglot-sql-python/README.md @@ -329,7 +329,7 @@ All functions are exported from `polyglot_sql`. Current dialect names returned by `polyglot_sql.dialects()`: -`athena`, `bigquery`, `clickhouse`, `cockroachdb`, `datafusion`, `databricks`, `doris`, `dremio`, `drill`, `druid`, `duckdb`, `dune`, `exasol`, `fabric`, `generic`, `hive`, `materialize`, `mysql`, `oracle`, `postgres`, `presto`, `redshift`, `risingwave`, `singlestore`, `snowflake`, `solr`, `spark`, `sqlite`, `starrocks`, `tableau`, `teradata`, `tidb`, `trino`, `tsql`. +`athena`, `bigquery`, `clickhouse`, `cockroachdb`, `datafusion`, `databricks`, `doris`, `dremio`, `drill`, `druid`, `duckdb`, `dune`, `exasol`, `fabric`, `generic`, `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/python/polyglot_sql/__init__.py b/crates/polyglot-sql-python/python/polyglot_sql/__init__.py index b7860e97..5e58db5b 100644 --- a/crates/polyglot-sql-python/python/polyglot_sql/__init__.py +++ b/crates/polyglot-sql-python/python/polyglot_sql/__init__.py @@ -596,6 +596,11 @@ def dense_rank(): FromTimeZone, FromUnixtime, Function, + Upsert, + StorageProperty, + Hierarchy, + ViewParameter, + Call, FunctionEmits, GapFill, Generate, @@ -1302,6 +1307,11 @@ def dense_rank(): "Alias", "Cast", "Function", + "Upsert", + "StorageProperty", + "Hierarchy", + "ViewParameter", + "Call", "AggregateFunction", "WindowFunction", "From", diff --git a/crates/polyglot-sql-python/python/polyglot_sql/__init__.pyi b/crates/polyglot-sql-python/python/polyglot_sql/__init__.pyi index 67ea46da..d2c659e1 100644 --- a/crates/polyglot-sql-python/python/polyglot_sql/__init__.pyi +++ b/crates/polyglot-sql-python/python/polyglot_sql/__init__.pyi @@ -855,6 +855,11 @@ class FromBase(Expression): ... class FromTimeZone(Expression): ... class FromUnixtime(Expression): ... class Function(Expression): ... +class Upsert(Expression): ... +class StorageProperty(Expression): ... +class Hierarchy(Expression): ... +class ViewParameter(Expression): ... +class Call(Expression): ... class FunctionEmits(Expression): ... class GapFill(Expression): ... class Generate(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/src/expr_types.rs b/crates/polyglot-sql-python/src/expr_types.rs index 83dc2766..b5cb040c 100644 --- a/crates/polyglot-sql-python/src/expr_types.rs +++ b/crates/polyglot-sql-python/src/expr_types.rs @@ -140,6 +140,11 @@ define_expression_subclasses!( Exists, MemberOf, Function, + Upsert, + StorageProperty, + Hierarchy, + ViewParameter, + Call, AggregateFunction, WindowFunction, From, diff --git a/crates/polyglot-sql-python/tests/test_dialects.py b/crates/polyglot-sql-python/tests/test_dialects.py index 85cbfb48..07414ed3 100644 --- a/crates/polyglot-sql-python/tests/test_dialects.py +++ b/crates/polyglot-sql-python/tests/test_dialects.py @@ -17,6 +17,7 @@ "exasol", "fabric", "generic", + "hana", "hive", "materialize", "mysql", diff --git a/crates/polyglot-sql-python/tests/test_expression.py b/crates/polyglot-sql-python/tests/test_expression.py index 7d8c3832..9ba1357c 100644 --- a/crates/polyglot-sql-python/tests/test_expression.py +++ b/crates/polyglot-sql-python/tests/test_expression.py @@ -3,6 +3,31 @@ import polyglot_sql +def test_hana_uses_shared_expression_subclasses_and_accessors(): + expression = polyglot_sql.parse_one( + "SELECT LOCATE(value, 'a', 1, 2), JSON_VALUE(payload, '$.n') FROM records", + dialect="hana", + ) + locate = expression.find(polyglot_sql.Function) + assert isinstance(locate, polyglot_sql.Function) + assert locate.name == "LOCATE" + assert len(locate.expressions) == 4 + assert isinstance(locate.expressions[0], polyglot_sql.Column) + assert locate.expressions[0].name == "value" + json_value = expression.find(polyglot_sql.JSONValue) + assert isinstance(json_value, polyglot_sql.JSONValue) + assert json_value.name == "JSON_VALUE" + assert json_value.this.name == "payload" + + +def test_quoted_functions_use_shared_class_across_dialects(): + for dialect in ("hana", "postgresql"): + expression = polyglot_sql.parse_one('SELECT "f"(x) FROM t', dialect=dialect) + function = expression.find(polyglot_sql.Function) + assert isinstance(function, polyglot_sql.Function) + assert function.name == "f" + + def test_parse_one_returns_expression_object(): expr = polyglot_sql.parse_one("SELECT a FROM t WHERE b = 1", dialect="postgres") diff --git a/crates/polyglot-sql-python/tests/test_transpile.py b/crates/polyglot-sql-python/tests/test_transpile.py index 4bfd5068..741a581c 100644 --- a/crates/polyglot-sql-python/tests/test_transpile.py +++ b/crates/polyglot-sql-python/tests/test_transpile.py @@ -188,3 +188,13 @@ def run(_): results = list(executor.map(run, range(32))) assert all(result == results[0] for result in results) + + +def test_hana_native_clauses_and_source_semantics(): + sql = "SELECT * FROM t FOR JSON ('arraywrap' = 'NO')" + assert polyglot_sql.transpile(sql, read="hana", write="hana") == [sql] + assert polyglot_sql.transpile( + "SELECT LOCATE('abcabc', 'bc')", read="hana", write="duckdb" + ) == ["SELECT STRPOS('abcabc', 'bc')"] + with pytest.raises(polyglot_sql.TranspileError): + polyglot_sql.transpile(sql, read="hana", write="duckdb") diff --git a/crates/polyglot-sql-wasm/Cargo.toml b/crates/polyglot-sql-wasm/Cargo.toml index 5669a150..b7921011 100644 --- a/crates/polyglot-sql-wasm/Cargo.toml +++ b/crates/polyglot-sql-wasm/Cargo.toml @@ -73,7 +73,7 @@ all-dialects = [ "dialect-singlestore", "dialect-cockroachdb", "dialect-tidb", "dialect-druid", "dialect-solr", "dialect-tableau", "dialect-dune", "dialect-fabric", "dialect-drill", - "dialect-dremio", "dialect-exasol", "dialect-datafusion", + "dialect-dremio", "dialect-exasol", "dialect-datafusion", "dialect-hana", ] dialect-postgresql = ["polyglot-sql/dialect-postgresql"] dialect-mysql = ["polyglot-sql/dialect-mysql"] @@ -108,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-wasm/src/lib.rs b/crates/polyglot-sql-wasm/src/lib.rs index 0e5e3e8e..d310ae71 100644 --- a/crates/polyglot-sql-wasm/src/lib.rs +++ b/crates/polyglot-sql-wasm/src/lib.rs @@ -851,6 +851,8 @@ fn get_dialects_internal() -> Vec<&'static str> { dialects.push("exasol"); #[cfg(feature = "dialect-datafusion")] dialects.push("datafusion"); + #[cfg(feature = "dialect-hana")] + dialects.push("hana"); dialects } @@ -2927,11 +2929,24 @@ mod tests { let dialects: Vec = serde_json::from_str(&result).unwrap(); let unique: std::collections::BTreeSet<&str> = dialects.iter().map(String::as_str).collect(); - assert_eq!(dialects.len(), 34); + assert_eq!(dialects.len(), 35); assert_eq!(unique.len(), dialects.len()); assert!(unique.contains("generic")); assert!(unique.contains("postgresql")); assert!(unique.contains("datafusion")); + assert!(unique.contains("hana")); + } + + #[test] + #[cfg(feature = "dialect-hana")] + fn test_hana_discovery_and_native_parse() { + let dialects: Vec = serde_json::from_str(&get_dialects()).unwrap(); + assert!(dialects.iter().any(|dialect| dialect == "hana")); + let dialect = + polyglot_sql::dialects::Dialect::get(polyglot_sql::dialects::DialectType::HANA); + let sql = "SELECT * FROM t FOR JSON ('arraywrap' = 'NO')"; + let ast = dialect.parse(sql).unwrap(); + assert_eq!(dialect.generate(&ast[0]).unwrap(), sql); } #[test] diff --git a/crates/polyglot-sql/Cargo.toml b/crates/polyglot-sql/Cargo.toml index 29cceb09..6b1090dc 100644 --- a/crates/polyglot-sql/Cargo.toml +++ b/crates/polyglot-sql/Cargo.toml @@ -47,7 +47,7 @@ all-dialects = [ "dialect-singlestore", "dialect-cockroachdb", "dialect-tidb", "dialect-druid", "dialect-solr", "dialect-tableau", "dialect-dune", "dialect-fabric", "dialect-drill", - "dialect-dremio", "dialect-exasol", "dialect-datafusion", + "dialect-dremio", "dialect-exasol", "dialect-datafusion", "dialect-hana", ] dialect-postgresql = [] dialect-mysql = [] @@ -82,6 +82,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 348eeb48..408cb420 100644 --- a/crates/polyglot-sql/README.md +++ b/crates/polyglot-sql/README.md @@ -488,7 +488,7 @@ assert_eq!(err.line(), None); ## Supported Dialects -Athena, BigQuery, ClickHouse, CockroachDB, DataFusion, Databricks, Doris, Dremio, Drill, Druid, DuckDB, Dune, Exasol, Fabric, Generic SQL, Hive, Materialize, MySQL, Oracle, PostgreSQL, Presto, Redshift, RisingWave, SingleStore, Snowflake, Solr, Spark, SQLite, StarRocks, Tableau, Teradata, TiDB, Trino, TSQL +Athena, BigQuery, ClickHouse, CockroachDB, DataFusion, Databricks, Doris, Dremio, Drill, Druid, DuckDB, Dune, Exasol, Fabric, Generic SQL, SAP 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/benches/allocation_hotspots.rs b/crates/polyglot-sql/benches/allocation_hotspots.rs index 14d7a1f9..d88b0b9f 100644 --- a/crates/polyglot-sql/benches/allocation_hotspots.rs +++ b/crates/polyglot-sql/benches/allocation_hotspots.rs @@ -3,62 +3,15 @@ use stats_alloc::{Region, Stats, StatsAlloc, INSTRUMENTED_SYSTEM}; use std::alloc::System; use std::hint::black_box; -#[global_allocator] -static GLOBAL: &StatsAlloc = &INSTRUMENTED_SYSTEM; - -const SHORT_ASCII: &str = - "SELECT a, b, SUM(c) AS total FROM events WHERE created_at >= '2025-01-01' GROUP BY a, b"; -const UNICODE: &str = - "SELECT \"Kundennummer\", 'Gr\u{00fc}\u{00df}e aus Z\u{00fc}rich' AS \"Mitteilung\" FROM \"Bestellungen\" WHERE \"Stadt\" = 'M\u{00fc}nchen'"; -const COMMENT_AND_STRING_HEAVY: &str = r#" --- leading comment -SELECT 'alpha''beta' AS value, "quoted name", E'line\nvalue' -FROM events /* source comment */ -WHERE payload = '{"key":"value"}' -- trailing comment -"#; - -fn large_token_list() -> String { - let values = (0..20_000) - .map(|value| value.to_string()) - .collect::>() - .join(", "); - format!("SELECT * FROM events WHERE event_id IN ({values})") -} +mod common; -fn many_columns() -> String { - format!( - "SELECT {} FROM t", - (0..1_000) - .map(|index| format!("c{index}")) - .collect::>() - .join(", ") - ) -} - -fn nested_functions() -> String { - format!( - "SELECT {}x{} FROM t", - "COALESCE(".repeat(20), - ", NULL)".repeat(20) - ) -} - -fn large_strings() -> String { - format!( - "SELECT {} FROM t", - vec![format!("'{}'", "x".repeat(100)); 500].join(", ") - ) -} +use common::{ + large_strings, large_token_list, many_columns, many_numbers, nested_functions, + COMMENT_AND_STRING_HEAVY, SHORT_ASCII, UNICODE, +}; -fn many_numbers() -> String { - format!( - "SELECT {} FROM t", - (0..10_000) - .map(|value| value.to_string()) - .collect::>() - .join(", ") - ) -} +#[global_allocator] +static GLOBAL: &StatsAlloc = &INSTRUMENTED_SYSTEM; fn measure(operation: impl FnOnce() -> T) -> Stats { let region = Region::new(GLOBAL); @@ -83,6 +36,13 @@ fn main() { let functions = nested_functions(); let strings = large_strings(); let numbers = many_numbers(); + let generic_functions = format!( + "SELECT {} FROM t", + (0..1_000) + .map(|i| format!("my_udf(c{i}) AS v{i}")) + .collect::>() + .join(", ") + ); let inputs = [ ("short_ascii", SHORT_ASCII), ("unicode", UNICODE), @@ -92,6 +52,7 @@ fn main() { ("nested_functions", functions.as_str()), ("large_strings", strings.as_str()), ("many_numbers", numbers.as_str()), + ("generic_functions", generic_functions.as_str()), ]; black_box(dialect.tokenize(SHORT_ASCII).unwrap()); @@ -119,4 +80,37 @@ fn main() { 0, measure(|| Dialect::get(DialectType::PostgreSQL)), ); + + for depth in [10, 20, 40, 80] { + let mut sql = "SELECT 1".to_owned(); + for _ in 0..depth { + sql = format!("SELECT ({sql})"); + } + for kind in [DialectType::PostgreSQL, DialectType::HANA] { + let dialect = Dialect::get(kind); + black_box(dialect.parse(&sql).unwrap()); + print_stats( + &format!("nested_{depth}_{kind}"), + "parse", + sql.len(), + measure(|| dialect.parse(&sql).unwrap()), + ); + } + } + + #[cfg(feature = "transpile")] + { + let options = polyglot_sql::TranspileOptions::strict(); + for (name, sql) in common::transpilation_queries() { + let run = + || polyglot_sql::transpile_with_by_name(&sql, "hana", "duckdb", &options).unwrap(); + black_box(run()); + print_stats( + &format!("hana_{name}"), + "transpile", + sql.len(), + measure(run), + ); + } + } } diff --git a/crates/polyglot-sql/benches/common/mod.rs b/crates/polyglot-sql/benches/common/mod.rs new file mode 100644 index 00000000..d2a5da07 --- /dev/null +++ b/crates/polyglot-sql/benches/common/mod.rs @@ -0,0 +1,93 @@ +//! SQL inputs shared by the latency and allocation hotspot benchmarks. +//! Dialect selection stays in the callers, so both suites use identical fixtures. + +pub(super) const SHORT_ASCII: &str = + "SELECT a, b, SUM(c) AS total FROM events WHERE created_at >= '2025-01-01' GROUP BY a, b"; + +pub(super) const UNICODE: &str = + "SELECT \"Kundennummer\", 'Gr\u{00fc}\u{00df}e aus Z\u{00fc}rich' AS \"Mitteilung\" FROM \"Bestellungen\" WHERE \"Stadt\" = 'M\u{00fc}nchen'"; + +pub(super) const COMMENT_AND_STRING_HEAVY: &str = r#" +-- leading comment +SELECT 'alpha''beta' AS value, "quoted name", E'line\nvalue' +FROM events /* source comment */ +WHERE payload = '{"key":"value"}' -- trailing comment +"#; + +pub(super) fn large_token_list() -> String { + let values = (0..20_000) + .map(|value| value.to_string()) + .collect::>() + .join(", "); + format!("SELECT * FROM events WHERE event_id IN ({values})") +} + +pub(super) fn many_columns() -> String { + format!( + "SELECT {} FROM t", + (0..1_000) + .map(|index| format!("c{index}")) + .collect::>() + .join(", ") + ) +} + +pub(super) fn nested_functions() -> String { + format!( + "SELECT {}x{} FROM t", + "COALESCE(".repeat(20), + ", NULL)".repeat(20) + ) +} + +pub(super) fn large_strings() -> String { + format!( + "SELECT {} FROM t", + vec![format!("'{}'", "x".repeat(100)); 500].join(", ") + ) +} + +pub(super) fn many_numbers() -> String { + format!( + "SELECT {} FROM t", + (0..10_000) + .map(|value| value.to_string()) + .collect::>() + .join(", ") + ) +} + +#[cfg(feature = "transpile")] +pub(super) fn transpilation_queries() -> Vec<(&'static str, String)> { + vec![ + ("short", "SELECT COALESCE(x, 0) FROM t".to_owned()), + ( + "wide", + format!( + "SELECT {} FROM t", + (0..100) + .map(|i| format!("COALESCE(c{i}, 0) AS v{i}")) + .collect::>() + .join(", ") + ), + ), + ( + "nested", + format!( + "SELECT {}x{} FROM t", + "COALESCE(".repeat(20), + ", 0)".repeat(20) + ), + ), + ( + "casts", + format!( + "SELECT {}", + (0..100) + .map(|i| format!("CAST({i}.123 AS DECIMAL(10, 2)) AS v{i}")) + .collect::>() + .join(", ") + ), + ), + ] +} diff --git a/crates/polyglot-sql/benches/performance_hotspots.rs b/crates/polyglot-sql/benches/performance_hotspots.rs index 48610284..64122b5c 100644 --- a/crates/polyglot-sql/benches/performance_hotspots.rs +++ b/crates/polyglot-sql/benches/performance_hotspots.rs @@ -4,18 +4,12 @@ use polyglot_sql::parser::{Parser, ParserConfig}; use polyglot_sql::ComplexityGuardOptions; use std::time::Duration; -const SHORT_ASCII: &str = - "SELECT a, b, SUM(c) AS total FROM events WHERE created_at >= '2025-01-01' GROUP BY a, b"; +mod common; -const UNICODE: &str = - "SELECT \"Kundennummer\", 'Gr\u{00fc}\u{00df}e aus Z\u{00fc}rich' AS \"Mitteilung\" FROM \"Bestellungen\" WHERE \"Stadt\" = 'M\u{00fc}nchen'"; - -const COMMENT_AND_STRING_HEAVY: &str = r#" --- leading comment -SELECT 'alpha''beta' AS value, "quoted name", E'line\nvalue' -FROM events /* source comment */ -WHERE payload = '{"key":"value"}' -- trailing comment -"#; +use common::{ + large_strings, large_token_list, many_columns, many_numbers, nested_functions, + COMMENT_AND_STRING_HEAVY, SHORT_ASCII, UNICODE, +}; const TPCH_STYLE: &str = r#" WITH regional_sales AS ( @@ -36,49 +30,6 @@ WHERE ranked.position <= 10 ORDER BY ranked.revenue DESC "#; -fn large_token_list() -> String { - let values = (0..20_000) - .map(|value| value.to_string()) - .collect::>() - .join(", "); - format!("SELECT * FROM events WHERE event_id IN ({values})") -} - -fn many_columns() -> String { - format!( - "SELECT {} FROM t", - (0..1_000) - .map(|index| format!("c{index}")) - .collect::>() - .join(", ") - ) -} - -fn nested_functions() -> String { - format!( - "SELECT {}x{} FROM t", - "COALESCE(".repeat(20), - ", NULL)".repeat(20) - ) -} - -fn large_strings() -> String { - format!( - "SELECT {} FROM t", - vec![format!("'{}'", "x".repeat(100)); 500].join(", ") - ) -} - -fn many_numbers() -> String { - format!( - "SELECT {} FROM t", - (0..10_000) - .map(|value| value.to_string()) - .collect::>() - .join(", ") - ) -} - fn disabled_guards() -> ComplexityGuardOptions { ComplexityGuardOptions { max_parser_depth: None, @@ -269,11 +220,49 @@ fn bench_validation_and_analysis(c: &mut Criterion) { let _ = c; } +fn bench_hana_nested_queries(c: &mut Criterion) { + let mut group = c.benchmark_group("nested_query_dialects"); + for depth in [10, 20, 40, 80] { + let mut sql = "SELECT 1".to_owned(); + for _ in 0..depth { + sql = format!("SELECT ({sql})"); + } + for kind in [DialectType::PostgreSQL, DialectType::HANA] { + let dialect = Dialect::get(kind); + group.bench_with_input(BenchmarkId::new(kind.to_string(), depth), &sql, |b, sql| { + b.iter(|| dialect.parse(black_box(sql)).unwrap()); + }); + } + } + group.finish(); +} + +fn bench_hana_source_validation(c: &mut Criterion) { + #[cfg(feature = "transpile")] + { + let options = polyglot_sql::TranspileOptions::strict(); + let mut group = c.benchmark_group("hana_source_validation"); + for (name, sql) in common::transpilation_queries() { + group.bench_with_input(BenchmarkId::from_parameter(name), &sql, |b, sql| { + b.iter(|| { + polyglot_sql::transpile_with_by_name(black_box(sql), "hana", "duckdb", &options) + .unwrap() + }); + }); + } + group.finish(); + } + #[cfg(not(feature = "transpile"))] + let _ = c; +} + criterion_group!( benches, bench_dialect_construction, bench_fresh_vs_reused_dialect, bench_tokenize_and_parse, - bench_validation_and_analysis + bench_validation_and_analysis, + bench_hana_nested_queries, + bench_hana_source_validation ); criterion_main!(benches); diff --git a/crates/polyglot-sql/src/ast_children.rs b/crates/polyglot-sql/src/ast_children.rs index ce438197..43142a83 100644 --- a/crates/polyglot-sql/src/ast_children.rs +++ b/crates/polyglot-sql/src/ast_children.rs @@ -15,7 +15,7 @@ allow(dead_code) )] -use crate::expressions::Expression; +use crate::expressions::{DataType, Expression}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ChildPathSegment { @@ -24,6 +24,13 @@ pub(crate) enum ChildPathSegment { } pub(crate) trait AstNode { + /// Visit expression children and embedded syntax types without constructing paths. + /// Expression descendants are left to the caller; nested type fields are visited here. + fn visit_syntax_untracked<'ast, F, T>(&'ast self, visitor: &mut F, type_visitor: &mut T) + where + F: FnMut(&'ast Expression), + T: FnMut(&'ast DataType); + fn visit_expressions<'ast, F>(&'ast self, path: &mut Vec, visitor: &mut F) where F: FnMut(&[ChildPathSegment], &'ast Expression); @@ -48,11 +55,76 @@ pub(crate) fn for_each_child_mut( expression.visit_expressions_mut(&mut visitor); } +pub(crate) fn for_each_child_untracked<'ast>( + expression: &'ast Expression, + mut visitor: impl FnMut(&'ast Expression), +) { + expression.visit_syntax_untracked(&mut visitor, &mut |_| {}); +} + +#[cfg(feature = "transpile")] +pub(crate) fn for_each_child_and_type_untracked<'ast>( + expression: &'ast Expression, + mut visitor: impl FnMut(&'ast Expression), + mut type_visitor: impl FnMut(&'ast DataType), +) { + expression.visit_syntax_untracked(&mut visitor, &mut type_visitor); +} + #[cfg(test)] mod tests { use super::*; use crate::Parser; + #[cfg(all(feature = "transpile", feature = "dialect-hana"))] + #[test] + fn syntax_visitor_covers_embedded_types_once_and_skips_inferred_metadata() { + use crate::expressions::HanaDataType; + use crate::{Dialect, DialectType}; + let hana = Dialect::get(DialectType::HANA); + for sql in [ + "ALTER TABLE t ADD (x SMALLDECIMAL ARRAY)", + "CREATE TABLE t (x SMALLDECIMAL ARRAY)", + "SELECT CAST(xs AS SMALLDECIMAL ARRAY) FROM t", + ] { + let ast = hana.parse(sql).unwrap(); + let mut pending = vec![&ast[0]]; + let mut names = Vec::new(); + let mut arrays = 0; + while let Some(node) = pending.pop() { + for_each_child_and_type_untracked( + node, + |child| pending.push(child), + |dt| match dt { + DataType::Hana { hana_type } => names.push(hana_type.name.as_str()), + DataType::Array { .. } => arrays += 1, + _ => {} + }, + ); + } + assert_eq!(names, ["SMALLDECIMAL"], "{sql}"); + assert_eq!(arrays, 1, "{sql}"); + } + let mut node = hana.parse("CAST(1 AS INT)").unwrap().remove(0); + node.set_inferred_type(DataType::Hana { + hana_type: HanaDataType { + name: "SMALLDECIMAL".into(), + parameters: vec![], + }, + }); + let mut names = Vec::new(); + for_each_child_and_type_untracked( + &node, + |_| {}, + |dt| { + if let DataType::Hana { hana_type } = dt { + names.push(hana_type.name.as_str()); + } + }, + ); + assert_eq!(names, ["INT"]); + } + #[test] fn immutable_and_mutable_visitors_cover_the_same_slots() { let mut expression = Parser::parse_sql( @@ -64,6 +136,19 @@ mod tests { let mut immutable_paths = Vec::new(); for_each_child(&expression, |path, _| immutable_paths.push(path.to_vec())); + let mut tracked = Vec::new(); + for_each_child(&expression, |_, child| { + tracked.push(child as *const Expression) + }); + let mut untracked = Vec::new(); + for_each_child_untracked(&expression, |child| { + untracked.push(child as *const Expression) + }); + assert_eq!( + tracked, untracked, + "path-free traversal preserves child identity and order" + ); + let mut mutable_count = 0; for_each_child_mut(&mut expression, |_| mutable_count += 1); diff --git a/crates/polyglot-sql/src/ast_mutation.rs b/crates/polyglot-sql/src/ast_mutation.rs index af217504..9a4b8fae 100644 --- a/crates/polyglot-sql/src/ast_mutation.rs +++ b/crates/polyglot-sql/src/ast_mutation.rs @@ -91,6 +91,8 @@ pub(crate) fn binary(kind: BinaryKind, left: Expression, right: Expression) -> E inferred_type: None, })), BinaryKind::RLike => Expression::RegexpLike(Box::new(RegexpFunc { + options: None, + this: operation.left, pattern: operation.right, flags: None, @@ -410,6 +412,7 @@ pub(crate) fn append_window( pub(crate) fn append_lock(expression: &mut Expression, kind: LockKind) -> Result<()> { as_select_mut(expression, "lock")?.locks.push(Lock { + ignore_locked: false, update: match kind { LockKind::Update => Some(Box::new(Expression::Boolean(BooleanLiteral { value: true, @@ -428,6 +431,8 @@ pub(crate) fn append_hint(expression: &mut Expression, text: String) -> Result<( select .hint .get_or_insert_with(|| Hint { + source_dialect: None, + expressions: Vec::new(), }) .expressions @@ -453,6 +458,7 @@ pub(crate) fn create_table_as( temporary, or_replace: replace, table_modifier: None, + source_dialect: None, as_select: Some(query), as_select_parenthesized: false, on_commit: None, diff --git a/crates/polyglot-sql/src/ast_transforms.rs b/crates/polyglot-sql/src/ast_transforms.rs index 780e2caf..9b0b301a 100644 --- a/crates/polyglot-sql/src/ast_transforms.rs +++ b/crates/polyglot-sql/src/ast_transforms.rs @@ -473,6 +473,9 @@ pub fn get_table_names(expr: &Expression) -> Vec { names.push(name); } } + Expression::Upsert(upsert) => { + push_table_ref_name(&upsert.table, &cte_aliases, &mut names); + } Expression::Insert(insert) => { push_table_ref_name(&insert.table, &cte_aliases, &mut names); } @@ -532,12 +535,7 @@ pub fn get_identifiers(expr: &Expression) -> Vec<&Expression> { /// Collect all function call nodes in the expression tree. pub fn get_functions(expr: &Expression) -> Vec<&Expression> { - expr.find_all(|e| { - matches!( - e, - Expression::Function(_) | Expression::AggregateFunction(_) - ) - }) + expr.find_all(crate::traversal::is_function) } /// Collect all literal value nodes in the expression tree. diff --git a/crates/polyglot-sql/src/dialects/bigquery.rs b/crates/polyglot-sql/src/dialects/bigquery.rs index b3ccaa47..221bdd91 100644 --- a/crates/polyglot-sql/src/dialects/bigquery.rs +++ b/crates/polyglot-sql/src/dialects/bigquery.rs @@ -1066,6 +1066,10 @@ impl BigQueryDialect { Ok(Expression::CurrentDate(crate::expressions::CurrentDate)) } "CURRENT_DATE" => Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "CURRENT_DATE".to_string(), args: f.args, distinct: false, @@ -1391,6 +1395,10 @@ impl BigQueryDialect { | "SAFE.PARSE_TIMESTAMP" => { let args = self.normalize_time_format_args(f.args); Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: f.name, args, distinct: f.distinct, @@ -1425,6 +1433,9 @@ impl BigQueryDialect { _ => path, }; Ok(Expression::JsonExtract(Box::new(JsonExtractFunc { + options: None, + source_dialect: None, + this, path: json_path, returning: None, diff --git a/crates/polyglot-sql/src/dialects/clickhouse.rs b/crates/polyglot-sql/src/dialects/clickhouse.rs index 030cef5c..4e9ea6dd 100644 --- a/crates/polyglot-sql/src/dialects/clickhouse.rs +++ b/crates/polyglot-sql/src/dialects/clickhouse.rs @@ -601,6 +601,8 @@ impl ClickHouseDialect { // STDDEV -> stddevSamp in ClickHouse (sample stddev) "STDDEV" if !f.args.is_empty() => { Ok(Expression::AggregateFunction(Box::new(AggregateFunction { + source_dialect: None, + name: "stddevSamp".to_string(), args: f.args, distinct: f.distinct, @@ -615,6 +617,8 @@ impl ClickHouseDialect { // STDDEV_POP -> stddevPop "STDDEV_POP" if !f.args.is_empty() => { Ok(Expression::AggregateFunction(Box::new(AggregateFunction { + source_dialect: None, + name: "stddevPop".to_string(), args: f.args, distinct: f.distinct, @@ -629,6 +633,8 @@ impl ClickHouseDialect { // VARIANCE -> varSamp in ClickHouse "VARIANCE" if !f.args.is_empty() => { Ok(Expression::AggregateFunction(Box::new(AggregateFunction { + source_dialect: None, + name: "varSamp".to_string(), args: f.args, distinct: f.distinct, @@ -643,6 +649,8 @@ impl ClickHouseDialect { // VAR_POP -> varPop "VAR_POP" if !f.args.is_empty() => { Ok(Expression::AggregateFunction(Box::new(AggregateFunction { + source_dialect: None, + name: "varPop".to_string(), args: f.args, distinct: f.distinct, @@ -657,6 +665,8 @@ impl ClickHouseDialect { // MEDIAN -> median "MEDIAN" if !f.args.is_empty() => { Ok(Expression::AggregateFunction(Box::new(AggregateFunction { + source_dialect: None, + name: "median".to_string(), args: f.args, distinct: f.distinct, diff --git a/crates/polyglot-sql/src/dialects/databricks.rs b/crates/polyglot-sql/src/dialects/databricks.rs index 1ae441b3..27d95f9e 100644 --- a/crates/polyglot-sql/src/dialects/databricks.rs +++ b/crates/polyglot-sql/src/dialects/databricks.rs @@ -828,6 +828,8 @@ impl DatabricksDialect { // APPROX_DISTINCT -> APPROX_COUNT_DISTINCT "APPROX_DISTINCT" if !f.args.is_empty() => { Ok(Expression::AggregateFunction(Box::new(AggregateFunction { + source_dialect: None, + name: "APPROX_COUNT_DISTINCT".to_string(), args: f.args, distinct: f.distinct, diff --git a/crates/polyglot-sql/src/dialects/duckdb.rs b/crates/polyglot-sql/src/dialects/duckdb.rs index a49e5e78..02ac4c58 100644 --- a/crates/polyglot-sql/src/dialects/duckdb.rs +++ b/crates/polyglot-sql/src/dialects/duckdb.rs @@ -527,6 +527,10 @@ impl DialectImpl for DuckDBDialect { // ===== Time functions ===== // CurrentTime -> CURRENT_TIME (no parens in DuckDB) Expression::CurrentTime(_) => Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "CURRENT_TIME".to_string(), args: vec![], distinct: false, @@ -822,6 +826,9 @@ impl DialectImpl for DuckDBDialect { other => other, }; Ok(Expression::JsonExtract(Box::new(JsonExtractFunc { + options: None, + source_dialect: None, + this: *e.this, path, returning: None, @@ -1073,6 +1080,8 @@ impl DialectImpl for DuckDBDialect { let filter = p.filter.clone(); Ok(Expression::AggregateFunction(Box::new( crate::expressions::AggregateFunction { + source_dialect: None, + name: "QUANTILE_CONT".to_string(), args: vec![column, percentile], distinct: false, @@ -1094,6 +1103,8 @@ impl DialectImpl for DuckDBDialect { let filter = p.filter.clone(); Ok(Expression::AggregateFunction(Box::new( crate::expressions::AggregateFunction { + source_dialect: None, + name: "QUANTILE_DISC".to_string(), args: vec![column, percentile], distinct: false, @@ -1119,6 +1130,8 @@ impl DialectImpl for DuckDBDialect { match (column, quantile) { (Some(col), Some(q)) => Ok(Expression::AggregateFunction(Box::new( crate::expressions::AggregateFunction { + source_dialect: None, + name: new_name.to_string(), args: vec![col, q], distinct: false, @@ -2098,6 +2111,9 @@ impl DuckDBDialect { let path = args.pop().unwrap(); let this = args.pop().unwrap(); Ok(Expression::JsonExtract(Box::new(JsonExtractFunc { + options: None, + source_dialect: None, + this, path, returning: None, @@ -2116,6 +2132,9 @@ impl DuckDBDialect { let path = args.pop().unwrap(); let this = args.pop().unwrap(); Ok(Expression::JsonExtractScalar(Box::new(JsonExtractFunc { + options: None, + source_dialect: None, + this, path, returning: None, @@ -2337,6 +2356,9 @@ impl DuckDBDialect { let this = args.remove(0); let path = args.remove(0); Ok(Expression::JsonExtract(Box::new(JsonExtractFunc { + options: None, + source_dialect: None, + this, path, returning: None, @@ -2355,6 +2377,9 @@ impl DuckDBDialect { let this = args.remove(0); let path = args.remove(0); Ok(Expression::JsonExtractScalar(Box::new(JsonExtractFunc { + options: None, + source_dialect: None, + this, path, returning: None, @@ -4333,6 +4358,9 @@ impl DuckDBDialect { }; let json_path = format!("$.{}", s); Ok(Expression::JsonExtract(Box::new(JsonExtractFunc { + options: None, + source_dialect: None, + this, path: Expression::Literal(Box::new(Literal::String(json_path))), returning: None, @@ -4364,6 +4392,9 @@ impl DuckDBDialect { _ => { // Unknown key type - use JSON arrow Ok(Expression::JsonExtract(Box::new(JsonExtractFunc { + options: None, + source_dialect: None, + this, path: Expression::JSONPath(Box::new(JSONPath { expressions: vec![ @@ -4412,6 +4443,9 @@ impl DuckDBDialect { _ => path, }; Ok(Expression::JsonExtract(Box::new(JsonExtractFunc { + options: None, + source_dialect: None, + this, path: json_path, returning: None, diff --git a/crates/polyglot-sql/src/dialects/hana.rs b/crates/polyglot-sql/src/dialects/hana.rs new file mode 100644 index 00000000..78a3d94c --- /dev/null +++ b/crates/polyglot-sql/src/dialects/hana.rs @@ -0,0 +1,39 @@ +//! SAP HANA Cloud and Platform SQL dialect. +//! +//! Registration is based on Torsten Glunde's contribution in PR #276. +//! Syntax and semantic handling live in the shared parser, AST, and generator. + +use super::{DialectImpl, DialectType}; +#[cfg(feature = "generate")] +use crate::generator::{GeneratorConfig, IdentifierQuoteStyle, NormalizeFunctions}; +use crate::tokens::{TokenType, TokenizerConfig}; + +pub struct HanaDialect; + +impl DialectImpl for HanaDialect { + fn dialect_type(&self) -> DialectType { + DialectType::HANA + } + + fn tokenizer_config(&self) -> TokenizerConfig { + let mut config = TokenizerConfig::default(); + config.identifiers.insert('"', '"'); + config.nested_comments = false; + config + .keywords + .insert("UNKNOWN".to_owned(), TokenType::Null); + config + } + + #[cfg(feature = "generate")] + fn generator_config(&self) -> GeneratorConfig { + GeneratorConfig { + dialect: Some(DialectType::HANA), + identifier_quote: '"', + identifier_quote_style: IdentifierQuoteStyle::DOUBLE_QUOTE, + normalize_functions: NormalizeFunctions::None, + alter_table_include_column_keyword: false, + ..Default::default() + } + } +} diff --git a/crates/polyglot-sql/src/dialects/mod.rs b/crates/polyglot-sql/src/dialects/mod.rs index 61cdc84c..4f89a2c6 100644 --- a/crates/polyglot-sql/src/dialects/mod.rs +++ b/crates/polyglot-sql/src/dialects/mod.rs @@ -51,6 +51,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")] @@ -120,6 +122,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")] @@ -204,6 +208,7 @@ use std::sync::{Arc, LazyLock, RwLock}; /// Dialect names are case-insensitive when parsed from strings via [`FromStr`]. /// Some dialects accept aliases (e.g., "mssql" and "sqlserver" both resolve to [`TSQL`](DialectType::TSQL)). #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(ts_rs::TS))] #[serde(rename_all = "lowercase")] pub enum DialectType { /// Standard SQL with no dialect-specific behavior (default). @@ -274,6 +279,8 @@ pub enum DialectType { Exasol, /// Apache DataFusion -- Arrow-based query engine with modern SQL extensions. DataFusion, + /// SAP HANA Cloud and SAP HANA Platform SQL. + HANA, } impl DialectType { @@ -330,6 +337,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"), } } } @@ -373,6 +381,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, @@ -688,6 +697,7 @@ where // semantics even though all physical children are visible to traversal APIs. fn uses_generated_dispatch(expression: &Expression) -> bool { match expression { + Expression::CreateTable(_) => true, Expression::Select(select) => { select.joins.is_empty() && select.with.is_none() @@ -747,6 +757,25 @@ where | Expression::Like(_) | Expression::ILike(_) | Expression::Function(_) + | Expression::AggregateFunction(_) + | Expression::JSONValue(_) + | Expression::JSONTable(_) + | Expression::JSONColumnDef(_) + | Expression::JsonQuery(_) + | Expression::RegexpLike(_) + | Expression::RegexpReplace(_) + | Expression::RegexpExtract(_) + | Expression::RegexpInstr(_) + | Expression::RegexpCount(_) + | Expression::Cube(_) + | Expression::Rollup(_) + | Expression::GroupingSets(_) + | Expression::Hint(_) + | Expression::PartitionByProperty(_) + | Expression::StorageProperty(_) + | Expression::Hierarchy(_) + | Expression::ViewParameter(_) + | Expression::Call(_) | Expression::Lead(_) | Expression::Lag(_) | Expression::Array(_) @@ -1048,6 +1077,10 @@ where // First recursively transform children, then apply the transform function let expr = match expr { Expression::Select(mut select) => { + select.query_hints = std::mem::take(&mut select.query_hints) + .into_iter() + .map(|hint| transform_recursive(hint, transform_fn)) + .collect::>>()?; select.expressions = select .expressions .into_iter() @@ -1998,6 +2031,18 @@ where Expression::UnixToTime(e) } + Expression::Upsert(mut upsert) => { + upsert.table = transform_table_ref_recursive(upsert.table, transform_fn)?; + upsert.source = transform_recursive(upsert.source, transform_fn)?; + if let Some(partition) = upsert.partition.take() { + upsert.partition = Some(transform_recursive(partition, transform_fn)?); + } + if let Some(condition) = upsert.condition.take() { + upsert.condition = Some(transform_recursive(condition, transform_fn)?); + } + Expression::Upsert(upsert) + } + // CreateTable: recurse into column defaults, on_update expressions, and data types Expression::CreateTable(mut ct) => { for col in &mut ct.columns { @@ -2387,6 +2432,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. @@ -2469,6 +2515,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), } } @@ -3182,7 +3230,17 @@ impl Dialect { // Apply preprocessing transforms based on dialect let preprocessed = self.preprocess(expr)?; // Then apply recursive transformation - transform_recursive(preprocessed, &self.transformer) + transform_recursive(preprocessed, &|node| { + // Calls with retained source semantics are lowered by the generator; + // ordinary AST visitors and optimizer passes still see their shared node kind. + if node.source_dialect().is_some() + || matches!(&node, Expression::Function(f) if !f.qualified_name.is_empty()) + { + Ok(node) + } else { + (self.transformer)(node) + } + }) } /// Apply dialect-specific preprocessing transforms @@ -3465,6 +3523,15 @@ impl Dialect { expressions .into_iter() .map(|expr| { + // Reject unsupported source semantics before normalization can + // erase them. This AST-only pass shares conversion decisions with + // the generator and never renders throwaway SQL. + Self::reject_hana_source_semantics( + &expr, + self.dialect_type, + target_dialect.generator_config.dialect.unwrap_or_default(), + )?; + // DuckDB source: normalize VARCHAR/CHAR to TEXT (DuckDB doesn't support // VARCHAR length constraints). This emulates Python sqlglot's DuckDB parser // where VARCHAR_LENGTH = None and VARCHAR maps to TEXT. @@ -3608,6 +3675,10 @@ impl Dialect { )); return Ok(Expression::Function(Box::new( crate::expressions::Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: f.name.clone(), args: new_args, distinct: f.distinct, @@ -3979,6 +4050,8 @@ impl Dialect { }; Ok(Expression::AggregateFunction(Box::new( crate::expressions::AggregateFunction { + source_dialect: None, + name: "COUNT_BIG".to_string(), args, distinct: c.distinct, @@ -4281,6 +4354,42 @@ impl Dialect { && select.for_json.is_empty() } + fn reject_hana_source_semantics( + expression: &Expression, + source: DialectType, + target: DialectType, + ) -> Result<()> { + if source != DialectType::HANA || target == DialectType::HANA { + return Ok(()); + } + let mut pending = vec![expression]; + while let Some(node) = pending.pop() { + if node.source_dialect() == Some(DialectType::HANA) { + Generator::validate_hana_source_node(node, target)?; + } + // A supported outer call does not establish support for its arguments. + // Types in column definitions, casts, routine signatures, etc. are + // embedded syntax, not Expression children. Validate each type once, + // before normalization can replace its enclosing ARRAY/STRUCT type. + // A scalar native CAST already uses a value-aware conversion check + // (e.g. a HANA TINYINT literal can fit a target signed integer). + let cast_type_validated = matches!(node, Expression::Cast(c) + if matches!(c.to, crate::expressions::DataType::Hana { .. })); + let mut type_result = Ok(()); + crate::ast_children::for_each_child_and_type_untracked( + node, + |child| pending.push(child), + |data_type| { + if !cast_type_validated && type_result.is_ok() { + type_result = Generator::validate_hana_source_type(data_type, target); + } + }, + ); + type_result?; + } + Ok(()) + } + fn reject_clickhouse_session_semantics( expr: &Expression, source: DialectType, @@ -10064,6 +10173,157 @@ impl Dialect { mod tests { use super::*; + #[cfg(all( + feature = "transpile", + feature = "dialect-hana", + feature = "dialect-duckdb", + feature = "dialect-trino" + ))] + #[test] + fn hana_validation_and_generation_agree_after_ast_serialization() { + let hana = Dialect::get(DialectType::HANA); + for (sql, duckdb, trino) in [ + ("SELECT COALESCE(IFNULL(x, 0), 1) FROM t", true, true), + ("SELECT LOCATE('abcabc', 'bc', 2)", true, true), + ("SELECT LOCATE('abcabc', 'bc', 1, 2)", false, true), + ("SELECT SUBSTRING('abc', -1, -2)", true, true), + ("SELECT TO_DECIMAL(12.345, 5, 2)", true, true), + ( + "SELECT CAST(12.345 AS DECIMAL(5)), CAST(-12.9 AS INT)", + true, + true, + ), + ("SELECT TO_DATE('2024-01-15', 'YYYY-MM-DD')", true, true), + ( + "SELECT TO_TIMESTAMP('2024-01-15', 'YYYY-MM-DD')", + false, + true, + ), + ( + "SELECT TO_VARCHAR(DATE '2024-01-15', 'YYYY-MM-DD')", + true, + true, + ), + ("SELECT CURRENT_UTCDATE", true, true), + ( + "SELECT CURRENT_UTCTIMESTAMP(7), CURRENT_UTCTIME", + false, + true, + ), + ("CREATE TABLE t (n INT, xs INT ARRAY)", true, true), + ("CREATE TABLE t (n SMALLDECIMAL ARRAY)", false, false), + ("ALTER TABLE t ADD (n SMALLDECIMAL ARRAY)", false, false), + ("ALTER TABLE t ADD (n INT ARRAY)", true, true), + ("SELECT COALESCE(ADD_DAYS(d, 1), d) FROM t", false, false), + ("SELECT COALESCE(CAST(x AS INT), 0) FROM t", false, false), + ( + "SELECT COALESCE(JSON_VALUE(j, '$.a'), 'x') FROM t", + false, + false, + ), + ( + "SELECT COALESCE(SUBSTR_REGEXPR('a' IN s), 'x') FROM t", + false, + false, + ), + ( + "SELECT COALESCE(STRING_AGG(x, ','), '') FROM t", + false, + false, + ), + ( + "CREATE TABLE t (n INT DEFAULT ADD_DAYS(d, 1))", + false, + false, + ), + ("SELECT * FROM t FOR JSON", false, false), + ("CREATE COLUMN TABLE t (n INT)", false, false), + ("SELECT * FROM t WITH HINT (NO_INLINE)", false, false), + ] { + let ast = hana.parse(sql).unwrap().remove(0); + let json = serde_json::to_string(&ast).unwrap(); + let ast: Expression = serde_json::from_str(&json).unwrap(); + for (target, supported) in [(DialectType::DuckDB, duckdb), (DialectType::Trino, trino)] + { + assert_eq!( + Dialect::reject_hana_source_semantics(&ast, DialectType::HANA, target).is_ok(), + supported, + "validation: {sql} -> {target}" + ); + assert_eq!( + Dialect::get(target).generate(&ast).is_ok(), + supported, + "generation: {sql} -> {target}" + ); + } + assert!(Dialect::reject_hana_source_semantics( + &ast, + DialectType::HANA, + DialectType::HANA + ) + .is_ok()); + } + } + + #[cfg(all( + feature = "transpile", + feature = "dialect-hana", + feature = "dialect-duckdb" + ))] + #[test] + fn hana_rejects_source_semantics_before_preprocessing_can_erase_them() { + use std::sync::atomic::{AtomicBool, Ordering}; + let called = Arc::new(AtomicBool::new(false)); + let seen = Arc::clone(&called); + let mut hana = Dialect::get(DialectType::HANA); + hana.custom_preprocess = Some(Box::new(move |_| { + seen.store(true, Ordering::Relaxed); + Ok(Expression::number(1)) + })); + for level in [ + UnsupportedLevel::Ignore, + UnsupportedLevel::Warn, + UnsupportedLevel::Raise, + UnsupportedLevel::Immediate, + ] { + for sql in [ + "SELECT COALESCE(ADD_DAYS(d, 1), d) FROM t", + "SELECT COALESCE(CAST(x AS INT), 0) FROM t", + "SELECT COALESCE(JSON_VALUE(j, '$.a'), 'x') FROM t", + "CREATE TABLE t (x SMALLDECIMAL ARRAY)", + "ALTER TABLE t ADD (x SMALLDECIMAL ARRAY)", + "SELECT CAST(xs AS SMALLDECIMAL ARRAY) FROM t", + "CREATE FUNCTION f(x SMALLDECIMAL ARRAY) RETURNS INT AS 1", + "SELECT * FROM t FOR JSON", + "SELECT * FROM t WITH HINT (NO_INLINE)", + ] { + assert!( + hana.transpile_with( + sql, + DialectType::DuckDB, + TranspileOptions::default().with_unsupported_level(level) + ) + .is_err(), + "{sql}" + ); + assert!( + !called.load(Ordering::Relaxed), + "preprocessing must not erase unsupported input: {sql}" + ); + } + } + let mut target = Dialect::get(DialectType::DuckDB); + Arc::make_mut(&mut target.generator_config).dialect = Some(DialectType::Generic); + assert!(hana + .transpile("SELECT LOCATE('abc', 'b')", &target) + .is_err()); + assert!(!called.load(Ordering::Relaxed)); + assert!(hana + .transpile("SELECT COALESCE(x, 0) FROM t", DialectType::DuckDB) + .is_ok()); + assert!(called.load(Ordering::Relaxed)); + } + #[test] fn built_in_dialect_instances_share_tokenizer_config() { let first = Dialect::get(DialectType::PostgreSQL); diff --git a/crates/polyglot-sql/src/dialects/mysql.rs b/crates/polyglot-sql/src/dialects/mysql.rs index 7a58c82e..f944281f 100644 --- a/crates/polyglot-sql/src/dialects/mysql.rs +++ b/crates/polyglot-sql/src/dialects/mysql.rs @@ -1246,6 +1246,9 @@ impl MySQLDialect { _ => path, }; Ok(Expression::JsonExtract(Box::new(JsonExtractFunc { + options: None, + source_dialect: None, + this, path: json_path, returning: None, diff --git a/crates/polyglot-sql/src/dialects/normalization/aggregates.rs b/crates/polyglot-sql/src/dialects/normalization/aggregates.rs index ed93f23a..f093a4eb 100644 --- a/crates/polyglot-sql/src/dialects/normalization/aggregates.rs +++ b/crates/polyglot-sql/src/dialects/normalization/aggregates.rs @@ -99,6 +99,8 @@ pub(super) fn rewrite( } Ok(Expression::AggregateFunction(Box::new(AggregateFunction { + source_dialect: None, + name: "medianExactWeightedInterpolatedOrNull".to_string(), args: vec![input, Expression::number(1)], distinct: false, @@ -193,6 +195,8 @@ pub(super) fn rewrite( "ARG_MIN_NULL" }; Ok(Expression::AggregateFunction(Box::new(AggregateFunction { + source_dialect: None, + name: func_name.to_string(), args: vec![agg.this, *having_expr], distinct: agg.distinct, @@ -741,6 +745,8 @@ pub(super) fn rewrite( let preserves_order = agg.limit.is_some(); Ok(Expression::AggregateFunction(Box::new( crate::expressions::AggregateFunction { + source_dialect: None, + name: "COLLECT_LIST".to_string(), args: vec![agg.this.clone()], distinct: agg.distinct, @@ -1101,6 +1107,8 @@ pub(super) fn rewrite( match target { DialectType::Presto => Ok(Expression::AggregateFunction(Box::new( crate::expressions::AggregateFunction { + source_dialect: None, + name: "SET_AGG".to_string(), args: f.args, distinct: false, @@ -1113,6 +1121,8 @@ pub(super) fn rewrite( ))), DialectType::Snowflake => Ok(Expression::AggregateFunction(Box::new( crate::expressions::AggregateFunction { + source_dialect: None, + name: "ARRAY_UNIQUE_AGG".to_string(), args: f.args, distinct: false, @@ -1158,6 +1168,8 @@ pub(super) fn rewrite( }; Ok(Expression::AggregateFunction(Box::new( crate::expressions::AggregateFunction { + source_dialect: None, + name: name.to_string(), args: f.args, distinct: f.distinct, diff --git a/crates/polyglot-sql/src/dialects/normalization/hana.rs b/crates/polyglot-sql/src/dialects/normalization/hana.rs new file mode 100644 index 00000000..10de7048 --- /dev/null +++ b/crates/polyglot-sql/src/dialects/normalization/hana.rs @@ -0,0 +1,151 @@ +//! Lower HANA system relations while respecting lexical CTE scope. + +use crate::dialects::DialectType; +use crate::error::{Error, Result}; +use crate::expressions::*; + +fn is_name(identifier: &Identifier, name: &str) -> bool { + if identifier.quoted { + identifier.name == name + } else { + identifier.name.eq_ignore_ascii_case(name) + } +} + +fn with_clause(expression: &mut Expression) -> Option<&mut Option> { + match expression { + Expression::Select(s) => Some(&mut s.with), + Expression::Union(s) => Some(&mut s.with), + Expression::Intersect(s) => Some(&mut s.with), + Expression::Except(s) => Some(&mut s.with), + Expression::Insert(s) => Some(&mut s.with), + Expression::Update(s) => Some(&mut s.with), + Expression::Delete(s) => Some(&mut s.with), + Expression::CreateTable(s) => Some(&mut s.with_cte), + Expression::Pivot(s) => Some(&mut s.with), + _ => None, + } +} + +pub(super) fn lower_dummy(mut expression: Expression, target: DialectType) -> Result { + visit(&mut expression, false, target)?; + Ok(expression) +} + +fn visit(expression: &mut Expression, shadowed: bool, target: DialectType) -> Result<()> { + #[cfg(feature = "stacker")] + { + stacker::maybe_grow(1024 * 1024, 8 * 1024 * 1024, || { + visit_inner(expression, shadowed, target) + }) + } + #[cfg(not(feature = "stacker"))] + { + visit_inner(expression, shadowed, target) + } +} + +fn visit_inner(expression: &mut Expression, mut shadowed: bool, target: DialectType) -> Result<()> { + if let Expression::Dot(dot) = expression { + if matches!(&dot.this, Expression::Column(column) + if is_name(&column.name, "DUMMY") && column.table.as_ref().is_some_and(|name| is_name(name, "SYS"))) + { + // Replacing a schema-qualified relation with an alias can change + // binding in correlated queries. Require an explicit source alias. + return Err(Error::unsupported( + "Schema-qualified HANA DUMMY columns require an explicit table alias", + target.to_string(), + )); + } + } + // A non-recursive CTE sees preceding CTEs, but not its own alias. Temporarily + // detach WITH so the generic child walk does not visit its bodies twice. + let mut with = with_clause(expression).and_then(Option::take); + if let Some(with) = &mut with { + if with.recursive && with.ctes.iter().any(|cte| is_name(&cte.alias, "DUMMY")) { + shadowed = true; + } + for cte in &mut with.ctes { + visit(&mut cte.this, shadowed, target)?; + shadowed |= is_name(&cte.alias, "DUMMY"); + } + if let Some(search) = &mut with.search { + visit(search, shadowed, target)?; + } + } + + if let Expression::Table(table) = expression { + let system_schema = table.schema.as_ref().is_some_and(|s| is_name(s, "SYS")); + if is_name(&table.name, "DUMMY") + && table.catalog.is_none() + && (system_schema || table.schema.is_none() && !shadowed) + { + if !matches!( + target, + DialectType::DuckDB + | DialectType::PostgreSQL + | DialectType::Trino + | DialectType::Presto + | DialectType::Athena + | DialectType::Dune + | DialectType::CockroachDB + | DialectType::Materialize + | DialectType::RisingWave + ) || table.when.is_some() + || table.only + || table.final_ + || table.table_sample.is_some() + || !table.hints.is_empty() + || table.system_time.is_some() + || !table.partitions.is_empty() + || table.identifier_func.is_some() + || table.changes.is_some() + || table.version.is_some() + { + return Err(Error::unsupported( + "HANA DUMMY relation with no verified target mapping", + target.to_string(), + )); + } + // SAP's system table is one row, one column named DUMMY, value 'X'. + // Keeping that row as a derived table preserves *, aliases, joins, + // predicates and aggregate cardinality, unlike dropping FROM blindly. + let mut row = Select::new(); + row.expressions.push(Expression::Alias(Box::new(Alias::new( + Expression::string("X"), + Identifier::new("DUMMY"), + )))); + row.leading_comments = std::mem::take(&mut table.leading_comments); + row.leading_comments.append(&mut table.trailing_comments); + let subquery = Subquery { + this: Expression::Select(Box::new(row)), + alias: Some(table.alias.clone().unwrap_or_else(|| table.name.clone())), + alias_explicit_as: true, + column_aliases: table.column_aliases.clone(), + alias_keyword: None, + order_by: None, + limit: None, + offset: None, + distribute_by: None, + sort_by: None, + cluster_by: None, + lateral: false, + modifiers_inside: true, + trailing_comments: Vec::new(), + inferred_type: None, + }; + *expression = Expression::Subquery(Box::new(subquery)); + } + } + + let mut result = Ok(()); + crate::ast_children::for_each_child_mut(expression, |child| { + if result.is_ok() { + result = visit(child, shadowed, target); + } + }); + if let Some(slot) = with_clause(expression) { + *slot = with; + } + result +} diff --git a/crates/polyglot-sql/src/dialects/normalization/json.rs b/crates/polyglot-sql/src/dialects/normalization/json.rs index 2205e9e6..a209f0a5 100644 --- a/crates/polyglot-sql/src/dialects/normalization/json.rs +++ b/crates/polyglot-sql/src/dialects/normalization/json.rs @@ -197,6 +197,9 @@ pub(super) fn rewrite( // json -> path arrow syntax Ok(Expression::JsonExtract(Box::new( crate::expressions::JsonExtractFunc { + options: None, + source_dialect: None, + this: f.this, path: f.path, returning: f.returning, @@ -733,6 +736,9 @@ pub(super) fn rewrite( // JSON_EXTRACT_SCALAR(x, '$.path') -> x ->> '$.path' Ok(Expression::JsonExtractScalar(Box::new( crate::expressions::JsonExtractFunc { + options: None, + source_dialect: None, + this: f.this, path: f.path, returning: f.returning, diff --git a/crates/polyglot-sql/src/dialects/normalization/mod.rs b/crates/polyglot-sql/src/dialects/normalization/mod.rs index 1ca6eb0e..756b4249 100644 --- a/crates/polyglot-sql/src/dialects/normalization/mod.rs +++ b/crates/polyglot-sql/src/dialects/normalization/mod.rs @@ -8,6 +8,7 @@ use super::*; mod aggregates; mod collections; +mod hana; mod json; mod operators; mod postgres_interval; @@ -83,9 +84,22 @@ pub(super) fn normalize( Scalar(scalar::Action), } + let expr = if source == DialectType::HANA && target != DialectType::HANA { + hana::lower_dummy(expr, target)? + } else { + expr + }; let expr = statements::normalize_root(expr, &context); transform_recursive(expr, &|e| { + // Source-bound nodes share the ordinary AST categories, but their lowering + // must retain the semantics recorded by the parser through serialization. + if e.source_dialect().is_some() + || matches!(&e, Expression::Function(f) if !f.qualified_name.is_empty()) + { + return Ok(e); + } + if matches!(source, DialectType::DataFusion) && matches!(target, DialectType::DuckDB) { if let Expression::Function(ref function) = e { if function.name.eq_ignore_ascii_case("NOW") && function.args.is_empty() { diff --git a/crates/polyglot-sql/src/dialects/normalization/scalar.rs b/crates/polyglot-sql/src/dialects/normalization/scalar.rs index ac339d69..1aa176e8 100644 --- a/crates/polyglot-sql/src/dialects/normalization/scalar.rs +++ b/crates/polyglot-sql/src/dialects/normalization/scalar.rs @@ -374,6 +374,8 @@ pub(super) fn rewrite( }; Ok(Expression::RegexpLike(Box::new( crate::expressions::RegexpFunc { + options: None, + this, pattern, flags, @@ -396,6 +398,8 @@ pub(super) fn rewrite( }; Ok(Expression::RegexpLike(Box::new( crate::expressions::RegexpFunc { + options: None, + this, pattern, flags, @@ -1395,6 +1399,9 @@ pub(super) fn rewrite( if is_text { Ok(Expression::JsonExtractScalar(Box::new( crate::expressions::JsonExtractFunc { + options: None, + source_dialect: None, + this: json_expr, path: Expression::string(&json_path), returning: None, @@ -1409,6 +1416,9 @@ pub(super) fn rewrite( } else { Ok(Expression::JsonExtract(Box::new( crate::expressions::JsonExtractFunc { + options: None, + source_dialect: None, + this: json_expr, path: Expression::string(&json_path), returning: None, @@ -1554,6 +1564,8 @@ pub(super) fn rewrite( // Convert to RegexpLike which generates as RLIKE/~/REGEXP_LIKE per dialect Ok(Expression::RegexpLike(Box::new( crate::expressions::RegexpFunc { + options: None, + this: str_expr, pattern, flags: None, @@ -2427,6 +2439,9 @@ pub(super) fn rewrite( let path = args.remove(0); Ok(Expression::JsonExtract(Box::new( crate::expressions::JsonExtractFunc { + options: None, + source_dialect: None, + this: json_expr, path, returning: None, @@ -2570,6 +2585,10 @@ pub(super) fn rewrite( // ARRAY_SUM(lambda, array) from Doris -> ClickHouse arraySum "ARRAY_SUM" if matches!(target, DialectType::ClickHouse) => { Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "arraySum".to_string(), args: f.args, distinct: f.distinct, @@ -3473,6 +3492,8 @@ pub(super) fn rewrite( { operators::snowflake_regexp_like_to_clickhouse( crate::expressions::RegexpFunc { + options: None, + this: str_expr, pattern, flags, @@ -3503,6 +3524,8 @@ pub(super) fn rewrite( } _ => Ok(Expression::RegexpLike(Box::new( crate::expressions::RegexpFunc { + options: None, + this: str_expr, pattern, flags, @@ -6975,6 +6998,9 @@ pub(super) fn rewrite( let this = args.remove(0); Ok(Expression::JsonExtract(Box::new( crate::expressions::JsonExtractFunc { + options: None, + source_dialect: None, + this, path, returning: None, @@ -9602,6 +9628,10 @@ pub(super) fn rewrite( DialectType::PostgreSQL => { // PostgreSQL: CURRENT_SCHEMA (no parens) Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "CURRENT_SCHEMA".to_string(), args: vec![], distinct: false, @@ -10993,6 +11023,8 @@ pub(super) fn rewrite( } Ok(Expression::AggregateFunction(Box::new( crate::expressions::AggregateFunction { + source_dialect: None, + name: "JSON_AGG".to_string(), args: vec![*ja.this], distinct: false, @@ -11761,6 +11793,8 @@ pub(super) fn rewrite( Ok(Expression::Paren(Box::new(Paren { this: Expression::RegexpLike(Box::new( crate::expressions::RegexpFunc { + options: None, + this: arg, pattern: Expression::Literal(Box::new(Literal::String( "^[[:ascii:]]*$".to_string(), @@ -14613,6 +14647,8 @@ pub(super) fn normalize_bigquery_function( let pattern = args.remove(0); Ok(Expression::RegexpLike(Box::new( crate::expressions::RegexpFunc { + options: None, + this: str_expr, pattern, flags: None, @@ -16466,6 +16502,9 @@ pub(super) fn normalize_bigquery_function( let path = args.remove(0); Ok(Expression::JsonExtract(Box::new( crate::expressions::JsonExtractFunc { + options: None, + source_dialect: None, + this: json_expr, path, returning: None, @@ -16503,6 +16542,9 @@ pub(super) fn normalize_bigquery_function( let path = args.remove(0); let arrow = Expression::JsonExtract(Box::new(crate::expressions::JsonExtractFunc { + options: None, + source_dialect: None, + this: json_expr, path, returning: None, @@ -16663,6 +16705,10 @@ pub(super) fn normalize_bigquery_function( } } else { Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: f.name, args, distinct: f.distinct, @@ -16686,6 +16732,10 @@ pub(super) fn normalize_bigquery_function( // For other targets (Hive/Spark/BigQuery): pass through as-is // BigQuery's default group behavior matches Hive/Spark for 2-arg case Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: f.name, args, distinct: f.distinct, @@ -17068,6 +17118,10 @@ pub(super) fn normalize_bigquery_function( // All others: pass through _ => Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: f.name, args, distinct: f.distinct, diff --git a/crates/polyglot-sql/src/dialects/normalization/statements.rs b/crates/polyglot-sql/src/dialects/normalization/statements.rs index 10e43291..2da07bb8 100644 --- a/crates/polyglot-sql/src/dialects/normalization/statements.rs +++ b/crates/polyglot-sql/src/dialects/normalization/statements.rs @@ -908,6 +908,7 @@ pub(super) fn transform_select_into( temporary: is_temp, or_replace: false, table_modifier: None, + source_dialect: None, as_select: Some(Expression::Select(new_select)), as_select_parenthesized: false, on_commit: None, diff --git a/crates/polyglot-sql/src/dialects/postgres.rs b/crates/polyglot-sql/src/dialects/postgres.rs index 62e7a623..a679ee58 100644 --- a/crates/polyglot-sql/src/dialects/postgres.rs +++ b/crates/polyglot-sql/src/dialects/postgres.rs @@ -268,6 +268,8 @@ impl DialectImpl for PostgresDialect { // LogicalAnd -> BOOL_AND Expression::LogicalAnd(f) => { Ok(Expression::AggregateFunction(Box::new(AggregateFunction { + source_dialect: None, + name: "BOOL_AND".to_string(), args: vec![f.this], distinct: f.distinct, @@ -282,6 +284,8 @@ impl DialectImpl for PostgresDialect { // LogicalOr -> BOOL_OR Expression::LogicalOr(f) => { Ok(Expression::AggregateFunction(Box::new(AggregateFunction { + source_dialect: None, + name: "BOOL_OR".to_string(), args: vec![f.this], distinct: f.distinct, @@ -806,6 +810,10 @@ impl DialectImpl for PostgresDialect { // ============================================ // CurrentTimestamp -> CURRENT_TIMESTAMP (no parens) Expression::CurrentTimestamp(_) => Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "CURRENT_TIMESTAMP".to_string(), args: vec![], distinct: false, @@ -825,6 +833,10 @@ impl DialectImpl for PostgresDialect { // CurrentDate -> CURRENT_DATE (no parens) Expression::CurrentDate(_) => Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "CURRENT_DATE".to_string(), args: vec![], distinct: false, diff --git a/crates/polyglot-sql/src/dialects/presto.rs b/crates/polyglot-sql/src/dialects/presto.rs index 43057a20..183e1901 100644 --- a/crates/polyglot-sql/src/dialects/presto.rs +++ b/crates/polyglot-sql/src/dialects/presto.rs @@ -180,6 +180,9 @@ impl DialectImpl for PrestoDialect { other => other, }; Ok(Expression::JsonExtract(Box::new(JsonExtractFunc { + options: None, + source_dialect: None, + this: *e.this, path, returning: None, @@ -802,6 +805,9 @@ impl PrestoDialect { _ => path, }; Ok(Expression::JsonExtract(Box::new(JsonExtractFunc { + options: None, + source_dialect: None, + this, path: json_path, returning: None, @@ -1021,6 +1027,8 @@ impl PrestoDialect { // VAR -> VAR_POP in Presto "VAR" if !f.args.is_empty() => { Ok(Expression::AggregateFunction(Box::new(AggregateFunction { + source_dialect: None, + name: "VAR_POP".to_string(), args: f.args, distinct: f.distinct, @@ -1035,6 +1043,8 @@ impl PrestoDialect { // VARIANCE -> VAR_SAMP in Presto (for sample variance) "VARIANCE" if !f.args.is_empty() => { Ok(Expression::AggregateFunction(Box::new(AggregateFunction { + source_dialect: None, + name: "VAR_SAMP".to_string(), args: f.args, distinct: f.distinct, diff --git a/crates/polyglot-sql/src/dialects/redshift.rs b/crates/polyglot-sql/src/dialects/redshift.rs index 388ed8fe..1cbca572 100644 --- a/crates/polyglot-sql/src/dialects/redshift.rs +++ b/crates/polyglot-sql/src/dialects/redshift.rs @@ -369,6 +369,8 @@ impl RedshiftDialect { let string = args.remove(0); let pattern = args.remove(0); Ok(Expression::RegexpLike(Box::new(RegexpFunc { + options: None, + this: string, pattern, flags: None, @@ -381,6 +383,8 @@ impl RedshiftDialect { let string = args.remove(0); let pattern = args.remove(0); Ok(Expression::RegexpLike(Box::new(RegexpFunc { + options: None, + this: string, pattern, flags: None, diff --git a/crates/polyglot-sql/src/dialects/snowflake.rs b/crates/polyglot-sql/src/dialects/snowflake.rs index 7deabe77..8f8f971a 100644 --- a/crates/polyglot-sql/src/dialects/snowflake.rs +++ b/crates/polyglot-sql/src/dialects/snowflake.rs @@ -1727,6 +1727,10 @@ impl SnowflakeDialect { .collect::>>()?; let f = Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: f.name, args: transformed_args, distinct: f.distinct, @@ -1790,6 +1794,10 @@ impl SnowflakeDialect { // NOW -> CURRENT_TIMESTAMP (preserving parens style) "NOW" => Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "CURRENT_TIMESTAMP".to_string(), args: f.args, distinct: false, @@ -1803,6 +1811,10 @@ impl SnowflakeDialect { // GETDATE -> CURRENT_TIMESTAMP (preserving parens style) "GETDATE" => Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "CURRENT_TIMESTAMP".to_string(), args: f.args, distinct: false, @@ -1819,6 +1831,10 @@ impl SnowflakeDialect { // but explicit CURRENT_TIMESTAMP calls should have parens "CURRENT_TIMESTAMP" if f.args.is_empty() => { Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "CURRENT_TIMESTAMP".to_string(), args: Vec::new(), distinct: false, @@ -2463,6 +2479,8 @@ impl SnowflakeDialect { let flags = args.next(); Ok(Expression::RegexpLike(Box::new( crate::expressions::RegexpFunc { + options: None, + this: left, pattern, flags, @@ -2761,6 +2779,10 @@ impl SnowflakeDialect { // SYSTIMESTAMP -> CURRENT_TIMESTAMP (preserving parens style) "SYSTIMESTAMP" => Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "CURRENT_TIMESTAMP".to_string(), args: f.args, distinct: false, @@ -2774,6 +2796,10 @@ impl SnowflakeDialect { // LOCALTIMESTAMP -> CURRENT_TIMESTAMP (preserving parens style) "LOCALTIMESTAMP" => Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "CURRENT_TIMESTAMP".to_string(), args: f.args, distinct: false, @@ -3847,6 +3873,8 @@ impl SnowflakeDialect { args.push(Expression::number(1)); Ok(Expression::AggregateFunction(Box::new( crate::expressions::AggregateFunction { + source_dialect: None, + name: "APPROX_TOP_K".to_string(), args, distinct: f.distinct, diff --git a/crates/polyglot-sql/src/dialects/spark.rs b/crates/polyglot-sql/src/dialects/spark.rs index cc71694c..6563a532 100644 --- a/crates/polyglot-sql/src/dialects/spark.rs +++ b/crates/polyglot-sql/src/dialects/spark.rs @@ -659,6 +659,10 @@ impl SparkDialect { }) .collect(); Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "STRUCT".to_string(), args: named_args, distinct: false, diff --git a/crates/polyglot-sql/src/dialects/trino.rs b/crates/polyglot-sql/src/dialects/trino.rs index 8eb0b5bb..63d22e77 100644 --- a/crates/polyglot-sql/src/dialects/trino.rs +++ b/crates/polyglot-sql/src/dialects/trino.rs @@ -472,6 +472,8 @@ impl TrinoDialect { // VAR -> VAR_POP in Trino "VAR" if !f.args.is_empty() => { Ok(Expression::AggregateFunction(Box::new(AggregateFunction { + source_dialect: None, + name: "VAR_POP".to_string(), args: f.args, distinct: f.distinct, @@ -486,6 +488,8 @@ impl TrinoDialect { // VARIANCE -> VAR_SAMP in Trino "VARIANCE" if !f.args.is_empty() => { Ok(Expression::AggregateFunction(Box::new(AggregateFunction { + source_dialect: None, + name: "VAR_SAMP".to_string(), args: f.args, distinct: f.distinct, diff --git a/crates/polyglot-sql/src/expressions.rs b/crates/polyglot-sql/src/expressions.rs index c88b6b6b..b3d00a9d 100644 --- a/crates/polyglot-sql/src/expressions.rs +++ b/crates/polyglot-sql/src/expressions.rs @@ -177,6 +177,13 @@ pub enum Expression { // Functions Function(Box), + + Upsert(Box), + StorageProperty(Box), + Hierarchy(Box), + ViewParameter(Box), + Call(Box), + AggregateFunction(Box), WindowFunction(Box), @@ -1115,6 +1122,42 @@ pub enum Expression { } impl Expression { + /// Source semantics that still require dialect-aware lowering. This metadata + /// survives JSON serialization and never changes generic traversal behavior. + pub fn source_dialect(&self) -> Option { + use crate::dialects::DialectType; + match self { + Self::Function(f) => f.source_dialect, + Self::AggregateFunction(f) => f.source_dialect, + Self::Select(s) => s.source_dialect, + Self::CreateTable(t) => t.source_dialect, + Self::Upsert(u) => u.source_dialect, + Self::Call(c) => c.source_dialect, + Self::Hierarchy(h) => h.source_dialect, + Self::ViewParameter(p) => p.source_dialect, + Self::StorageProperty(p) => p.source_dialect, + Self::Hint(h) => h.source_dialect, + Self::JSONValue(j) => j.source_dialect, + Self::JSONTable(j) => j.source_dialect, + Self::JSONColumnDef(j) => j.source_dialect, + Self::JsonQuery(j) | Self::JsonExtract(j) | Self::JsonExtractScalar(j) => { + j.source_dialect + } + Self::RegexpLike(r) => r.options.as_ref().and_then(|o| o.source_dialect), + Self::RegexpReplace(r) => r.options.as_ref().and_then(|o| o.source_dialect), + Self::RegexpExtract(r) => r.options.as_ref().and_then(|o| o.source_dialect), + Self::RegexpInstr(r) => r.options.as_ref().and_then(|o| o.source_dialect), + Self::RegexpCount(r) => r.options.as_ref().and_then(|o| o.source_dialect), + Self::Cube(g) => g.options.as_ref().and_then(|o| o.source_dialect), + Self::Rollup(g) => g.options.as_ref().and_then(|o| o.source_dialect), + Self::GroupingSets(g) => g.options.as_ref().and_then(|o| o.source_dialect), + Self::PartitionByProperty(p) => p.specification.as_ref().and_then(|s| s.source_dialect), + Self::DataType(DataType::Hana { .. }) => Some(DialectType::HANA), + Self::Cast(c) if matches!(c.to, DataType::Hana { .. }) => Some(DialectType::HANA), + _ => None, + } + } + /// Create a `Column` variant, boxing the value automatically. #[inline] pub fn boxed_column(col: Column) -> Self { @@ -1145,6 +1188,8 @@ impl Expression { | Expression::PipeOperator(_) // DML + | Expression::Upsert(_) + | Expression::Call(_) | Expression::Insert(_) | Expression::Update(_) | Expression::Delete(_) @@ -1917,6 +1962,11 @@ impl Expression { Expression::Exists(_) => "exists", Expression::MemberOf(_) => "member_of", Expression::Function(_) => "function", + Expression::Upsert(_) => "upsert", + Expression::StorageProperty(_) => "storage_property", + Expression::Hierarchy(_) => "hierarchy", + Expression::ViewParameter(_) => "view_parameter", + Expression::Call(_) => "call", Expression::AggregateFunction(_) => "aggregate_function", Expression::WindowFunction(_) => "window_function", Expression::From(_) => "from", @@ -2777,6 +2827,11 @@ impl Expression { /// Returns the primary child expression (".this" in sqlglot). pub fn get_this(&self) -> Option<&Expression> { match self { + Expression::JSONValue(j) => Some(&j.this), + Expression::JSONTable(j) => Some(&j.this), + Expression::JSONColumnDef(j) => j.this.as_deref(), + Expression::Hierarchy(hierarchy) => Some(&hierarchy.source), + Expression::Upsert(upsert) => Some(&upsert.source), // Unary ops Expression::Not(u) | Expression::Neg(u) | Expression::BitwiseNot(u) => Some(&u.this), // UnaryFunc variants @@ -3070,6 +3125,7 @@ impl Expression { Expression::Select(s) => &s.expressions, Expression::Function(f) => &f.args, Expression::AggregateFunction(f) => &f.args, + Expression::Call(f) => &f.arguments, Expression::From(f) => &f.expressions, Expression::GroupBy(g) => &g.expressions, Expression::In(i) => &i.expressions, @@ -3091,6 +3147,9 @@ impl Expression { /// Returns the name of this expression as a string slice. pub fn get_name(&self) -> &str { match self { + Expression::JSONValue(_) => "JSON_VALUE", + Expression::JSONTable(_) => "JSON_TABLE", + Expression::JsonQuery(_) => "JSON_QUERY", Expression::Identifier(id) => &id.name, Expression::Column(col) => &col.name.name, Expression::Table(t) => &t.name.name, @@ -3098,6 +3157,7 @@ impl Expression { Expression::Star(_) => "*", Expression::Function(f) => &f.name, Expression::AggregateFunction(f) => &f.name, + Expression::Hierarchy(h) => &h.name, Expression::Alias(a) => a.this.get_name(), Expression::Boolean(b) => { if b.value { @@ -3693,6 +3753,18 @@ pub struct Select { /// T-SQL FOR JSON clause options (PATH, AUTO, ROOT, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER) #[serde(default, skip_serializing_if = "Vec::is_empty")] pub for_json: Vec, + /// Structured result serialization, including a bare FOR JSON/XML clause. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub result_serialization: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub query_collation: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub query_hints: Vec, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub total_rowcount: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ast(skip)] + pub source_dialect: Option, /// Leading comments before the statement #[serde(default)] pub leading_comments: Vec, @@ -3752,6 +3824,11 @@ impl Select { locks: Vec::new(), for_xml: Vec::new(), for_json: Vec::new(), + result_serialization: None, + query_collation: None, + query_hints: Vec::new(), + total_rowcount: false, + source_dialect: None, leading_comments: Vec::new(), post_select_comments: Vec::new(), kind: None, @@ -4885,6 +4962,175 @@ pub struct Exists { pub not: bool, } +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +pub struct Upsert { + /// Retained source semantics; serialized independently of generator configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ast(skip)] + pub source_dialect: Option, + + pub table: TableRef, + pub partition: Option, + pub columns: Vec, + pub source: Expression, + pub condition: Option, + pub primary_key: bool, +} + +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +pub struct PartitionSpec { + /// Retained source semantics; serialized independently of generator configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ast(skip)] + pub source_dialect: Option, + + pub method: String, + pub columns: Vec, + pub partitions: Option, + pub ranges: Vec, + pub primary_key_check: Option, + pub properties: Vec, + pub subpartition: Option>, +} + +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +pub struct PartitionRangeSpec { + pub name: Option, + pub kind: String, + pub values: Vec, + pub dynamic: bool, + pub direction: Option, + pub threshold: Option, + pub interval: Option<(Expression, Option)>, + pub properties: Vec, +} + +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +pub struct StorageProperty { + /// Retained source semantics; serialized independently of generator configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ast(skip)] + pub source_dialect: Option, + + pub name: String, + pub values: Vec, +} + +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +pub struct Hierarchy { + /// Retained source semantics; serialized independently of generator configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ast(skip)] + pub source_dialect: Option, + + pub name: String, + pub source: Expression, + pub start: Option, + pub siblings: Vec, + pub depth: Option, + pub multiparent: Option, + pub orphan: Option, + pub cycle: Option, + pub cache: Option, +} + +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +pub struct ViewParameter { + /// Retained source semantics; serialized independently of generator configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ast(skip)] + pub source_dialect: Option, + + pub name: Identifier, + pub value: Expression, +} + +#[derive( + polyglot_sql_ast_derive::AstNode, Debug, Clone, Default, PartialEq, Serialize, Deserialize, +)] +#[cfg_attr(feature = "bindings", derive(TS))] +pub struct JsonOptions { + pub wrapper: Option, + pub on_empty: Option, + pub on_error: Option, +} + +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +pub struct SqlBehavior { + /// ERROR, NULL, DEFAULT, EMPTY ARRAY, or EMPTY OBJECT. + pub kind: String, + pub value: Option, +} + +/// Procedure invocation, including library member calls and asynchronous execution. +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +pub struct Call { + /// Retained source semantics; serialized independently of generator configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ast(skip)] + pub source_dialect: Option, + + pub name: Vec, + pub member: Option, + pub arguments: Vec, + pub asynchronous: bool, + pub hints: Vec, +} + +/// Regex search options shared by predicates, extraction, replacement, and position calls. +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +pub struct RegexOptions { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ast(skip)] + pub source_dialect: Option, + /// Original spelling when a dialect has more than one native alias. + pub source_name: String, + #[serde(default)] + pub negated: bool, + pub start: Option, + pub occurrence: Option, + pub flags: Option, + pub position_after: Option, +} + +/// Grouping-set selection and result-delivery options. +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +pub struct GroupingOptions { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ast(skip)] + pub source_dialect: Option, + pub best: Option, + pub limit: Option, + pub offset: Option, + pub subtotal: bool, + pub balance: bool, + pub total: bool, + pub structured: bool, + pub overview: bool, + pub prefix: Option, + pub multiple_resultsets: bool, +} + +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +pub struct ResultSerialization { + /// JSON or XML, validated by the HANA parser. + pub format: String, + pub options: Vec<(String, String)>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub returning: Option, +} + /// Represent a scalar function call (e.g. `UPPER(name)`, `COALESCE(a, b)`). /// /// This is the generic function node. Well-known aggregates, window functions, @@ -4894,6 +5140,17 @@ pub struct Exists { #[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "bindings", derive(TS))] pub struct Function { + /// Qualified name components preserve quoting without changing the node kind. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub qualified_name: Vec, + /// Optional DEFAULT/NULL/ERROR behavior after the arguments. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub on_error: Option>, + /// Dialect whose call semantics must be retained through independent generation. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ast(skip)] + pub source_dialect: Option, + /// The function name, as originally written (may be schema-qualified). pub name: String, /// Positional arguments to the function. @@ -4923,6 +5180,9 @@ pub struct Function { impl Default for Function { fn default() -> Self { Self { + source_dialect: None, + qualified_name: Vec::new(), + on_error: None, name: String::new(), args: Vec::new(), distinct: false, @@ -4937,8 +5197,24 @@ impl Default for Function { } impl Function { + /// Construct a call with individually quoted qualified name components. + pub fn qualified(name: Vec, args: Vec) -> Self { + Self { + name: name + .iter() + .map(|part| part.name.as_str()) + .collect::>() + .join("."), + qualified_name: name, + args, + ..Default::default() + } + } pub fn new(name: impl Into, args: Vec) -> Self { Self { + source_dialect: None, + qualified_name: Vec::new(), + on_error: None, name: name.into(), args, distinct: false, @@ -4963,6 +5239,11 @@ impl Function { )] #[cfg_attr(feature = "bindings", derive(TS))] pub struct AggregateFunction { + /// Dialect whose call semantics must be retained through independent generation. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ast(skip)] + pub source_dialect: Option, + /// The aggregate function name (e.g. "JSON_AGG", "XMLAGG"). pub name: String, /// Positional arguments. @@ -5302,6 +5583,9 @@ pub struct LateralView { #[cfg_attr(feature = "bindings", derive(TS))] #[cfg_attr(feature = "bindings", ts(export))] pub struct Hint { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ast(skip)] + pub source_dialect: Option, pub expressions: Vec, } @@ -5310,6 +5594,13 @@ pub struct Hint { #[cfg_attr(feature = "bindings", derive(TS))] #[cfg_attr(feature = "bindings", ts(export))] pub enum HintExpression { + /// A structured hint directive with optional execution scope modifiers. + Directive { + name: Identifier, + arguments: Option>, + remote: bool, + cascade: bool, + }, /// Function-style hint: USE_HASH(table) Function { name: String, args: Vec }, /// Simple identifier hint: PARALLEL @@ -5953,6 +6244,11 @@ pub enum DataType { oracle_type: OracleDataType, }, + /// A HANA type whose domain or syntax is not represented by a common type. + Hana { + hana_type: HanaDataType, + }, + // String Char { length: Option, @@ -6103,6 +6399,14 @@ pub enum DataType { Unknown, } +/// Source-specific HANA type, retained until a target is selected. +#[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "bindings", derive(TS))] +pub struct HanaDataType { + pub name: String, + pub parameters: Vec, +} + impl DataType { /// Canonical unsigned names and their bit-width aliases. Parser entry points /// and legacy Custom-type normalization share this mapping. @@ -6505,6 +6809,9 @@ pub struct SplitFunc { #[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "bindings", derive(TS))] pub struct RegexpFunc { + /// Additional search boundaries and retained source semantics. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, pub this: Expression, pub pattern: Expression, pub flags: Option, @@ -6514,6 +6821,9 @@ pub struct RegexpFunc { #[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "bindings", derive(TS))] pub struct RegexpReplaceFunc { + /// Additional search boundaries and retained source semantics. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, pub this: Expression, pub pattern: Expression, pub replacement: Expression, @@ -6524,6 +6834,9 @@ pub struct RegexpReplaceFunc { #[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "bindings", derive(TS))] pub struct RegexpExtractFunc { + /// Additional search boundaries and retained source semantics. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, pub this: Expression, pub pattern: Expression, pub group: Option, @@ -7243,6 +7556,13 @@ pub struct FunctionEmits { #[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "bindings", derive(TS))] pub struct JsonExtractFunc { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ast(skip)] + pub source_dialect: Option, + /// Structured SQL/JSON wrapper and error/empty behavior. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, + pub this: Expression, pub path: Expression, pub returning: Option, @@ -7635,6 +7955,11 @@ pub struct CreateTable { /// Table modifier: DYNAMIC, ICEBERG, EXTERNAL, HYBRID (Snowflake) #[serde(default, skip_serializing_if = "Option::is_none")] pub table_modifier: Option, + /// Dialect whose call semantics must be retained through independent generation. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ast(skip)] + pub source_dialect: Option, + pub as_select: Option, /// Whether the AS SELECT was wrapped in parentheses #[serde(default)] @@ -7763,6 +8088,7 @@ impl CreateTable { temporary: false, or_replace: false, table_modifier: None, + source_dialect: None, as_select: None, as_select_parenthesized: false, on_commit: None, @@ -11234,6 +11560,9 @@ pub struct Group { #[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "bindings", derive(TS))] pub struct Cube { + /// Selection, subtotal, and result-delivery options for this grouping operation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, #[serde(default)] pub expressions: Vec, } @@ -11242,6 +11571,9 @@ pub struct Cube { #[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "bindings", derive(TS))] pub struct Rollup { + /// Selection, subtotal, and result-delivery options for this grouping operation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, #[serde(default)] pub expressions: Vec, } @@ -11250,6 +11582,9 @@ pub struct Rollup { #[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "bindings", derive(TS))] pub struct GroupingSets { + /// Selection, subtotal, and result-delivery options for this grouping operation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, #[serde(default)] pub expressions: Vec, } @@ -11778,6 +12113,9 @@ pub struct PartitionedByProperty { #[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "bindings", derive(TS))] pub struct PartitionByProperty { + /// Structured partition scheme when the dialect supports methods and ranges. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub specification: Option, #[serde(default)] pub expressions: Vec, } @@ -12384,6 +12722,8 @@ pub struct Schema { #[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "bindings", derive(TS))] pub struct Lock { + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub ignore_locked: bool, #[serde(default)] pub update: Option>, #[serde(default)] @@ -14109,6 +14449,18 @@ pub struct JSONExists { #[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "bindings", derive(TS))] pub struct JSONColumnDef { + /// Typed column definition; legacy dialects may use the textual kind field. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub encoding: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ast(skip)] + pub source_dialect: Option, + /// Structured SQL/JSON wrapper and error/empty behavior. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, + #[serde(default)] pub this: Option>, #[serde(default)] @@ -14157,6 +14509,13 @@ pub struct JSONStripNulls { #[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "bindings", derive(TS))] pub struct JSONValue { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ast(skip)] + pub source_dialect: Option, + /// Structured SQL/JSON wrapper and error/empty behavior. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, + pub this: Box, #[serde(default)] pub path: Option>, @@ -14188,6 +14547,13 @@ pub struct JSONRemove { #[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "bindings", derive(TS))] pub struct JSONTable { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ast(skip)] + pub source_dialect: Option, + /// Structured SQL/JSON wrapper and error/empty behavior. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, + pub this: Box, #[serde(default)] pub schema: Option>, @@ -14790,6 +15156,9 @@ pub struct RegexpFullMatch { #[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "bindings", derive(TS))] pub struct RegexpInstr { + /// Additional search boundaries and retained source semantics. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, pub this: Box, pub expression: Box, #[serde(default)] @@ -14818,6 +15187,9 @@ pub struct RegexpSplit { #[derive(polyglot_sql_ast_derive::AstNode, Debug, Clone, PartialEq, Serialize, Deserialize)] #[cfg_attr(feature = "bindings", derive(TS))] pub struct RegexpCount { + /// Additional search boundaries and retained source semantics. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub options: Option, pub this: Box, pub expression: Box, #[serde(default)] diff --git a/crates/polyglot-sql/src/format_tokens.rs b/crates/polyglot-sql/src/format_tokens.rs index 0a655de8..66fa9371 100644 --- a/crates/polyglot-sql/src/format_tokens.rs +++ b/crates/polyglot-sql/src/format_tokens.rs @@ -165,3 +165,54 @@ mod tests { assert_eq!(convert_format_tokens("", TEST_MAPPING), None); } } + +/// Verified HANA datetime tokens shared by the percent-format function families. +/// Unknown alphabetic tokens, precision masks, and unterminated quoted literals +/// are rejected rather than copied into a different format language. +#[cfg(feature = "generate")] +pub(crate) fn hana_datetime_format(input: &str, mysql_style: bool) -> Option { + let mut cursor = FormatTokenCursor::new(input); + let mut result = String::new(); + while let Some(ch) = cursor.peek() { + if ch == '"' { + cursor.next_char(); + loop { + let ch = cursor.next_char()?; + if ch == '"' { + break; + } + if ch == '%' { + result.push('%'); + } + result.push(ch); + } + continue; + } + let mut matched = false; + for (source, target) in [ + ("HH24", "%H"), + ("YYYY", "%Y"), + ("MM", "%m"), + ("DD", "%d"), + ("MI", if mysql_style { "%i" } else { "%M" }), + ("SS", if mysql_style { "%s" } else { "%S" }), + ] { + if cursor.consume_prefix(source, true) { + result.push_str(target); + matched = true; + break; + } + } + if !matched { + if ch.is_ascii_alphabetic() || ch.is_ascii_digit() { + return None; + } + cursor.next_char(); + if ch == '%' { + result.push('%'); + } + result.push(ch); + } + } + Some(result) +} diff --git a/crates/polyglot-sql/src/function_registry.rs b/crates/polyglot-sql/src/function_registry.rs index 15510937..f1d44d1e 100644 --- a/crates/polyglot-sql/src/function_registry.rs +++ b/crates/polyglot-sql/src/function_registry.rs @@ -2243,6 +2243,11 @@ pub(crate) fn is_aggregate_function_name_for_dialect( ) -> bool { let upper = name.to_uppercase(); is_aggregate_function_name(name) + || (dialect == Some(DialectType::HANA) + && matches!( + upper.as_str(), + "AUTO_CORR" | "CROSS_CORR" | "CORR_SPEARMAN" | "DFT" | "NTH_VALUE" | "VAR" + )) || (matches!(dialect, Some(DialectType::DuckDB)) && DUCKDB_AGGREGATE_FUNCTION_NAME_SET.contains(upper.as_str())) } @@ -3061,3 +3066,327 @@ mod tests { ); } } + +/// HANA calls with source-specific semantics or argument grammar. +pub(crate) fn hana_uses_source_node(name: &str) -> bool { + (is_hana_builtin(name) + || matches!( + name, + "HIERARCHY" | "HIERARCHY_SPANTREE" | "CONTAINS" | "FUZZY" + )) + && !matches!( + name, + "COUNT" + | "SUM" + | "MIN" + | "MAX" + | "AVG" + | "CAST" + | "EXTRACT" + | "TRIM" + | "ROW_NUMBER" + | "RANK" + | "DENSE_RANK" + | "LEAD" + | "LAG" + | "NTILE" + | "CUME_DIST" + | "PERCENT_RANK" + ) +} + +/// Built-ins inventoried from HANA Platform 2.0 SPS 08 and Cloud QRC 2/2026. +/// Recognition is separate from target support: a known name is never evidence +/// that a target implements the same operation. +pub(crate) fn is_hana_builtin(name: &str) -> bool { + const NAMES: &[&str] = &[ + "ABAP_ALPHANUM", + "ABAP_DF16RAW_TO_SMALLDECIMAL", + "ABAP_DF34RAW_TO_DECIMAL", + "ABAP_LOWER", + "ABAP_NUMC", + "ABAP_UPPER", + "ABS", + "ACOS", + "ADD_DAYS", + "ADD_MONTHS", + "ADD_MONTHS_LAST", + "ADD_NANO100", + "ADD_SECONDS", + "ADD_WORKDAYS", + "ADD_YEARS", + "AI", + "AI_TEXT_COMPLETION", + "ALLOW_PRECISION_LOSS", + "ASCII", + "ASIN", + "ATAN", + "ATAN2", + "AUTO_CORR", + "AVG", + "BINNING", + "BINTOHEX", + "BINTONHEX", + "BINTOSTR", + "BITAND", + "BITCOUNT", + "BITNOT", + "BITOR", + "BITSET", + "BITUNSET", + "BITXOR", + "CARDINALITY", + "CAST", + "CEIL", + "CHAR", + "COALESCE", + "CONCAT", + "CONCAT_NAZ", + "CONVERT_CURRENCY", + "CONVERT_UNIT", + "CORR", + "CORR_SPEARMAN", + "COS", + "COSH", + "COSINE_SIMILARITY", + "COT", + "COUNT", + "CROSS_CORR", + "CUBIC_SPLINE_APPROX", + "CUME_DIST", + "CURRENT_CONNECTION", + "CURRENT_DATABASE", + "CURRENT_DATE", + "CURRENT_IDENTITY_VALUE", + "CURRENT_MVCC_SNAPSHOT_TIMESTAMP", + "CURRENT_OBJECT_SCHEMA", + "CURRENT_ROLEGROUP", + "CURRENT_SCHEMA", + "CURRENT_SITE_ID", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "CURRENT_TRANSACTION_ISOLATION_LEVEL", + "CURRENT_UPDATE_STATEMENT_SEQUENCE", + "CURRENT_UPDATE_TRANSACTION", + "CURRENT_USER", + "CURRENT_USERGROUP", + "CURRENT_USER_ID", + "CURRENT_UTCDATE", + "CURRENT_UTCTIME", + "CURRENT_UTCTIMESTAMP", + "DAYNAME", + "DAYOFMONTH", + "DAYOFYEAR", + "DAYS_BETWEEN", + "DENSE_RANK", + "DFT", + "ESCAPE_DOUBLE_QUOTES", + "ESCAPE_SINGLE_QUOTES", + "EXP", + "EXPRESSION_MACRO", + "EXTRACT", + "FIRST_VALUE", + "FLOOR", + "GENERATE_PASSWORD", + "GET_DELTA_LAKE_TABLE_CHANGES", + "GET_DELTA_LAKE_TABLE_VERSION", + "GREATEST", + "GROUPING", + "GROUPING_ID", + "GROUP_SCORE", + "HAMMING_DISTANCE", + "HASH_MD5", + "HASH_SHA256", + "HEXTOBIN", + "HEXTONUM", + "HOUR", + "IFNULL", + "INDEXING_ERROR_CODE", + "INDEXING_ERROR_MESSAGE", + "INDEXING_STATUS", + "INITCAP", + "ISOWEEK", + "IS_SQL_INJECTION_SAFE", + "JSON", + "JSON_QUERY", + "JSON_TABLE", + "JSON_VALUE", + "L2DISTANCE", + "L2NORM", + "L2NORMALIZE", + "LAG", + "LANGUAGE", + "LAST_DAY", + "LAST_VALUE", + "LCASE", + "LEAD", + "LEAST", + "LEFT", + "LENGTH", + "LINEAR_APPROX", + "LN", + "LOCALTOUTC", + "LOCATE", + "LOCATE_REGEXPR", + "LOG", + "LOWER", + "LPAD", + "LTRIM", + "MAP", + "MAX", + "MEDIAN", + "MEMBER_AT", + "MIMETYPE", + "MIN", + "MINUTE", + "MOD", + "MONTH", + "MONTHNAME", + "MONTHS_BETWEEN", + "NANO100_BETWEEN", + "NCHAR", + "NDIV0", + "NEWUID", + "NEXT_DAY", + "NORMALIZE", + "NOW", + "NTH_VALUE", + "NTILE", + "NULLIF", + "NUMTOHEX", + "OCCURRENCES_REGEXPR", + "PARSE_CERTIFICATES", + "PARSE_JSON", + "PARTITION_ALLOCATION", + "PARTITION_HASH", + "PERCENTILE_CONT", + "PERCENTILE_DISC", + "PERCENT_RANK", + "PLAINTEXT", + "POWER", + "QUARTER", + "RAND", + "RANDOM_PARTITION", + "RAND_SECURE", + "RANK", + "RECORD_COMMIT_TIMESTAMP", + "RECORD_ID", + "REPLACE", + "REPLACE_REGEXPR", + "RESULT_CACHE_ID", + "RESULT_CACHE_REFRESH_TIME", + "RIGHT", + "ROUND", + "ROW_NUMBER", + "RPAD", + "RTRIM", + "SCORE", + "SECOND", + "SECONDS_BETWEEN", + "SERIES_DISAGGREGATE", + "SERIES_ELEMENT_TO_PERIOD", + "SERIES_FILTER", + "SERIES_GENERATE", + "SERIES_PERIOD_TO_ELEMENT", + "SERIES_ROUND", + "SESSION_CONTEXT", + "SESSION_USER", + "SIGN", + "SIN", + "SINH", + "SOUNDEX", + "SQL", + "SQRT", + "STATEMENT_EXECUTION_HOST", + "STATEMENT_EXECUTION_PORT", + "STDDEV", + "STDDEV_POP", + "STDDEV_SAMP", + "STRING_AGG", + "STRTOBIN", + "SUBARRAY", + "SUBSTR", + "SUBSTRING", + "SUBSTRING_REGEXPR", + "SUBSTR_AFTER", + "SUBSTR_BEFORE", + "SUBSTR_REGEXPR", + "SUBVECTOR", + "SUM", + "SYSUUID", + "TAN", + "TANH", + "TO_ALPHANUM", + "TO_ARRAY", + "TO_BIGINT", + "TO_BINARY", + "TO_BLOB", + "TO_BOOLEAN", + "TO_CLOB", + "TO_DATE", + "TO_DATS", + "TO_DECIMAL", + "TO_DOUBLE", + "TO_FIXEDCHAR", + "TO_HALF_VECTOR", + "TO_INT", + "TO_INTEGER", + "TO_NCLOB", + "TO_NVARCHAR", + "TO_REAL", + "TO_REAL_VECTOR", + "TO_SECONDDATE", + "TO_SMALLDECIMAL", + "TO_SMALLINT", + "TO_TIME", + "TO_TIMESTAMP", + "TO_TINYINT", + "TO_VARBINARY", + "TO_VARCHAR", + "TRIM", + "TRIM_ARRAY", + "UCASE", + "UMINUS", + "UNICODE", + "UPPER", + "UTCTOLOCAL", + "VALIDATE_USERGROUP_CONNECT_RESTRICTION", + "VALIDATE_USERGROUP_CONNECT_RESTRICTION_DETAILS", + "VAR", + "VAR_POP", + "VAR_SAMP", + "VECTOR_EMBEDDING", + "WEEK", + "WEEKDAY", + "WEIGHTED_AVG", + "WIDTH_BUCKET", + "WORKDAYS_BETWEEN", + "XMLEXTRACT", + "XMLEXTRACTVALUE", + "XMLTABLE", + "YEAR", + "YEARS_BETWEEN", + ]; + NAMES.binary_search(&name).is_ok() + || matches!( + name, + "SERIES_GENERATE_INTEGER" + | "SERIES_GENERATE_DECIMAL" + | "SERIES_GENERATE_DATE" + | "SERIES_GENERATE_TIME" + | "SERIES_GENERATE_TIMESTAMP" + | "SUBSTR_REGEXPR" + | "SERIES_DISAGGREGATE_BIGINT" + | "SERIES_DISAGGREGATE_DATE" + | "SERIES_DISAGGREGATE_DECIMAL" + | "SERIES_DISAGGREGATE_INTEGER" + | "SERIES_DISAGGREGATE_SECONDDATE" + | "SERIES_DISAGGREGATE_SMALLDECIMAL" + | "SERIES_DISAGGREGATE_SMALLINT" + | "SERIES_DISAGGREGATE_TIME" + | "SERIES_DISAGGREGATE_TIMESTAMP" + | "SERIES_DISAGGREGATE_TINYINT" + | "ST_GEOMFROMTEXT" + | "ST_GEOMFROMWKB" + ) +} diff --git a/crates/polyglot-sql/src/generator.rs b/crates/polyglot-sql/src/generator.rs index cfb57ee1..f16c5d85 100644 --- a/crates/polyglot-sql/src/generator.rs +++ b/crates/polyglot-sql/src/generator.rs @@ -184,6 +184,39 @@ enum TsqlDatePart { Unsupported(String), } +// Validated conversion decisions shared by rendering and source checks. +enum HanaFunctionMapping { + Quoted, + Coalesce, + Locate { + start: i64, + occurrence: i64, + }, + Substring { + start: i64, + length: Option, + }, + Decimal { + precision: i64, + scale: i64, + }, + Datetime { + mask: String, + formatting: bool, + date: bool, + }, + Utc { + precision: i64, + date: bool, + time: bool, + }, +} + +enum HanaTypeMapping<'a> { + Name(&'a str), + Standard(DataType), +} + /// Identifier quote style (start/end characters) #[derive(Debug, Clone, Copy, PartialEq)] pub struct IdentifierQuoteStyle { @@ -2528,6 +2561,84 @@ impl Generator { Ok(sql) } + /// Check a source node using the same decisions as rendering, without emitting SQL. + /// Descendants are checked by the transpilation pipeline. + #[cfg(feature = "transpile")] + pub(crate) fn validate_hana_source_node(node: &Expression, target: DialectType) -> Result<()> { + let unsupported_feature = match node { + Expression::Function(f) => { + Self::hana_function_mapping(f, target)?; + return Ok(()); + } + Expression::Cast(c) => { + if let DataType::Hana { hana_type } = &c.to { + match hana_type.name.as_str() { + "TINYINT" | "SMALLINT" | "INT" | "BIGINT" => { + Self::hana_integer_cast(c, &hana_type.name, target)?; + } + "DECIMAL" => { + Self::hana_decimal_cast(c, hana_type, target)?; + } + _ => { + Self::hana_type_mapping(hana_type, target)?; + } + } + } + return Ok(()); + } + Expression::DataType(DataType::Hana { hana_type }) => { + Self::hana_type_mapping(hana_type, target)?; + return Ok(()); + } + Expression::Select(s) => return Self::validate_hana_select(s, target), + Expression::CreateTable(t) => return Self::validate_hana_create_table(t, target), + Expression::AggregateFunction(f) => { + return Err(Self::hana_unsupported_for_target( + target, + format!("HANA aggregate {} has no verified target mapping", f.name), + )) + } + Expression::Upsert(_) => { + "HANA UPSERT requires target-specific key and update semantics" + } + Expression::Call(_) => "HANA CALL requires a verified target procedure signature", + Expression::Hierarchy(_) => "HANA hierarchy has no verified target mapping", + Expression::ViewParameter(_) => { + "HANA calculation-view PLACEHOLDER has no verified target mapping" + } + Expression::StorageProperty(_) => { + "HANA storage property has no verified target mapping" + } + Expression::Hint(_) => "Optimizer hint has no verified target mapping", + Expression::JSONValue(_) + | Expression::JSONTable(_) + | Expression::JSONColumnDef(_) + | Expression::JsonQuery(_) + | Expression::JsonExtract(_) + | Expression::JsonExtractScalar(_) => { + "SQL/JSON path and error semantics have no verified target mapping" + } + Expression::RegexpLike(_) + | Expression::RegexpReplace(_) + | Expression::RegexpExtract(_) + | Expression::RegexpInstr(_) + | Expression::RegexpCount(_) => { + "Regular-expression source semantics have no verified target mapping" + } + Expression::Cube(_) | Expression::Rollup(_) | Expression::GroupingSets(_) => { + "HANA grouping-set selection and result delivery options" + } + Expression::PartitionByProperty(_) => { + "HANA partitioning has no verified target mapping" + } + _ => "HANA source semantics have no verified target mapping", + }; + Err(Self::hana_unsupported_for_target( + target, + unsupported_feature, + )) + } + fn generate_expression(&mut self, expr: &Expression) -> Result<()> { #[cfg(feature = "stacker")] { @@ -2547,6 +2658,57 @@ impl Generator { } fn generate_expression_inner(&mut self, expr: &Expression) -> Result<()> { + if self.config.dialect == Some(DialectType::HANA) + && matches!( + expr, + Expression::StringAgg(_) | Expression::ListAgg(_) | Expression::GroupConcat(_) + ) + { + return Err(self.hana_unsupported( + "STRING_AGG/LISTAGG/GROUP_CONCAT to HANA requires a verified conversion of NULL input semantics", + )); + } + if self.config.dialect == Some(DialectType::HANA) + && matches!( + expr, + Expression::Raw(_) + | Expression::Command(_) + | Expression::ILike(_) + | Expression::DateAdd(_) + | Expression::DateSub(_) + | Expression::DateDiff(_) + | Expression::TimestampAdd(_) + | Expression::TimestampSub(_) + | Expression::TimestampDiff(_) + | Expression::StrToDate(_) + | Expression::StrToTime(_) + | Expression::TimeToStr(_) + | Expression::ToNumber(_) + | Expression::Substring(_) + | Expression::StrPosition(_) + | Expression::CurrentDate(_) + | Expression::CurrentTime(_) + | Expression::CurrentTimestamp(_) + ) + { + return Err(self.hana_unsupported(format!( + "{} requires a verified conversion to HANA semantics", + expr.variant_name() + ))); + } + + let regex_options = match expr { + Expression::RegexpLike(r) => r.options.as_ref(), + Expression::RegexpReplace(r) => r.options.as_ref(), + Expression::RegexpExtract(r) => r.options.as_ref(), + Expression::RegexpInstr(r) => r.options.as_ref(), + Expression::RegexpCount(r) => r.options.as_ref(), + _ => None, + }; + if let Some(options) = regex_options { + return self.generate_source_regex(expr, options); + } + match expr { Expression::Select(select) => self.generate_select(select), Expression::Union(union) => self.generate_union(union), @@ -2579,6 +2741,33 @@ impl Generator { Expression::Collation(coll) => self.generate_collation(coll), Expression::Case(case) => self.generate_case(case), Expression::Function(func) => self.generate_function(func), + Expression::Hint(hint) => self.generate_hint(hint), + Expression::Upsert(upsert) => self.generate_hana_upsert(upsert), + Expression::Hierarchy(hierarchy) => self.generate_hana_hierarchy(hierarchy), + Expression::Call(call) => self.generate_hana_call(call), + Expression::ViewParameter(placeholder) => { + if self.config.dialect != Some(DialectType::HANA) { + return Err(self.hana_unsupported( + "HANA calculation-view PLACEHOLDER has no verified target mapping", + )); + } + self.write("PLACEHOLDER."); + self.generate_identifier(&placeholder.name)?; + self.write(" => "); + self.generate_expression(&placeholder.value) + } + Expression::StorageProperty(property) => { + if self.config.dialect != Some(DialectType::HANA) { + return Err(self + .hana_unsupported("HANA storage property has no verified target mapping")); + } + self.write(&property.name); + for value in &property.values { + self.write(" "); + self.generate_expression(value)?; + } + Ok(()) + } Expression::FunctionEmits(fe) => self.generate_function_emits(fe), Expression::AggregateFunction(func) => self.generate_aggregate_function(func), Expression::WindowFunction(wf) => self.generate_window_function(wf), @@ -4664,7 +4853,25 @@ impl Generator { Some(outer) } + fn validate_hana_select(select: &Select, target: DialectType) -> Result<()> { + if target != DialectType::HANA + && (select.result_serialization.is_some() + || select.query_collation.is_some() + || !select.query_hints.is_empty() + || select.total_rowcount + || select.locks.iter().any(|lock| lock.ignore_locked)) + { + return Err(Self::hana_unsupported_for_target( + target, + "HANA SELECT serialization or hints have no verified target mapping", + )); + } + Ok(()) + } + fn generate_select(&mut self, select: &Select) -> Result<()> { + Self::validate_hana_select(select, self.config.dialect.unwrap_or_default())?; + use crate::dialects::DialectType; if let Some(resolved_select) = self.resolve_tsql_null_ordering_for_select(select) { @@ -5043,6 +5250,9 @@ impl Generator { self.write_formatted_comment(comment); } } + } else if self.config.dialect == Some(DialectType::HANA) { + // A user CTE named DUMMY must not capture the synthetic one-row source. + self.write(" FROM SYS.DUMMY"); } // JOINs - handle nested join structure for pretty printing @@ -5748,6 +5958,10 @@ impl Generator { } } + if select.total_rowcount { + self.write(" TOTAL ROWCOUNT"); + } + // FOR UPDATE/SHARE locks // Skip locking clauses for dialects that don't support them if self.config.locking_reads_supported { @@ -5762,6 +5976,47 @@ impl Generator { } } + { + if let Some(serialization) = &select.result_serialization { + self.write(" FOR "); + self.write(&serialization.format); + if !serialization.options.is_empty() { + self.write(" ("); + for (i, (key, value)) in serialization.options.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.generate_string_literal(key)?; + self.write(" = "); + self.generate_string_literal(value)?; + } + self.write(")"); + } + } + if let Some(returning) = select + .result_serialization + .as_ref() + .and_then(|s| s.returning.as_ref()) + { + self.write(" RETURNS "); + self.generate_data_type(returning)?; + } + if let Some(collation) = &select.query_collation { + self.write(" WITH COLLATION "); + self.generate_identifier(collation)?; + } + if !select.query_hints.is_empty() { + self.write(" WITH HINT ("); + for (i, hint) in select.query_hints.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.generate_expression(hint)?; + } + self.write(")"); + } + } + // FOR XML clause (T-SQL) if !select.for_xml.is_empty() { if self.config.pretty { @@ -8476,7 +8731,25 @@ impl Generator { // ==================== DDL Generation ==================== + fn validate_hana_create_table(table: &CreateTable, target: DialectType) -> Result<()> { + if table.source_dialect != Some(DialectType::HANA) || target == DialectType::HANA { + return Ok(()); + } + if table.table_modifier.as_ref().is_some_and(|m| { + m.split_whitespace() + .any(|p| matches!(p, "COLUMN" | "ROW" | "LOCAL" | "GLOBAL")) + }) { + return Err(Self::hana_unsupported_for_target( + target, + "HANA table storage/scope has no verified target mapping", + )); + } + Ok(()) + } + fn generate_create_table(&mut self, ct: &CreateTable) -> Result<()> { + Self::validate_hana_create_table(ct, self.config.dialect.unwrap_or_default())?; + // Athena: Determine if this is Hive-style DDL or Trino-style DML // CREATE TABLE AS SELECT uses Trino (double quotes) // CREATE TABLE (without AS SELECT) and CREATE EXTERNAL TABLE use Hive (backticks) @@ -8553,7 +8826,13 @@ impl Generator { self.write_keyword("OR REPLACE"); } - if ct.temporary { + if ct.temporary + && !(self.config.dialect == Some(DialectType::HANA) + && ct + .table_modifier + .as_ref() + .is_some_and(|m| m.contains("TEMPORARY"))) + { self.write_space(); // Oracle uses GLOBAL TEMPORARY TABLE syntax if matches!(self.config.dialect, Some(DialectType::Oracle)) { @@ -11745,7 +12024,10 @@ impl Generator { AlterTableAction::AddColumns { columns, cascade } => { // Oracle uses ADD (...) without COLUMNS keyword // Hive/Spark uses ADD COLUMNS (...) - let is_oracle = matches!(self.config.dialect, Some(DialectType::Oracle)); + let is_oracle = matches!( + self.config.dialect, + Some(DialectType::Oracle | DialectType::HANA) + ); if is_oracle { self.write_keyword("ADD"); } else { @@ -17330,6 +17612,20 @@ impl Generator { /// Generate a query hint /*+ ... */ fn generate_hint(&mut self, hint: &Hint) -> Result<()> { + if hint.source_dialect.is_some() { + if hint.source_dialect != self.config.dialect { + return Err(self.hana_unsupported("Optimizer hint has no verified target mapping")); + } + for (i, expression) in hint.expressions.iter().enumerate() { + if i > 0 { + self.write(", "); + } + let text = self.hint_expression_to_string(expression)?; + self.write(&text); + } + return Ok(()); + } + use crate::dialects::DialectType; // Output hints for dialects that support them, or when no dialect is specified (identity tests) @@ -17513,6 +17809,33 @@ impl Generator { /// Convert a hint expression to a string, handling multiline formatting for long arguments fn hint_expression_to_string(&mut self, expr: &HintExpression) -> Result { match expr { + HintExpression::Directive { + name, + arguments, + remote, + cascade, + } => { + let mut generator = self.child_generator(); + generator.generate_identifier(name)?; + if let Some(arguments) = arguments { + generator.write("("); + for (i, argument) in arguments.iter().enumerate() { + if i > 0 { + generator.write(", "); + } + generator.generate_expression(argument)?; + } + generator.write(")"); + } + if *remote { + generator.write(" REMOTE"); + } + if *cascade { + generator.write(" CASCADE"); + } + Ok(generator.output) + } + HintExpression::Function { name, args } => { // Generate each argument to a string let arg_strings: Vec = args @@ -18049,7 +18372,61 @@ impl Generator { Ok(()) } + fn validate_cast_to_hana(&self, cast: &Cast) -> Result<()> { + if self.config.dialect != Some(DialectType::HANA) { + return Ok(()); + } + // Native HANA casts retain DataType::Hana. A generic DECIMAL does not + // establish source rounding/overflow semantics, including for TRY/SAFE_CAST. + if matches!(cast.to, DataType::Decimal { .. }) { + return Err(self.hana_unsupported( + "Decimal CAST to HANA requires a verified source conversion; numeric rounding and overflow rules differ", + )); + } + if matches!( + cast.to, + DataType::SmallInt { .. } + | DataType::Int { .. } + | DataType::BigInt { .. } + | DataType::TinyInt { .. } + ) && (Self::hana_integer(&cast.this).is_none() + || matches!(cast.to, DataType::TinyInt { .. }) + && !Self::hana_integer(&cast.this).is_some_and(|n| (0..=127).contains(&n))) + { + return Err(self.hana_unsupported( + "Integer CAST to HANA requires a verified source conversion; numeric rounding and character conversion rules differ", + )); + } + Ok(()) + } + fn generate_cast(&mut self, cast: &Cast) -> Result<()> { + self.validate_cast_to_hana(cast)?; + if self.config.dialect != Some(DialectType::HANA) { + if let DataType::Hana { hana_type } = &cast.to { + if matches!( + hana_type.name.as_str(), + "TINYINT" | "SMALLINT" | "INT" | "BIGINT" + ) { + return self.generate_hana_integer_cast(cast, &hana_type.name); + } + if hana_type.name == "DECIMAL" { + let (precision, scale) = Self::hana_decimal_cast( + cast, + hana_type, + self.config.dialect.unwrap_or_default(), + )?; + return self.generate_hana_decimal(&cast.this, precision, scale); + } + } + } + + if self.config.dialect != Some(DialectType::HANA) { + // Some target CAST mappings replace an entire container type with + // CHAR/JSON; validate its leaves before those shortcuts can discard them. + Self::validate_hana_data_type(&cast.to, self.config.dialect.unwrap_or_default())?; + } + use crate::dialects::DialectType; // SingleStore uses :> syntax @@ -18582,7 +18959,1018 @@ impl Generator { Ok(()) } + fn generate_hana_partition( + &mut self, + partition: &PartitionSpec, + subpartition: bool, + ) -> Result<()> { + if self.config.dialect != Some(DialectType::HANA) { + return Err(self.hana_unsupported("HANA partitioning has no verified target mapping")); + } + self.write(if subpartition { + "SUBPARTITION BY " + } else { + "PARTITION BY " + }); + self.write(&partition.method); + if !partition.columns.is_empty() { + self.write(" ("); + for (i, column) in partition.columns.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.generate_expression(column)?; + } + self.write(")"); + } + if let Some(check) = partition.primary_key_check { + self.write(if check { + " PRIMARY KEY CHECK" + } else { + " NO PRIMARY KEY CHECK" + }); + } + if let Some(count) = &partition.partitions { + self.write(" PARTITIONS "); + self.generate_expression(count)?; + } + if !partition.ranges.is_empty() { + self.write(" ("); + for (i, range) in partition.ranges.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.write("PARTITION "); + if let Some(name) = &range.name { + self.generate_identifier(name)?; + self.write(" "); + } + match range.kind.as_str() { + "OTHERS" => self.write("OTHERS"), + "RANGE" if range.values.len() == 2 => { + self.generate_expression(&range.values[0])?; + self.write(" <= VALUES < "); + self.generate_expression(&range.values[1])?; + } + "LESS" | "AT_LEAST" | "VALUE" if range.values.len() == 1 => { + self.write(match range.kind.as_str() { + "LESS" => "VALUES < ", + "AT_LEAST" => "VALUES >= ", + _ => "VALUES = ", + }); + self.generate_expression(&range.values[0])?; + } + _ => return Err(self.hana_unsupported("Invalid HANA partition range AST")), + } + if range.dynamic { + self.write(" DYNAMIC"); + } + if let Some(direction) = &range.direction { + self.write(" "); + self.write(direction); + } + if let Some(threshold) = &range.threshold { + self.write(" THRESHOLD "); + self.generate_expression(threshold)?; + } + if let Some((value, unit)) = &range.interval { + self.write(" INTERVAL "); + self.generate_expression(value)?; + if let Some(unit) = unit { + self.write(" "); + self.write(unit); + } + } + for property in &range.properties { + self.write(" "); + self.generate_expression(property)?; + } + } + self.write(")"); + } + for property in &partition.properties { + self.write(" "); + self.generate_expression(property)?; + } + if let Some(sub) = &partition.subpartition { + self.write(" "); + self.generate_hana_partition(sub, true)?; + } + Ok(()) + } + + fn generate_hana_call(&mut self, call: &Call) -> Result<()> { + if self.config.dialect != Some(DialectType::HANA) { + return Err( + self.hana_unsupported("HANA CALL requires a verified target procedure signature") + ); + } + self.write("CALL "); + for (i, name) in call.name.iter().enumerate() { + if i > 0 { + self.write("."); + } + self.generate_identifier(name)?; + } + if let Some(member) = &call.member { + self.write(":"); + self.generate_identifier(member)?; + } + self.write("("); + for (i, argument) in call.arguments.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.generate_expression(argument)?; + } + self.write(")"); + if call.asynchronous { + self.write(" ASYNC"); + } + if !call.hints.is_empty() { + self.write(" WITH HINT ("); + for (i, hint) in call.hints.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.generate_expression(hint)?; + } + self.write(")"); + } + Ok(()) + } + + fn check_json_source( + &self, + source: Option, + options: Option<&JsonOptions>, + ) -> Result<()> { + if source == Some(DialectType::HANA) && self.config.dialect != source { + return Err(self.hana_unsupported( + "SQL/JSON path and error semantics have no verified target mapping", + )); + } + if options.is_some() && self.config.dialect != Some(DialectType::HANA) { + return Err( + self.hana_unsupported("SQL/JSON behavior options have no verified target mapping") + ); + } + Ok(()) + } + + fn generate_json_options(&mut self, options: &JsonOptions) -> Result<()> { + if let Some(wrapper) = &options.wrapper { + self.write(" "); + self.write(wrapper); + self.write(" WRAPPER"); + } + for (behavior, suffix) in [ + (&options.on_empty, " ON EMPTY"), + (&options.on_error, " ON ERROR"), + ] { + if let Some(behavior) = behavior { + self.write(" "); + self.write(&behavior.kind); + if let Some(value) = &behavior.value { + self.write(" "); + self.generate_expression(value)?; + } + self.write(suffix); + } + } + Ok(()) + } + + fn generate_hana_hierarchy(&mut self, hierarchy: &Hierarchy) -> Result<()> { + if self.config.dialect != Some(DialectType::HANA) { + return Err(self.hana_unsupported("HANA hierarchy has no verified target mapping")); + } + self.write(&hierarchy.name); + self.write("(SOURCE "); + self.generate_expression(&hierarchy.source)?; + if let Some(start) = &hierarchy.start { + self.write(" START WHERE "); + self.generate_expression(start)?; + } + if !hierarchy.siblings.is_empty() { + self.write(" SIBLING ORDER BY "); + for (i, order) in hierarchy.siblings.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.generate_ordered(order)?; + } + } + if let Some(depth) = &hierarchy.depth { + self.write(" DEPTH "); + self.generate_expression(depth)?; + } + for (keyword, option) in [ + ("MULTIPARENT", &hierarchy.multiparent), + ("ORPHAN", &hierarchy.orphan), + ("CYCLE", &hierarchy.cycle), + ] { + if let Some(option) = option { + self.write(" "); + self.write(keyword); + if !option.is_empty() { + self.write(" "); + self.write(option); + } + } + } + if let Some(cache) = &hierarchy.cache { + self.write(" "); + self.write(cache); + } + self.write(")"); + Ok(()) + } + + fn generate_hana_upsert(&mut self, upsert: &Upsert) -> Result<()> { + if self.config.dialect != Some(DialectType::HANA) { + return Err(self.hana_unsupported( + "HANA UPSERT requires target-specific key and update semantics", + )); + } + self.write("UPSERT "); + self.generate_table(&upsert.table)?; + if let Some(partition) = &upsert.partition { + self.write(" PARTITION ("); + self.generate_expression(partition)?; + self.write(")"); + } + if !upsert.columns.is_empty() { + self.write(" ("); + for (i, column) in upsert.columns.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.generate_identifier(column)?; + } + self.write(")"); + } + self.write(" "); + self.generate_expression(&upsert.source)?; + if let Some(condition) = &upsert.condition { + self.write(" WHERE "); + self.generate_expression(condition)?; + } + if upsert.primary_key { + self.write(" WITH PRIMARY KEY"); + } + Ok(()) + } + + fn generate_source_regex(&mut self, expr: &Expression, options: &RegexOptions) -> Result<()> { + if self.config.dialect != Some(DialectType::HANA) { + return Err(self.hana_unsupported( + "Regular-expression source semantics have no verified target mapping", + )); + } + let (subject, pattern, flag, start, occurrence, group, replacement) = match expr { + Expression::RegexpLike(r) => ( + &r.this, + &r.pattern, + r.flags.as_ref(), + None, + None, + None, + None, + ), + Expression::RegexpExtract(r) => ( + &r.this, + &r.pattern, + options.flags.as_ref(), + options.start.as_ref(), + options.occurrence.as_ref(), + r.group.as_ref(), + None, + ), + Expression::RegexpReplace(r) => ( + &r.this, + &r.pattern, + r.flags.as_ref(), + options.start.as_ref(), + options.occurrence.as_ref(), + None, + Some(&r.replacement), + ), + Expression::RegexpInstr(r) => ( + r.this.as_ref(), + r.expression.as_ref(), + r.parameters.as_deref(), + r.position.as_deref(), + r.occurrence.as_deref(), + r.group.as_deref(), + None, + ), + Expression::RegexpCount(r) => ( + r.this.as_ref(), + r.expression.as_ref(), + r.parameters.as_deref(), + r.position.as_deref(), + None, + None, + None, + ), + _ => unreachable!("regex options belong to regex expressions"), + }; + let predicate = options.source_name == "LIKE_REGEXPR"; + if predicate { + self.generate_expression(subject)?; + self.write(if options.negated { + " NOT LIKE_REGEXPR " + } else { + " LIKE_REGEXPR " + }); + } else { + self.write(&options.source_name); + self.write("("); + if let Some(after) = options.position_after { + self.write(if after { "AFTER " } else { "START " }); + } + } + self.generate_expression(pattern)?; + if let Some(flag) = flag { + self.write(" FLAG "); + self.generate_expression(flag)?; + } + if !predicate { + self.write(" IN "); + self.generate_expression(subject)?; + } + for (keyword, argument) in [ + (" WITH ", replacement), + (" FROM ", start), + (" OCCURRENCE ", occurrence), + (" GROUP ", group), + ] { + if let Some(argument) = argument { + self.write(keyword); + self.generate_expression(argument)?; + } + } + if !predicate { + self.write(")"); + } + Ok(()) + } + + fn hana_integer(expr: &Expression) -> Option { + match expr { + Expression::Literal(l) => match l.as_ref() { + Literal::Number(n) => n.parse().ok(), + _ => None, + }, + Expression::Neg(n) => Self::hana_integer(&n.this)?.checked_neg(), + _ => None, + } + } + + fn hana_exact_numeric(expr: &Expression) -> bool { + match expr { + Expression::Literal(l) => { + matches!(l.as_ref(), Literal::Number(n) if !n.contains(['e', 'E'])) + } + Expression::Neg(n) => Self::hana_exact_numeric(&n.this), + Expression::Cast(c) => { + matches!( + &c.to, + DataType::Decimal { + precision: Some(_), + .. + } + ) || matches!(&c.to, DataType::Hana { hana_type } if hana_type.name == "DECIMAL" && !hana_type.parameters.is_empty()) + } + _ => false, + } + } + + fn hana_portable_string(expr: &Expression) -> bool { + // HANA indexes CESU-8/UTF-16 units; these targets index Unicode code + // points. Unknown/binary/supplementary-character inputs need another mapping. + matches!(expr, Expression::Literal(l) if matches!(l.as_ref(), Literal::String(s) | Literal::NationalString(s) if s.chars().all(|c| (c as u32) <= 0xffff))) + } + + fn hana_trino_target(target: DialectType) -> bool { + matches!( + target, + DialectType::Trino | DialectType::Presto | DialectType::Athena | DialectType::Dune + ) + } + + fn hana_numeric_target(target: DialectType) -> bool { + Self::hana_trino_target(target) + || matches!( + target, + DialectType::DuckDB + | DialectType::PostgreSQL + | DialectType::CockroachDB + | DialectType::Materialize + | DialectType::RisingWave + ) + } + + /// Return whether truncation is needed before an integer conversion. + fn hana_integer_cast(cast: &Cast, name: &str, target: DialectType) -> Result { + let integer = Self::hana_integer(&cast.this); + let tinyint_fits = name != "TINYINT" + || target == DialectType::DuckDB + || integer.is_some_and(|n| (0..=255).contains(&n)); + if !Self::hana_numeric_target(target) + || !tinyint_fits + || !Self::hana_exact_numeric(&cast.this) + || cast.format.is_some() + || cast.default.is_some() + { + return Err(Self::hana_unsupported_for_target( + target, + format!( + "HANA {name} CAST requires verified numeric input and matching overflow semantics" + ), + )); + } + Ok(integer.is_none()) + } + + fn generate_hana_integer_cast(&mut self, cast: &Cast, name: &str) -> Result<()> { + let target = self.config.dialect.unwrap_or_default(); + let truncate = Self::hana_integer_cast(cast, name, target)?; + self.write("CAST("); + if truncate { + self.write( + if matches!( + target, + DialectType::Trino + | DialectType::Presto + | DialectType::Athena + | DialectType::Dune + ) { + "TRUNCATE(" + } else { + "TRUNC(" + }, + ); + } + self.generate_expression(&cast.this)?; + if truncate { + self.write(")"); + } + self.write(" AS "); + self.write(match name { + "TINYINT" if target == DialectType::DuckDB => "UTINYINT", + "TINYINT" => "SMALLINT", + name => name, + }); + self.write(")"); + Ok(()) + } + + fn hana_unsupported_for_target( + target: DialectType, + feature: impl Into, + ) -> crate::error::Error { + crate::error::Error::unsupported(feature, target.to_string()) + } + + fn hana_unsupported(&self, feature: impl Into) -> crate::error::Error { + Self::hana_unsupported_for_target(self.config.dialect.unwrap_or_default(), feature) + } + + fn generate_grouping_options( + &mut self, + kind: &str, + expressions: &[Expression], + grouping: &GroupingOptions, + ) -> Result<()> { + if self.config.dialect != Some(DialectType::HANA) { + return Err( + self.hana_unsupported("HANA grouping-set selection and result delivery options") + ); + } + self.write(kind); + for (keyword, value) in [ + (" BEST ", &grouping.best), + (" LIMIT ", &grouping.limit), + (" OFFSET ", &grouping.offset), + ] { + if let Some(value) = value { + self.write(keyword); + self.generate_expression(value)?; + } + } + for (enabled, keyword) in [ + (grouping.subtotal, " WITH SUBTOTAL"), + (grouping.balance, " WITH BALANCE"), + (grouping.total, " WITH TOTAL"), + (grouping.structured, " STRUCTURED RESULT"), + (grouping.overview, " WITH OVERVIEW"), + ] { + if enabled { + self.write(keyword); + } + } + if let Some(prefix) = &grouping.prefix { + self.write(" PREFIX "); + self.generate_expression(prefix)?; + } + if grouping.multiple_resultsets { + self.write(" MULTIPLE RESULTSETS"); + } + self.write(" ("); + for (i, expression) in expressions.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.generate_expression(expression)?; + } + self.write(")"); + Ok(()) + } + + fn generate_sql_behavior(&mut self, behavior: &SqlBehavior) -> Result<()> { + self.write(&behavior.kind); + if let Some(value) = &behavior.value { + self.write(" "); + self.generate_expression(value)?; + } + Ok(()) + } + + fn hana_function_mapping( + function: &Function, + target: DialectType, + ) -> Result { + if function.quoted { + return Ok(HanaFunctionMapping::Quoted); + } + let name = if function.name.bytes().any(|c| c.is_ascii_lowercase()) { + Cow::Owned(function.name.to_ascii_uppercase()) + } else { + Cow::Borrowed(function.name.as_str()) + }; + let args = &function.args; + if matches!(name.as_ref(), "COALESCE" | "IFNULL") && args.len() >= 2 { + return Ok(HanaFunctionMapping::Coalesce); + } + if name == "LOCATE" && (2..=4).contains(&args.len()) && Self::hana_numeric_target(target) { + let start = args.get(2).map_or(Some(1), Self::hana_integer); + let occurrence = args.get(3).map_or(Some(1), Self::hana_integer); + if let (Some(start), Some(occurrence)) = (start, occurrence) { + if start >= 0 + && occurrence > 0 + && (Self::hana_trino_target(target) || occurrence == 1) + && args[..2].iter().all(Self::hana_portable_string) + && (!matches!(&args[1], Expression::Literal(l) if matches!(l.as_ref(), Literal::String(s) | Literal::NationalString(s) if s.is_empty())) + || (start <= 1 && occurrence == 1)) + { + return Ok(HanaFunctionMapping::Locate { + start: start.max(1), + occurrence, + }); + } + } + } + if matches!(name.as_ref(), "SUBSTRING" | "SUBSTR") + && (2..=3).contains(&args.len()) + && Self::hana_numeric_target(target) + && Self::hana_portable_string(&args[0]) + { + if let Some(start) = Self::hana_integer(&args[1]) { + let length = args.get(2).map(Self::hana_integer); + if !matches!(length, Some(None)) { + return Ok(HanaFunctionMapping::Substring { + start: start.max(1), + length: length.flatten().map(|n| n.max(0)), + }); + } + } + } + if name == "TO_DECIMAL" && args.len() == 3 { + let (precision, scale) = Self::hana_decimal_mapping( + &args[0], + Self::hana_integer(&args[1]), + Self::hana_integer(&args[2]), + target, + )?; + return Ok(HanaFunctionMapping::Decimal { precision, scale }); + } + if matches!( + name.as_ref(), + "TO_DATE" | "TO_TIMESTAMP" | "TO_VARCHAR" | "TO_NVARCHAR" + ) && args.len() == 2 + && (Self::hana_trino_target(target) || target == DialectType::DuckDB) + && (name != "TO_TIMESTAMP" || target == DialectType::Trino) + { + if let Expression::Literal(literal) = &args[1] { + if let Literal::String(mask) = literal.as_ref() { + if let Some(mask) = crate::format_tokens::hana_datetime_format( + mask, + Self::hana_trino_target(target), + ) { + let formatting = matches!(name.as_ref(), "TO_VARCHAR" | "TO_NVARCHAR"); + let known_datetime = matches!(&args[0], Expression::Cast(c) if matches!(c.to, DataType::Date | DataType::Timestamp { .. } | DataType::Time { .. })) + || matches!(&args[0], Expression::Cast(c) if matches!(&c.to, DataType::Hana { hana_type } if matches!(hana_type.name.as_str(), "TIMESTAMP" | "TIME" | "SECONDDATE"))) + || matches!(&args[0], Expression::Literal(l) if matches!(l.as_ref(), Literal::Date(_) | Literal::Timestamp(_))); + if !formatting || known_datetime { + return Ok(HanaFunctionMapping::Datetime { + mask, + formatting, + date: name == "TO_DATE", + }); + } + } + } + } + } + if matches!( + name.as_ref(), + "CURRENT_UTCDATE" | "CURRENT_UTCTIME" | "CURRENT_UTCTIMESTAMP" + ) && Self::hana_numeric_target(target) + { + let precision = match args.as_slice() { + [] => Some(3), + [arg] => Self::hana_integer(arg), + _ => None, + }; + if let Some(precision) = precision { + if (0..=7).contains(&precision) + && (target == DialectType::Trino || name == "CURRENT_UTCDATE") + { + return Ok(HanaFunctionMapping::Utc { + precision, + date: name == "CURRENT_UTCDATE", + time: name == "CURRENT_UTCTIME", + }); + } + } + } + Err(Self::hana_unsupported_for_target( + target, + format!("HANA {name}: no verified mapping for these arguments"), + )) + } + + fn generate_hana_function(&mut self, function: &Function) -> Result<()> { + if self.config.dialect == Some(DialectType::HANA) { + return self.generate_function(function); + } + let target = self.config.dialect.unwrap_or_default(); + let args = &function.args; + let trino = matches!( + target, + DialectType::Trino | DialectType::Presto | DialectType::Athena | DialectType::Dune + ); + match Self::hana_function_mapping(function, target)? { + HanaFunctionMapping::Quoted => { + let mut identifier = Identifier::new(&function.name); + identifier.quoted = true; + self.generate_identifier(&identifier)?; + self.write("("); + for (i, arg) in args.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.generate_expression(arg)?; + } + self.write(")"); + } + HanaFunctionMapping::Coalesce => return self.generate_vararg_func("COALESCE", args), + HanaFunctionMapping::Locate { start, occurrence } => { + let haystack = self.generate_to_string(&args[0])?; + let needle = self.generate_to_string(&args[1])?; + let searched = if start == 1 { + haystack + } else { + format!("SUBSTR({haystack}, {start})") + }; + let found = if trino && occurrence != 1 { + format!("STRPOS({searched}, {needle}, {occurrence})") + } else { + format!("STRPOS({searched}, {needle})") + }; + if start == 1 { + self.write(&found); + } else { + self.write(&format!( + "CASE WHEN {found} = 0 THEN 0 ELSE {found} + {} END", + start - 1 + )); + } + } + HanaFunctionMapping::Substring { start, length } => { + self.write_func_name("SUBSTRING"); + self.write("("); + self.generate_expression(&args[0])?; + self.write(&format!(", {start}")); + if let Some(length) = length { + self.write(&format!(", {length}")); + } + self.write(")"); + } + HanaFunctionMapping::Decimal { precision, scale } => { + return self.generate_hana_decimal(&args[0], precision, scale) + } + HanaFunctionMapping::Datetime { + mask, + formatting, + date, + } => { + let value = self.generate_to_string(&args[0])?; + let mask = self.generate_to_string(&Expression::string(mask))?; + if formatting { + let function = if trino { "DATE_FORMAT" } else { "STRFTIME" }; + self.write(&format!("{function}({value}, {mask})")); + } else { + let function = if trino { "DATE_PARSE" } else { "STRPTIME" }; + let result_type = if date { + "DATE" + } else if trino { + "TIMESTAMP(7)" + } else { + "TIMESTAMP" + }; + self.write(&format!( + "CAST({function}({value}, {mask}) AS {result_type})" + )); + } + } + HanaFunctionMapping::Utc { + precision, + date, + time, + } => { + let utc = if trino && !date && !time { + format!("CURRENT_TIMESTAMP({precision}) AT TIME ZONE 'UTC'") + } else { + "CURRENT_TIMESTAMP AT TIME ZONE 'UTC'".to_owned() + }; + let data_type = if date { + "DATE".to_owned() + } else if time { + "TIME(0)".to_owned() + } else { + format!("TIMESTAMP({precision})") + }; + self.write(&format!("CAST({utc} AS {data_type})")); + } + } + Ok(()) + } + + fn hana_decimal_mapping( + input: &Expression, + precision: Option, + scale: Option, + target: DialectType, + ) -> Result<(i64, i64)> { + if let (Some(precision), Some(scale)) = (precision, scale) { + if Self::hana_numeric_target(target) + && (1..=38).contains(&precision) + && (0..=precision).contains(&scale) + && Self::hana_exact_numeric(input) + { + return Ok((precision, scale)); + } + } + Err(Self::hana_unsupported_for_target( + target, + "HANA TO_DECIMAL: no verified mapping for these arguments", + )) + } + + fn hana_decimal_cast( + cast: &Cast, + data_type: &HanaDataType, + target: DialectType, + ) -> Result<(i64, i64)> { + let (precision, scale) = match data_type.parameters.as_slice() { + [p] => (Some(i64::from(*p)), Some(0)), + [p, s] => (Some(i64::from(*p)), Some(i64::from(*s))), + _ => (None, None), + }; + Self::hana_decimal_mapping(&cast.this, precision, scale, target) + } + + fn generate_hana_decimal( + &mut self, + value: &Expression, + precision: i64, + scale: i64, + ) -> Result<()> { + let truncate = if matches!( + self.config.dialect, + Some( + DialectType::Trino | DialectType::Presto | DialectType::Athena | DialectType::Dune + ) + ) { + "TRUNCATE" + } else { + "TRUNC" + }; + self.write("CAST("); + self.write(truncate); + self.write("("); + self.generate_expression(value)?; + self.write(&format!(", {scale}) AS DECIMAL({precision}, {scale}))")); + Ok(()) + } + + fn hana_type_mapping( + data_type: &HanaDataType, + target: DialectType, + ) -> Result> { + let mapping = match (data_type.name.as_str(), data_type.parameters.as_slice()) { + ("SMALLINT" | "INT" | "BIGINT", []) => HanaTypeMapping::Name(&data_type.name), + ("TIMESTAMP", []) if target == DialectType::Trino => { + HanaTypeMapping::Name("TIMESTAMP(7)") + } + ("TIME", []) if target == DialectType::Trino => HanaTypeMapping::Name("TIME(0)"), + ("FLOAT", [] | [1..=53]) + if matches!( + target, + DialectType::Trino + | DialectType::Presto + | DialectType::DuckDB + | DialectType::PostgreSQL + ) => + { + HanaTypeMapping::Name(if data_type.parameters.first().is_some_and(|p| *p <= 24) { + "REAL" + } else if target == DialectType::PostgreSQL { + "DOUBLE PRECISION" + } else { + "DOUBLE" + }) + } + ("TINYINT", []) if matches!(target, DialectType::DuckDB | DialectType::ClickHouse) => { + HanaTypeMapping::Standard(DataType::UInt8) + } + ("DECIMAL", [p, s]) if (1..=38).contains(p) && s <= p => { + HanaTypeMapping::Standard(DataType::Decimal { + precision: Some(*p), + scale: Some(*s), + }) + } + _ => { + return Err(Self::hana_unsupported_for_target( + target, + format!( + "HANA {} has no lossless type mapping for this target", + data_type.name + ), + )) + } + }; + Ok(mapping) + } + + #[cfg(feature = "transpile")] + pub(crate) fn validate_hana_source_type( + data_type: &DataType, + target: DialectType, + ) -> Result<()> { + if let DataType::Hana { hana_type } = data_type { + Self::hana_type_mapping(hana_type, target)?; + } + Ok(()) + } + + fn validate_hana_data_type(data_type: &DataType, target: DialectType) -> Result<()> { + // Validate one complete type before rendering can collapse a container + // to a target fallback such as JSON or NVARCHAR(MAX). + let mut pending = Vec::new(); + let mut current = data_type; + loop { + match current { + DataType::Hana { hana_type } => { + Self::hana_type_mapping(hana_type, target)?; + } + DataType::Array { element_type, .. } + | DataType::List { element_type } + | DataType::Nullable { + inner: element_type, + } + | DataType::Vector { + element_type: Some(element_type), + .. + } => { + current = element_type; + continue; + } + DataType::Map { + key_type, + value_type, + } => { + pending.push(value_type.as_ref()); + current = key_type; + continue; + } + DataType::Struct { fields, .. } => { + pending.extend(fields.iter().map(|f| &f.data_type)) + } + DataType::Union { fields } => pending.extend(fields.iter().map(|(_, t)| t)), + DataType::Object { fields, .. } => pending.extend(fields.iter().map(|(_, t, _)| t)), + _ => {} + } + match pending.pop() { + Some(next) => current = next, + None => return Ok(()), + } + } + } + + fn generate_hana_data_type(&mut self, data_type: &HanaDataType) -> Result<()> { + if self.config.dialect != Some(DialectType::HANA) { + match Self::hana_type_mapping(data_type, self.config.dialect.unwrap_or_default())? { + HanaTypeMapping::Name(name) => self.write(name), + HanaTypeMapping::Standard(data_type) => return self.generate_data_type(&data_type), + } + return Ok(()); + } + self.write(&data_type.name); + if !data_type.parameters.is_empty() { + self.write("("); + for (i, parameter) in data_type.parameters.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.write(¶meter.to_string()); + } + self.write(")"); + } + Ok(()) + } + fn generate_function(&mut self, func: &Function) -> Result<()> { + if func.source_dialect == Some(DialectType::HANA) + && self.config.dialect != Some(DialectType::HANA) + { + return self.generate_hana_function(func); + } + if !func.qualified_name.is_empty() { + for (i, component) in func.qualified_name.iter().enumerate() { + if i > 0 { + self.write("."); + } + self.generate_identifier(component)?; + } + if !func.no_parens { + self.write("("); + if func.distinct { + self.write("DISTINCT "); + } + for (i, argument) in func.args.iter().enumerate() { + if i > 0 { + self.write(", "); + } + self.generate_expression(argument)?; + } + if let Some(behavior) = &func.on_error { + if self.config.dialect != Some(DialectType::HANA) { + return Err(self.hana_unsupported( + "Function error behavior has no verified target mapping", + )); + } + self.write(" "); + self.generate_sql_behavior(behavior)?; + self.write(" ON ERROR"); + } + self.write(")"); + } + for comment in &func.trailing_comments { + self.write_space(); + self.write_formatted_comment(comment); + } + return Ok(()); + } + if func.on_error.is_some() && self.config.dialect != Some(DialectType::HANA) { + return Err( + self.hana_unsupported("Function error behavior has no verified target mapping") + ); + } + + if self.config.dialect == Some(DialectType::HANA) + && !func.quoted + && matches!( + func.name.to_ascii_uppercase().as_str(), + "DATE_ADD" + | "DATE_SUB" + | "DATE_DIFF" + | "DATEADD" + | "DATEDIFF" + | "STRFTIME" + | "STRPTIME" + | "DATE_PARSE" + | "DATE_FORMAT" + ) + { + return Err(self.hana_unsupported(format!( + "{} requires a verified conversion to HANA semantics", + func.name + ))); + } // Normalize function name based on dialect settings let normalized_name = if func.name.eq_ignore_ascii_case("GROUPING") && func.args.len() > 1 @@ -19285,6 +20673,11 @@ impl Generator { } } + if let Some(behavior) = &func.on_error { + self.write(" "); + self.generate_sql_behavior(behavior)?; + self.write(" ON ERROR"); + } if use_brackets { self.write("]"); } else { @@ -19311,6 +20704,15 @@ impl Generator { } fn generate_aggregate_function(&mut self, func: &AggregateFunction) -> Result<()> { + if func.source_dialect == Some(DialectType::HANA) + && self.config.dialect != Some(DialectType::HANA) + { + return Err(self.hana_unsupported(format!( + "HANA aggregate {} has no verified target mapping", + func.name + ))); + } + // Normalize function name based on dialect settings let mut normalized_name = self.normalize_func_name(&func.name); @@ -22392,6 +23794,10 @@ impl Generator { } } Expression::Function(Box::new(crate::expressions::Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: f.name.clone(), args: new_args, distinct: f.distinct, @@ -23028,6 +24434,23 @@ impl Generator { // JSON function generators fn generate_json_extract(&mut self, name: &str, f: &JsonExtractFunc) -> Result<()> { + self.check_json_source(f.source_dialect, f.options.as_ref())?; + if f.source_dialect == Some(DialectType::HANA) { + self.write(name); + self.write("("); + self.generate_expression(&f.this)?; + self.write(", "); + self.generate_expression(&f.path)?; + if let Some(returning) = &f.returning { + self.write(" RETURNING "); + self.generate_data_type(returning)?; + } + if let Some(options) = &f.options { + self.generate_json_options(options)?; + } + self.write(")"); + return Ok(()); + } use crate::dialects::DialectType; // Check if we should use arrow syntax (-> or ->>) @@ -23669,6 +25092,7 @@ impl Generator { // Type conversion generators fn generate_try_cast(&mut self, cast: &Cast) -> Result<()> { + self.validate_cast_to_hana(cast)?; use crate::dialects::DialectType; // SingleStore uses !:> syntax for try cast @@ -23731,6 +25155,7 @@ impl Generator { } fn generate_safe_cast(&mut self, cast: &Cast) -> Result<()> { + self.validate_cast_to_hana(cast)?; self.write_keyword("SAFE_CAST"); self.write("("); self.generate_expression(&cast.this)?; @@ -25919,6 +27344,14 @@ impl Generator { } fn generate_data_type(&mut self, dt: &DataType) -> Result<()> { + if self.config.dialect != Some(DialectType::HANA) { + Self::validate_hana_data_type(dt, self.config.dialect.unwrap_or_default())?; + } + self.generate_data_type_inner(dt) + } + + // Recursive rendering uses this method so each type is validated only once. + fn generate_data_type_inner(&mut self, dt: &DataType) -> Result<()> { use crate::dialects::DialectType; match dt { @@ -26192,6 +27625,7 @@ impl Generator { } } } + DataType::Hana { hana_type } => self.generate_hana_data_type(hana_type)?, DataType::Oracle { oracle_type } => { self.write_oracle_data_type(oracle_type)?; } @@ -26779,11 +28213,15 @@ impl Generator { } => { // Dialect-specific array syntax match self.config.dialect { + Some(DialectType::HANA) => { + self.generate_data_type_inner(element_type)?; + self.write(" ARRAY"); + } Some(DialectType::PostgreSQL) | Some(DialectType::Redshift) | Some(DialectType::DuckDB) => { // PostgreSQL uses TYPE[] or TYPE[N] syntax - self.generate_data_type(element_type)?; + self.generate_data_type_inner(element_type)?; if let Some(dim) = dimension { self.write(&format!("[{}]", dim)); } else { @@ -26792,7 +28230,7 @@ impl Generator { } Some(DialectType::BigQuery) => { self.write_keyword("ARRAY<"); - self.generate_data_type(element_type)?; + self.generate_data_type_inner(element_type)?; self.write(">"); } Some(DialectType::Snowflake) @@ -26805,7 +28243,7 @@ impl Generator { } else { self.write_keyword("ARRAY("); } - self.generate_data_type(element_type)?; + self.generate_data_type_inner(element_type)?; self.write(")"); } Some(DialectType::TSQL) @@ -26822,14 +28260,14 @@ impl Generator { _ => { // Default: use angle bracket syntax (ARRAY) self.write_keyword("ARRAY<"); - self.generate_data_type(element_type)?; + self.generate_data_type_inner(element_type)?; self.write(">"); } } } DataType::List { element_type } => { // Materialize: element_type LIST (postfix syntax) - self.generate_data_type(element_type)?; + self.generate_data_type_inner(element_type)?; self.write_keyword(" LIST"); } DataType::Map { @@ -26841,9 +28279,9 @@ impl Generator { Some(DialectType::Materialize) => { // Materialize: MAP[key_type => value_type] self.write_keyword("MAP["); - self.generate_data_type(key_type)?; + self.generate_data_type_inner(key_type)?; self.write(" => "); - self.generate_data_type(value_type)?; + self.generate_data_type_inner(value_type)?; self.write("]"); } Some(DialectType::Snowflake) @@ -26853,9 +28291,9 @@ impl Generator { | Some(DialectType::Trino) | Some(DialectType::Athena) => { self.write_keyword("MAP("); - self.generate_data_type(key_type)?; + self.generate_data_type_inner(key_type)?; self.write(", "); - self.generate_data_type(value_type)?; + self.generate_data_type_inner(value_type)?; self.write(")"); } Some(DialectType::ClickHouse) => { @@ -26864,17 +28302,17 @@ impl Generator { self.write("Map("); let saved_depth = self.clickhouse_nullable_depth; self.clickhouse_nullable_depth = -1; // suppress Nullable for key - self.generate_data_type(key_type)?; + self.generate_data_type_inner(key_type)?; self.clickhouse_nullable_depth = saved_depth; self.write(", "); - self.generate_data_type(value_type)?; + self.generate_data_type_inner(value_type)?; self.write(")"); } _ => { self.write_keyword("MAP<"); - self.generate_data_type(key_type)?; + self.generate_data_type_inner(key_type)?; self.write(", "); - self.generate_data_type(value_type)?; + self.generate_data_type_inner(value_type)?; self.write(">"); } } @@ -26910,7 +28348,7 @@ impl Generator { // Snowflake format: VECTOR(type, dimension) self.write_keyword("VECTOR("); if let Some(ref et) = element_type { - self.generate_data_type(et)?; + self.generate_data_type_inner(et)?; if dimension.is_some() { self.write(", "); } @@ -26929,7 +28367,7 @@ impl Generator { } self.generate_type_field_name(name); self.write(" "); - self.generate_data_type(dt)?; + self.generate_data_type_inner(dt)?; if *not_null { self.write_keyword(" NOT NULL"); } @@ -26954,7 +28392,7 @@ impl Generator { self.generate_type_field_name(&field.name); self.write(" "); } - self.generate_data_type(&field.data_type)?; + self.generate_data_type_inner(&field.data_type)?; } self.write(")"); } @@ -26969,7 +28407,7 @@ impl Generator { self.generate_type_field_name(&field.name); self.write(" "); } - self.generate_data_type(&field.data_type)?; + self.generate_data_type_inner(&field.data_type)?; } self.write(")"); } @@ -26984,7 +28422,7 @@ impl Generator { self.generate_type_field_name(&field.name); self.write(" "); } - self.generate_data_type(&field.data_type)?; + self.generate_data_type_inner(&field.data_type)?; } self.write(")"); } @@ -26999,7 +28437,7 @@ impl Generator { self.generate_type_field_name(&field.name); self.write(" "); } - self.generate_data_type(&field.data_type)?; + self.generate_data_type_inner(&field.data_type)?; } self.write(")"); } @@ -27014,7 +28452,7 @@ impl Generator { self.generate_type_field_name(&field.name); self.write(" "); } - self.generate_data_type(&field.data_type)?; + self.generate_data_type_inner(&field.data_type)?; } self.write(")"); } @@ -27038,7 +28476,7 @@ impl Generator { self.generate_type_field_name(&field.name); self.write(" "); } - self.generate_data_type(&field.data_type)?; + self.generate_data_type_inner(&field.data_type)?; } self.write(")"); } else { @@ -27053,7 +28491,7 @@ impl Generator { self.write(self.config.struct_field_sep); } // For anonymous fields, just output the type - self.generate_data_type(&field.data_type)?; + self.generate_data_type_inner(&field.data_type)?; // Spark/Databricks: Output COMMENT clause if present if let Some(comment) = &field.comment { self.write(" COMMENT '"); @@ -27120,7 +28558,7 @@ impl Generator { self.generate_type_field_name(name); self.write(" "); } - self.generate_data_type(dt)?; + self.generate_data_type_inner(dt)?; } self.write(")"); } @@ -27131,20 +28569,20 @@ impl Generator { // Suppress inner Nullable wrapping to prevent Nullable(Nullable(...)) let saved_depth = self.clickhouse_nullable_depth; self.clickhouse_nullable_depth = -1; - self.generate_data_type(inner)?; + self.generate_data_type_inner(inner)?; self.clickhouse_nullable_depth = saved_depth; self.write(")"); } else { // Map ClickHouse-specific custom type names to standard types match inner.as_ref() { DataType::Custom { name } if name.eq_ignore_ascii_case("DATETIME") => { - self.generate_data_type(&DataType::Timestamp { + self.generate_data_type_inner(&DataType::Timestamp { precision: None, timezone: false, })?; } _ => { - self.generate_data_type(inner)?; + self.generate_data_type_inner(inner)?; } } } @@ -29648,6 +31086,9 @@ impl Generator { } } self.write_keyword("COLUMNS"); + if self.config.dialect == Some(DialectType::HANA) { + self.write(" "); + } self.write("("); self.generate_expression(&e.this)?; self.write(")"); @@ -29821,6 +31262,12 @@ impl Generator { /// Handles MySQL/PostgreSQL: GENERATED ALWAYS AS (expr) STORED|VIRTUAL /// Handles TSQL: AS (expr) [PERSISTED] [NOT NULL] fn generate_computed_column_inline(&mut self, cc: &ComputedColumn) -> Result<()> { + if self.config.dialect == Some(DialectType::HANA) { + self.write("GENERATED ALWAYS AS "); + self.generate_expression(&cc.expression)?; + return Ok(()); + } + let computed_expr = if matches!( self.config.dialect, Some(DialectType::TSQL) | Some(DialectType::Fabric) @@ -30609,6 +32056,9 @@ impl Generator { } fn generate_cube(&mut self, e: &Cube) -> Result<()> { + if let Some(options) = &e.options { + return self.generate_grouping_options("CUBE", &e.expressions, options); + } // Python: return f"CUBE {self.wrap(expressions)}" if expressions else "WITH CUBE" if e.expressions.is_empty() { self.write_keyword("WITH CUBE"); @@ -32479,6 +33929,9 @@ impl Generator { } fn generate_grouping_sets(&mut self, e: &GroupingSets) -> Result<()> { + if let Some(options) = &e.options { + return self.generate_grouping_options("GROUPING SETS", &e.expressions, options); + } // Python: return f"GROUPING SETS {self.wrap(grouping_sets)}" self.write_keyword("GROUPING SETS"); self.write(" ("); @@ -33160,6 +34613,7 @@ impl Generator { } fn generate_json_column_def(&mut self, e: &JSONColumnDef) -> Result<()> { + self.check_json_source(e.source_dialect, e.options.as_ref())?; // Python: NESTED PATH path schema | this kind PATH path [FOR ORDINALITY] if let Some(nested_schema) = &e.nested_schema { self.write_keyword("NESTED"); @@ -33175,6 +34629,10 @@ impl Generator { if let Some(this) = &e.this { self.generate_expression(this)?; } + if let Some(data_type) = &e.data_type { + self.write_space(); + self.generate_data_type(data_type)?; + } if let Some(kind) = &e.kind { self.write_space(); self.write(kind); @@ -33183,6 +34641,10 @@ impl Generator { self.write_space(); self.write_keyword("FORMAT JSON"); } + if let Some(encoding) = &e.encoding { + self.write(" ENCODING "); + self.write(encoding); + } if let Some(path) = &e.path { self.write_space(); self.write_keyword("PATH"); @@ -33193,6 +34655,9 @@ impl Generator { self.write_keyword(" FOR ORDINALITY"); } } + if let Some(options) = &e.options { + self.generate_json_options(options)?; + } Ok(()) } @@ -33592,6 +35057,9 @@ impl Generator { // COLUMNS(col1 type, col2 type, ...) // When pretty printing and content is too wide, format with each column on a separate line self.write_keyword("COLUMNS"); + if self.config.dialect == Some(DialectType::HANA) { + self.write(" "); + } self.write("("); if self.config.pretty && !e.expressions.is_empty() { @@ -33668,6 +35136,7 @@ impl Generator { } fn generate_json_table(&mut self, e: &JSONTable) -> Result<()> { + self.check_json_source(e.source_dialect, e.options.as_ref())?; // JSON_TABLE(this, path [error_handling] [empty_handling] schema) self.write_keyword("JSON_TABLE"); self.write("("); @@ -33688,6 +35157,9 @@ impl Generator { self.write_space(); self.generate_expression(schema)?; } + if let Some(options) = &e.options { + self.generate_json_options(options)?; + } self.write(")"); Ok(()) } @@ -33702,6 +35174,7 @@ impl Generator { } fn generate_json_value(&mut self, e: &JSONValue) -> Result<()> { + self.check_json_source(e.source_dialect, e.options.as_ref())?; // JSON_VALUE(this, path RETURNING type ON condition) self.write_keyword("JSON_VALUE"); self.write("("); @@ -33720,6 +35193,9 @@ impl Generator { self.write_space(); self.generate_expression(on_condition)?; } + if let Some(options) = &e.options { + self.generate_json_options(options)?; + } self.write(")"); Ok(()) } @@ -33993,6 +35469,9 @@ impl Generator { } fn generate_lock(&mut self, e: &Lock) -> Result<()> { + if e.ignore_locked && self.config.dialect != Some(DialectType::HANA) { + return Err(self.hana_unsupported("HANA IGNORE LOCKED has no verified target mapping")); + } // Python: FOR UPDATE|FOR SHARE [OF tables] [NOWAIT|WAIT n] if e.update.is_some() { if e.key.is_some() { @@ -34005,6 +35484,9 @@ impl Generator { self.write_keyword("FOR KEY SHARE"); } else { self.write_keyword("FOR SHARE"); + if self.config.dialect == Some(DialectType::HANA) { + self.write(" LOCK"); + } } } if !e.expressions.is_empty() { @@ -34016,6 +35498,9 @@ impl Generator { self.generate_expression(expr)?; } } + if e.ignore_locked { + self.write(" IGNORE LOCKED"); + } // Handle wait option following Python sqlglot convention: // - Boolean(true) -> NOWAIT // - Boolean(false) -> SKIP LOCKED @@ -35628,6 +37113,9 @@ impl Generator { } fn generate_partition_by_property(&mut self, e: &PartitionByProperty) -> Result<()> { + if let Some(specification) = &e.specification { + return self.generate_hana_partition(specification, false); + } // BigQuery table property: PARTITION BY expression [, expression ...] self.write_keyword("PARTITION BY"); self.write_space(); @@ -36771,6 +38259,9 @@ impl Generator { } fn generate_rollup(&mut self, e: &Rollup) -> Result<()> { + if let Some(options) = &e.options { + return self.generate_grouping_options("ROLLUP", &e.expressions, options); + } // Python: return f"ROLLUP {self.wrap(expressions)}" if expressions else "WITH ROLLUP" if e.expressions.is_empty() { self.write_keyword("WITH ROLLUP"); @@ -41842,6 +43333,25 @@ mod tests { use super::*; use crate::parser::Parser; + #[cfg(all( + feature = "transpile", + feature = "dialect-hana", + feature = "dialect-duckdb" + ))] + #[test] + fn hana_substring_mapping_preserves_function_name_configuration() { + let ast = crate::Dialect::get(DialectType::HANA) + .parse("SELECT SUBSTRING('abc', -1, 2)") + .unwrap() + .remove(0); + let sql = crate::Dialect::get(DialectType::DuckDB) + .generate_with_overrides(&ast, |config| { + config.normalize_functions = crate::generator::NormalizeFunctions::Lower; + }) + .unwrap(); + assert_eq!(sql, "SELECT substring('abc', 1, 2)"); + } + fn roundtrip(sql: &str) -> String { let ast = Parser::parse_sql(sql).unwrap(); Generator::sql(&ast[0]).unwrap() diff --git a/crates/polyglot-sql/src/lineage.rs b/crates/polyglot-sql/src/lineage.rs index ffbbcece..5e105786 100644 --- a/crates/polyglot-sql/src/lineage.rs +++ b/crates/polyglot-sql/src/lineage.rs @@ -373,6 +373,7 @@ fn lineage_normalized_expression(sql: &Expression) -> Expression { .map(|query| attach_with_to_query(query.clone(), create.with_cte.clone())) .unwrap_or_else(|| sql.clone()), Expression::CreateView(create) => lineage_normalized_expression(&create.query), + Expression::Upsert(upsert) => lineage_normalized_expression(&upsert.source), Expression::Insert(insert) => insert .query .as_ref() diff --git a/crates/polyglot-sql/src/optimizer/canonicalize.rs b/crates/polyglot-sql/src/optimizer/canonicalize.rs index e4d0ac6e..1f4d7b29 100644 --- a/crates/polyglot-sql/src/optimizer/canonicalize.rs +++ b/crates/polyglot-sql/src/optimizer/canonicalize.rs @@ -170,42 +170,32 @@ fn canonicalize_recursive(expression: Expression, dialect: Option) remove_redundant_casts(result) } - // Function expressions - Expression::Function(func) => { - let args = func + // Preserve source metadata while canonicalizing shared function children. + Expression::Function(mut func) => { + func.args = func .args .into_iter() .map(|e| canonicalize_recursive(e, dialect)) .collect(); - Expression::Function(Box::new(crate::expressions::Function { - name: func.name, - args, - distinct: func.distinct, - trailing_comments: func.trailing_comments, - use_bracket_syntax: func.use_bracket_syntax, - no_parens: func.no_parens, - quoted: func.quoted, - span: None, - inferred_type: None, - })) + if let Some(behavior) = &mut func.on_error { + behavior.value = behavior + .value + .take() + .map(|e| canonicalize_recursive(e, dialect)); + } + func.span = None; + func.inferred_type = None; + Expression::Function(func) } - - Expression::AggregateFunction(agg) => { - let args = agg + Expression::AggregateFunction(mut agg) => { + agg.args = agg .args .into_iter() .map(|e| canonicalize_recursive(e, dialect)) .collect(); - Expression::AggregateFunction(Box::new(crate::expressions::AggregateFunction { - name: agg.name, - args, - distinct: agg.distinct, - filter: agg.filter.map(|f| canonicalize_recursive(f, dialect)), - order_by: agg.order_by, - limit: agg.limit, - ignore_nulls: agg.ignore_nulls, - inferred_type: None, - })) + agg.filter = agg.filter.map(|e| canonicalize_recursive(e, dialect)); + agg.inferred_type = None; + Expression::AggregateFunction(agg) } // Alias diff --git a/crates/polyglot-sql/src/optimizer/normalize_identifiers.rs b/crates/polyglot-sql/src/optimizer/normalize_identifiers.rs index f78774b1..08b20362 100644 --- a/crates/polyglot-sql/src/optimizer/normalize_identifiers.rs +++ b/crates/polyglot-sql/src/optimizer/normalize_identifiers.rs @@ -33,9 +33,10 @@ impl Default for NormalizationStrategy { pub fn get_normalization_strategy(dialect: Option) -> NormalizationStrategy { match dialect { // Uppercase dialects - Some(DialectType::Oracle) | Some(DialectType::Snowflake) | Some(DialectType::Exasol) => { - NormalizationStrategy::Uppercase - } + Some(DialectType::Oracle) + | Some(DialectType::Snowflake) + | Some(DialectType::Exasol) + | Some(DialectType::HANA) => NormalizationStrategy::Uppercase, // Case-sensitive dialects Some(DialectType::MySQL) | Some(DialectType::ClickHouse) => { NormalizationStrategy::CaseSensitive @@ -247,6 +248,17 @@ fn normalize_expression(expression: Expression, strategy: NormalizationStrategy) .into_iter() .map(|e| normalize_expression(e, strategy)) .collect(); + func.qualified_name = func + .qualified_name + .into_iter() + .map(|i| normalize_identifier(i, strategy)) + .collect(); + if let Some(behavior) = &mut func.on_error { + behavior.value = behavior + .value + .take() + .map(|e| normalize_expression(e, strategy)); + } Expression::Function(Box::new(func)) } Expression::AggregateFunction(agg) => { diff --git a/crates/polyglot-sql/src/optimizer/pushdown_projections.rs b/crates/polyglot-sql/src/optimizer/pushdown_projections.rs index 79fcbb3b..cf87d095 100644 --- a/crates/polyglot-sql/src/optimizer/pushdown_projections.rs +++ b/crates/polyglot-sql/src/optimizer/pushdown_projections.rs @@ -310,6 +310,8 @@ pub fn default_selection(is_agg: bool) -> Expression { // MAX(1) AS _ Expression::Alias(Box::new(Alias { this: Expression::AggregateFunction(Box::new(AggregateFunction { + source_dialect: None, + name: "MAX".to_string(), args: vec![Expression::Literal(Box::new(Literal::Number( "1".to_string(), diff --git a/crates/polyglot-sql/src/optimizer/set_operation_types.rs b/crates/polyglot-sql/src/optimizer/set_operation_types.rs index 45f1f095..fd1a3ad1 100644 --- a/crates/polyglot-sql/src/optimizer/set_operation_types.rs +++ b/crates/polyglot-sql/src/optimizer/set_operation_types.rs @@ -329,6 +329,7 @@ fn family(dialect: DialectType) -> Family { Doris | StarRocks => Family::Standard, Drill | Dremio => Family::Limited, Exasol => Family::Standard, + HANA => Family::Limited, Druid | Solr | Tableau => Family::Limited, } } diff --git a/crates/polyglot-sql/src/parser.rs b/crates/polyglot-sql/src/parser.rs index 2bab9243..c0baeebf 100644 --- a/crates/polyglot-sql/src/parser.rs +++ b/crates/polyglot-sql/src/parser.rs @@ -28,6 +28,7 @@ use crate::guard::{ ComplexityGuardOptions, TokenGuardStats, }; use crate::tokens::{ParserToken, Span, Token, TokenType, Tokenizer, TokenizerConfig}; +use crate::traversal::ExpressionWalk; use std::collections::HashSet; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, LazyLock, OnceLock}; @@ -37,6 +38,7 @@ use std::sync::{Arc, LazyLock, OnceLock}; #[derive(Default)] struct ParserRecursion { depth: AtomicUsize, + statements: AtomicUsize, failure: OnceLock<(usize, usize, Span)>, } @@ -1101,8 +1103,26 @@ impl Parser { return Err(self.end_of_input_error()); } let start_pos = self.current; - match self.with_parser_depth(|parser| parser.parse_statement_inner()) { - Ok(expr) => Ok(expr), + let hana = self.config.dialect == Some(crate::dialects::DialectType::HANA); + let outer_hana_statement = + hana && self.recursion.statements.fetch_add(1, Ordering::Relaxed) == 0; + let result = self.with_parser_depth(|parser| parser.parse_statement_inner()); + if hana { + self.recursion.statements.fetch_sub(1, Ordering::Relaxed); + } + match result { + Ok(expr) => { + // Nested statements are checked by their outer statement. Walking + // every completed subtree makes a chain of subqueries quadratic. + if outer_hana_statement + && expr + .dfs() + .any(|node| matches!(node, Expression::Raw(_) | Expression::Command(_))) + { + return Err(self.parse_error("HANA syntax requires structured parsing; raw statement fallback is not supported")); + } + Ok(expr) + } Err(err) if self.should_fallback_clickhouse_statement_error(start_pos, &err) => { self.current = start_pos; self.fallback_to_command(start_pos) @@ -1223,6 +1243,11 @@ impl Parser { } let statement_start = self.current; + if self.config.dialect == Some(crate::dialects::DialectType::HANA) + && self.check_identifier("UPSERT") + { + return self.parse_hana_upsert(); + } match self.peek().token_type { // Handle hint comment /*+ ... */ before a statement - convert to regular comment @@ -2731,10 +2756,14 @@ impl Parser { let mut expressions = Vec::new(); if self.match_token(TokenType::Rollup) { expressions.push(Expression::Rollup(Box::new(Rollup { + options: None, + expressions: Vec::new(), }))); } else if self.match_token(TokenType::Cube) { expressions.push(Expression::Cube(Box::new(Cube { + options: None, + expressions: Vec::new(), }))); } @@ -3016,6 +3045,10 @@ impl Parser { (limit, offset) }; + let hana_total_rowcount = self.config.dialect == Some(crate::dialects::DialectType::HANA) + && limit.is_some() + && self.match_text_seq(&["TOTAL", "ROWCOUNT"]); + // Parse FETCH FIRST/NEXT clause let fetch = if self.match_token(TokenType::Fetch) { Some(self.parse_fetch()?) @@ -3028,6 +3061,8 @@ impl Parser { // Parse FOR UPDATE/SHARE locks or FOR XML/JSON (T-SQL) let (locks, for_xml, for_json) = self.parse_locks_and_for_xml()?; + let (result_serialization, query_collation, query_hints) = + self.parse_hana_select_options()?; let option = if matches!( self.config.dialect, @@ -3086,9 +3121,21 @@ impl Parser { hint, connect, into, - locks, for_xml, for_json, + source_dialect: self.config.dialect.filter(|d| { + *d == crate::dialects::DialectType::HANA + && (hana_total_rowcount + || result_serialization.is_some() + || query_collation.is_some() + || !query_hints.is_empty() + || locks.iter().any(|lock| lock.ignore_locked)) + }), + result_serialization, + query_collation, + query_hints, + total_rowcount: hana_total_rowcount, + locks, leading_comments, post_select_comments, kind, @@ -4255,6 +4302,13 @@ impl Parser { // Build SELECT expression let select = Select { + source_dialect: None, + total_rowcount: false, + + query_collation: None, + query_hints: Vec::new(), + result_serialization: None, + expressions, from: Some(from), joins, @@ -4286,6 +4340,7 @@ impl Parser { locks: Vec::new(), for_xml: Vec::new(), for_json: Vec::new(), + leading_comments: Vec::new(), post_select_comments: Vec::new(), kind: None, @@ -4571,6 +4626,10 @@ impl Parser { })) } else { Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: first_name, args, distinct: false, @@ -5244,6 +5303,19 @@ impl Parser { version: None, span: None, }) + } else if self.config.dialect == Some(crate::dialects::DialectType::HANA) + && self.match_token(TokenType::LParen) + { + let arguments = if self.check(TokenType::RParen) { + Vec::new() + } else { + self.parse_function_arguments()? + }; + self.expect(TokenType::RParen)?; + Expression::Function(Box::new(Function::qualified( + vec![first_ident, second_ident, third_ident], + arguments, + ))) } else if self.match_token(TokenType::LParen) { // catalog.schema.function() - table-valued function let args = if self.check(TokenType::RParen) { @@ -5254,6 +5326,10 @@ impl Parser { self.expect(TokenType::RParen)?; let trailing_comments = self.previous_trailing_comments().to_vec(); Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: format!("{}.{}.{}", first_name, second_name, third_name), args, distinct: false, @@ -5300,6 +5376,19 @@ impl Parser { span: None, }) } + } else if self.config.dialect == Some(crate::dialects::DialectType::HANA) + && self.match_token(TokenType::LParen) + { + let arguments = if self.check(TokenType::RParen) { + Vec::new() + } else { + self.parse_function_arguments()? + }; + self.expect(TokenType::RParen)?; + Expression::Function(Box::new(Function::qualified( + vec![first_ident, second_ident], + arguments, + ))) } else if self.match_token(TokenType::LParen) { // schema.function() - table-valued function let args = if self.check(TokenType::RParen) { @@ -5310,6 +5399,10 @@ impl Parser { self.expect(TokenType::RParen)?; let trailing_comments = self.previous_trailing_comments().to_vec(); Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: format!("{}.{}", first_name, second_name), args, distinct: false, @@ -5358,7 +5451,11 @@ impl Parser { } } else if self.match_token(TokenType::LParen) { // Handle JSON_TABLE specially - it has COLUMNS clause syntax - if first_name.eq_ignore_ascii_case("JSON_TABLE") { + if self.config.dialect == Some(crate::dialects::DialectType::HANA) + && first_name.eq_ignore_ascii_case("JSON_TABLE") + { + self.parse_hana_json("JSON_TABLE")? + } else if first_name.eq_ignore_ascii_case("JSON_TABLE") { // Parse the JSON expression (use parse_bitwise to avoid consuming FORMAT) let this = self .parse_bitwise()? @@ -5424,12 +5521,22 @@ impl Parser { self.expect(TokenType::RParen)?; Expression::JSONTable(Box::new(JSONTable { + options: None, + source_dialect: None, + this: Box::new(this_with_format), schema: schema.map(Box::new), path, error_handling, empty_handling, })) + } else if self.config.dialect == Some(crate::dialects::DialectType::HANA) + && matches!( + first_name.to_ascii_uppercase().as_str(), + "HIERARCHY" | "HIERARCHY_SPANTREE" + ) + { + self.parse_hana_hierarchy(&first_name.to_ascii_uppercase())? } else if first_name.eq_ignore_ascii_case("XMLTABLE") { // Handle XMLTABLE specially - it has COLUMNS clause syntax // XMLTABLE([XMLNAMESPACES(...),] '/xpath' PASSING xml_doc COLUMNS ...) @@ -5463,6 +5570,10 @@ impl Parser { self.expect(TokenType::RParen)?; let trailing_comments = self.previous_trailing_comments().to_vec(); Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: first_name.to_string(), args: vec![query], distinct: false, @@ -5473,6 +5584,10 @@ impl Parser { span: None, inferred_type: None, })) + } else if self.config.dialect == Some(crate::dialects::DialectType::HANA) + && crate::function_registry::is_hana_builtin(&first_name.to_ascii_uppercase()) + { + self.parse_typed_function(&first_name, &first_name.to_ascii_uppercase(), false)? } else { // Simple table function like UNNEST(), GAP_FILL(), etc. let args = if self.check(TokenType::RParen) { @@ -5532,6 +5647,10 @@ impl Parser { first_name.clone() }; let func = Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: func_name, args, distinct: false, @@ -7479,6 +7598,10 @@ impl Parser { let trailing_comments = self.previous_trailing_comments().to_vec(); // Create a Function expression to represent IDENTIFIER(arg) let identifier_func = Expression::Function(Box::new(crate::expressions::Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "IDENTIFIER".to_string(), args: vec![arg], distinct: false, @@ -8349,10 +8472,14 @@ impl Parser { self.skip(); // consume WITH if self.match_token(TokenType::Cube) { expressions.push(Expression::Cube(Box::new(Cube { + options: None, + expressions: Vec::new(), }))); } else if self.match_token(TokenType::Rollup) { expressions.push(Expression::Rollup(Box::new(Rollup { + options: None, + expressions: Vec::new(), }))); } @@ -8373,7 +8500,13 @@ impl Parser { loop { // Check for GROUPING SETS, CUBE, ROLLUP - let expr = if self.check_identifier("GROUPING") + let expr = if self.config.dialect == Some(crate::dialects::DialectType::HANA) + && (self.check(TokenType::Cube) + || self.check(TokenType::Rollup) + || (self.check_identifier("GROUPING") && self.check_next_identifier("SETS"))) + { + self.parse_hana_grouping()? + } else if self.check_identifier("GROUPING") && self .peek_nth(1) .map_or(false, |t| t.text.eq_ignore_ascii_case("SETS")) @@ -8381,12 +8514,17 @@ impl Parser { self.skip(); self.skip(); true - } { + } + { // GROUPING SETS (...) self.expect(TokenType::LParen)?; let args = self.parse_grouping_sets_args()?; self.expect(TokenType::RParen)?; Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "GROUPING SETS".to_string(), args, distinct: false, @@ -8403,6 +8541,10 @@ impl Parser { let args = self.parse_expression_list()?; self.expect(TokenType::RParen)?; Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "CUBE".to_string(), args, distinct: false, @@ -8419,6 +8561,10 @@ impl Parser { let args = self.parse_expression_list()?; self.expect(TokenType::RParen)?; Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "ROLLUP".to_string(), args, distinct: false, @@ -8476,11 +8622,15 @@ impl Parser { if self.match_token(TokenType::Cube) { // WITH CUBE - add Cube with empty expressions expressions.push(Expression::Cube(Box::new(Cube { + options: None, + expressions: Vec::new(), }))); } else if self.match_token(TokenType::Rollup) { // WITH ROLLUP - add Rollup with empty expressions expressions.push(Expression::Rollup(Box::new(Rollup { + options: None, + expressions: Vec::new(), }))); } @@ -8514,7 +8664,13 @@ impl Parser { loop { // Check for nested GROUPING SETS, CUBE, ROLLUP - let expr = if self.check_identifier("GROUPING") + let expr = if self.config.dialect == Some(crate::dialects::DialectType::HANA) + && (self.check(TokenType::Cube) + || self.check(TokenType::Rollup) + || (self.check_identifier("GROUPING") && self.check_next_identifier("SETS"))) + { + self.parse_hana_grouping()? + } else if self.check_identifier("GROUPING") && self .peek_nth(1) .map_or(false, |t| t.text.eq_ignore_ascii_case("SETS")) @@ -8522,12 +8678,17 @@ impl Parser { self.skip(); self.skip(); true - } { + } + { // Nested GROUPING SETS (...) self.expect(TokenType::LParen)?; let inner_args = self.parse_grouping_sets_args()?; self.expect(TokenType::RParen)?; Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "GROUPING SETS".to_string(), args: inner_args, distinct: false, @@ -8544,6 +8705,10 @@ impl Parser { let inner_args = self.parse_expression_list()?; self.expect(TokenType::RParen)?; Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "CUBE".to_string(), args: inner_args, distinct: false, @@ -8560,6 +8725,10 @@ impl Parser { let inner_args = self.parse_expression_list()?; self.expect(TokenType::RParen)?; Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "ROLLUP".to_string(), args: inner_args, distinct: false, @@ -9071,6 +9240,12 @@ impl Parser { let mut for_json = Vec::new(); loop { + if self.config.dialect == Some(crate::dialects::DialectType::HANA) + && self.check(TokenType::For) + && (self.check_next_identifier("JSON") || self.check_next_identifier("XML")) + { + break; + } let (update, key) = if self.match_keywords(&[TokenType::For, TokenType::Update]) { // FOR UPDATE ( @@ -9095,6 +9270,11 @@ impl Parser { // FOR SHARE self.skip(); // consume FOR self.skip(); // consume SHARE + if self.config.dialect == Some(crate::dialects::DialectType::HANA) + && !self.match_keyword("LOCK") + { + return Err(self.parse_error("HANA requires FOR SHARE LOCK")); + } (None, None) } else if self.check_identifier("LOCK") && self.check_next(TokenType::In) { // LOCK IN SHARE MODE (MySQL) -> converted to FOR SHARE @@ -9175,7 +9355,10 @@ impl Parser { None }; + let ignore_locked = self.config.dialect == Some(crate::dialects::DialectType::HANA) + && self.match_text_seq(&["IGNORE", "LOCKED"]); locks.push(Lock { + ignore_locked, update, expressions, wait, @@ -9186,6 +9369,929 @@ impl Parser { Ok((locks, for_xml, for_json)) } + fn parse_hana_json(&mut self, name: &str) -> Result { + let input = self.parse_bitwise_or()?; + self.expect(TokenType::Comma)?; + let path = self.parse_bitwise_or()?; + let columns = if name == "JSON_TABLE" { + self.parse_hana_json_columns()? + } else { + Vec::new() + }; + let (returning, options) = + self.parse_hana_json_options(name == "JSON_QUERY", name != "JSON_TABLE")?; + self.expect(TokenType::RParen)?; + Ok(match name { + "JSON_TABLE" => Expression::JSONTable(Box::new(JSONTable { + source_dialect: self.config.dialect, + this: Box::new(input), + path: Some(Box::new(path)), + schema: Some(Box::new(Expression::JSONSchema(Box::new(JSONSchema { + expressions: columns, + })))), + error_handling: None, + empty_handling: None, + options: Some(options), + })), + "JSON_QUERY" => Expression::JsonQuery(Box::new(JsonExtractFunc { + source_dialect: self.config.dialect, + this: input, + path, + returning, + options: Some(options), + arrow_syntax: false, + hash_arrow_syntax: false, + wrapper_option: None, + quotes_option: None, + on_scalar_string: false, + on_error: None, + })), + _ => Expression::JSONValue(Box::new(JSONValue { + source_dialect: self.config.dialect, + this: Box::new(input), + path: Some(Box::new(path)), + returning: returning.map(|t| Box::new(Expression::DataType(t))), + on_condition: None, + options: Some(options), + })), + }) + } + + fn parse_hana_json_columns(&mut self) -> Result> { + self.with_parser_depth(|parser| parser.parse_hana_json_columns_inner()) + } + + fn parse_hana_json_columns_inner(&mut self) -> Result> { + if !self.match_keyword("COLUMNS") { + return Err(self.parse_error("Expected COLUMNS in JSON_TABLE")); + } + self.expect(TokenType::LParen)?; + let mut result = Vec::new(); + loop { + let nested = self.match_keyword("NESTED"); + let name = if nested { + None + } else { + Some(self.expect_identifier_or_keyword_with_quoted()?) + }; + let ordinality = !nested && self.match_text_seq(&["FOR", "ORDINALITY"]); + let data_type = if !nested && !ordinality { + Some(self.parse_data_type()?) + } else { + None + }; + let format_json = !nested && self.match_text_seq(&["FORMAT", "JSON"]); + let encoding = if format_json && self.match_keyword("ENCODING") { + if !self.match_texts(&["UTF8", "UTF16", "UTF32"]) { + return Err(self.parse_error("Expected UTF8, UTF16, or UTF32")); + } + Some(self.previous_text().to_ascii_uppercase()) + } else { + None + }; + let path = if !ordinality { + if !self.match_keyword("PATH") { + return Err(self.parse_error("Expected PATH in JSON_TABLE column")); + } + Some(self.parse_bitwise_or()?) + } else { + None + }; + let columns = if nested { + self.parse_hana_json_columns()? + } else { + Vec::new() + }; + let options = if !nested && !ordinality { + self.parse_hana_json_options(format_json, false)?.1 + } else { + JsonOptions::default() + }; + result.push(Expression::JSONColumnDef(Box::new(JSONColumnDef { + source_dialect: self.config.dialect, + this: name.map(|n| Box::new(Expression::Identifier(n))), + kind: None, + data_type, + path: path.map(Box::new), + ordinality: ordinality + .then(|| Box::new(Expression::Boolean(BooleanLiteral { value: true }))), + format_json, + encoding, + nested_schema: nested.then(|| { + Box::new(Expression::JSONSchema(Box::new(JSONSchema { + expressions: columns, + }))) + }), + options: Some(options), + }))); + if !self.match_token(TokenType::Comma) { + break; + } + } + self.expect(TokenType::RParen)?; + Ok(result) + } + + fn parse_hana_json_options( + &mut self, + query: bool, + returning_allowed: bool, + ) -> Result<(Option, JsonOptions)> { + let returning = if returning_allowed && self.match_token(TokenType::Returning) { + Some(self.parse_data_type()?) + } else { + None + }; + let wrapper = if query + && (self.check(TokenType::With) || self.peek_text().eq_ignore_ascii_case("WITHOUT")) + { + let mut value = self.advance_text()?.to_ascii_uppercase(); + if value == "WITH" && self.match_texts(&["CONDITIONAL", "UNCONDITIONAL"]) { + value.push(' '); + value.push_str(&self.previous_text().to_ascii_uppercase()); + } + if self.match_keyword("ARRAY") { + value.push_str(" ARRAY"); + } + if !self.match_keyword("WRAPPER") { + return Err(self.parse_error("Expected WRAPPER")); + } + Some(value) + } else { + None + }; + let mut options = JsonOptions { + wrapper, + ..Default::default() + }; + while self.peek_text().eq_ignore_ascii_case("ERROR") + || self.check(TokenType::Null) + || self.check(TokenType::Default) + || (query && self.peek_text().eq_ignore_ascii_case("EMPTY")) + { + let kind = self.advance_text()?.to_ascii_uppercase(); + let (kind, value) = if kind == "DEFAULT" { + if query { + return Err(self.parse_error("JSON_QUERY has no DEFAULT behavior")); + } + (kind, Some(self.parse_bitwise_or()?)) + } else if kind == "EMPTY" { + if !self.match_texts(&["ARRAY", "OBJECT"]) { + return Err(self.parse_error("Expected ARRAY or OBJECT")); + } + ( + format!("EMPTY {}", self.previous_text().to_ascii_uppercase()), + None, + ) + } else { + (kind, None) + }; + self.expect(TokenType::On)?; + let empty = self.match_keyword("EMPTY"); + if !empty && !self.match_keyword("ERROR") { + return Err(self.parse_error("Expected EMPTY or ERROR after ON")); + } + let field = if empty { + &mut options.on_empty + } else { + &mut options.on_error + }; + if field.is_some() { + return Err(self.parse_error("Duplicate JSON behavior clause")); + } + *field = Some(SqlBehavior { kind, value }); + } + Ok((returning, options)) + } + + fn parse_hana_hierarchy(&mut self, name: &str) -> Result { + if !self.match_keyword("SOURCE") { + return Err(self.parse_error("Expected SOURCE in HANA hierarchy")); + } + let source = if self.match_token(TokenType::LParen) { + let source = self.parse_statement()?; + self.expect(TokenType::RParen)?; + Expression::Paren(Box::new(Paren { + this: source, + trailing_comments: Vec::new(), + })) + } else { + Expression::Table(Box::new(self.parse_table_ref()?)) + }; + let start = if self.match_keyword("START") { + self.expect(TokenType::Where)?; + Some(self.parse_expression()?) + } else { + None + }; + let siblings = if self.match_keyword("SIBLING") { + self.expect(TokenType::Order)?; + self.expect(TokenType::By)?; + self.parse_order_by_list()? + } else { + Vec::new() + }; + let depth = if self.match_keyword("DEPTH") { + Some(self.parse_primary()?) + } else { + None + }; + let multiparent = if self.match_keyword("MULTIPARENT") { + if self.match_keyword("ERROR") { + Some("ERROR".to_owned()) + } else if self.match_text_seq(&["LEAVES", "ONLY"]) { + Some("LEAVES ONLY".to_owned()) + } else { + Some(String::new()) + } + } else { + None + }; + let orphan = if self.match_keyword("ORPHAN") { + if !self.match_texts(&["IGNORE", "ERROR", "ADOPT", "ROOT"]) { + return Err(self.parse_error("Expected HANA ORPHAN handling")); + } + Some(self.previous_text().to_ascii_uppercase()) + } else { + None + }; + let cycle = if self.match_keyword("CYCLE") { + if !self.match_texts(&["BREAKUP", "ERROR"]) { + return Err(self.parse_error("Expected HANA CYCLE handling")); + } + Some(self.previous_text().to_ascii_uppercase()) + } else { + None + }; + let cache = if self.match_keyword("CACHE") { + Some( + if self.match_keyword("FORCE") { + "CACHE FORCE" + } else { + "CACHE" + } + .to_owned(), + ) + } else if self.match_text_seq(&["NO", "CACHE"]) { + Some("NO CACHE".to_owned()) + } else { + None + }; + if depth.is_some() && orphan.is_some() { + return Err(self.parse_error("HANA DEPTH and ORPHAN cannot be combined")); + } + if name == "HIERARCHY_SPANTREE" + && (multiparent.is_some() || orphan.is_some() || cycle.is_some()) + { + return Err( + self.parse_error("HIERARCHY_SPANTREE has no MULTIPARENT, ORPHAN, or CYCLE clauses") + ); + } + self.expect(TokenType::RParen)?; + Ok(Expression::Hierarchy(Box::new(Hierarchy { + source_dialect: self.config.dialect, + name: name.to_owned(), + source, + start, + siblings, + depth, + multiparent, + orphan, + cycle, + cache, + }))) + } + + fn parse_hana_upsert(&mut self) -> Result { + self.skip(); // UPSERT + let table = self.parse_table_ref()?; + let partition = if self.match_token(TokenType::Partition) { + self.expect(TokenType::LParen)?; + let partition = self.parse_expression()?; + self.expect(TokenType::RParen)?; + Some(partition) + } else { + None + }; + let columns = if self.match_token(TokenType::LParen) { + let columns = self.parse_identifier_list()?; + self.expect(TokenType::RParen)?; + columns + } else { + Vec::new() + }; + let values = self.check(TokenType::Values); + let source = if values { + self.parse_values()? + } else { + self.parse_statement()? + }; + let condition = if values && self.match_token(TokenType::Where) { + Some(self.parse_expression()?) + } else { + None + }; + let primary_key = if values && condition.is_none() && self.match_token(TokenType::With) { + if !self.match_text_seq(&["PRIMARY", "KEY"]) && !self.match_token(TokenType::PrimaryKey) + { + return Err(self.parse_error("Expected PRIMARY KEY after UPSERT WITH")); + } + true + } else { + false + }; + Ok(Expression::Upsert(Box::new(Upsert { + source_dialect: self.config.dialect, + table, + partition, + columns, + source, + condition, + primary_key, + }))) + } + + fn parse_hana_partition(&mut self) -> Result { + if !self.match_token(TokenType::PartitionBy) { + self.expect(TokenType::Partition)?; + self.expect(TokenType::By)?; + } + Ok(Expression::PartitionByProperty(Box::new( + PartitionByProperty { + expressions: Vec::new(), + specification: Some(self.parse_hana_partition_spec()?), + }, + ))) + } + + fn parse_hana_partition_spec(&mut self) -> Result { + self.with_parser_depth(|parser| parser.parse_hana_partition_spec_inner()) + } + + fn parse_hana_partition_spec_inner(&mut self) -> Result { + let method = self.expect_identifier_or_keyword()?.to_ascii_uppercase(); + if !matches!(method.as_str(), "HASH" | "ROUNDROBIN" | "RANGE") { + return Err(self.parse_error("Expected HASH, ROUNDROBIN, or RANGE partitioning")); + } + let columns = if method != "ROUNDROBIN" { + self.expect(TokenType::LParen)?; + let columns = self.parse_expression_list()?; + self.expect(TokenType::RParen)?; + columns + } else { + Vec::new() + }; + let primary_key_check = + if self.check(TokenType::PrimaryKey) || self.peek_text().eq_ignore_ascii_case("NO") { + let enabled = !self.match_keyword("NO"); + self.expect(TokenType::PrimaryKey)?; + self.match_token(TokenType::Key); + self.expect(TokenType::Check)?; + Some(enabled) + } else { + None + }; + let mut ranges = Vec::new(); + let partitions = if method == "RANGE" { + self.expect(TokenType::LParen)?; + loop { + self.expect(TokenType::Partition)?; + let name = if self.check(TokenType::QuotedIdentifier) + || (self.is_identifier_token() + && !self.peek_text().eq_ignore_ascii_case("OTHERS")) + { + Some(self.expect_identifier_or_keyword_with_quoted()?) + } else { + None + }; + let (kind, values) = if self.match_keyword("OTHERS") { + ("OTHERS", Vec::new()) + } else if self.match_token(TokenType::Values) { + let kind = if self.match_token(TokenType::Lt) { + "LESS" + } else if self.match_token(TokenType::Gte) { + "AT_LEAST" + } else if self.match_token(TokenType::Eq) { + "VALUE" + } else { + return Err(self.parse_error("Expected <, >=, or = after VALUES")); + }; + (kind, vec![self.parse_bitwise_or()?]) + } else { + let lower = self.parse_bitwise_or()?; + self.expect(TokenType::Lte)?; + self.expect(TokenType::Values)?; + self.expect(TokenType::Lt)?; + ("RANGE", vec![lower, self.parse_bitwise_or()?]) + }; + let dynamic = self.match_keyword("DYNAMIC"); + let direction = if dynamic + && self.match_texts(&["INCREASING", "DECREASING", "BIDIRECTIONAL"]) + { + Some(self.previous_text().to_ascii_uppercase()) + } else { + None + }; + let threshold = if dynamic && self.match_keyword("THRESHOLD") { + Some(self.parse_primary()?) + } else { + None + }; + let interval = + if dynamic && threshold.is_none() && self.match_token(TokenType::Interval) { + let value = self.parse_primary()?; + let unit = if self.match_texts(&["YEAR", "MONTH", "HOUR"]) { + Some(self.previous_text().to_ascii_uppercase()) + } else { + None + }; + Some((value, unit)) + } else { + None + }; + if dynamic && kind != "OTHERS" { + return Err(self.parse_error("DYNAMIC applies only to OTHERS")); + } + let mut properties = Vec::new(); + while let Some(property) = self.parse_hana_storage_property()? { + properties.push(property); + } + ranges.push(PartitionRangeSpec { + name, + kind: kind.to_owned(), + values, + dynamic, + direction, + threshold, + interval, + properties, + }); + if !self.match_token(TokenType::Comma) { + break; + } + } + self.expect(TokenType::RParen)?; + None + } else { + if !self.match_keyword("PARTITIONS") { + return Err(self.parse_error("Expected PARTITIONS")); + } + Some(self.parse_primary()?) + }; + let mut properties = Vec::new(); + while let Some(property) = self.parse_hana_storage_property()? { + properties.push(property); + } + let subpartition = if self.match_keyword("SUBPARTITION") { + self.expect(TokenType::By)?; + Some(Box::new(self.parse_hana_partition_spec()?)) + } else { + None + }; + Ok(PartitionSpec { + source_dialect: self.config.dialect, + method, + columns, + partitions, + ranges, + primary_key_check, + properties, + subpartition, + }) + } + + fn parse_hana_storage_property(&mut self) -> Result> { + let mut words = Vec::new(); + let mut values = Vec::new(); + let name = self.peek_text().to_ascii_uppercase(); + match name.as_str() { + "COLUMN" | "PAGE" if self.check_next_identifier("LOADABLE") => { + self.skip(); + self.skip(); + words.extend([name, "LOADABLE".to_owned()]); + } + "INSERT" | "LOGGING" => { + self.skip(); + words.push(name); + if !self.match_texts(&["ON", "OFF"]) { + return Err(self.parse_error("Expected ON or OFF")); + } + words.push(self.previous_text().to_ascii_uppercase()); + } + "AUTO" if self.check_next_identifier("MERGE") => { + self.skip(); + self.skip(); + words.extend(["AUTO".to_owned(), "MERGE".to_owned()]); + } + "NO" if self.check_next_identifier("AUTO") => { + self.skip(); + self.skip(); + if !self.match_keyword("MERGE") { + return Err(self.parse_error("Expected MERGE")); + } + words.extend(["NO".to_owned(), "AUTO".to_owned(), "MERGE".to_owned()]); + } + "UNLOAD" if self.check_next_identifier("PRIORITY") => { + self.skip(); + self.skip(); + words.extend(["UNLOAD".to_owned(), "PRIORITY".to_owned()]); + values.push(self.parse_primary()?); + } + "PERSISTENT" if self.check_next_identifier("MEMORY") => { + self.skip(); + self.skip(); + words.extend(["PERSISTENT".to_owned(), "MEMORY".to_owned()]); + if !self.match_texts(&["ON", "OFF"]) { + return Err(self.parse_error("Expected ON or OFF")); + } + words.push(self.previous_text().to_ascii_uppercase()); + } + "GROUP" => { + self.skip(); + words.push(name); + if !self.match_texts(&["NAME", "TYPE", "SUBTYPE"]) { + return Err(self.parse_error("Expected NAME, TYPE, or SUBTYPE")); + } + words.push(self.previous_text().to_ascii_uppercase()); + values.push(self.parse_primary()?); + } + "RECORD" + if self.check_next(TokenType::Commit) || self.check_next_identifier("COMMIT") => + { + self.skip(); + self.skip(); + if !self.match_keyword("TIMESTAMP") { + return Err(self.parse_error("Expected TIMESTAMP")); + } + words.extend([ + "RECORD".to_owned(), + "COMMIT".to_owned(), + "TIMESTAMP".to_owned(), + ]); + } + _ => return Ok(None), + } + Ok(Some(Expression::StorageProperty(Box::new( + StorageProperty { + source_dialect: self.config.dialect, + name: words.join(" "), + values, + }, + )))) + } + + fn parse_hana_regex( + &mut self, + operation: &str, + subject: Option, + ) -> Result { + let predicate = subject.is_some(); + let position_after = + if operation == "LOCATE_REGEXPR" && self.match_texts(&["START", "AFTER"]) { + Some(self.previous_text().eq_ignore_ascii_case("AFTER")) + } else { + None + }; + let pattern = self.parse_bitwise_or()?; + let flag = if self.match_keyword("FLAG") { + Some(self.parse_bitwise_or()?) + } else { + None + }; + let subject = if let Some(subject) = subject { + subject + } else { + self.expect(TokenType::In)?; + self.parse_bitwise_or()? + }; + let replacement = if !predicate && self.match_token(TokenType::With) { + Some(self.parse_bitwise_or()?) + } else { + None + }; + let start = if !predicate && self.match_token(TokenType::From) { + Some(self.parse_bitwise_or()?) + } else { + None + }; + let occurrence = if !predicate && self.match_keyword("OCCURRENCE") { + if self.match_token(TokenType::All) { + Some(Expression::Var(Box::new(Var { + this: "ALL".to_owned(), + }))) + } else { + Some(self.parse_bitwise_or()?) + } + } else { + None + }; + let group = if !predicate && self.match_token(TokenType::Group) { + Some(self.parse_bitwise_or()?) + } else { + None + }; + if replacement.is_some() && operation != "REPLACE_REGEXPR" + || group.is_some() + && !matches!( + operation, + "SUBSTRING_REGEXPR" | "SUBSTR_REGEXPR" | "LOCATE_REGEXPR" + ) + || occurrence.is_some() && operation == "OCCURRENCES_REGEXPR" + { + return Err(self + .parse_error("Clause is not supported by this HANA regular-expression operation")); + } + if !predicate { + self.expect(TokenType::RParen)?; + } + let mut options = RegexOptions { + source_dialect: self.config.dialect, + source_name: operation.to_owned(), + negated: false, + start: None, + occurrence: None, + flags: None, + position_after, + }; + Ok(match operation { + "LIKE_REGEXPR" => Expression::RegexpLike(Box::new(RegexpFunc { + this: subject, + pattern, + flags: flag, + options: Some(options), + })), + "LOCATE_REGEXPR" => Expression::RegexpInstr(Box::new(RegexpInstr { + this: Box::new(subject), + expression: Box::new(pattern), + position: start.map(Box::new), + occurrence: occurrence.map(Box::new), + parameters: flag.map(Box::new), + group: group.map(Box::new), + option: None, + options: Some(options), + })), + "OCCURRENCES_REGEXPR" => Expression::RegexpCount(Box::new(RegexpCount { + this: Box::new(subject), + expression: Box::new(pattern), + position: start.map(Box::new), + parameters: flag.map(Box::new), + options: Some(options), + })), + "REPLACE_REGEXPR" => { + options.start = start; + options.occurrence = occurrence; + Expression::RegexpReplace(Box::new(RegexpReplaceFunc { + this: subject, + pattern, + flags: flag, + replacement: replacement.ok_or_else(|| { + self.parse_error("REPLACE_REGEXPR requires WITH replacement") + })?, + options: Some(options), + })) + } + _ => { + options.start = start; + options.occurrence = occurrence; + options.flags = flag; + Expression::RegexpExtract(Box::new(RegexpExtractFunc { + this: subject, + pattern, + group, + options: Some(options), + })) + } + }) + } + + fn parse_hana_grouping(&mut self) -> Result { + self.with_parser_depth(|parser| parser.parse_hana_grouping_inner()) + } + + fn parse_hana_grouping_inner(&mut self) -> Result { + let kind = if self.match_text_seq(&["GROUPING", "SETS"]) { + "GROUPING SETS".to_owned() + } else { + self.advance_text()?.to_ascii_uppercase() + }; + let best = if self.match_keyword("BEST") { + Some(self.parse_hana_grouping_integer(true)?) + } else { + None + }; + let limit = if self.match_token(TokenType::Limit) { + Some(self.parse_hana_grouping_integer(false)?) + } else { + None + }; + let offset = if limit.is_some() && self.match_token(TokenType::Offset) { + Some(self.parse_hana_grouping_integer(false)?) + } else { + None + }; + let subtotal = self.match_text_seq(&["WITH", "SUBTOTAL"]); + let balance = self.match_text_seq(&["WITH", "BALANCE"]); + let total = self.match_text_seq(&["WITH", "TOTAL"]); + let structured = self.match_text_seq(&["STRUCTURED", "RESULT"]); + let overview = structured && self.match_text_seq(&["WITH", "OVERVIEW"]); + let prefix = if structured && self.match_keyword("PREFIX") { + Some(Expression::string(self.expect(TokenType::String)?.text)) + } else { + None + }; + let multiple_resultsets = + prefix.is_none() && self.match_text_seq(&["MULTIPLE", "RESULTSETS"]); + self.expect(TokenType::LParen)?; + let expressions = self.parse_grouping_sets_args()?; + self.expect(TokenType::RParen)?; + let has_options = best.is_some() + || limit.is_some() + || subtotal + || balance + || total + || structured + || multiple_resultsets; + let options = has_options.then_some(GroupingOptions { + source_dialect: self.config.dialect, + best, + limit, + offset, + subtotal, + balance, + total, + structured, + overview, + prefix, + multiple_resultsets, + }); + Ok(match kind.as_str() { + "ROLLUP" => Expression::Rollup(Box::new(Rollup { + expressions, + options, + })), + "CUBE" => Expression::Cube(Box::new(Cube { + expressions, + options, + })), + _ => Expression::GroupingSets(Box::new(GroupingSets { + expressions, + options, + })), + }) + } + + fn parse_hana_grouping_integer(&mut self, signed: bool) -> Result { + let negative = signed && self.match_token(TokenType::Dash); + if signed && !negative { + self.match_token(TokenType::Plus); + } + let value = self.expect(TokenType::Number)?.text; + if value.parse::().is_err() { + return Err(self.parse_error("Expected an integer grouping-set option")); + } + Ok(Expression::Literal(Box::new(Literal::Number( + if negative { format!("-{value}") } else { value }, + )))) + } + + fn parse_hana_timezone(&mut self, name: &str) -> Result { + let mut arguments = Vec::new(); + let mut on_error = None; + loop { + let behavior = self.peek_text().to_ascii_uppercase(); + if behavior == "DEFAULT" + || (matches!(behavior.as_str(), "NULL" | "ERROR") && self.check_next(TokenType::On)) + { + self.skip(); + let value = if behavior == "DEFAULT" { + Some(Expression::string(self.expect(TokenType::String)?.text)) + } else { + None + }; + self.expect(TokenType::On)?; + if !self.match_keyword("ERROR") { + return Err(self.parse_error("Expected ON ERROR")); + } + on_error = Some(SqlBehavior { + kind: behavior, + value, + }); + break; + } + arguments.push(self.parse_expression()?); + if self.match_token(TokenType::Comma) { + continue; + } + if self.check(TokenType::RParen) { + break; + } + if !matches!( + self.peek_text().to_ascii_uppercase().as_str(), + "DEFAULT" | "NULL" | "ERROR" + ) { + return Err(self.parse_error("Expected comma or timezone error clause")); + } + } + self.expect(TokenType::RParen)?; + if !(1..=4).contains(&arguments.len()) { + return Err(self.parse_error("HANA timezone conversion expects one to four arguments")); + } + let mut function = Function::new(name, arguments); + function.source_dialect = self.config.dialect; + function.on_error = on_error.map(Box::new); + Ok(Expression::Function(Box::new(function))) + } + + fn parse_hana_select_options( + &mut self, + ) -> Result<( + Option, + Option, + Vec, + )> { + if self.config.dialect != Some(crate::dialects::DialectType::HANA) { + return Ok((None, None, Vec::new())); + } + let mut serialization = None; + if self.check(TokenType::For) + && (self.check_next_identifier("JSON") || self.check_next_identifier("XML")) + { + self.skip(); + let format = self.advance_text()?.to_ascii_uppercase(); + let mut options = Vec::new(); + if self.match_token(TokenType::LParen) { + loop { + let key = self.expect(TokenType::String)?.text; + self.expect(TokenType::Eq)?; + let value = self.expect(TokenType::String)?.text; + options.push((key, value)); + if !self.match_token(TokenType::Comma) { + break; + } + } + self.expect(TokenType::RParen)?; + } + let returning = if self.match_keyword("RETURNS") { + Some(self.parse_data_type()?) + } else { + None + }; + serialization = Some(ResultSerialization { + format, + options, + returning, + }); + } + let collation = if self.check(TokenType::With) && self.check_next_identifier("COLLATION") { + self.skip(); + self.skip(); + Some(self.expect_identifier_or_keyword_with_quoted()?) + } else { + None + }; + let hints = self.parse_hana_hints()?; + Ok((serialization, collation, hints)) + } + + fn parse_hana_hints(&mut self) -> Result> { + let mut hints = Vec::new(); + if self.check(TokenType::With) && self.check_next_identifier("HINT") { + self.skip(); + self.skip(); + self.expect(TokenType::LParen)?; + loop { + let name = self.expect_identifier_or_keyword_with_quoted()?; + let arguments = if self.match_token(TokenType::LParen) { + let args = if self.check(TokenType::RParen) { + Vec::new() + } else { + self.parse_function_arguments()? + }; + self.expect(TokenType::RParen)?; + Some(args) + } else { + None + }; + let remote = self.match_keyword("REMOTE"); + let cascade = self.match_keyword("CASCADE"); + hints.push(Expression::Hint(Box::new(Hint { + source_dialect: self.config.dialect, + expressions: vec![HintExpression::Directive { + name, + arguments, + remote, + cascade, + }], + }))); + if !self.match_token(TokenType::Comma) { + break; + } + } + self.expect(TokenType::RParen)?; + } + Ok(hints) + } + /// Parse FOR XML options (T-SQL) /// Syntax: FOR XML PATH|RAW|AUTO|EXPLICIT [('element')] [, BINARY BASE64] [, ELEMENTS [XSINIL|ABSENT]] [, TYPE] [, ROOT('name')] fn parse_for_xml_options(&mut self) -> Result> { @@ -10002,7 +11108,10 @@ impl Parser { vec![HintExpression::Raw(hint_text)] }; - Ok(Hint { expressions }) + Ok(Hint { + source_dialect: None, + expressions, + }) } /// Parse SAMPLE / TABLESAMPLE / USING SAMPLE clause @@ -11129,6 +12238,10 @@ impl Parser { }; self.expect(TokenType::RParen)?; function_target = Some(Box::new(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: func_name, args, distinct: false, @@ -11815,6 +12928,10 @@ impl Parser { let args = self.parse_expression_list()?; self.expect(TokenType::RParen)?; return Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "REPLACE".to_string(), args, distinct: false, @@ -12578,8 +13695,28 @@ impl Parser { let or_replace = self.match_keywords(&[TokenType::Or, TokenType::Replace]); let or_alter = !or_replace && self.match_text_seq(&["OR", "ALTER"]); + // HANA orders scope, lifetime, and storage before TABLE. + let hana_modifier = if self.config.dialect == Some(crate::dialects::DialectType::HANA) { + let mut parts = Vec::new(); + if self.match_texts(&["LOCAL", "GLOBAL"]) { + parts.push(self.previous_text().to_ascii_uppercase()); + self.expect(TokenType::Temporary)?; + parts.push("TEMPORARY".to_owned()); + } else if self.match_token(TokenType::Temporary) { + parts.push("TEMPORARY".to_owned()); + } + if self.match_texts(&["COLUMN", "ROW"]) { + parts.push(self.previous_text().to_ascii_uppercase()); + } + (!parts.is_empty()).then(|| parts.join(" ")) + } else { + None + }; // Handle TEMPORARY - let temporary = self.match_token(TokenType::Temporary); + let temporary = hana_modifier + .as_ref() + .is_some_and(|m| m.contains("TEMPORARY")) + || self.match_token(TokenType::Temporary); // Handle MATERIALIZED let materialized = self.match_token(TokenType::Materialized); @@ -12627,7 +13764,9 @@ impl Parser { let secure = self.match_identifier("SECURE"); // Handle table modifiers: DYNAMIC, ICEBERG, EXTERNAL, HYBRID, TRANSIENT (Snowflake), UNLOGGED (PostgreSQL) - let mut table_modifier: Option = if self.check_identifier("DYNAMIC") { + let mut table_modifier: Option = if hana_modifier.is_some() { + hana_modifier + } else if self.check_identifier("DYNAMIC") { self.skip(); Some("DYNAMIC".to_string()) } else if self.check_identifier("ICEBERG") { @@ -13187,6 +14326,10 @@ impl Parser { temporary, or_replace, table_modifier: table_modifier.map(|s| s.to_string()), + source_dialect: self + .config + .dialect + .filter(|d| *d == crate::dialects::DialectType::HANA), as_select: None, as_select_parenthesized: false, on_commit: None, @@ -13292,6 +14435,10 @@ impl Parser { temporary, or_replace, table_modifier: Some("EXTERNAL".to_string()), + source_dialect: self + .config + .dialect + .filter(|d| *d == crate::dialects::DialectType::HANA), // BigQuery EXTERNAL tables don't support AS SELECT as_select: None, as_select_parenthesized: false, @@ -13358,6 +14505,10 @@ impl Parser { temporary, or_replace, table_modifier: table_modifier.map(|s| s.to_string()), + source_dialect: self + .config + .dialect + .filter(|d| *d == crate::dialects::DialectType::HANA), as_select: None, as_select_parenthesized: false, on_commit: None, @@ -13568,6 +14719,10 @@ impl Parser { temporary, or_replace, table_modifier: table_modifier.map(|s| s.to_string()), + source_dialect: self + .config + .dialect + .filter(|d| *d == crate::dialects::DialectType::HANA), as_select: None, as_select_parenthesized: false, on_commit: None, @@ -13683,6 +14838,10 @@ impl Parser { temporary, or_replace, table_modifier: table_modifier.map(|s| s.to_string()), + source_dialect: self + .config + .dialect + .filter(|d| *d == crate::dialects::DialectType::HANA), as_select: Some(query), as_select_parenthesized, on_commit, @@ -13748,6 +14907,10 @@ impl Parser { temporary, or_replace, table_modifier: table_modifier.map(|s| s.to_string()), + source_dialect: self + .config + .dialect + .filter(|d| *d == crate::dialects::DialectType::HANA), as_select, as_select_parenthesized, on_commit: None, @@ -13835,6 +14998,10 @@ impl Parser { temporary, or_replace, table_modifier: table_modifier.map(|s| s.to_string()), + source_dialect: self + .config + .dialect + .filter(|d| *d == crate::dialects::DialectType::HANA), as_select, as_select_parenthesized: false, on_commit: None, @@ -13882,6 +15049,10 @@ impl Parser { temporary, or_replace, table_modifier: table_modifier.map(|s| s.to_string()), + source_dialect: self + .config + .dialect + .filter(|d| *d == crate::dialects::DialectType::HANA), as_select: None, as_select_parenthesized: false, on_commit: None, @@ -13927,6 +15098,10 @@ impl Parser { temporary, or_replace, table_modifier: table_modifier.map(|s| s.to_string()), + source_dialect: self + .config + .dialect + .filter(|d| *d == crate::dialects::DialectType::HANA), as_select: None, as_select_parenthesized: false, on_commit: None, @@ -13988,6 +15163,10 @@ impl Parser { temporary, or_replace, table_modifier: table_modifier.map(|s| s.to_string()), + source_dialect: self + .config + .dialect + .filter(|d| *d == crate::dialects::DialectType::HANA), as_select, as_select_parenthesized: false, on_commit: None, @@ -14044,6 +15223,10 @@ impl Parser { temporary, or_replace, table_modifier: table_modifier.map(|s| s.to_string()), + source_dialect: self + .config + .dialect + .filter(|d| *d == crate::dialects::DialectType::HANA), as_select: Some(query), as_select_parenthesized: true, on_commit: None, @@ -14186,6 +15369,10 @@ impl Parser { temporary, or_replace, table_modifier: table_modifier.map(|s| s.to_string()), + source_dialect: self + .config + .dialect + .filter(|d| *d == crate::dialects::DialectType::HANA), as_select: None, as_select_parenthesized: false, on_commit: None, @@ -14789,6 +15976,18 @@ impl Parser { self.parse_clickhouse_table_properties(&mut table_properties)?; } + if self.config.dialect == Some(crate::dialects::DialectType::HANA) { + loop { + if self.check(TokenType::Partition) || self.check(TokenType::PartitionBy) { + table_properties.push(self.parse_hana_partition()?); + } else if let Some(property) = self.parse_hana_storage_property()? { + table_properties.push(property); + } else { + break; + } + } + } + // Parse PARTITION BY RANGE/LIST/HASH(columns) for regular CREATE TABLE let is_bigquery = matches!( self.config.dialect, @@ -15241,6 +16440,10 @@ impl Parser { temporary, or_replace, table_modifier: table_modifier.map(|s| s.to_string()), + source_dialect: self + .config + .dialect + .filter(|d| *d == crate::dialects::DialectType::HANA), as_select, as_select_parenthesized: false, on_commit, @@ -15393,6 +16596,10 @@ impl Parser { temporary, or_replace, table_modifier: table_modifier.map(|s| s.to_string()), + source_dialect: self + .config + .dialect + .filter(|d| *d == crate::dialects::DialectType::HANA), as_select: None, as_select_parenthesized: false, on_commit: None, @@ -17055,6 +18262,20 @@ impl Parser { col_def .constraint_order .push(ConstraintType::ComputedColumn); + } else if self.config.dialect == Some(crate::dialects::DialectType::HANA) { + let expression = self.parse_expression()?; + col_def + .constraints + .push(ColumnConstraint::ComputedColumn(ComputedColumn { + expression: Box::new(expression), + persisted: false, + not_null: false, + persistence_kind: None, + data_type: None, + })); + col_def + .constraint_order + .push(ConstraintType::ComputedColumn); } else { // Fallback: treat as GENERATED AS IDENTITY without explicit IDENTITY keyword col_def @@ -17662,6 +18883,10 @@ impl Parser { // Create a Function expression for BUCKET/TRUNCATE partition_exprs.push(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: func_name, args, distinct: false, @@ -25292,6 +26517,42 @@ impl Parser { /// Parse CALL statement (stored procedure call) /// CALL procedure_name(args, ...) fn parse_call(&mut self) -> Result { + if self.config.dialect == Some(crate::dialects::DialectType::HANA) { + self.skip(); + let mut name = vec![self.expect_identifier_or_keyword_with_quoted()?]; + if self.match_token(TokenType::Dot) { + name.push(self.expect_identifier_or_keyword_with_quoted()?); + } + let member = if self.match_token(TokenType::Colon) { + Some(self.expect_identifier_or_keyword_with_quoted()?) + } else { + None + }; + let arguments = if self.match_token(TokenType::LParen) { + let args = if self.check(TokenType::RParen) { + Vec::new() + } else { + self.parse_function_arguments()? + }; + self.expect(TokenType::RParen)?; + args + } else { + Vec::new() + }; + let asynchronous = self.match_keyword("ASYNC"); + let hints = self.parse_hana_hints()?; + if member.is_some() && (asynchronous || !hints.is_empty()) { + return Err(self.parse_error("Library member CALL does not support ASYNC or hints")); + } + return Ok(Expression::Call(Box::new(Call { + source_dialect: self.config.dialect, + name, + member, + arguments, + asynchronous, + hints, + }))); + } let call_token = self.advance()?; // consume CALL // Collect remaining tokens with their types @@ -29326,7 +30587,20 @@ impl Parser { global_in = true; } - let expr = if self.match_token(TokenType::Eq) { + let expr = if self.config.dialect == Some(crate::dialects::DialectType::HANA) + && (self.peek_text().eq_ignore_ascii_case("LIKE_REGEXPR") + || (self.check(TokenType::Not) && self.check_next_identifier("LIKE_REGEXPR"))) + { + let negated = self.match_token(TokenType::Not); + self.skip(); + let mut regex = self.parse_hana_regex("LIKE_REGEXPR", Some(left))?; + if let Expression::RegexpLike(ref mut r) = regex { + if let Some(options) = &mut r.options { + options.negated = negated; + } + } + regex + } else if self.match_token(TokenType::Eq) { // Check for ANY/ALL subquery if self.match_token(TokenType::Any) || self.match_token(TokenType::Some) { let was_any = self.previous_token_type() == Some(TokenType::Any); @@ -29662,6 +30936,8 @@ impl Parser { // PostgreSQL ~ (regexp match) operator / RLIKE / REGEXP let right = self.parse_bitwise_or()?; Expression::RegexpLike(Box::new(RegexpFunc { + options: None, + this: left, pattern: right, flags: None, @@ -29675,6 +30951,8 @@ impl Parser { self.skip(); // consume REGEXP_LIKE let right = self.parse_bitwise_or()?; Expression::RegexpLike(Box::new(RegexpFunc { + options: None, + this: left, pattern: right, flags: None, @@ -29723,6 +31001,8 @@ impl Parser { // PostgreSQL !~ (NOT regexp match) operator let right = self.parse_bitwise_or()?; let regexp_expr = Expression::RegexpLike(Box::new(RegexpFunc { + options: None, + this: left, pattern: right, flags: None, @@ -30008,6 +31288,8 @@ impl Parser { } else if self.match_token(TokenType::RLike) { let right = self.parse_bitwise_or()?; let regexp_expr = Expression::RegexpLike(Box::new(RegexpFunc { + options: None, + this: left, pattern: right, flags: None, @@ -32395,7 +33677,31 @@ impl Parser { // Check for no-paren functions like CURRENT_TIMESTAMP, CURRENT_DATE, etc. // These should be parsed as functions even without parentheses let upper_name = self.peek_text().to_ascii_uppercase(); - if !self.check_next(TokenType::LParen) + if self.config.dialect == Some(crate::dialects::DialectType::HANA) + && !self.check(TokenType::QuotedIdentifier) + && !self.check_next(TokenType::Dot) + && !self.check_next(TokenType::LParen) + && matches!( + upper_name.as_str(), + "CURRENT_UTCTIMESTAMP" + | "CURRENT_UTCDATE" + | "CURRENT_UTCTIME" + | "CURRENT_CONNECTION" + | "CURRENT_TRANSACTION_ISOLATION_LEVEL" + | "SYSUUID" + | "SESSION_USER" + | "CURRENT_SCHEMA" + | "CURRENT_USER" + ) + { + let mut function = Function::new(self.advance()?.text, Vec::new()); + function.no_parens = true; + function.source_dialect = self.config.dialect; + return Ok(Expression::Function(Box::new(function))); + } + if !(self.config.dialect == Some(crate::dialects::DialectType::HANA) + && self.check(TokenType::QuotedIdentifier)) + && !self.check_next(TokenType::LParen) && !self.check_next(TokenType::Dot) && crate::function_registry::is_no_paren_function_name_upper(upper_name.as_str()) && !(matches!( @@ -32405,6 +33711,10 @@ impl Parser { { let token = self.advance()?; let func = Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: token.text.to_string(), args: Vec::new(), distinct: false, @@ -32463,6 +33773,10 @@ impl Parser { let args = self.parse_function_args_list()?; self.expect(TokenType::RParen)?; return self.maybe_parse_over(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: dynamic_name, args, distinct: false, @@ -32877,6 +34191,49 @@ impl Parser { #[inline(never)] fn parse_primary_slow(&mut self) -> Result { + if self.config.dialect == Some(crate::dialects::DialectType::HANA) + && !self.check(TokenType::QuotedIdentifier) + && !self.check_next(TokenType::Dot) + && matches!( + self.peek_text().to_ascii_uppercase().as_str(), + "CURRENT_DATE" + | "CURRENT_TIME" + | "CURRENT_TIMESTAMP" + | "CURRENT_CONNECTION" + | "CURRENT_TRANSACTION_ISOLATION_LEVEL" + | "SYSUUID" + | "SESSION_USER" + | "CURRENT_SCHEMA" + | "CURRENT_USER" + ) + { + let name = self.advance_text()?; + let upper = name.to_ascii_uppercase(); + let parenthesized = self.match_token(TokenType::LParen); + let args = if parenthesized { + if upper != "CURRENT_TIMESTAMP" { + return Err( + self.parse_error("This HANA reserved expression does not take parentheses") + ); + } + let precision = self.expect(TokenType::Number)?; + if !precision.text.parse::().is_ok_and(|p| p <= 7) { + return Err( + self.parse_error("HANA timestamp precision must be between 0 and 7") + ); + } + self.expect(TokenType::RParen)?; + vec![Expression::Literal(Box::new(Literal::Number( + precision.text, + )))] + } else { + Vec::new() + }; + let mut function = Function::new(name, args); + function.no_parens = !parenthesized; + function.source_dialect = self.config.dialect; + return Ok(Expression::Function(Box::new(function))); + } // Handle APPROXIMATE COUNT(DISTINCT expr) - Redshift syntax // Parses as ApproxDistinct expression if self.check(TokenType::Var) && self.peek_text().eq_ignore_ascii_case("APPROXIMATE") { @@ -32898,6 +34255,10 @@ impl Parser { }; self.expect(TokenType::RParen)?; let func = Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: format!("APPROXIMATE {}", name), args, distinct: false, @@ -33310,6 +34671,10 @@ impl Parser { let predicate = self.parse_expression()?; self.expect(TokenType::RParen)?; return Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "EXISTS".to_string(), args: vec![array_expr, predicate], distinct: false, @@ -33655,6 +35020,10 @@ impl Parser { }; self.expect(TokenType::RParen)?; let func_expr = Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "ROW".to_string(), args, distinct: false, @@ -34192,6 +35561,10 @@ impl Parser { }; self.expect(TokenType::RParen)?; let func = Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: token.text.to_string(), // Preserve original case; generator handles normalization args, distinct: false, @@ -34218,6 +35591,10 @@ impl Parser { }; self.expect(TokenType::RParen)?; let func = Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: token.text.to_string(), args, distinct: false, @@ -34250,6 +35627,10 @@ impl Parser { }; self.expect(TokenType::RParen)?; let func = Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: token.text.to_string(), args, distinct: false, @@ -34306,6 +35687,10 @@ impl Parser { }; self.expect(TokenType::RParen)?; let func = Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: token.text.to_string(), args, distinct: false, @@ -34321,6 +35706,10 @@ impl Parser { // No parens - parse as no-paren function let token = self.advance()?; let func = Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: token.text.to_string(), args: Vec::new(), distinct: false, @@ -34995,6 +36384,10 @@ impl Parser { let args = self.parse_function_args_list()?; self.expect(TokenType::RParen)?; return Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: token.text, args, distinct: false, @@ -35046,6 +36439,10 @@ impl Parser { if self.check(TokenType::RParen) { self.skip(); return Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -35106,6 +36503,10 @@ impl Parser { } Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -35451,6 +36852,10 @@ impl Parser { all_args.extend(remaining); self.expect(TokenType::RParen)?; Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args: all_args, distinct: false, @@ -35562,6 +36967,10 @@ impl Parser { let dimension = self.parse_expression()?; self.expect(TokenType::RParen)?; return Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args: vec![this, dimension], distinct: false, @@ -35726,6 +37135,10 @@ impl Parser { self.expect(TokenType::RParen)?; // Treat as function call Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args: vec![this], distinct: false, @@ -35770,6 +37183,10 @@ impl Parser { let mut args = vec![part, from_expr]; self.normalize_date_part_arg("DATE_PART", &mut args); Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "DATE_PART".to_string(), args, distinct: false, @@ -35807,6 +37224,10 @@ impl Parser { let mut args = vec![first_arg, second_arg, third_arg]; self.normalize_date_part_arg(name, &mut args); Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -35821,6 +37242,10 @@ impl Parser { // BigQuery 2-arg syntax: DATE_ADD(date, interval) self.expect(TokenType::RParen)?; Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args: vec![first_arg, second_arg], distinct: false, @@ -35869,6 +37294,10 @@ impl Parser { } self.normalize_date_part_arg(name, &mut args); Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -35963,6 +37392,10 @@ impl Parser { if self.check(TokenType::RParen) { self.skip(); return Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args: vec![], distinct: false, @@ -35978,6 +37411,10 @@ impl Parser { if !self.check(TokenType::Comma) && self.check(TokenType::RParen) { self.skip(); return Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args: vec![first], distinct: false, @@ -36074,6 +37511,10 @@ impl Parser { } self.expect(TokenType::RParen)?; return Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -36122,6 +37563,10 @@ impl Parser { let args = self.parse_function_arguments()?; self.expect(TokenType::RParen)?; Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -36200,6 +37645,10 @@ impl Parser { )))) } else { Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -36217,6 +37666,10 @@ impl Parser { self.expect(TokenType::RParen)?; if args.len() > 2 { Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "COALESCE".to_string(), args, distinct: false, @@ -36238,6 +37691,10 @@ impl Parser { )))) } else { Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -36264,6 +37721,10 @@ impl Parser { )))) } else { Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -36294,6 +37755,10 @@ impl Parser { let args = self.parse_function_arguments()?; self.expect(TokenType::RParen)?; return Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -36314,6 +37779,10 @@ impl Parser { let args = self.parse_expression_list()?; self.expect(TokenType::RParen)?; return Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -36346,6 +37815,10 @@ impl Parser { }; self.expect(TokenType::RParen)?; Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -36376,6 +37849,10 @@ impl Parser { )))) } else { Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -36407,6 +37884,10 @@ impl Parser { )))) } else { Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -36456,6 +37937,10 @@ impl Parser { } else { self.expect(TokenType::RParen)?; Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args: Vec::new(), distinct: false, @@ -36495,6 +37980,10 @@ impl Parser { } self.expect(TokenType::RParen)?; Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "XMLATTRIBUTES".to_string(), args: attrs, distinct: false, @@ -36514,6 +38003,10 @@ impl Parser { }; self.expect(TokenType::RParen)?; Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "XMLCOMMENT".to_string(), args, distinct: false, @@ -36544,6 +38037,10 @@ impl Parser { if !self.check_keyword_text("AGAINST") { return Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "MATCH".to_string(), args: expressions, distinct: false, @@ -36641,6 +38138,10 @@ impl Parser { })))) } else { Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args: expressions, distinct: false, @@ -36711,6 +38212,10 @@ impl Parser { args.push(s); } return Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: func_name.to_string(), args, distinct: false, @@ -36752,6 +38257,10 @@ impl Parser { self.expect(TokenType::RParen)?; let func_name = if is_try { "TRY_CONVERT" } else { "CONVERT" }; Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: func_name.to_string(), args, distinct: false, @@ -36766,6 +38275,10 @@ impl Parser { self.expect(TokenType::RParen)?; let func_name = if is_try { "TRY_CONVERT" } else { "CONVERT" }; Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: func_name.to_string(), args: vec![this], distinct: false, @@ -36913,6 +38426,10 @@ impl Parser { } else { self.expect(TokenType::RParen)?; Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args: vec![this, replacement], distinct: false, @@ -36927,6 +38444,10 @@ impl Parser { } else { self.expect(TokenType::RParen)?; Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args: vec![this], distinct: false, @@ -36969,6 +38490,10 @@ impl Parser { let args = self.parse_expression_list()?; self.expect(TokenType::RParen)?; Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -37000,6 +38525,10 @@ impl Parser { if self.check(TokenType::RParen) { self.skip(); return Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args: vec![], distinct: false, @@ -37081,6 +38610,10 @@ impl Parser { fn make_unquoted_function(name: &str, args: Vec) -> Expression { Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -37100,6 +38633,8 @@ impl Parser { filter: Option, ) -> Expression { Expression::AggregateFunction(Box::new(AggregateFunction { + source_dialect: None, + name: name.to_string(), args, distinct, @@ -37249,12 +38784,122 @@ impl Parser { /// Parse a typed function call (after the opening paren) /// Following Python SQLGlot pattern: match all function aliases to typed expressions + fn parse_hana_typed_function( + &mut self, + name: &str, + upper_name: &str, + quoted: bool, + ) -> Result { + if quoted { + let mut expression = self.parse_generic_function(name, true)?; + if let Expression::Function(function) = &mut expression { + let mut identifier = Identifier::new(name); + identifier.quoted = true; + function.qualified_name = vec![identifier]; + } + return Ok(expression); + } + if matches!(upper_name, "LOCALTOUTC" | "UTCTOLOCAL") { + return self.parse_hana_timezone(upper_name); + } + if matches!(upper_name, "JSON_VALUE" | "JSON_QUERY" | "JSON_TABLE") { + return self.parse_hana_json(upper_name); + } + if matches!(upper_name, "HIERARCHY" | "HIERARCHY_SPANTREE") { + return self.parse_hana_hierarchy(upper_name); + } + if matches!( + upper_name, + "SUBSTR_REGEXPR" + | "SUBSTRING_REGEXPR" + | "LOCATE_REGEXPR" + | "OCCURRENCES_REGEXPR" + | "REPLACE_REGEXPR" + ) { + return self.parse_hana_regex(upper_name, None); + } + if (crate::function_registry::is_hana_builtin(upper_name) + || matches!(upper_name, "CONTAINS" | "FUZZY")) + && !matches!( + upper_name, + "COUNT" + | "SUM" + | "MIN" + | "MAX" + | "AVG" + | "CAST" + | "EXTRACT" + | "TRIM" + | "ROW_NUMBER" + | "RANK" + | "DENSE_RANK" + | "LEAD" + | "LAG" + | "NTILE" + | "CUME_DIST" + | "PERCENT_RANK" + | "JSON_TABLE" + | "JSON_QUERY" + | "JSON_VALUE" + ) + { + let expr = self.parse_generic_function(name, false)?; + if let Expression::Function(mut f) = expr { + let arity_valid = match upper_name { + "LOCATE" => (2..=4).contains(&f.args.len()), + "SUBSTRING" | "SUBSTR" => (2..=3).contains(&f.args.len()), + "TO_DECIMAL" => (1..=3).contains(&f.args.len()), + "TO_DATE" | "TO_TIMESTAMP" | "TO_VARCHAR" | "TO_NVARCHAR" => { + (1..=2).contains(&f.args.len()) + } + "CURRENT_UTCTIMESTAMP" => { + f.args.len() == 1 + && matches!(&f.args[0], Expression::Literal(l) if matches!(l.as_ref(), Literal::Number(n) if n.parse::().is_ok_and(|n| n <= 7))) + } + "CURRENT_UTCDATE" | "CURRENT_UTCTIME" => false, + "ADD_DAYS" | "ADD_MONTHS" | "ADD_MONTHS_LAST" | "ADD_YEARS" | "ADD_SECONDS" + | "DAYS_BETWEEN" | "MONTHS_BETWEEN" | "SECONDS_BETWEEN" | "IFNULL" + | "BITAND" | "BITOR" | "BITXOR" => f.args.len() == 2, + "BITNOT" => f.args.len() == 1, + _ => true, + }; + if !arity_valid { + return Err( + self.parse_error(format!("Invalid arguments for HANA {upper_name}")) + ); + } + f.source_dialect = self.config.dialect; + return Ok(Expression::Function(f)); + } + return Ok(match expr { + Expression::AggregateFunction(mut f) => { + f.source_dialect = self.config.dialect; + Expression::AggregateFunction(f) + } + Expression::WithinGroup(mut w) => { + if let Expression::AggregateFunction(mut f) = w.this { + f.source_dialect = self.config.dialect; + w.this = Expression::AggregateFunction(f); + } + Expression::WithinGroup(w) + } + other => other, + }); + } + self.parse_generic_function(name, quoted) + } + fn parse_typed_function( &mut self, name: &str, upper_name: &str, quoted: bool, ) -> Result { + if self.config.dialect == Some(crate::dialects::DialectType::HANA) + && (quoted || crate::function_registry::hana_uses_source_node(upper_name)) + { + return self.parse_hana_typed_function(name, upper_name, quoted); + } // ClickHouse bitOr/bitAnd/bitXor are scalar functions, not aggregate-family calls. // Routing them directly to the generic parser avoids aggregate-specific recursion // and matches the syntax used throughout the ClickHouse parser corpus. @@ -37325,6 +38970,10 @@ impl Parser { } _ => { return Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -37446,6 +39095,10 @@ impl Parser { self.expect(TokenType::RParen)?; // Multiple args without DISTINCT - treat as generic function return Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -37536,6 +39189,8 @@ impl Parser { || filter.is_some() { Ok(Expression::AggregateFunction(Box::new(AggregateFunction { + source_dialect: None, + name: name.to_string(), args, distinct, @@ -37547,6 +39202,10 @@ impl Parser { }))) } else { Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -37581,6 +39240,10 @@ impl Parser { }; self.expect(TokenType::RParen)?; Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -37603,6 +39266,10 @@ impl Parser { // Pass the query directly as an argument to ARRAY function // The generator will handle it correctly return Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args: vec![query], distinct: false, @@ -37675,6 +39342,10 @@ impl Parser { })); return Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args: vec![subquery], distinct: false, @@ -37699,6 +39370,10 @@ impl Parser { }; self.expect(TokenType::RParen)?; Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -37770,6 +39445,10 @@ impl Parser { Some(crate::dialects::DialectType::ClickHouse) ) { Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args: Vec::new(), distinct: false, @@ -37850,6 +39529,8 @@ impl Parser { if is_duckdb_top_n { let filter = self.parse_filter_clause()?; Ok(Expression::AggregateFunction(Box::new(AggregateFunction { + source_dialect: None, + name: name.to_string(), args, distinct, @@ -37861,6 +39542,10 @@ impl Parser { }))) } else { Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -38216,6 +39901,10 @@ impl Parser { self.expect(TokenType::RParen)?; let trailing_comments = self.previous_trailing_comments().to_vec(); Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -38523,6 +40212,10 @@ impl Parser { } self.expect(TokenType::RParen)?; let func_expr = Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -38612,6 +40305,9 @@ impl Parser { self.expect(TokenType::RParen)?; let func = JsonExtractFunc { + options: None, + source_dialect: None, + this, path, returning, @@ -38646,6 +40342,10 @@ impl Parser { all_args.extend(remaining); self.expect(TokenType::RParen)?; Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args: all_args, distinct: false, @@ -39062,6 +40762,9 @@ impl Parser { self.expect(TokenType::RParen)?; Ok(Expression::JSONTable(Box::new(JSONTable { + options: None, + source_dialect: None, + this: Box::new(this_with_format), schema: schema.map(Box::new), path, @@ -39116,6 +40819,10 @@ impl Parser { } self.expect(TokenType::RParen)?; Ok(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: name.to_string(), args, distinct: false, @@ -39138,7 +40845,9 @@ impl Parser { /// Parse a generic function call (fallback for unrecognized functions) fn parse_generic_function(&mut self, name: &str, quoted: bool) -> Result { - let is_known_agg = self.is_aggregate_function(name); + let is_known_agg = !(quoted + && self.config.dialect == Some(crate::dialects::DialectType::HANA)) + && self.is_aggregate_function(name); let (mut args, distinct) = if self.check(TokenType::RParen) { (Vec::new(), false) @@ -39249,6 +40958,8 @@ impl Parser { self.expect(TokenType::RParen)?; let func_expr = Expression::AggregateFunction(Box::new(AggregateFunction { + source_dialect: None, + name: name.to_string(), args, distinct, @@ -39268,6 +40979,8 @@ impl Parser { let filter = self.parse_filter_clause()?; if let Some(filter_expr) = filter { return Ok(Expression::AggregateFunction(Box::new(AggregateFunction { + source_dialect: None, + name: format!("__WITHIN_GROUP_{}", name), args: vec![within, filter_expr], distinct: false, @@ -39298,6 +41011,8 @@ impl Parser { if filter.is_some() || is_known_agg || ignore_nulls.is_some() { Ok(Expression::AggregateFunction(Box::new(AggregateFunction { + source_dialect: None, + name: name.to_string(), args, distinct, @@ -39366,6 +41081,21 @@ impl Parser { } fn parse_single_function_argument(&mut self) -> Result { + if self.config.dialect == Some(crate::dialects::DialectType::HANA) + && self.check_identifier("PLACEHOLDER") + && self.check_next(TokenType::Dot) + { + self.skip(); + self.skip(); + let name = self.expect_identifier_with_quoted()?; + self.expect(TokenType::FArrow)?; + let value = self.parse_expression()?; + return Ok(Expression::ViewParameter(Box::new(ViewParameter { + source_dialect: self.config.dialect, + name, + value, + }))); + } let is_table_or_model_arg = if !self.is_at_end() { self.check(TokenType::Table) || self.peek_text().eq_ignore_ascii_case("MODEL") } else { @@ -40204,6 +41934,10 @@ impl Parser { let values = self.parse_expression()?; self.expect(TokenType::RBracket)?; expr = Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "MAP".to_string(), args: vec![keys, values], distinct: false, @@ -40681,6 +42415,9 @@ impl Parser { // Use parse_json_path_operand to get only the immediate operand for proper left-to-right associativity let path = self.parse_json_path_operand()?; expr = Expression::JsonExtract(Box::new(JsonExtractFunc { + options: None, + source_dialect: None, + this: expr, path, returning: None, @@ -40696,6 +42433,9 @@ impl Parser { // Use parse_json_path_operand to get only the immediate operand for proper left-to-right associativity let path = self.parse_json_path_operand()?; expr = Expression::JsonExtractScalar(Box::new(JsonExtractFunc { + options: None, + source_dialect: None, + this: expr, path, returning: None, @@ -40720,6 +42460,9 @@ impl Parser { // Use parse_json_path_operand to get only the immediate operand for proper left-to-right associativity let path = self.parse_json_path_operand()?; expr = Expression::JsonExtractScalar(Box::new(JsonExtractFunc { + options: None, + source_dialect: None, + this: expr, path, returning: None, @@ -42321,7 +44064,9 @@ impl Parser { fn parse_data_type_inner(&mut self) -> Result { // Handle special token types that represent data type keywords // Teradata tokenizes ST_GEOMETRY as TokenType::Geometry - if self.check(TokenType::Geometry) { + if self.check(TokenType::Geometry) + && self.config.dialect != Some(crate::dialects::DialectType::HANA) + { let _token = self.advance()?; let (subtype, srid) = self.parse_spatial_type_args()?; return Ok(DataType::Geometry { subtype, srid }); @@ -42335,6 +44080,15 @@ impl Parser { raw_name.push_str(&part); } let mut name = raw_name.to_ascii_uppercase(); + if self.config.dialect == Some(crate::dialects::DialectType::HANA) { + name = match name.as_str() { + "INTEGER" => "INT".to_owned(), + "DEC" => "DECIMAL".to_owned(), + "LONGDATE" => "TIMESTAMP".to_owned(), + "DAYDATE" => "DATE".to_owned(), + _ => name, + }; + } // SQL standard: NATIONAL CHAR/CHARACTER → NCHAR if name == "NATIONAL" { @@ -42369,6 +44123,69 @@ impl Parser { } } + if self.config.dialect == Some(crate::dialects::DialectType::HANA) + && matches!( + name.as_str(), + "TINYINT" + | "SMALLINT" + | "INT" + | "BIGINT" + | "FLOAT" + | "SMALLDECIMAL" + | "DECIMAL" + | "ALPHANUM" + | "SECONDDATE" + | "TIMESTAMP" + | "TIME" + | "NVARCHAR" + | "VARCHAR" + | "CLOB" + | "NCLOB" + | "TEXT" + | "BINTEXT" + | "SHORTTEXT" + | "REAL_VECTOR" + | "HALF_VECTOR" + | "ST_POINT" + | "ST_GEOMETRY" + ) + { + let mut parameters = Vec::new(); + if self.match_token(TokenType::LParen) { + loop { + let token = self.expect(TokenType::Number)?; + let parameter = token.text.parse::().map_err(|_| { + self.parse_error("Expected a non-negative integer HANA type parameter") + })?; + parameters.push(parameter); + if !self.match_token(TokenType::Comma) { + break; + } + } + self.expect(TokenType::RParen)?; + } + let valid = match (name.as_str(), parameters.as_slice()) { + ("FLOAT", [] | [1..=53]) => true, + ("DECIMAL", [] | [1..=38]) => true, + ("DECIMAL", [p, scale]) => (1..=38).contains(p) && scale <= p, + ("NVARCHAR" | "VARCHAR", [] | [1..=5000]) => true, + ("ALPHANUM", [] | [1..=127]) => true, + ("SHORTTEXT", [] | [1..=5000]) => true, + ("REAL_VECTOR" | "HALF_VECTOR" | "ST_POINT" | "ST_GEOMETRY", [] | [_]) => true, + ( + "TINYINT" | "SMALLINT" | "INT" | "BIGINT" | "SMALLDECIMAL" | "SECONDDATE" + | "TIMESTAMP" | "TIME" | "CLOB" | "NCLOB" | "TEXT" | "BINTEXT", + [], + ) => true, + _ => false, + }; + if !valid { + return Err(self.parse_error(format!("Invalid HANA {name} type parameters"))); + } + return self.finish_data_type(DataType::Hana { + hana_type: HanaDataType { name, parameters }, + }); + } if let Some(data_type) = self.parse_oracle_data_type(&name)? { return Ok(data_type); } @@ -44505,6 +46322,23 @@ impl Parser { /// Convert a DataType to a string representation for JSONColumnDef.kind fn data_type_to_string(&self, dt: &DataType) -> String { match dt { + DataType::Hana { hana_type } => { + if hana_type.parameters.is_empty() { + hana_type.name.clone() + } else { + format!( + "{}({})", + hana_type.name, + hana_type + .parameters + .iter() + .map(u32::to_string) + .collect::>() + .join(", ") + ) + } + } + DataType::Int { length: Some(n), integer_spelling: true, @@ -50946,9 +52780,15 @@ impl Parser { self.expect(TokenType::RParen)?; if is_cube { - Ok(Some(Expression::Cube(Box::new(Cube { expressions })))) + Ok(Some(Expression::Cube(Box::new(Cube { + options: None, + expressions, + })))) } else { - Ok(Some(Expression::Rollup(Box::new(Rollup { expressions })))) + Ok(Some(Expression::Rollup(Box::new(Rollup { + options: None, + expressions, + })))) } } @@ -52329,6 +54169,10 @@ impl Parser { if !self.check_next(TokenType::LParen) { self.skip(); return Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name, // Preserve original case; generator handles normalization args: Vec::new(), distinct: false, @@ -52377,6 +54221,10 @@ impl Parser { // Handle window specifications let func_expr = Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name, // Preserve original case; generator handles normalization args, distinct, @@ -52909,6 +54757,8 @@ impl Parser { self.expect(TokenType::RParen)?; Ok(Some(Expression::GroupingSets(Box::new(GroupingSets { + options: None, + expressions, })))) } @@ -53043,6 +54893,8 @@ impl Parser { let hint_text = parts.join(" "); Ok(Some(Expression::Hint(Box::new(Hint { + source_dialect: None, + expressions: vec![HintExpression::Raw(hint_text)], })))) } @@ -53256,6 +55108,10 @@ impl Parser { if self.check(TokenType::RParen) { self.skip(); // consume RParen return Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: original_name.clone().unwrap_or_else(|| "IF".to_string()), args: vec![], distinct: false, @@ -53288,6 +55144,10 @@ impl Parser { })))); } else if args.len() == 1 { return Ok(Some(Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: original_name.clone().unwrap_or_else(|| "IF".to_string()), args, distinct: false, @@ -53917,6 +55777,12 @@ impl Parser { pub fn parse_json_column_def(&mut self) -> Result> { if self.match_text_seq(&["NESTED"]) { return Ok(Some(Expression::JSONColumnDef(Box::new(JSONColumnDef { + source_dialect: None, + + data_type: None, + encoding: None, + options: None, + this: None, kind: None, format_json: false, @@ -54167,6 +56033,12 @@ impl Parser { let nested_schema = self.parse_json_table_columns()?; return Ok(Some(Expression::JSONColumnDef(Box::new(JSONColumnDef { + source_dialect: None, + + data_type: None, + encoding: None, + options: None, + this: None, kind: None, format_json: false, @@ -54210,6 +56082,12 @@ impl Parser { }; Ok(Some(Expression::JSONColumnDef(Box::new(JSONColumnDef { + source_dialect: None, + + data_type: None, + encoding: None, + options: None, + this: name.map(Box::new), kind, format_json, @@ -54274,6 +56152,9 @@ impl Parser { let schema = self.parse_json_schema()?; Ok(Some(Expression::JSONTable(Box::new(JSONTable { + options: None, + source_dialect: None, + this: Box::new(this), schema: schema.map(Box::new), path, @@ -54309,6 +56190,9 @@ impl Parser { }; Ok(Some(Expression::JSONValue(Box::new(JSONValue { + options: None, + source_dialect: None, + this: Box::new(this), path: Some(Box::new(path)), returning, @@ -57011,6 +58895,13 @@ impl Parser { pub fn parse_pipe_syntax_aggregate(&mut self) -> Result> { if self.match_text_seq(&["AGGREGATE"]) { return Ok(Some(Expression::Select(Box::new(Select { + source_dialect: None, + total_rowcount: false, + + query_collation: None, + query_hints: Vec::new(), + result_serialization: None, + expressions: Vec::new(), from: None, joins: Vec::new(), @@ -57042,6 +58933,7 @@ impl Parser { locks: Vec::new(), for_xml: Vec::new(), for_json: Vec::new(), + leading_comments: Vec::new(), post_select_comments: Vec::new(), kind: None, @@ -57134,6 +59026,13 @@ impl Parser { pub fn parse_pipe_syntax_extend(&mut self) -> Result> { if self.match_text_seq(&["EXTEND"]) { return Ok(Some(Expression::Select(Box::new(Select { + source_dialect: None, + total_rowcount: false, + + query_collation: None, + query_hints: Vec::new(), + result_serialization: None, + expressions: Vec::new(), from: None, joins: Vec::new(), @@ -57165,6 +59064,7 @@ impl Parser { locks: Vec::new(), for_xml: Vec::new(), for_json: Vec::new(), + leading_comments: Vec::new(), post_select_comments: Vec::new(), kind: None, @@ -61091,6 +62991,10 @@ impl Parser { self.expect(TokenType::RParen)?; let func_expr = Expression::Function(Box::new(Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: func_name, args, distinct: false, @@ -63171,7 +65075,10 @@ impl Parser { } Ok(Some(Expression::PartitionByProperty(Box::new( - PartitionByProperty { expressions }, + PartitionByProperty { + specification: None, + expressions, + }, )))) } diff --git a/crates/polyglot-sql/src/transforms.rs b/crates/polyglot-sql/src/transforms.rs index 1ffb8489..24f548f0 100644 --- a/crates/polyglot-sql/src/transforms.rs +++ b/crates/polyglot-sql/src/transforms.rs @@ -3749,6 +3749,10 @@ pub fn no_ilike_sql(expr: Expression) -> Result { Expression::ILike(ilike) => { // Create LOWER(left) LIKE LOWER(right) let lower_left = Expression::Function(Box::new(crate::expressions::Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "LOWER".to_string(), args: vec![ilike.left], distinct: false, @@ -3761,6 +3765,10 @@ pub fn no_ilike_sql(expr: Expression) -> Result { })); let lower_right = Expression::Function(Box::new(crate::expressions::Function { + on_error: None, + qualified_name: Vec::new(), + source_dialect: None, + name: "LOWER".to_string(), args: vec![ilike.right], distinct: false, diff --git a/crates/polyglot-sql/src/traversal.rs b/crates/polyglot-sql/src/traversal.rs index 3db9fd71..ccb6197d 100644 --- a/crates/polyglot-sql/src/traversal.rs +++ b/crates/polyglot-sql/src/traversal.rs @@ -211,7 +211,7 @@ impl<'a> Iterator for DfsIter<'a> { let expr = self.stack.pop()?; let child_start = self.stack.len(); - crate::ast_children::for_each_child(expr, |_, child| self.stack.push(child)); + crate::ast_children::for_each_child_untracked(expr, |child| self.stack.push(child)); self.stack[child_start..].reverse(); Some(expr) @@ -244,7 +244,7 @@ impl<'a> Iterator for BfsIter<'a> { fn next(&mut self) -> Option { let expr = self.queue.pop_front()?; - crate::ast_children::for_each_child(expr, |_, child| self.queue.push_back(child)); + crate::ast_children::for_each_child_untracked(expr, |child| self.queue.push_back(child)); Some(expr) } @@ -480,7 +480,7 @@ pub fn is_literal(expr: &Expression) -> bool { pub fn is_function(expr: &Expression) -> bool { matches!( expr, - Expression::Function(_) | Expression::AggregateFunction(_) + Expression::Function(_) | Expression::AggregateFunction(_) | Expression::Hierarchy(_) ) } diff --git a/crates/polyglot-sql/src/validation.rs b/crates/polyglot-sql/src/validation.rs index ea6e9a3c..051b7f21 100644 --- a/crates/polyglot-sql/src/validation.rs +++ b/crates/polyglot-sql/src/validation.rs @@ -766,6 +766,17 @@ fn data_type_family(data_type: &DataType) -> TypeFamily { DataType::Float { .. } | DataType::Double { .. } | DataType::Decimal { .. } => { TypeFamily::Numeric } + DataType::Hana { hana_type } => match hana_type.name.as_str() { + "TINYINT" | "SMALLINT" | "INT" | "BIGINT" => TypeFamily::Integer, + "TIMESTAMP" | "SECONDDATE" => TypeFamily::Timestamp, + "TIME" => TypeFamily::Time, + "FLOAT" | "DECIMAL" | "SMALLDECIMAL" => TypeFamily::Numeric, + "BINTEXT" => TypeFamily::Binary, + "ALPHANUM" | "VARCHAR" | "NVARCHAR" | "CLOB" | "NCLOB" | "TEXT" | "SHORTTEXT" => { + TypeFamily::String + } + _ => TypeFamily::Unknown, + }, DataType::Oracle { oracle_type } => match oracle_type { OracleDataType::Number { .. } | OracleDataType::BinaryFloat diff --git a/crates/polyglot-sql/src/validation/tests.rs b/crates/polyglot-sql/src/validation/tests.rs index 3952ac70..5061c3ff 100644 --- a/crates/polyglot-sql/src/validation/tests.rs +++ b/crates/polyglot-sql/src/validation/tests.rs @@ -2109,6 +2109,51 @@ fn test_validate_with_schema_type_check_comparison_mismatch() { .any(|e| e.code == validation_codes::E_INCOMPATIBLE_COMPARISON_TYPES)); } +#[cfg(feature = "dialect-hana")] +#[test] +fn hana_integer_casts_participate_in_type_validation() { + let schema = ValidationSchema { + tables: vec![], + strict: Some(true), + }; + let opts = SchemaValidationOptions { + check_types: true, + strict: Some(true), + ..Default::default() + }; + for data_type in ["TINYINT", "SMALLINT", "INT", "INTEGER", "BIGINT"] { + for (sql, code) in [ + ( + format!("SELECT CAST(1 AS {data_type}) + 'x'"), + validation_codes::E_INVALID_ARITHMETIC_TYPE, + ), + ( + format!("SELECT CAST(1 AS {data_type}) = DATE '2024-01-01'"), + validation_codes::E_INCOMPATIBLE_COMPARISON_TYPES, + ), + ( + format!("SELECT LENGTH(CAST(1 AS {data_type}))"), + validation_codes::E_INVALID_FUNCTION_ARGUMENT_TYPE, + ), + ] { + let result = validate_with_schema(&sql, DialectType::HANA, &schema, &opts); + assert!(!result.valid, "{sql}"); + assert!( + result.errors.iter().any(|e| e.code == code), + "{sql}: {result:?}" + ); + } + for sql in [ + format!("SELECT CAST(1 AS {data_type}) + 2"), + format!("SELECT CAST(1 AS {data_type}) = 2"), + format!("SELECT ABS(CAST(1 AS {data_type}))"), + ] { + let result = validate_with_schema(&sql, DialectType::HANA, &schema, &opts); + assert!(result.valid, "{sql}: {result:?}"); + } + } +} + #[test] fn test_validate_with_schema_type_check_arithmetic_mismatch() { let schema = base_schema(); diff --git a/crates/polyglot-sql/tests/common/test_runner.rs b/crates/polyglot-sql/tests/common/test_runner.rs index 1263ce8c..8442c706 100644 --- a/crates/polyglot-sql/tests/common/test_runner.rs +++ b/crates/polyglot-sql/tests/common/test_runner.rs @@ -307,6 +307,7 @@ pub fn parse_dialect(name: &str) -> Option { "druid" => Some(DialectType::Druid), "dune" => Some(DialectType::Dune), "exasol" => Some(DialectType::Exasol), + "hana" | "saphana" | "sap_hana" => Some(DialectType::HANA), "fabric" => Some(DialectType::Fabric), "solr" => Some(DialectType::Solr), "datafusion" | "arrow-datafusion" | "arrow_datafusion" => Some(DialectType::DataFusion), 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..6b7d3c75 --- /dev/null +++ b/crates/polyglot-sql/tests/custom_fixtures/hana/ddl.json @@ -0,0 +1,66 @@ +{ + "dialect": "hana", + "category": "ddl", + "identity": [ + { + "sql": "CREATE TABLE t (id INTEGER, name NVARCHAR(100), salary SMALLDECIMAL, created SECONDDATE)", + "description": "CREATE TABLE with HANA types", + "expected": "CREATE TABLE t (id INT, name NVARCHAR(100), salary SMALLDECIMAL, created SECONDDATE)" + }, + { + "sql": "CREATE TABLE t (id INTEGER NOT NULL PRIMARY KEY, name VARCHAR(100) NOT NULL)", + "description": "CREATE TABLE with constraints", + "expected": "CREATE TABLE t (id INT NOT NULL PRIMARY KEY, name VARCHAR(100) NOT NULL)" + }, + { + "sql": "CREATE TABLE t (a NVARCHAR(255), b SMALLDECIMAL)", + "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", + "expected": "CREATE TABLE t (a INT, b NVARCHAR(50) NOT NULL, c DECIMAL(10, 2))" + }, + { + "sql": "CREATE TABLE t (a INTEGER)", + "description": "CREATE TABLE", + "expected": "CREATE TABLE t (a INT)" + }, + { + "sql": "DROP TABLE t", + "description": "DROP TABLE" + }, + { + "sql": "DROP TABLE t", + "description": "DROP TABLE", + "expected": "DROP TABLE t" + }, + { + "sql": "ALTER TABLE t ADD (x INTEGER)", + "description": "ALTER TABLE ADD COLUMN", + "expected": "ALTER TABLE t ADD (x INT)" + }, + { + "sql": "CREATE TABLE t (a INTEGER, b INTEGER, PRIMARY KEY (a))", + "description": "CREATE TABLE with table-level PRIMARY KEY", + "expected": "CREATE TABLE t (a INT, b INT, PRIMARY KEY (a))" + }, + { + "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..1fe77a1d --- /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 SELECT 1, 'a' FROM DUMMY UNION ALL SELECT 2, 'b' FROM DUMMY UNION ALL SELECT 3, 'c' FROM DUMMY", + "description": "Insert several rows using a union query" + }, + { + "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..68c91838 --- /dev/null +++ b/crates/polyglot-sql/tests/custom_fixtures/hana/functions.json @@ -0,0 +1,136 @@ +{ + "dialect": "hana", + "category": "functions", + "identity": [ + { + "sql": "SELECT ADD_DAYS(d, 7) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT ADD_MONTHS(d, 3) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT ADD_SECONDS(ts, 30) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT ADD_YEARS(d, 1) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT DAYS_BETWEEN(d1, d2) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT MONTHS_BETWEEN(d1, d2) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT SECONDS_BETWEEN(d1, d2) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT YEARS_BETWEEN(d1, d2) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT TO_VARCHAR(x) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT TO_VARCHAR(d, 'YYYY-MM-DD') FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT TO_INTEGER(s) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT TO_DECIMAL(s) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT IFNULL(a, b) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT SUBSTR(s, 1, 3) FROM t", + "description": "Native SUBSTR alias" + }, + { + "sql": "SELECT LCASE(s) FROM t", + "description": "LCASE canonicalized to LOWER by parser" + }, + { + "sql": "SELECT UCASE(s) FROM t", + "description": "UCASE canonicalized to UPPER by parser" + }, + { + "sql": "SELECT LOCATE('abc', s) FROM t", + "description": "LOCATE canonicalized to STRPOS with arg swap by parser" + }, + { + "sql": "SELECT CURRENT_UTCTIMESTAMP FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT CURRENT_UTCDATE FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT CURRENT_UTCTIME FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT TRUNC(x, 2) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT BITAND(a, b) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT BITOR(a, b) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT BITNOT(a) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT BINTOSTR(HEXTOBIN('414243')) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT IFNULL(a, b) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT CASE WHEN cond THEN a ELSE b END FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT add_days(d, 7) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT Add_Months(d, 3) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT lcase(s) FROM t", + "description": "Mixed-case lcase canonicalized to LOWER by parser" + }, + { + "sql": "SELECT CAST(x AS INTEGER) FROM t", + "description": "Explicit type conversion", + "expected": "SELECT CAST(x AS INT) FROM t" + }, + { + "sql": "SELECT * FROM t WHERE LOWER(name) LIKE '%john%'", + "description": "Native HANA calls preserve their name and arguments" + } + ], + "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..155f747e --- /dev/null +++ b/crates/polyglot-sql/tests/custom_fixtures/hana/identity.json @@ -0,0 +1,163 @@ +{ + "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 FROM DUMMY UNION ALL SELECT 2 FROM DUMMY", + "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), IFNULL(a, b) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "sql": "SELECT SUBSTR(s, 1, 3) FROM t", + "description": "Native SUBSTR alias" + }, + { + "sql": "SELECT add_days(d, 7) FROM t", + "description": "Native HANA calls preserve their name and arguments" + }, + { + "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 FROM DUMMY), cte2 AS (SELECT 2 AS y FROM DUMMY) SELECT * FROM cte1, cte2", + "description": "Multiple CTEs" + }, + { + "sql": "SELECT * FROM t WHERE EXISTS (SELECT 1 FROM s WHERE s.id = t.id)", + "description": "EXISTS subquery", + "expected": "SELECT * FROM t WHERE EXISTS(SELECT 1 FROM s WHERE s.id = t.id)" + }, + { + "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", + "description": "Single-line comment preserved as block comment", + "expected": "SELECT a FROM t /* trailing 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", + "description": "No nested comments: first */ closes the comment", + "expected": "SELECT a FROM t WHERE x = 1" + }, + { + "sql": "select a, b from t where x > 1", + "description": "Lowercase keywords normalized to uppercase", + "expected": "SELECT a, b FROM t WHERE x > 1" + } + ], + "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..d842f64f --- /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", + "description": "SELECT ALL", + "expected": "SELECT x FROM t" + }, + { + "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/types.json b/crates/polyglot-sql/tests/custom_fixtures/hana/types.json new file mode 100644 index 00000000..3b66eb65 --- /dev/null +++ b/crates/polyglot-sql/tests/custom_fixtures/hana/types.json @@ -0,0 +1,39 @@ +{ + "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", + "description": "Native HANA calls preserve their name and arguments" + } + ], + "transpilation": [] +} diff --git a/crates/polyglot-sql/tests/dialect_matrix.rs b/crates/polyglot-sql/tests/dialect_matrix.rs index 67c123c5..a6959026 100644 --- a/crates/polyglot-sql/tests/dialect_matrix.rs +++ b/crates/polyglot-sql/tests/dialect_matrix.rs @@ -28,7 +28,7 @@ fn transpile_succeeds(sql: &str, from: DialectType, to: DialectType) -> bool { mod strict_unsupported_regressions { use super::*; - fn transpile_with_level( + pub(super) fn transpile_with_level( sql: &str, read: DialectType, write: DialectType, @@ -484,7 +484,6 @@ mod strict_unsupported_regressions { "SELECT count(t.id) FROM t", "SELECT sum(t.amount) FROM t", "SELECT t.* FROM t", - "SELECT count(x) FILTER (WHERE EXISTS (SELECT t.* FROM t)) FROM u", ]; for write in [DialectType::Fabric, DialectType::TSQL] { @@ -520,6 +519,24 @@ mod strict_unsupported_regressions { } } + #[test] + fn strict_tsql_targets_reject_filter_subqueries_lowered_into_aggregate_arguments() { + // FILTER becomes CASE inside COUNT. SQL Server/Fabric do not allow a + // subquery in that argument, even though the FILTER is valid PostgreSQL. + for target in [DialectType::TSQL, DialectType::Fabric] { + let error = transpile_with_level( + "SELECT count(x) FILTER (WHERE EXISTS (SELECT t.* FROM t)) FROM u", + DialectType::PostgreSQL, + target, + UnsupportedLevel::Raise, + ) + .unwrap_err(); + assert!(error + .to_string() + .contains("aggregate arguments containing subqueries")); + } + } + #[test] fn strict_transpile_rejects_postgres_composite_values_for_tsql_targets() { let unsupported = [ @@ -4912,3 +4929,826 @@ mod secondary_dialects { )); } } + +#[cfg(feature = "dialect-hana")] +mod hana_regressions { + use super::strict_unsupported_regressions::transpile_with_level; + use super::*; + use polyglot_sql::expressions::Expression; + use polyglot_sql::ExpressionWalk; + + #[test] + fn integer_casts_preserve_hana_truncation_and_reject_unverified_domains() { + let sql = "SELECT CAST(10.9 AS INTEGER), CAST(-10.9 AS BIGINT), CAST(10.9 AS TINYINT), CAST(-10.9 AS SMALLINT)"; + assert_eq!(transpile(sql, DialectType::HANA, DialectType::DuckDB), + "SELECT CAST(TRUNC(10.9) AS INT), CAST(TRUNC(-10.9) AS BIGINT), CAST(TRUNC(10.9) AS UTINYINT), CAST(TRUNC(-10.9) AS SMALLINT)"); + for target in [DialectType::PostgreSQL, DialectType::Trino] { + let sql = "SELECT CAST(-10.9 AS BIGINT)"; + let output = transpile(sql, DialectType::HANA, target); + assert!(output.contains(if target == DialectType::Trino { + "TRUNCATE(-10.9)" + } else { + "TRUNC(-10.9)" + })); + } + let hana = Dialect::get(DialectType::HANA); + for sql in [ + "SELECT CAST('10.9' AS INT)", + "SELECT CAST(x AS INT) FROM t", + "SELECT CAST(256 AS TINYINT)", + ] { + let ast = hana.parse(sql).unwrap(); + let json = serde_json::to_string(&ast).unwrap(); + let decoded: Vec = serde_json::from_str(&json).unwrap(); + assert!(hana.generate(&decoded[0]).is_ok(), "native {sql}"); + for target in [DialectType::PostgreSQL, DialectType::Trino] { + assert!( + Dialect::get(target).generate(&decoded[0]).is_err(), + "{sql} -> {target:?}" + ); + for level in [UnsupportedLevel::Ignore, UnsupportedLevel::Raise] { + assert!(transpile_with_level(sql, DialectType::HANA, target, level).is_err()); + } + } + } + for sql in [ + "SELECT CAST(10.9 AS INT)", + "SELECT CAST(-10.9 AS BIGINT)", + "SELECT CAST('10.9' AS INT)", + "SELECT CAST(x AS SMALLINT) FROM t", + ] { + assert!( + transpile_with_level( + sql, + DialectType::PostgreSQL, + DialectType::HANA, + UnsupportedLevel::Raise + ) + .is_err(), + "{sql}" + ); + } + assert_eq!( + transpile( + "SELECT CAST(42 AS INT)", + DialectType::PostgreSQL, + DialectType::HANA + ), + "SELECT CAST(42 AS INT) FROM SYS.DUMMY" + ); + for sql in [ + "SELECT CAST(-32768 AS SMALLINT)", + "SELECT CAST(2147483647 AS INTEGER)", + "SELECT CAST(-9223372036854775808 AS BIGINT)", + ] { + assert!( + transpile_with_level( + sql, + DialectType::HANA, + DialectType::DuckDB, + UnsupportedLevel::Raise + ) + .is_ok(), + "{sql}" + ); + } + } + + #[test] + fn incoming_decimal_casts_require_verified_rounding_and_overflow_semantics() { + for source in [DialectType::PostgreSQL, DialectType::DuckDB] { + for sql in [ + "SELECT CAST(12.349 AS DECIMAL(5, 2))", + "SELECT CAST(-12.349 AS DECIMAL(5, 2))", + "SELECT CAST(999.999 AS DECIMAL(5, 2))", + "SELECT CAST('12.349' AS DECIMAL(5, 2))", + "SELECT CAST(x AS DECIMAL(5, 2)) FROM t", + "SELECT CAST(12.349 AS DECIMAL)", + "SELECT TRY_CAST(12.349 AS DECIMAL(5, 2))", + "SELECT SAFE_CAST(12.349 AS DECIMAL(5, 2))", + ] { + let ast = Dialect::get(source).parse(sql).unwrap(); + let error = Dialect::get(DialectType::HANA) + .generate(&ast[0]) + .unwrap_err(); + assert!( + error.to_string().contains("Decimal CAST to HANA"), + "{sql}: {error}" + ); + for level in [ + UnsupportedLevel::Ignore, + UnsupportedLevel::Warn, + UnsupportedLevel::Raise, + UnsupportedLevel::Immediate, + ] { + let error = + transpile_with_level(sql, source, DialectType::HANA, level).unwrap_err(); + assert!( + error.to_string().contains("Decimal CAST to HANA"), + "{sql}: {error}" + ); + } + } + } + for sql in [ + "SELECT CAST(12.349 AS DECIMAL(5, 2)) FROM t", + "SELECT CAST(-12.349 AS DECIMAL(5, 2)) FROM t", + "SELECT CAST(x AS DECIMAL) FROM t", + ] { + assert_eq!(transpile(sql, DialectType::HANA, DialectType::HANA), sql); + } + assert_eq!( + transpile( + "CREATE TABLE t (x DECIMAL(5, 2))", + DialectType::PostgreSQL, + DialectType::HANA + ), + "CREATE TABLE t (x DECIMAL(5, 2))" + ); + } + + #[test] + fn synthetic_dummy_is_not_captured_by_ctes() { + for (sql, expected) in [ + ( + "WITH DUMMY AS (SELECT x FROM t) SELECT 42", + "WITH DUMMY AS (SELECT x FROM t) SELECT 42 FROM SYS.DUMMY", + ), + ( + r#"WITH "DUMMY" AS (SELECT x FROM t) SELECT 42"#, + r#"WITH "DUMMY" AS (SELECT x FROM t) SELECT 42 FROM SYS.DUMMY"#, + ), + ( + "WITH DUMMY AS (SELECT 7 AS x) SELECT (SELECT 42) FROM DUMMY", + "WITH DUMMY AS (SELECT 7 AS x FROM SYS.DUMMY) SELECT (SELECT 42 FROM SYS.DUMMY) FROM DUMMY", + ), + ( + "WITH DUMMY AS (SELECT x FROM t) SELECT x FROM DUMMY", + "WITH DUMMY AS (SELECT x FROM t) SELECT x FROM DUMMY", + ), + ] { + assert_eq!(transpile(sql, DialectType::PostgreSQL, DialectType::HANA), expected); + let ast = Dialect::get(DialectType::PostgreSQL).parse(sql).unwrap(); + assert_eq!(Dialect::get(DialectType::HANA).generate(&ast[0]).unwrap(), expected); + // Explicit relations, including the injected system relation, survive native generation. + assert_eq!(transpile(expected, DialectType::HANA, DialectType::HANA), expected); + } + } + + #[test] + fn incoming_string_aggregation_requires_verified_null_semantics() { + for sql in [ + "SELECT STRING_AGG(x, ',' ORDER BY id) FROM t", + "SELECT STRING_AGG(NULL, ',') FROM t", + "SELECT STRING_AGG(x, ',') FROM t WHERE 1 = 0", + "SELECT STRING_AGG(x, ',') OVER (PARTITION BY grp) FROM t", + "SELECT STRING_AGG(x, ',') FILTER (WHERE keep), COUNT(*) FROM t", + ] { + for source in [DialectType::PostgreSQL, DialectType::DuckDB] { + let error = + transpile_with_level(sql, source, DialectType::HANA, UnsupportedLevel::Raise) + .unwrap_err(); + assert!(error.to_string().contains("STRING_AGG"), "{sql}: {error}"); + } + } + assert!(Dialect::get(DialectType::HANA) + .transpile("SELECT STRING_AGG(x, ',') FROM t", DialectType::HANA) + .is_ok()); + } + + #[test] + fn dummy_relations_preserve_rows_columns_aliases_and_cte_scope() { + assert!(Dialect::get(DialectType::HANA) + .transpile("SELECT SYS.DUMMY.DUMMY FROM SYS.DUMMY", DialectType::DuckDB,) + .unwrap_err() + .to_string() + .contains("explicit table alias")); + for target in [ + DialectType::DuckDB, + DialectType::PostgreSQL, + DialectType::Trino, + ] { + for sql in [ + "SELECT 1 FROM SYS.DUMMY", + "SELECT * FROM DUMMY", + "SELECT d.DUMMY FROM SYS.DUMMY AS d", + "SELECT COUNT(*) FROM DUMMY WHERE 1 = 0", + ] { + let output = transpile(sql, DialectType::HANA, target); + assert!(output.contains("SELECT 'X' AS DUMMY"), "{output}"); + assert!(!output.contains("FROM SYS.DUMMY"), "{output}"); + } + } + for sql in [ + "SELECT * FROM app.DUMMY", + "SELECT * FROM \"dummy\"", + "WITH DUMMY AS (SELECT 7 AS n) SELECT n FROM DUMMY", + ] { + let output = transpile(sql, DialectType::HANA, DialectType::DuckDB); + assert!(!output.contains("SELECT 'X'"), "{output}"); + } + // The alias is not visible in its own non-recursive definition. + for sql in [ + "WITH DUMMY AS (SELECT * FROM SYS.DUMMY) SELECT * FROM DUMMY", + "WITH DUMMY AS (SELECT * FROM DUMMY) SELECT * FROM DUMMY", + "WITH first_cte AS (SELECT * FROM DUMMY), DUMMY AS (SELECT 7 AS n), last_cte AS (SELECT n FROM DUMMY) SELECT * FROM first_cte", + ] { + let output = transpile(sql, DialectType::HANA, DialectType::DuckDB); + assert_eq!(output.matches("SELECT 'X'").count(), 1, "{output}"); + } + // A nested WITH does not shadow a sibling or its enclosing query. + let output = transpile( + "SELECT (WITH DUMMY AS (SELECT 7 AS n) SELECT n FROM DUMMY) FROM DUMMY", + DialectType::HANA, + DialectType::DuckDB, + ); + assert_eq!(output.matches("SELECT 'X'").count(), 1, "{output}"); + } + + #[test] + fn outer_statement_validation_still_rejects_nested_raw_fallbacks() { + let hana = Dialect::get(DialectType::HANA); + for sql in [ + "UNSUPPORTED STATEMENT", + "SELECT (UNSUPPORTED STATEMENT)", + "CREATE TABLE t AS (UNSUPPORTED STATEMENT)", + ] { + assert!(hana.parse(sql).is_err(), "{sql}"); + } + let mut sql = "SELECT 1".to_owned(); + for _ in 0..80 { + sql = format!("SELECT ({sql})"); + } + assert!(hana.parse(&sql).is_ok()); + assert!(hana.parse("SELECT 1; UNSUPPORTED STATEMENT").is_err()); + } + + #[test] + fn shared_function_nodes_participate_in_generic_analysis() { + use polyglot_sql::optimizer::{normalize_identifiers, TypeAnnotator}; + let hana = Dialect::get(DialectType::HANA); + let ast = hana + .parse("SELECT COALESCE(mixed, 1), mixed FROM tab") + .unwrap() + .remove(0); + assert!(ast.dfs().any(|e| matches!(e, Expression::Function(_)))); + let normalized = normalize_identifiers(ast, Some(DialectType::HANA)); + assert_eq!( + hana.generate(&normalized).unwrap(), + "SELECT COALESCE(MIXED, 1), MIXED FROM TAB" + ); + let ast = hana + .parse("SELECT COALESCE(1, 2) FROM DUMMY") + .unwrap() + .remove(0); + let call = ast + .dfs() + .find(|e| matches!(e, Expression::Function(_))) + .unwrap(); + assert_eq!(call.source_dialect(), Some(DialectType::HANA)); + assert!(matches!( + TypeAnnotator::new(None, Some(DialectType::HANA)).annotate(call), + Some(polyglot_sql::expressions::DataType::Int { .. }) + )); + for dialect in [DialectType::HANA, DialectType::PostgreSQL] { + let ast = Dialect::get(dialect) + .parse(r#"SELECT "f"(x) FROM t"#) + .unwrap() + .remove(0); + let call = ast + .dfs() + .find(|e| matches!(e, Expression::Function(_))) + .unwrap(); + assert_eq!( + call.source_dialect(), + None, + "quoted UDFs are not native built-ins" + ); + } + } + + #[test] + fn source_metadata_survives_optimizers_and_standalone_generation() { + use polyglot_sql::optimizer::{canonicalize, normalize_identifiers}; + let hana = Dialect::get(DialectType::HANA); + let trino = Dialect::get(DialectType::Trino); + let ast = hana + .parse("SELECT LOCATE('abcabc', 'bc', 1, 2) FROM DUMMY") + .unwrap() + .remove(0); + let ast = canonicalize( + normalize_identifiers(ast, Some(DialectType::HANA)), + Some(DialectType::HANA), + ); + let call = ast + .dfs() + .find(|e| matches!(e, Expression::Function(_))) + .unwrap(); + let json = serde_json::to_string(call).unwrap(); + assert!(json.contains("\"source_dialect\":\"hana\"")); + assert!(!json.contains("hana_function")); + let call: Expression = serde_json::from_str(&json).unwrap(); + assert_eq!(trino.generate(&call).unwrap(), "STRPOS('abcabc', 'bc', 2)"); + assert_eq!( + hana.generate(&call).unwrap(), + "LOCATE('abcabc', 'bc', 1, 2)" + ); + for sql in [ + "SELECT SUBSTR_REGEXPR('a' IN value OCCURRENCE 2) FROM t", + "SELECT JSON_VALUE(payload, '$.n' DEFAULT 0 ON EMPTY) FROM t", + "SELECT STRING_AGG(value, ',' ORDER BY id) FROM t", + ] { + let ast = hana.parse(sql).unwrap().remove(0); + let json = serde_json::to_string(&ast).unwrap(); + let decoded: Expression = serde_json::from_str(&json).unwrap(); + assert_eq!(hana.generate(&decoded).unwrap(), sql); + assert!(trino.generate(&decoded).is_err(), "{sql}"); + assert!(!decoded.dfs().any(|e| e.variant_name().starts_with("hana_"))); + } + } + + #[test] + fn create_table_traversal_is_independent_of_source_metadata() { + use polyglot_sql::ast_transforms::rename_columns; + let hana = Dialect::get(DialectType::HANA); + let ast = hana + .parse("CREATE TABLE t (value INT CHECK (value > 0))") + .unwrap() + .remove(0); + let mut outputs = Vec::new(); + for source in [None, Some(DialectType::HANA)] { + let mut ast = ast.clone(); + if let Expression::CreateTable(table) = &mut ast { + table.source_dialect = source; + } + let renamed = + rename_columns(ast, &[("value".to_owned(), "new_value".to_owned())].into()); + outputs.push(hana.generate(&renamed).unwrap()); + } + assert_eq!(outputs[0], outputs[1]); + assert!(outputs[0].contains("CHECK (new_value > 0)")); + } + + #[test] + fn native_syntax_uses_shared_expression_categories() { + let hana = Dialect::get(DialectType::HANA); + for (sql, expected) in [ + ("SELECT JSON_VALUE(j, '$.a') FROM t", "j_s_o_n_value"), + ("SELECT JSON_QUERY(j, '$.a') FROM t", "json_query"), + ( + "SELECT * FROM JSON_TABLE(j, '$' COLUMNS (v INT PATH '$.v'))", + "j_s_o_n_table", + ), + ( + "SELECT * FROM t WHERE value LIKE_REGEXPR 'a'", + "regexp_like", + ), + ( + "SELECT SUBSTR_REGEXPR('a' IN value) FROM t", + "regexp_extract", + ), + ( + "SELECT a FROM t GROUP BY ROLLUP STRUCTURED RESULT (a)", + "rollup", + ), + ("SELECT * FROM t WITH HINT (NO_INLINE)", "hint"), + ( + "CREATE TABLE t (id INT) PARTITION BY HASH (id) PARTITIONS 4", + "partition_by_property", + ), + ("CALL demo.proc(1)", "call"), + ("UPSERT t VALUES (1) WITH PRIMARY KEY", "upsert"), + ] { + let ast = hana.parse(sql).unwrap().remove(0); + assert!( + ast.dfs().any(|e| e.variant_name() == expected), + "{sql}: expected {expected}" + ); + assert_eq!(hana.generate(&ast).unwrap(), sql); + } + } + + #[test] + fn native_semantics_survive_transpile_and_json() { + let hana = Dialect::get(DialectType::HANA); + for sql in [ + "SELECT LOCATE('abcabc', 'bc', 1, 2) FROM DUMMY", + "SELECT TO_DECIMAL(7654321.888888, 10, 3) FROM DUMMY", + "SELECT CURRENT_UTCTIMESTAMP(7), CURRENT_UTCDATE, CURRENT_UTCTIME FROM DUMMY", + "SELECT ADD_DAYS(d, 7), BITAND(a, b), TO_VARCHAR(d, 'YYYY-MM-DD') FROM t", + "SELECT * FROM t FOR JSON", + "SELECT * FROM t FOR XML", + "SELECT * FROM t FOR JSON ('arraywrap' = 'NO')", + "SELECT * FROM t WITH HINT (NO_INLINE)", + "ALTER TABLE t ADD (x INT)", + "CREATE TABLE t (xs INT ARRAY)", + "CREATE COLUMN TABLE t (id INT)", + "CREATE ROW TABLE t (id INT)", + "CREATE LOCAL TEMPORARY COLUMN TABLE t (id INT)", + "SELECT * FROM t FOR UPDATE IGNORE LOCKED", + "SELECT * FROM t WHERE CONTAINS(name, 'abc', FUZZY(0.8))", + "SELECT * FROM t WHERE name LIKE_REGEXPR '^a' FLAG 'i'", + "SELECT SUBSTR_REGEXPR('a' IN s OCCURRENCE 2) FROM t", + "UPSERT t VALUES (1, 8) WITH PRIMARY KEY", + "UPSERT t VALUES (2, 2) WHERE id = 2", + "UPSERT t SELECT id, val FROM source", + "CREATE COLUMN TABLE t (id INT) PARTITION BY HASH (id) PARTITIONS 4", + "CREATE COLUMN TABLE t (id INT) PARTITION BY HASH (id) NO PRIMARY KEY CHECK PARTITIONS 4", + "CREATE COLUMN TABLE t (id INT) PARTITION BY RANGE (id) PRIMARY KEY CHECK (PARTITION OTHERS)", + "SELECT * FROM HIERARCHY(SOURCE (SELECT id AS node_id, parent AS parent_id FROM t))", + "SELECT * FROM HIERARCHY(SOURCE t START WHERE id = 1 SIBLING ORDER BY id ORPHAN ROOT)", + r#"SELECT * FROM "_SYS_BIC"."pkg/view"(PLACEHOLDER."$$P$$" => 'X')"#, + "SELECT * FROM t FOR JSON ('arraywrap' = 'NO') RETURNS NVARCHAR(5000)", + "SELECT * FROM t FOR XML RETURNS NCLOB", + "SELECT * FROM t FOR SHARE LOCK WAIT 3", + "SELECT * FROM t FOR SHARE LOCK IGNORE LOCKED", + "SELECT * FROM t ORDER BY a WITH COLLATION ENGLISH WITH HINT (NO_INLINE CASCADE)", + "CALL demo.proc(1, ?) ASYNC WITH HINT (NO_INLINE)", + "CALL demo.library:member(1)", + "SELECT JSON_VALUE(j, '$.a' RETURNING INT DEFAULT 0 ON EMPTY ERROR ON ERROR) FROM t", + "SELECT JSON_QUERY(j, '$.a' WITH CONDITIONAL ARRAY WRAPPER EMPTY ARRAY ON EMPTY NULL ON ERROR) FROM t", + "SELECT * FROM JSON_TABLE(j, '$' COLUMNS (rn FOR ORDINALITY, v NVARCHAR(20) PATH '$.v' DEFAULT 'missing' ON EMPTY ERROR ON ERROR))", + "SELECT * FROM JSON_TABLE(j, '$' COLUMNS (NESTED PATH '$.a[*]' COLUMNS (v INT PATH '$.v')) ERROR ON ERROR)", + "SELECT CAST(x AS ST_GEOMETRY(4326)), CAST(y AS REAL_VECTOR(3)) FROM t", + "CREATE COLUMN TABLE t (id INT) NO AUTO MERGE UNLOAD PRIORITY 5", + "CREATE TABLE t (id INT) RECORD COMMIT TIMESTAMP", + "CREATE TABLE t (id INT, next_id INT GENERATED ALWAYS AS id + 1)", + + "CREATE COLUMN TABLE t (id INT) PARTITION BY RANGE (id) (PARTITION 0 <= VALUES < 100, PARTITION OTHERS)", + "CREATE COLUMN TABLE t (id INT) PARTITION BY HASH (id) PARTITIONS 4 SUBPARTITION BY RANGE (id) (PARTITION VALUES < 100, PARTITION OTHERS DYNAMIC THRESHOLD 1000)", + "CREATE COLUMN TABLE t (id INT) PARTITION BY RANGE (id) (PARTITION VALUES = 1 PAGE LOADABLE, PARTITION OTHERS DYNAMIC INCREASING INTERVAL 10)", + "SELECT CURRENT_CONNECTION, CURRENT_SCHEMA, CURRENT_USER, SESSION_USER, SYSUUID FROM DUMMY", + "SELECT NTH_VALUE(x, 2 ORDER BY y), STRING_AGG(s, ',' ORDER BY id) FROM t", + "SELECT * FROM t WHERE s NOT LIKE_REGEXPR '^a' FLAG 'i'", + "SELECT REPLACE_REGEXPR('a' IN s WITH 'b' OCCURRENCE ALL) FROM t", + "SELECT LOCATE_REGEXPR(AFTER 'a' IN s FROM 2 OCCURRENCE 1 GROUP 0) FROM t", + "SELECT a, b, SUM(x) FROM t GROUP BY GROUPING SETS BEST -1 LIMIT 2 OFFSET 1 WITH SUBTOTAL WITH BALANCE WITH TOTAL ((a), (b))", + "SELECT a, SUM(x) FROM t GROUP BY ROLLUP STRUCTURED RESULT WITH OVERVIEW PREFIX '#groups' (a)", + "SELECT a, SUM(x) FROM t GROUP BY CUBE MULTIPLE RESULTSETS (a)", + "SELECT * FROM t LIMIT 1 OFFSET 2 TOTAL ROWCOUNT", + "SELECT LOCALTOUTC(ts, 'EST', 'sap' NULL ON ERROR) FROM t", + "SELECT UTCTOLOCAL(ts, 'INVALID', 'sap' DEFAULT '2025-01-01 00:00:00' ON ERROR) FROM t", + ] { + let ast = hana.parse(sql).unwrap_or_else(|err| panic!("{sql}: {err}")); + assert!(!ast[0].dfs().any(|node| matches!(node, Expression::Raw(_) | Expression::Command(_))), "{sql}"); + let json = serde_json::to_string(&ast).unwrap(); + let decoded: Vec = serde_json::from_str(&json).unwrap(); + let output = hana.generate(&decoded[0]).unwrap(); + assert_eq!(output, sql, "AST round trip: {sql}"); + assert_eq!(hana.transpile(sql, DialectType::HANA).unwrap(), vec![sql], "transpile: {sql}"); + } + } + + #[test] + fn preserves_quoted_and_qualified_names() { + let sql = "SELECT \"CURRENT_UTCDATE\", t.CURRENT_UTCTIME, \"ADD_DAYS\"(d, 1) FROM t"; + assert_eq!(transpile(sql, DialectType::HANA, DialectType::HANA), sql); + } + + #[test] + fn native_children_participate_in_ast_mutations() { + use polyglot_sql::ast_transforms::{rename_columns, rename_tables}; + let hana = Dialect::get(DialectType::HANA); + for sql in [ + "SELECT LOCATE(value, 'a'), JSON_VALUE(payload, '$.n' DEFAULT value ON EMPTY) FROM records", + "SELECT SUBSTR_REGEXPR('a' IN value), LOCALTOUTC(value, 'UTC') FROM records ORDER BY value", + "SELECT value, SUM(n) FROM records GROUP BY ROLLUP BEST 1 (value)", + "SELECT * FROM HIERARCHY(SOURCE (SELECT value AS node_id, parent AS parent_id FROM records))", + "UPSERT target_table SELECT value FROM source_table", + "CREATE TABLE records (value INT, next_value INT GENERATED ALWAYS AS value + 1) PARTITION BY HASH (value) PARTITIONS 4", + ] { + let ast = hana.parse(sql).unwrap().remove(0); + assert!(ast.dfs().any(|e| matches!(e, Expression::Column(c) if c.name.name == "value")), "{sql}"); + let renamed = rename_columns(ast.clone(), &[("value".to_owned(), "renamed".to_owned())].into()); + assert!(!renamed.dfs().any(|e| matches!(e, Expression::Column(c) if c.name.name == "value")), "{sql}"); + assert!(renamed.dfs().any(|e| matches!(e, Expression::Column(c) if c.name.name == "renamed")), "{sql}"); + assert!(ast.dfs().any(|e| matches!(e, Expression::Column(c) if c.name.name == "value")), "{sql}"); + } + let ast = hana + .parse("UPSERT target_table SELECT value FROM source_table") + .unwrap() + .remove(0); + let renamed = rename_tables( + ast, + &[ + ("target_table".to_owned(), "new_target".to_owned()), + ("source_table".to_owned(), "new_source".to_owned()), + ] + .into(), + ); + assert_eq!( + hana.generate(&renamed).unwrap(), + "UPSERT new_target SELECT value FROM new_source" + ); + } + + #[test] + fn quoted_reserved_names_and_qualified_calls_are_not_builtins() { + for (sql, trino) in [ + ( + r#"SELECT "CURRENT_USER", t.CURRENT_SCHEMA, demo."ADD_DAYS"(d, 1) FROM t"#, + r#"SELECT "CURRENT_USER", t."CURRENT_SCHEMA", demo."ADD_DAYS"(d, 1) FROM t"#, + ), + (r#"SELECT "COUNT"(x) FROM t"#, r#"SELECT "COUNT"(x) FROM t"#), + ] { + assert_eq!(transpile(sql, DialectType::HANA, DialectType::HANA), sql); + assert_eq!(transpile(sql, DialectType::HANA, DialectType::Trino), trino); + } + } + + #[test] + fn incoming_queries_generate_native_syntax_or_report_unsupported() { + assert_eq!( + transpile("SELECT 1", DialectType::Generic, DialectType::HANA), + "SELECT 1 FROM SYS.DUMMY" + ); + for (sql, source) in [ + ("SELECT SUBSTRING('abcdef', -2, 2)", DialectType::DuckDB), + ("SELECT DATE_ADD('day', 1, d) FROM t", DialectType::Trino), + ("SELECT x ILIKE 'a%' FROM t", DialectType::PostgreSQL), + ] { + assert!( + Dialect::get(source) + .transpile(sql, DialectType::HANA) + .is_err(), + "{sql}" + ); + } + } + + #[test] + fn source_casts_retain_conversion_semantics() { + assert_eq!( + transpile("SELECT UNKNOWN", DialectType::HANA, DialectType::HANA), + "SELECT NULL FROM SYS.DUMMY" + ); + assert_eq!( + transpile("SELECT UNKNOWN", DialectType::HANA, DialectType::DuckDB), + "SELECT NULL" + ); + assert_eq!( + transpile( + r#"SELECT "UNKNOWN" FROM t"#, + DialectType::HANA, + DialectType::HANA + ), + r#"SELECT "UNKNOWN" FROM t"# + ); + + for (sql, expected) in [ + ( + "SELECT CAST(-12.349 AS DECIMAL(5, 2))", + "SELECT CAST(TRUNC(-12.349, 2) AS DECIMAL(5, 2))", + ), + ( + "SELECT CAST(12.99 AS DECIMAL(5))", + "SELECT CAST(TRUNC(12.99, 0) AS DECIMAL(5, 0))", + ), + ( + "SELECT CAST(-12.349 AS DEC(5, 2))", + "SELECT CAST(TRUNC(-12.349, 2) AS DECIMAL(5, 2))", + ), + ] { + assert_eq!( + transpile(sql, DialectType::HANA, DialectType::DuckDB), + expected + ); + } + assert_eq!( + transpile( + "SELECT CAST(255 AS TINYINT)", + DialectType::HANA, + DialectType::Trino + ), + "SELECT CAST(255 AS SMALLINT)" + ); + assert!(Dialect::get(DialectType::HANA) + .transpile( + "SELECT CAST(x AS DECIMAL(10, 2)) FROM t", + DialectType::DuckDB + ) + .is_err()); + } + + #[test] + fn rejects_unverified_string_domains_and_volatile_bounds() { + for sql in [ + "SELECT SUBSTRING(s, -2, 2) FROM t", + "SELECT SUBSTRING('abcdef', RAND(), 2) FROM DUMMY", + "SELECT SUBSTRING('a😀b', 2, 1) FROM DUMMY", + "SELECT SUBSTRING(x'ABCDEF', 1, 2) FROM DUMMY", + "SELECT LOCATE('a😀b', 'b') FROM DUMMY", + "SELECT LOCATE(s, 'b') FROM t", + "SELECT LOCATE('abc', '', 2) FROM DUMMY", + "SELECT LOCATE('abc', '', 1, 2) FROM DUMMY", + "SELECT TO_DECIMAL(CAST(s AS NVARCHAR(30)), 10, 2) FROM t", + ] { + for target in [ + DialectType::Trino, + DialectType::DuckDB, + DialectType::PostgreSQL, + ] { + assert!( + Dialect::get(DialectType::HANA) + .transpile(sql, target) + .is_err(), + "{sql} -> {target}" + ); + } + } + } + + #[test] + fn validates_native_precision_and_function_arity() { + let hana = Dialect::get(DialectType::HANA); + for sql in [ + "SELECT LOCATE('a') FROM DUMMY", + "SELECT SUBSTRING('a') FROM DUMMY", + "SELECT CURRENT_UTCTIMESTAMP(8) FROM DUMMY", + "SELECT CURRENT_UTCTIMESTAMP(x) FROM t", + "SELECT CAST(x AS FLOAT(0)) FROM t", + "SELECT CAST(x AS FLOAT(54)) FROM t", + "SELECT CAST(x AS DECIMAL(10, 11)) FROM t", + "SELECT CAST(x AS SMALLDECIMAL(10, 2)) FROM t", + "SELECT CAST(x AS NVARCHAR(5001)) FROM t", + ] { + assert!(hana.parse(sql).is_err(), "{sql}"); + } + } + + #[test] + fn source_semantics_generate_safe_target_expressions() { + for target in [ + DialectType::Presto, + DialectType::Athena, + DialectType::Dune, + DialectType::DuckDB, + ] { + assert!(Dialect::get(DialectType::HANA) + .transpile( + "SELECT TO_TIMESTAMP(s, 'YYYY-MM-DD HH24:MI:SS') FROM t", + target + ) + .is_err()); + } + for (sql, target, expected) in [ + ( + "SELECT LOCATE('abcabc', 'bc')", + DialectType::Trino, + "SELECT STRPOS('abcabc', 'bc')", + ), + ( + "SELECT LOCATE('length in char', '')", + DialectType::Trino, + "SELECT STRPOS('length in char', '')", + ), + ( + "SELECT TO_TIMESTAMP('2024-01-15 12:30:45', 'YYYY-MM-DD HH24:MI:SS')", + DialectType::Trino, + "SELECT CAST(DATE_PARSE('2024-01-15 12:30:45', '%Y-%m-%d %H:%i:%s') AS TIMESTAMP(7))", + ), + ( + "SELECT SUBSTRING('abcdef', -2, 2)", + DialectType::DuckDB, + "SELECT SUBSTRING('abcdef', 1, 2)", + ), + ( + "SELECT TO_DECIMAL(7654321.888888, 10, 3)", + DialectType::DuckDB, + "SELECT CAST(TRUNC(7654321.888888, 3) AS DECIMAL(10, 3))", + ), + ( + "SELECT TO_DATE('2024-01-15', 'YYYY-MM-DD')", + DialectType::Trino, + "SELECT CAST(DATE_PARSE('2024-01-15', '%Y-%m-%d') AS DATE)", + ), + ( + "SELECT CAST(x AS FLOAT(24)), CAST(y AS FLOAT(53)) FROM t", + DialectType::Trino, + "SELECT CAST(x AS REAL), CAST(y AS DOUBLE) FROM t", + ), + ( + "SELECT CAST(x AS LONGDATE), CAST(y AS TIME) FROM t", + DialectType::Trino, + "SELECT CAST(x AS TIMESTAMP(7)), CAST(y AS TIME(0)) FROM t", + ), + ] { + assert_eq!(transpile(sql, DialectType::HANA, target), expected); + let ast = Dialect::get(DialectType::HANA).parse(sql).unwrap(); + assert_eq!(Dialect::get(target).generate(&ast[0]).unwrap(), expected); + } + } + + #[test] + fn incompatible_hana_features_fail_in_every_mode() { + for sql in [ + "SELECT * FROM t FOR JSON", + "SELECT * FROM t FOR XML", + "SELECT TO_VARCHAR(1234.5, '9,999.00')", + "SELECT TO_VARCHAR(d, fmt) FROM t", + "SELECT TO_TIMESTAMP(s, 'YYYY-MM-DD HH24:MI:SS.FF7') FROM t", + "SELECT CAST(x AS SMALLDECIMAL) FROM t", + "CREATE TABLE t (x SMALLDECIMAL ARRAY)", + "ALTER TABLE t ADD (x SMALLDECIMAL ARRAY)", + "ALTER TABLE t ADD x SMALLDECIMAL ARRAY", + "ALTER TABLE t ALTER COLUMN x TYPE SMALLDECIMAL ARRAY", + "SELECT CAST(xs AS SMALLDECIMAL ARRAY) FROM t", + "CREATE FUNCTION f(x SMALLDECIMAL ARRAY) RETURNS INT AS 1", + "SELECT ADD_MONTHS_LAST(d, 1) FROM t", + "SELECT * FROM t WITH HINT (NO_INLINE)", + "SELECT * FROM t FOR UPDATE IGNORE LOCKED", + "SELECT * FROM HIERARCHY(SOURCE t)", + "SELECT JSON_VALUE(j, '$.a' ERROR ON ERROR) FROM t", + "SELECT SUBSTR_REGEXPR('a' IN s) FROM t", + "UPSERT t VALUES (1) WITH PRIMARY KEY", + "CREATE COLUMN TABLE t (a INT)", + "CALL demo.proc(1)", + "SELECT a, SUM(x) FROM t GROUP BY ROLLUP BEST 1 (a)", + "SELECT * FROM t LIMIT 1 TOTAL ROWCOUNT", + "SELECT LOCALTOUTC(ts, 'EST', 'sap' NULL ON ERROR) FROM t", + ] { + for target in [ + DialectType::Generic, + DialectType::PostgreSQL, + DialectType::MySQL, + DialectType::BigQuery, + DialectType::Snowflake, + DialectType::DuckDB, + DialectType::SQLite, + DialectType::Hive, + DialectType::Spark, + DialectType::Trino, + DialectType::Presto, + DialectType::Redshift, + DialectType::TSQL, + DialectType::Oracle, + DialectType::ClickHouse, + DialectType::Databricks, + DialectType::Athena, + DialectType::Teradata, + DialectType::Doris, + DialectType::StarRocks, + DialectType::Materialize, + DialectType::RisingWave, + DialectType::SingleStore, + DialectType::CockroachDB, + DialectType::TiDB, + DialectType::Druid, + DialectType::Solr, + DialectType::Tableau, + DialectType::Dune, + DialectType::Fabric, + DialectType::Drill, + DialectType::Dremio, + DialectType::Exasol, + DialectType::DataFusion, + ] { + let ast = Dialect::get(DialectType::HANA).parse(sql).unwrap(); + assert!( + Dialect::get(target).generate(&ast[0]).is_err(), + "direct: {sql} -> {target}" + ); + for level in [ + UnsupportedLevel::Ignore, + UnsupportedLevel::Warn, + UnsupportedLevel::Raise, + UnsupportedLevel::Immediate, + ] { + assert!( + Dialect::get(DialectType::HANA) + .transpile_with( + sql, + target, + TranspileOptions::default().with_unsupported_level(level) + ) + .is_err(), + "{sql} -> {target}: {level:?}" + ); + } + } + } + } + + #[test] + fn hana_storage_checks_do_not_apply_to_other_sources() { + let sql = "CREATE GLOBAL TEMPORARY TABLE t (a INT)"; + let output = Dialect::get(DialectType::Teradata) + .transpile(sql, DialectType::Teradata) + .unwrap(); + assert!(output[0].contains("GLOBAL TEMPORARY TABLE")); + assert!(Dialect::get(DialectType::HANA) + .transpile(sql, DialectType::Teradata) + .is_err()); + } + + #[test] + fn unrelated_custom_types_are_unchanged() { + for name in ["SMALLDECIMAL", "ALPHANUM", "SECONDDATE"] { + let sql = format!("SELECT CAST(x AS {name}) FROM t"); + assert_eq!( + transpile(&sql, DialectType::PostgreSQL, DialectType::PostgreSQL), + sql + ); + } + } +} diff --git a/packages/go/integration_test.go b/packages/go/integration_test.go index de234433..b951c3cd 100644 --- a/packages/go/integration_test.go +++ b/packages/go/integration_test.go @@ -1547,3 +1547,45 @@ func TestIntegrationPackageLevelAPI(t *testing.T) { } assertValidJSON(t, "OpenLineageRunEvent wrapper", runEvent.Event) } + +func TestIntegrationHana(t *testing.T) { + client := integrationClient(t) + sql := "SELECT * FROM t FOR JSON ('arraywrap' = 'NO')" + output, err := client.Transpile(sql, "hana", "hana") + if err != nil || len(output) != 1 || output[0] != sql { + t.Fatalf("HANA native round trip: %v, %v", output, err) + } + if _, err := client.Transpile(sql, "hana", "duckdb"); err == nil { + t.Fatal("expected unsupported HANA serialization error") + } + output, err = client.Transpile("SELECT LOCATE('abcabc', 'bc')", "hana", "duckdb") + if err != nil || len(output) != 1 || output[0] != "SELECT STRPOS('abcabc', 'bc')" { + t.Fatalf("HANA LOCATE: %v, %v", output, err) + } + for _, tc := range []struct{ sql, target, want string }{ + {"SELECT LOCATE('abcabc', 'bc', 1, 2)", "trino", "SELECT STRPOS('abcabc', 'bc', 2)"}, + {sql, "duckdb", ""}, + } { + parsed, err := client.Parse(tc.sql, "hana") + if err != nil { + t.Fatal(err) + } + var decoded any + if err := json.Unmarshal(parsed, &decoded); err != nil { + t.Fatal(err) + } + serialized, err := json.Marshal(decoded) + if err != nil { + t.Fatal(err) + } + // Independent generation receives only the serialized AST and target. + generated, err := client.Generate(serialized, tc.target) + if tc.want == "" { + if err == nil { + t.Fatal("serialized AST lost unsupported source semantics") + } + } else if err != nil || len(generated) != 1 || generated[0] != tc.want { + t.Fatalf("serialized HANA AST generation: %v, %v", generated, err) + } + } +} 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 cd98d04e..2ccaa4a5 100644 --- a/packages/sdk/README.md +++ b/packages/sdk/README.md @@ -1031,6 +1031,7 @@ This limit covers recursive parsing, not arbitrary programmatic AST construction | ClickHouse | `Dialect.ClickHouse` | | CockroachDB | `Dialect.CockroachDB` | | DataFusion | `Dialect.DataFusion` | +| SAP HANA | `Dialect.HANA` | | Databricks | `Dialect.Databricks` | | Doris | `Dialect.Doris` | | Dremio | `Dialect.Dremio` | diff --git a/packages/sdk/plugins/wasmCjsPlugin.test.ts b/packages/sdk/plugins/wasmCjsPlugin.test.ts new file mode 100644 index 00000000..fbccc4d0 --- /dev/null +++ b/packages/sdk/plugins/wasmCjsPlugin.test.ts @@ -0,0 +1,57 @@ +import { join } from 'node:path'; +import { runInNewContext } from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; +import { wasmCjsPlugin } from './wasmCjsPlugin'; + +describe('CJS WASM startup', () => { + it.each([ + true, + false, + ])('defers initialization and runs optional startup once (startup=%s)', async (startup) => { + const chunk = { + type: 'chunk', + code: ` +let wasm; +function __wbg_set_wasm(value) { wasm = value; } +const __vite__wasmUrl = new URL("./polyglot_sql.wasm", import.meta.url).href; +const __vite__wasmModule = await __vite__initWasm({}, __vite__wasmUrl); +const __wbindgen_start = __vite__wasmModule.__wbindgen_start; +__wbg_set_wasm(wasm$2); +${startup ? '__wbindgen_start();' : ''} +async function init() { return Promise.resolve(); } +function isInitialized() { return true; } +export { init, isInitialized }; +`, + }; + const hook = wasmCjsPlugin().generateBundle; + if (typeof hook !== 'function') + throw new Error('Expected generateBundle hook'); + Reflect.apply(hook, {}, [{}, { 'index.cjs': chunk }, false]); + + const start = vi.fn(); + const instantiate = vi.fn(async () => ({ + instance: { exports: { __wbindgen_start: start } }, + })); + const readFileSync = vi.fn(() => new Uint8Array()); + const module = { + exports: {} as { init(): Promise; isInitialized(): boolean }, + }; + runInNewContext(chunk.code, { + module, + exports: module.exports, + __dirname: '/virtual', + require: (name: string) => + name === 'path' ? { join } : { readFileSync }, + WebAssembly: { instantiate }, + }); + + expect(module.exports.isInitialized()).toBe(false); + expect(readFileSync).not.toHaveBeenCalled(); + expect(start).not.toHaveBeenCalled(); + await Promise.all([module.exports.init(), module.exports.init()]); + await module.exports.init(); + expect(module.exports.isInitialized()).toBe(true); + expect(instantiate).toHaveBeenCalledTimes(1); + expect(start).toHaveBeenCalledTimes(startup ? 1 : 0); + }); +}); diff --git a/packages/sdk/plugins/wasmCjsPlugin.ts b/packages/sdk/plugins/wasmCjsPlugin.ts index 1b12e9d6..31032b3d 100644 --- a/packages/sdk/plugins/wasmCjsPlugin.ts +++ b/packages/sdk/plugins/wasmCjsPlugin.ts @@ -100,6 +100,17 @@ export function wasmCjsPlugin(): Plugin { '\n', ); + // wasm-bindgen may emit a startup call as well. Its export is only + // available after instantiation, just like the deferred bindings above. + if (code.includes('\n__wbindgen_start();\n')) { + code = code.replace('\n__wbindgen_start();\n', '\n'); + code = code.replace( + ' __wbg_set_wasm(__vite__wasmModule);', + ' __wbg_set_wasm(__vite__wasmModule);\n' + + ' __vite__wasmModule.__wbindgen_start();', + ); + } + // ── Transform 5: Replace init() and isInitialized() ───────── code = code.replace( /async function init\(\)\s*\{[\s\S]*?return Promise\.resolve\(\);\s*\}/, diff --git a/packages/sdk/src/ast/types/guards.ts b/packages/sdk/src/ast/types/guards.ts index 41365a40..523e89b0 100644 --- a/packages/sdk/src/ast/types/guards.ts +++ b/packages/sdk/src/ast/types/guards.ts @@ -296,7 +296,13 @@ export const isDropView = isType('drop_view'); /** Check if expression is a DML query (SELECT, INSERT, UPDATE, DELETE) */ export function isQuery(expr: Expression): boolean { const t = getExprType(expr); - return t === 'select' || t === 'insert' || t === 'update' || t === 'delete'; + return ( + t === 'select' || + t === 'insert' || + t === 'update' || + t === 'delete' || + t === 'upsert' + ); } /** Check if expression is a set operation (UNION, INTERSECT, EXCEPT) */ diff --git a/packages/sdk/src/ast/visitor/visitor.test.ts b/packages/sdk/src/ast/visitor/visitor.test.ts index cf97bd62..4c3148ec 100644 --- a/packages/sdk/src/ast/visitor/visitor.test.ts +++ b/packages/sdk/src/ast/visitor/visitor.test.ts @@ -18,7 +18,7 @@ import { isExpressionValue, makeExpr, } from '../helpers'; -import { isExpressionType } from '../types/guards'; +import { isExpressionType, isFunction } from '../types/guards'; import { addSelectColumns, addWhere, @@ -1458,3 +1458,33 @@ describe('Integration Tests', () => { expect(regenerated.toUpperCase()).toContain('FROM'); }); }); + +describe('HANA AST traversal', () => { + it('uses shared node guards and retains source semantics after JSON serialization', () => { + const sql = + "SELECT LOCATE(value, 'a', 1, 2), JSON_VALUE(payload, '$.n' DEFAULT 0 ON EMPTY) FROM records"; + const ast = parseFirstWithDialect(sql, Dialect.HANA); + expect(findAll(ast, isFunction)).toHaveLength(1); + const call = findAll(ast, isFunction)[0]; + if (!isFunction(call)) throw new Error('Expected shared Function node'); + expect(call.function.source_dialect).toBe('hana'); + expect( + findAll(ast, (node) => isExpressionType(node, 'j_s_o_n_value')), + ).toHaveLength(1); + expect(getColumns(ast).map(columnReference)).toEqual(['value', 'payload']); + const copied: Expression = JSON.parse(JSON.stringify(clone(ast))); + expect(generate([copied], Dialect.HANA)).toEqual( + generate([ast], Dialect.HANA), + ); + const renamed = renameColumns(copied, { + value: 'needle_source', + payload: 'document', + }); + expect(getColumns(renamed).map(columnReference)).toEqual([ + 'needle_source', + 'document', + ]); + expect(getColumns(ast).map(columnReference)).toEqual(['value', 'payload']); + expect(generate([copied], Dialect.DuckDB).success).toBe(false); + }); +}); diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts index 9d556f85..94148e59 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', } /**