@@ -228,6 +228,10 @@ fn parse_timezone(timezone: &str) -> Result<SparkTimeZone> {
228228/// their values are ignored on null timestamp rows. Computed timezone expressions are evaluated
229229/// only for rows with non-null timestamps. Empty batches evaluate neither child.
230230///
231+ /// Registering [`SparkFromUtcTimestamp`] as a UDF does not use this expression: ordinary SQL
232+ /// evaluates the UDF's arguments eagerly. Consumers must explicitly construct this expression
233+ /// after planning and coercing its children to obtain conditional timezone evaluation.
234+ ///
231235/// This intentionally differs from Spark's generated evaluation of foldable timezones, which
232236/// evaluates the timezone and validates a non-null value before processing timestamp rows. This
233237/// expression can therefore skip an invalid timezone or an error in the timezone expression when
@@ -242,6 +246,50 @@ pub struct SparkFromUtcTimestampExpr {
242246
243247impl SparkFromUtcTimestampExpr {
244248 /// Construct an expression that skips computed timezone evaluation for null timestamps.
249+ ///
250+ /// Both children must already have supported types: a timestamp (any time unit, with or
251+ /// without timezone metadata) and a string (`Utf8`, `LargeUtf8`, or `Utf8View`). This
252+ /// constructor does not validate or coerce their types. Insert required casts during
253+ /// planning, keeping timezone casts inside the timezone child so they are also skipped
254+ /// for null timestamps.
255+ ///
256+ /// # Example
257+ ///
258+ /// A consumer can plan typed children before constructing the physical expression.
259+ /// The invalid integer cast in the timezone child is skipped for the null timestamp.
260+ ///
261+ /// ```
262+ /// use std::sync::Arc;
263+ /// use arrow::array::{ArrayRef, StringArray, TimestampMicrosecondArray};
264+ /// use arrow::datatypes::{DataType, TimeUnit};
265+ /// use arrow::record_batch::RecordBatch;
266+ /// use datafusion::prelude::{SessionContext, col};
267+ /// use datafusion_common::DFSchema;
268+ /// use datafusion_expr::cast;
269+ /// use datafusion_physical_expr_common::physical_expr::PhysicalExpr;
270+ /// use datafusion_spark::function::datetime::from_utc_timestamp::SparkFromUtcTimestampExpr;
271+ ///
272+ /// let batch = RecordBatch::try_from_iter([
273+ /// ("ts", Arc::new(StringArray::from(vec![Some("1970-01-01T00:00:00"), None])) as ArrayRef),
274+ /// ("value", Arc::new(StringArray::from(vec!["1", "invalid"])) as ArrayRef),
275+ /// ])?;
276+ /// let schema = DFSchema::try_from(batch.schema().as_ref().clone())?;
277+ /// let ctx = SessionContext::new();
278+ /// let timestamp = ctx.create_physical_expr(
279+ /// cast(col("ts"), DataType::Timestamp(TimeUnit::Microsecond, None)),
280+ /// &schema,
281+ /// )?;
282+ /// let timezone = ctx.parse_sql_expr(
283+ /// "CASE WHEN CAST(value AS INT) = 1 THEN 'GMT+1' ELSE 'UTC' END",
284+ /// &schema,
285+ /// )?;
286+ /// let timezone = ctx.create_physical_expr(cast(timezone, DataType::Utf8), &schema)?;
287+ /// let expr = SparkFromUtcTimestampExpr::new(timestamp, timezone);
288+ /// let result = expr.evaluate(&batch)?.into_array(batch.num_rows())?;
289+ /// let expected = TimestampMicrosecondArray::from(vec![Some(3_600_000_000), None]);
290+ /// assert_eq!(result.as_ref(), &expected);
291+ /// # Ok::<(), datafusion_common::DataFusionError>(())
292+ /// ```
245293 pub fn new (
246294 timestamp : Arc < dyn PhysicalExpr > ,
247295 timezone : Arc < dyn PhysicalExpr > ,
@@ -280,7 +328,11 @@ impl Hash for SparkFromUtcTimestampExpr {
280328
281329impl PhysicalExpr for SparkFromUtcTimestampExpr {
282330 fn fmt_sql ( & self , f : & mut Formatter < ' _ > ) -> std:: fmt:: Result {
283- Display :: fmt ( self , f)
331+ write ! ( f, "from_utc_timestamp(" ) ?;
332+ self . timestamp . fmt_sql ( f) ?;
333+ write ! ( f, ", " ) ?;
334+ self . timezone . fmt_sql ( f) ?;
335+ write ! ( f, ")" )
284336 }
285337
286338 fn return_field ( & self , input_schema : & Schema ) -> Result < FieldRef > {
@@ -362,8 +414,10 @@ mod tests {
362414 use datafusion:: physical_expr:: expressions:: {
363415 BinaryExpr , CaseExpr , CastExpr , Column , Literal ,
364416 } ;
365- use datafusion_common:: { ScalarValue , config:: ConfigOptions } ;
417+ use datafusion:: prelude:: SessionContext ;
418+ use datafusion_common:: { DFSchema , ScalarValue , config:: ConfigOptions } ;
366419 use datafusion_expr:: Operator ;
420+ use datafusion_physical_expr_common:: physical_expr:: fmt_sql;
367421
368422 fn expression_batch ( timestamps : Vec < Option < i64 > > , values : Vec < & str > ) -> RecordBatch {
369423 RecordBatch :: try_new (
@@ -627,6 +681,76 @@ mod tests {
627681 assert ! ( error. to_string( ) . contains( "invalid" ) ) ;
628682 }
629683
684+ #[ tokio:: test]
685+ async fn planned_udf_requires_explicit_conditional_expression ( ) -> Result < ( ) > {
686+ let ctx = SessionContext :: new ( ) ;
687+ ctx. register_udf (
688+ crate :: function:: datetime:: from_utc_timestamp ( )
689+ . as_ref ( )
690+ . clone ( ) ,
691+ ) ;
692+ let batch = RecordBatch :: try_from_iter ( [
693+ (
694+ "timestamp_text" ,
695+ Arc :: new ( StringArray :: from ( vec ! [
696+ None ,
697+ Some ( "1970-01-01T00:00:00" ) ,
698+ Some ( "1970-01-01T00:00:00" ) ,
699+ ] ) ) as ArrayRef ,
700+ ) ,
701+ (
702+ "value" ,
703+ Arc :: new ( StringArray :: from ( vec ! [ "invalid" , "0" , "1" ] ) ) as ArrayRef ,
704+ ) ,
705+ ] ) ?;
706+ ctx. register_batch ( "input" , batch. clone ( ) ) ?;
707+ let sql_expr = "from_utc_timestamp(timestamp_text, \
708+ CASE WHEN CAST(value AS INT) = 0 THEN 'UTC' ELSE 'PST' END)";
709+
710+ // Registering the UDF leaves ordinary SQL argument evaluation eager.
711+ let error = ctx
712+ . sql ( & format ! ( "SELECT {sql_expr} FROM input" ) )
713+ . await ?
714+ . collect ( )
715+ . await
716+ . unwrap_err ( ) ;
717+ assert ! ( error. to_string( ) . contains( "invalid" ) ) ;
718+
719+ // Reuse the planner's children, including its implicit string-to-timestamp cast.
720+ let schema = DFSchema :: try_from ( batch. schema ( ) . as_ref ( ) . clone ( ) ) ?;
721+ let planned =
722+ ctx. create_physical_expr ( ctx. parse_sql_expr ( sql_expr, & schema) ?, & schema) ?;
723+ let children = planned. children ( ) ;
724+ let conditional = SparkFromUtcTimestampExpr :: new (
725+ Arc :: clone ( children[ 0 ] ) ,
726+ Arc :: clone ( children[ 1 ] ) ,
727+ ) ;
728+ assert_eq ! (
729+ conditional. data_type( batch. schema_ref( ) ) ?,
730+ DataType :: Timestamp ( TimeUnit :: Microsecond , None )
731+ ) ;
732+ assert_eq ! (
733+ fmt_sql( & conditional) . to_string( ) ,
734+ fmt_sql( planned. as_ref( ) ) . to_string( )
735+ ) ;
736+ let result = conditional. evaluate ( & batch) ?. into_array ( batch. num_rows ( ) ) ?;
737+ let expected =
738+ TimestampMicrosecondArray :: from ( vec ! [ None , Some ( 0 ) , Some ( -28_800_000_000 ) ] ) ;
739+ assert_eq ! ( result. as_ref( ) , & expected) ;
740+
741+ // The same computed timezone must fail when its timestamp is required.
742+ let non_null_batch = RecordBatch :: try_new (
743+ batch. schema ( ) ,
744+ vec ! [
745+ Arc :: new( StringArray :: from( vec![ "1970-01-01T00:00:00" ; 3 ] ) ) ,
746+ Arc :: clone( batch. column( 1 ) ) ,
747+ ] ,
748+ ) ?;
749+ let error = conditional. evaluate ( & non_null_batch) . unwrap_err ( ) ;
750+ assert ! ( error. to_string( ) . contains( "invalid" ) ) ;
751+ Ok ( ( ) )
752+ }
753+
630754 #[ test]
631755 fn timezone_column_ignores_values_on_null_timestamp_rows ( ) {
632756 let summer = 1_719_792_000_123_456 ;
0 commit comments