diff --git a/packages/cubejs-schema-compiler/src/adapter/SnowflakeQuery.ts b/packages/cubejs-schema-compiler/src/adapter/SnowflakeQuery.ts index c341f025cbb3c..57e0c108235a5 100644 --- a/packages/cubejs-schema-compiler/src/adapter/SnowflakeQuery.ts +++ b/packages/cubejs-schema-compiler/src/adapter/SnowflakeQuery.ts @@ -116,6 +116,7 @@ export class SnowflakeQuery extends BaseQuery { templates.expressions.extract = 'EXTRACT({{ date_part }} FROM {{ expr }})'; templates.expressions.interval = 'INTERVAL \'{{ interval }}\''; templates.expressions.timestamp_literal = '\'{{ value }}\'::timestamp_tz'; + templates.expressions.timestamp_tz_named_timezone_cast = 'CONVERT_TIMEZONE(\'{{ timezone }}\', \'UTC\', \'{{ timestamp }}\'::timestamp_ntz)'; templates.expressions.like = '{{ expr }} {% if negated %}NOT {% endif %}LIKE {{ pattern }}{% if default_escape %} ESCAPE \'\\\\\'{% endif %}'; templates.expressions.ilike = '{{ expr }} {% if negated %}NOT {% endif %}ILIKE {{ pattern }}{% if default_escape %} ESCAPE \'\\\\\'{% endif %}'; templates.operators.is_not_distinct_from = 'IS NOT DISTINCT FROM'; diff --git a/rust/cubesql/cubesql/src/compile/engine/df/wrapper.rs b/rust/cubesql/cubesql/src/compile/engine/df/wrapper.rs index cdd5142376396..64e5dd111962c 100644 --- a/rust/cubesql/cubesql/src/compile/engine/df/wrapper.rs +++ b/rust/cubesql/cubesql/src/compile/engine/df/wrapper.rs @@ -19,6 +19,7 @@ use crate::{ AliasedColumn, DataSource, LoadRequestMeta, MetaContext, SpanId, SqlGenerator, SqlTemplates, TransportLoadRequestQuery, TransportService, }, + utils::{parse_named_timezone_timestamp, TIMESTAMP_TZ_NAMED_TIMEZONE_CAST_TEMPLATE}, CubeError, }; use chrono::{Days, NaiveDate, SecondsFormat, TimeZone, Utc}; @@ -1816,6 +1817,38 @@ impl WrappedSelectNode { .map_err(|e| DataFusionError::Internal(format!("Can't generate SQL for cast: {}", e))) } + fn generate_sql_for_named_timezone_timestamp_cast( + sql_generator: Arc, + expr: &Expr, + data_type: &DataType, + ) -> result::Result, DataFusionError> { + // DataFusion erases WITH TIME ZONE for these literals. + if !matches!(data_type, DataType::Timestamp(_, _)) { + return Ok(None); + } + + let sql_templates = sql_generator.get_sql_templates(); + if !sql_templates.contains_template(TIMESTAMP_TZ_NAMED_TIMEZONE_CAST_TEMPLATE) { + return Ok(None); + } + + if let Expr::Literal(ScalarValue::Utf8(Some(value))) = expr { + if let Some((timestamp, timezone)) = parse_named_timezone_timestamp(value) { + return sql_templates + .timestamp_tz_named_timezone_cast_expr(timestamp, timezone, value.clone()) + .map(Some) + .map_err(|e| { + DataFusionError::Internal(format!( + "Can't generate SQL for timestamp with named timezone cast: {}", + e + )) + }); + } + } + + Ok(None) + } + fn generate_sql_type( sql_generator: Arc, data_type: DataType, @@ -2127,6 +2160,13 @@ impl WrappedSelectNode { subqueries, ), Expr::Cast { expr, data_type } => { + if let Some(resulting_sql) = Self::generate_sql_for_named_timezone_timestamp_cast( + sql_generator.clone(), + expr.as_ref(), + &data_type, + )? { + return Ok((resulting_sql, sql_query)); + } let (expr, sql_query) = Self::generate_sql_for_expr( sql_query, sql_generator.clone(), diff --git a/rust/cubesql/cubesql/src/compile/rewrite/rules/wrapper/cast.rs b/rust/cubesql/cubesql/src/compile/rewrite/rules/wrapper/cast.rs index 222822db79de5..848c7c6934542 100644 --- a/rust/cubesql/cubesql/src/compile/rewrite/rules/wrapper/cast.rs +++ b/rust/cubesql/cubesql/src/compile/rewrite/rules/wrapper/cast.rs @@ -1,15 +1,50 @@ -use crate::compile::rewrite::{ - cast_expr, rewrite, rewriter::CubeRewrite, rules::wrapper::WrapperRules, - wrapper_pullup_replacer, wrapper_pushdown_replacer, +use crate::{ + compile::rewrite::{ + cast_expr, rewrite, rewriter::CubeEGraph, rewriter::CubeRewrite, + rules::wrapper::WrapperRules, transforming_rewrite, wrapper_pullup_replacer, + wrapper_pushdown_replacer, wrapper_replacer_context, CastExprDataType, LiteralExprValue, + LogicalPlanLanguage, + }, + transport::DataSource, + utils::{parse_named_timezone_timestamp, TIMESTAMP_TZ_NAMED_TIMEZONE_CAST_TEMPLATE}, }; +use crate::{var, var_iter}; +use datafusion::{arrow::datatypes::DataType, scalar::ScalarValue}; +use egg::{Id, Subst}; impl WrapperRules { pub fn cast_rules(&self, rules: &mut Vec) { rules.extend(vec![ - rewrite( + transforming_rewrite( "wrapper-push-down-cast", - wrapper_pushdown_replacer(cast_expr("?expr", "?data_type"), "?context"), - cast_expr(wrapper_pushdown_replacer("?expr", "?context"), "?data_type"), + wrapper_pushdown_replacer( + cast_expr("?expr", "?data_type"), + wrapper_replacer_context( + "?alias_to_cube", + "?push_to_cube", + "?in_projection", + "?cube_members", + "?grouped_subqueries", + "?ungrouped_scan", + "?input_data_source", + ), + ), + cast_expr( + wrapper_pushdown_replacer( + "?expr", + wrapper_replacer_context( + "?alias_to_cube", + "?push_to_cube", + "?in_projection", + "?cube_members", + "?grouped_subqueries", + "?ungrouped_scan", + "?input_data_source", + ), + ), + "?data_type", + ), + self.transform_cast_pushdown("?input_data_source", "?expr", "?data_type"), ), rewrite( "wrapper-pull-up-cast", @@ -18,4 +53,55 @@ impl WrapperRules { ), ]); } + + fn transform_cast_pushdown( + &self, + input_data_source_var: &str, + expr_var: &str, + data_type_var: &str, + ) -> impl Fn(&mut CubeEGraph, &mut Subst) -> bool { + let input_data_source_var = var!(input_data_source_var); + let expr_var = var!(expr_var); + let data_type_var = var!(data_type_var); + let meta = self.meta_context.clone(); + move |egraph, subst| { + let is_named_timezone_timestamp_cast = + var_iter!(egraph[subst[data_type_var]], CastExprDataType).any(|data_type| { + // DataFusion erases WITH TIME ZONE for these literals. + matches!(data_type, DataType::Timestamp(_, _)) + && Self::expr_is_named_timezone_string_literal(egraph, subst[expr_var]) + }); + + if !is_named_timezone_timestamp_cast { + return true; + } + + let Ok(data_source) = Self::get_data_source(egraph, subst, input_data_source_var) + else { + return false; + }; + + match data_source { + DataSource::Specific(_) => Self::can_rewrite_template( + &data_source, + &meta, + TIMESTAMP_TZ_NAMED_TIMEZONE_CAST_TEMPLATE, + ), + // This template is target-specific. + DataSource::Unrestricted => false, + } + } + } + + fn expr_is_named_timezone_string_literal(egraph: &CubeEGraph, expr_id: Id) -> bool { + egraph[expr_id].nodes.iter().any(|node| { + if let LogicalPlanLanguage::LiteralExpr([value_id]) = node { + var_iter!(egraph[*value_id], LiteralExprValue).any(|literal| { + matches!(literal, ScalarValue::Utf8(Some(value)) if parse_named_timezone_timestamp(value).is_some()) + }) + } else { + false + } + }) + } } diff --git a/rust/cubesql/cubesql/src/compile/router.rs b/rust/cubesql/cubesql/src/compile/router.rs index 76cf2877696ae..abba1f5d90bb6 100644 --- a/rust/cubesql/cubesql/src/compile/router.rs +++ b/rust/cubesql/cubesql/src/compile/router.rs @@ -16,9 +16,9 @@ use crate::{ auth_service::SqlAuthServiceAuthenticateRequest, dataframe, statement::{ - ApproximateCountDistinctVisitor, CastReplacer, RedshiftDatePartReplacer, - SensitiveDataSanitizer, SqlParser062Normalizer, ToTimestampReplacer, - UdfWildcardArgReplacer, + ApproximateCountDistinctVisitor, CastReplacer, PlainTimestampTimezoneSuffixReplacer, + RedshiftDatePartReplacer, SensitiveDataSanitizer, SqlParser062Normalizer, + ToTimestampReplacer, UdfWildcardArgReplacer, }, ColumnFlags, ColumnType, Session, SessionManager, SessionState, }, @@ -744,6 +744,7 @@ impl QueryRouter { pub fn rewrite_statement(stmt: ast::Statement) -> ast::Statement { let stmt = SqlParser062Normalizer::new().replace(stmt); + let stmt = PlainTimestampTimezoneSuffixReplacer::new().replace(stmt); let stmt = CastReplacer::new().replace(stmt); let stmt = ToTimestampReplacer::new().replace(stmt); let stmt = UdfWildcardArgReplacer::new().replace(stmt); diff --git a/rust/cubesql/cubesql/src/compile/test/test_wrapper.rs b/rust/cubesql/cubesql/src/compile/test/test_wrapper.rs index be61997e50c7a..ee54049d09359 100644 --- a/rust/cubesql/cubesql/src/compile/test/test_wrapper.rs +++ b/rust/cubesql/cubesql/src/compile/test/test_wrapper.rs @@ -24,6 +24,13 @@ use crate::{ CubeError, }; +fn timestamp_tz_named_timezone_cast_template() -> Vec<(String, String)> { + vec![( + "expressions/timestamp_tz_named_timezone_cast".to_string(), + "CONVERT_TIMEZONE('{{ timezone }}', 'UTC', '{{ timestamp }}'::timestamp_ntz)".to_string(), + )] +} + #[tokio::test] async fn test_simple_wrapper() { if !Rewriter::sql_push_down_enabled() { @@ -496,7 +503,7 @@ async fn test_wrapper_timestamptz() { } init_testing_logger(); - let query_plan = convert_select_to_query_plan( + let query_plan = convert_select_to_query_plan_customized( // language=PostgreSQL r#" SELECT @@ -513,6 +520,7 @@ GROUP BY "# .to_string(), DatabaseProtocol::PostgreSQL, + timestamp_tz_named_timezone_cast_template(), ) .await; @@ -532,6 +540,225 @@ GROUP BY )); } +/// Using TIMESTAMP WITH TIME ZONE with an IANA timezone in wrapper should render proper timestamptz in SQL +#[tokio::test] +async fn test_wrapper_timestamptz_named_timezone() { + if !Rewriter::sql_push_down_enabled() { + return; + } + init_testing_logger(); + + let query_plan = convert_select_to_query_plan_customized( + // language=PostgreSQL + r#" +SELECT + customer_gender +FROM KibanaSampleDataEcommerce +WHERE + order_date >= TIMESTAMP WITH TIME ZONE '2026-06-14T00:00:00 America/Los_Angeles' + AND +-- This filter should trigger pushdown + LOWER(customer_gender) = 'male' +GROUP BY + 1 +; + "# + .to_string(), + DatabaseProtocol::PostgreSQL, + timestamp_tz_named_timezone_cast_template(), + ) + .await; + + let physical_plan = query_plan.as_physical_plan().await.unwrap(); + println!( + "Physical plan: {}", + displayable(physical_plan.as_ref()).indent() + ); + + let wrapped_sql = &query_plan + .as_logical_plan() + .find_cube_scan_wrapped_sql() + .wrapped_sql + .sql; + assert!(wrapped_sql.contains( + "${KibanaSampleDataEcommerce.order_date} >= CONVERT_TIMEZONE('America/Los_Angeles', 'UTC', '2026-06-14T00:00:00'::timestamp_ntz)" + )); +} + +/// A named timezone timestamp without a seconds component should still render proper timestamptz in SQL +#[tokio::test] +async fn test_wrapper_timestamptz_named_timezone_no_seconds() { + if !Rewriter::sql_push_down_enabled() { + return; + } + init_testing_logger(); + + let query_plan = convert_select_to_query_plan_customized( + // language=PostgreSQL + r#" +SELECT + customer_gender +FROM KibanaSampleDataEcommerce +WHERE + order_date >= TIMESTAMP WITH TIME ZONE '2026-06-14 00:00 America/Los_Angeles' + AND +-- This filter should trigger pushdown + LOWER(customer_gender) = 'male' +GROUP BY + 1 +; + "# + .to_string(), + DatabaseProtocol::PostgreSQL, + timestamp_tz_named_timezone_cast_template(), + ) + .await; + + let physical_plan = query_plan.as_physical_plan().await.unwrap(); + println!( + "Physical plan: {}", + displayable(physical_plan.as_ref()).indent() + ); + + let wrapped_sql = &query_plan + .as_logical_plan() + .find_cube_scan_wrapped_sql() + .wrapped_sql + .sql; + assert!(wrapped_sql.contains( + "${KibanaSampleDataEcommerce.order_date} >= CONVERT_TIMEZONE('America/Los_Angeles', 'UTC', '2026-06-14 00:00'::timestamp_ntz)" + )); +} + +#[tokio::test] +async fn test_wrapper_timestamptz_named_timezone_rejects_malformed_timestamp() { + if !Rewriter::sql_push_down_enabled() { + return; + } + init_testing_logger(); + + let query_plan = convert_select_to_query_plan_customized( + // language=PostgreSQL + r#" +SELECT + customer_gender +FROM KibanaSampleDataEcommerce +WHERE + order_date >= TIMESTAMP WITH TIME ZONE '2026-06-14 00:00'' ) OR TRUE -- America/Los_Angeles' + AND +-- This filter should trigger pushdown + LOWER(customer_gender) = 'male' +GROUP BY + 1 +; + "# + .to_string(), + DatabaseProtocol::PostgreSQL, + timestamp_tz_named_timezone_cast_template(), + ) + .await; + + let physical_plan = query_plan.as_physical_plan().await.unwrap(); + println!( + "Physical plan: {}", + displayable(physical_plan.as_ref()).indent() + ); + + let wrapped_sql = &query_plan + .as_logical_plan() + .find_cube_scan_wrapped_sql() + .wrapped_sql + .sql; + assert!(!wrapped_sql.contains("CONVERT_TIMEZONE")); + assert!(!wrapped_sql.contains("OR TRUE")); +} + +/// Using plain TIMESTAMP with an IANA timezone-like suffix should not render as timestamptz +#[tokio::test] +async fn test_wrapper_timestamp_named_timezone_not_timestamptz() { + if !Rewriter::sql_push_down_enabled() { + return; + } + init_testing_logger(); + + let query_plan = convert_select_to_query_plan_customized( + // language=PostgreSQL + r#" +SELECT + customer_gender +FROM KibanaSampleDataEcommerce +WHERE + order_date >= TIMESTAMP '2026-06-14 00:00 America/Los_Angeles' + AND +-- This filter should trigger pushdown + LOWER(customer_gender) = 'male' +GROUP BY + 1 +; + "# + .to_string(), + DatabaseProtocol::PostgreSQL, + timestamp_tz_named_timezone_cast_template(), + ) + .await; + + let physical_plan = query_plan.as_physical_plan().await.unwrap(); + println!( + "Physical plan: {}", + displayable(physical_plan.as_ref()).indent() + ); + + let wrapped_sql = &query_plan + .as_logical_plan() + .find_cube_scan_wrapped_sql() + .wrapped_sql + .sql; + assert!(!wrapped_sql.contains("CONVERT_TIMEZONE")); + assert!(!wrapped_sql.contains("America/Los_Angeles")); + assert!(wrapped_sql.contains("CAST($1 AS TIMESTAMP)")); +} + +#[tokio::test] +async fn test_wrapper_timestamptz_named_timezone_no_template_fallback() { + if !Rewriter::sql_push_down_enabled() { + return; + } + init_testing_logger(); + + let query_plan = convert_select_to_query_plan( + // language=PostgreSQL + r#" +SELECT + customer_gender +FROM KibanaSampleDataEcommerce +WHERE + order_date >= TIMESTAMP WITH TIME ZONE '2026-06-14T00:00:00 America/Los_Angeles' + AND +-- This filter should trigger pushdown + LOWER(customer_gender) = 'male' +GROUP BY + 1 +; + "# + .to_string(), + DatabaseProtocol::PostgreSQL, + ) + .await; + + let physical_plan = query_plan.as_physical_plan().await.unwrap(); + println!( + "Physical plan: {}", + displayable(physical_plan.as_ref()).indent() + ); + + let wrapped_sql = &query_plan + .as_logical_plan() + .find_cube_scan_wrapped_sql() + .wrapped_sql + .sql; + assert!(!wrapped_sql.contains("CONVERT_TIMEZONE")); +} + // TODO add more time zones // TODO add more TS syntax variants // TODO add TIMESTAMPTZ variant diff --git a/rust/cubesql/cubesql/src/sql/statement.rs b/rust/cubesql/cubesql/src/sql/statement.rs index af3b33164f3c3..4083eadc8ab63 100644 --- a/rust/cubesql/cubesql/src/sql/statement.rs +++ b/rust/cubesql/cubesql/src/sql/statement.rs @@ -11,7 +11,7 @@ use sqlparser::ast::{ use std::{collections::HashMap, error::Error}; use super::types::ColumnType; -use crate::sql::postgres::ConnectionError; +use crate::{sql::postgres::ConnectionError, utils::parse_named_timezone_timestamp}; #[derive(Debug)] enum PlaceholderType { @@ -856,6 +856,81 @@ impl CastReplacer { } } +#[derive(Debug)] +pub struct PlainTimestampTimezoneSuffixReplacer {} + +impl PlainTimestampTimezoneSuffixReplacer { + pub fn new() -> Self { + Self {} + } + + pub fn replace(mut self, stmt: ast::Statement) -> ast::Statement { + let mut result = stmt; + + self.visit_statement(&mut result).unwrap(); + + result + } + + fn is_plain_timestamp_data_type(data_type: &ast::DataType) -> bool { + matches!( + data_type, + ast::DataType::Timestamp( + _, + ast::TimezoneInfo::None | ast::TimezoneInfo::WithoutTimeZone + ) + ) + } + + fn strip_plain_timestamp_timezone_suffix(value: &mut ast::Value) { + // Postgres ignores zones in timestamp without time zone literals. + let timestamp = match value { + Value::SingleQuotedString(str) | Value::DoubleQuotedString(str) => { + parse_named_timezone_timestamp(str).map(|(timestamp, _)| timestamp) + } + _ => None, + }; + + if let Some(timestamp) = timestamp { + *value = Value::SingleQuotedString(timestamp); + } + } + + fn strip_plain_timestamp_timezone_literal(expr: &mut Expr) { + if let Expr::Value(value) = expr { + Self::strip_plain_timestamp_timezone_suffix(&mut value.value); + } + } +} + +impl<'ast> Visitor<'ast, ConnectionError> for PlainTimestampTimezoneSuffixReplacer { + fn transform_expr(&mut self, expr: &mut Expr) -> Result<(), ConnectionError> { + if let Expr::TypedString(typed_string) = expr { + if Self::is_plain_timestamp_data_type(&typed_string.data_type) { + Self::strip_plain_timestamp_timezone_suffix(&mut typed_string.value.value); + } + } + + Ok(()) + } + + fn visit_cast(&mut self, expr: &mut Expr) -> Result<(), ConnectionError> { + if let Expr::Cast { + expr: cast_expr, + data_type, + .. + } = expr + { + self.visit_expr(&mut *cast_expr)?; + if Self::is_plain_timestamp_data_type(data_type) { + Self::strip_plain_timestamp_timezone_literal(cast_expr); + } + } + + Ok(()) + } +} + impl<'ast> Visitor<'ast, ConnectionError> for CastReplacer { fn visit_cast(&mut self, expr: &mut Expr) -> Result<(), ConnectionError> { if let Expr::Cast { @@ -1373,6 +1448,40 @@ mod tests { Ok(()) } + fn run_plain_timestamp_timezone_suffix_replacer( + input: &str, + output: &str, + ) -> Result<(), CubeError> { + let stmt = Parser::parse_sql(&PostgreSqlDialect {}, &input) + .unwrap() + .pop() + .expect("must contain at least one statement"); + + let replacer = PlainTimestampTimezoneSuffixReplacer::new(); + let res = replacer.replace(stmt); + + assert_eq!(res.to_string(), output); + + Ok(()) + } + + fn run_plain_timestamp_timezone_suffix_replacer_unchanged( + input: &str, + ) -> Result<(), CubeError> { + let stmt = Parser::parse_sql(&PostgreSqlDialect {}, &input) + .unwrap() + .pop() + .expect("must contain at least one statement"); + let expected = stmt.to_string(); + + let replacer = PlainTimestampTimezoneSuffixReplacer::new(); + let res = replacer.replace(stmt); + + assert_eq!(res.to_string(), expected); + + Ok(()) + } + #[test] fn test_cast_replacer() -> Result<(), CubeError> { run_cast_replacer("SELECT 'pg_class'::regclass", "SELECT 1259")?; @@ -1403,6 +1512,48 @@ mod tests { Ok(()) } + #[test] + fn test_plain_timestamp_timezone_suffix_replacer() -> Result<(), CubeError> { + run_plain_timestamp_timezone_suffix_replacer( + "SELECT TIMESTAMP '2026-06-14 00:00 America/Los_Angeles'", + "SELECT TIMESTAMP '2026-06-14 00:00'", + )?; + run_plain_timestamp_timezone_suffix_replacer( + "SELECT CAST('2026-06-14 00:00 America/Los_Angeles' AS TIMESTAMP)", + "SELECT CAST('2026-06-14 00:00' AS TIMESTAMP)", + )?; + run_plain_timestamp_timezone_suffix_replacer_unchanged( + "SELECT TIMESTAMP 'not a timestamp America/Los_Angeles'", + )?; + + Ok(()) + } + + #[test] + fn test_plain_timestamp_timezone_suffix_replacer_leaves_timestamptz_unchanged( + ) -> Result<(), CubeError> { + run_plain_timestamp_timezone_suffix_replacer_unchanged( + "SELECT TIMESTAMP WITH TIME ZONE '2026-06-14T00:00:00 America/Los_Angeles'", + )?; + run_plain_timestamp_timezone_suffix_replacer_unchanged( + "SELECT CAST('2026-06-14 00:00 America/Los_Angeles' AS TIMESTAMPTZ)", + )?; + run_plain_timestamp_timezone_suffix_replacer_unchanged( + "SELECT CAST(order_date AS TIMESTAMPTZ)", + )?; + run_plain_timestamp_timezone_suffix_replacer_unchanged( + "SELECT CAST(order_date AS TIMESTAMP WITH TIME ZONE)", + )?; + run_plain_timestamp_timezone_suffix_replacer_unchanged( + "SELECT TIMESTAMP WITH TIME ZONE '2026-06-14 00:00:00+02:00'", + )?; + run_plain_timestamp_timezone_suffix_replacer_unchanged( + "SELECT CAST('2026-06-14 00:00:00+02:00' AS TIMESTAMPTZ)", + )?; + + Ok(()) + } + fn run_redshift_date_part_replacer(input: &str, output: &str) -> Result<(), CubeError> { let stmts = Parser::parse_sql(&PostgreSqlDialect {}, &input).unwrap(); diff --git a/rust/cubesql/cubesql/src/transport/service.rs b/rust/cubesql/cubesql/src/transport/service.rs index b2b4b75bf6246..ad15feba79a82 100644 --- a/rust/cubesql/cubesql/src/transport/service.rs +++ b/rust/cubesql/cubesql/src/transport/service.rs @@ -892,6 +892,22 @@ impl SqlTemplates { self.render_template("expressions/timestamp_literal", context! { value => value }) } + pub fn timestamp_tz_named_timezone_cast_expr( + &self, + timestamp: String, + timezone: String, + value: String, + ) -> Result { + self.render_template( + "expressions/timestamp_tz_named_timezone_cast", + context! { + timestamp => timestamp, + timezone => timezone, + value => value, + }, + ) + } + pub fn like_expr( &self, like_type: LikeType, diff --git a/rust/cubesql/cubesql/src/utils/mod.rs b/rust/cubesql/cubesql/src/utils/mod.rs index 8d3b2cc28246d..11a219fbd222f 100644 --- a/rust/cubesql/cubesql/src/utils/mod.rs +++ b/rust/cubesql/cubesql/src/utils/mod.rs @@ -1,4 +1,6 @@ use crate::compile::rewrite::rewriter::CubeEGraph; +use chrono::NaiveDateTime; +use chrono_tz::Tz; use datafusion::scalar::ScalarValue; use sha2::{Digest, Sha256}; use std::{ @@ -7,6 +9,27 @@ use std::{ hash::{Hash, Hasher}, }; +pub const TIMESTAMP_TZ_NAMED_TIMEZONE_CAST_TEMPLATE: &str = + "expressions/timestamp_tz_named_timezone_cast"; + +pub fn parse_named_timezone_timestamp(value: &str) -> Option<(String, String)> { + let value = value.trim(); + let (timestamp, timezone) = value.rsplit_once(char::is_whitespace)?; + let timestamp = timestamp.trim(); + let timezone = timezone.trim(); + [ + "%Y-%m-%dT%H:%M:%S%.f", + "%Y-%m-%d %H:%M:%S%.f", + "%Y-%m-%dT%H:%M", + "%Y-%m-%d %H:%M", + ] + .iter() + .find_map(|format| NaiveDateTime::parse_from_str(timestamp, format).ok())?; + timezone.parse::().ok()?; + + Some((timestamp.to_string(), timezone.to_string())) +} + pub struct ShaHasher { hasher: Sha256, }