@@ -76,8 +76,10 @@ pub fn array_with_timezone(
7676 assert ! ( !timezone. is_empty( ) ) ;
7777 match to_type {
7878 Some ( DataType :: Utf8 ) | Some ( DataType :: Date32 ) => Ok ( array) ,
79- Some ( DataType :: Timestamp ( _, Some ( _) ) ) => {
80- timestamp_ntz_to_timestamp ( array, timezone. as_str ( ) , Some ( timezone. as_str ( ) ) )
79+ Some ( DataType :: Timestamp ( _, Some ( target_tz) ) ) => {
80+ // Interpret NTZ as local time in session TZ; annotate output with target TZ
81+ // so the result has the exact annotation the caller expects.
82+ timestamp_ntz_to_timestamp ( array, timezone. as_str ( ) , Some ( target_tz. as_ref ( ) ) )
8183 }
8284 Some ( DataType :: Timestamp ( TimeUnit :: Microsecond , None ) ) => {
8385 // Convert from Timestamp(Millisecond, None) to Timestamp(Microsecond, None)
@@ -100,8 +102,8 @@ pub fn array_with_timezone(
100102 assert ! ( !timezone. is_empty( ) ) ;
101103 match to_type {
102104 Some ( DataType :: Utf8 ) | Some ( DataType :: Date32 ) => Ok ( array) ,
103- Some ( DataType :: Timestamp ( _, Some ( _ ) ) ) => {
104- timestamp_ntz_to_timestamp ( array, timezone. as_str ( ) , Some ( timezone . as_str ( ) ) )
105+ Some ( DataType :: Timestamp ( _, Some ( target_tz ) ) ) => {
106+ timestamp_ntz_to_timestamp ( array, timezone. as_str ( ) , Some ( target_tz . as_ref ( ) ) )
105107 }
106108 _ => {
107109 // Not supported
@@ -117,8 +119,8 @@ pub fn array_with_timezone(
117119 assert ! ( !timezone. is_empty( ) ) ;
118120 match to_type {
119121 Some ( DataType :: Utf8 ) | Some ( DataType :: Date32 ) => Ok ( array) ,
120- Some ( DataType :: Timestamp ( _, Some ( _ ) ) ) => {
121- timestamp_ntz_to_timestamp ( array, timezone. as_str ( ) , Some ( timezone . as_str ( ) ) )
122+ Some ( DataType :: Timestamp ( _, Some ( target_tz ) ) ) => {
123+ timestamp_ntz_to_timestamp ( array, timezone. as_str ( ) , Some ( target_tz . as_ref ( ) ) )
122124 }
123125 _ => {
124126 // Not supported
@@ -179,7 +181,7 @@ fn datetime_cast_err(value: i64) -> ArrowError {
179181/// Parameters:
180182/// tz - timezone used to interpret local_datetime
181183/// local_datetime - a naive local datetime to resolve
182- fn resolve_local_datetime ( tz : & Tz , local_datetime : NaiveDateTime ) -> DateTime < Tz > {
184+ pub ( crate ) fn resolve_local_datetime ( tz : & Tz , local_datetime : NaiveDateTime ) -> DateTime < Tz > {
183185 match tz. from_local_datetime ( & local_datetime) {
184186 LocalResult :: Single ( dt) => dt,
185187 LocalResult :: Ambiguous ( dt, _) => dt,
@@ -210,7 +212,7 @@ fn resolve_local_datetime(tz: &Tz, local_datetime: NaiveDateTime) -> DateTime<Tz
210212/// array - input array of timestamp without timezone
211213/// tz - timezone of the values in the input array
212214/// to_timezone - timezone to change the input values to
213- fn timestamp_ntz_to_timestamp (
215+ pub ( crate ) fn timestamp_ntz_to_timestamp (
214216 array : ArrayRef ,
215217 tz : & str ,
216218 to_timezone : Option < & str > ,
@@ -259,6 +261,41 @@ fn timestamp_ntz_to_timestamp(
259261 }
260262}
261263
264+ /// Converts a `Timestamp(Microsecond, Some(_))` array to `Timestamp(Microsecond, None)`
265+ /// (TIMESTAMP_NTZ) by interpreting the UTC epoch value in the given session timezone and
266+ /// storing the resulting local datetime as epoch-relative microseconds without a TZ annotation.
267+ ///
268+ /// Matches Spark: `convertTz(ts, ZoneOffset.UTC, zoneId)`
269+ pub ( crate ) fn cast_timestamp_to_ntz (
270+ array : ArrayRef ,
271+ timezone : & str ,
272+ ) -> Result < ArrayRef , ArrowError > {
273+ assert ! ( !timezone. is_empty( ) ) ;
274+ let tz: Tz = timezone. parse ( ) ?;
275+ match array. data_type ( ) {
276+ DataType :: Timestamp ( TimeUnit :: Microsecond , Some ( _) ) => {
277+ let array = as_primitive_array :: < TimestampMicrosecondType > ( & array) ;
278+ let result: PrimitiveArray < TimestampMicrosecondType > = array. try_unary ( |value| {
279+ as_datetime :: < TimestampMicrosecondType > ( value)
280+ . ok_or_else ( || datetime_cast_err ( value) )
281+ . map ( |utc_naive| {
282+ // Convert UTC naive datetime → local datetime in session TZ
283+ let local_dt = tz. from_utc_datetime ( & utc_naive) ;
284+ // Re-encode as epoch-relative μs treating local time as UTC anchor.
285+ // This produces the NTZ representation (no offset applied).
286+ local_dt. naive_local ( ) . and_utc ( ) . timestamp_micros ( )
287+ } )
288+ } ) ?;
289+ // No timezone annotation on output = TIMESTAMP_NTZ
290+ Ok ( Arc :: new ( result) )
291+ }
292+ _ => Err ( ArrowError :: CastError ( format ! (
293+ "cast_timestamp_to_ntz: unexpected input type {:?}" ,
294+ array. data_type( )
295+ ) ) ) ,
296+ }
297+ }
298+
262299/// This takes for special pre-casting cases of Spark. E.g., Timestamp to String.
263300fn pre_timestamp_cast ( array : ArrayRef , timezone : String ) -> Result < ArrayRef , ArrowError > {
264301 assert ! ( !timezone. is_empty( ) ) ;
@@ -401,4 +438,55 @@ mod tests {
401438 micros_for( "2024-10-27 00:30:00" )
402439 ) ;
403440 }
441+
442+ // Helper: build a Timestamp(Microsecond, Some(tz)) array from a UTC datetime string
443+ fn ts_with_tz ( utc_datetime : & str , tz : & str ) -> ArrayRef {
444+ let dt = NaiveDateTime :: parse_from_str ( utc_datetime, "%Y-%m-%d %H:%M:%S" ) . unwrap ( ) ;
445+ let ts = dt. and_utc ( ) . timestamp_micros ( ) ;
446+ Arc :: new ( TimestampMicrosecondArray :: from ( vec ! [ ts] ) . with_timezone ( tz. to_string ( ) ) )
447+ }
448+
449+ #[ test]
450+ fn test_cast_timestamp_to_ntz_utc ( ) {
451+ // In UTC, local time == UTC time, so NTZ value == UTC epoch value
452+ let input = ts_with_tz ( "2024-01-15 10:30:00" , "UTC" ) ;
453+ let result = cast_timestamp_to_ntz ( input, "UTC" ) . unwrap ( ) ;
454+ let out = as_primitive_array :: < TimestampMicrosecondType > ( & result) ;
455+ // Expected NTZ value: epoch μs for "2024-01-15 10:30:00" as if it were UTC
456+ let expected = NaiveDateTime :: parse_from_str ( "2024-01-15 10:30:00" , "%Y-%m-%d %H:%M:%S" )
457+ . unwrap ( )
458+ . and_utc ( )
459+ . timestamp_micros ( ) ;
460+ assert_eq ! ( out. value( 0 ) , expected) ;
461+ assert_eq ! ( out. timezone( ) , None ) ; // no TZ annotation = NTZ
462+ }
463+
464+ #[ test]
465+ fn test_cast_timestamp_to_ntz_offset_timezone ( ) {
466+ // UTC epoch for "2024-01-15 15:30:00 UTC" cast to NTZ with session TZ = America/New_York (UTC-5)
467+ // Local time in NY = 10:30:00 → NTZ should store epoch μs for "2024-01-15 10:30:00"
468+ let input = ts_with_tz ( "2024-01-15 15:30:00" , "UTC" ) ;
469+ let result = cast_timestamp_to_ntz ( input, "America/New_York" ) . unwrap ( ) ;
470+ let out = as_primitive_array :: < TimestampMicrosecondType > ( & result) ;
471+ let expected = NaiveDateTime :: parse_from_str ( "2024-01-15 10:30:00" , "%Y-%m-%d %H:%M:%S" )
472+ . unwrap ( )
473+ . and_utc ( )
474+ . timestamp_micros ( ) ;
475+ assert_eq ! ( out. value( 0 ) , expected) ;
476+ assert_eq ! ( out. timezone( ) , None ) ;
477+ }
478+
479+ #[ test]
480+ fn test_cast_timestamp_to_ntz_dst ( ) {
481+ // During DST: UTC epoch for "2024-07-04 16:30:00 UTC", session TZ = America/New_York (UTC-4 in summer)
482+ // Local time in NY = 12:30:00 → NTZ stores epoch μs for "2024-07-04 12:30:00"
483+ let input = ts_with_tz ( "2024-07-04 16:30:00" , "UTC" ) ;
484+ let result = cast_timestamp_to_ntz ( input, "America/New_York" ) . unwrap ( ) ;
485+ let out = as_primitive_array :: < TimestampMicrosecondType > ( & result) ;
486+ let expected = NaiveDateTime :: parse_from_str ( "2024-07-04 12:30:00" , "%Y-%m-%d %H:%M:%S" )
487+ . unwrap ( )
488+ . and_utc ( )
489+ . timestamp_micros ( ) ;
490+ assert_eq ! ( out. value( 0 ) , expected) ;
491+ }
404492}
0 commit comments