@@ -76,9 +76,9 @@ 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 ( ) ) )
81- }
79+ // Pass through for Timestamp(_, Some(_)) targets: the cast_array dispatch arm
80+ // handles NTZ → TIMESTAMP conversion with correct "UTC" output annotation.
81+ Some ( DataType :: Timestamp ( _ , Some ( _ ) ) ) => Ok ( array ) ,
8282 Some ( DataType :: Timestamp ( TimeUnit :: Microsecond , None ) ) => {
8383 // Convert from Timestamp(Millisecond, None) to Timestamp(Microsecond, None)
8484 let millis_array = as_primitive_array :: < TimestampMillisecondType > ( & array) ;
@@ -100,9 +100,9 @@ pub fn array_with_timezone(
100100 assert ! ( !timezone. is_empty( ) ) ;
101101 match to_type {
102102 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- }
103+ // Pass through for Timestamp(_, Some(_)) targets: the cast_array dispatch arm
104+ // handles NTZ → TIMESTAMP conversion with correct "UTC" output annotation.
105+ Some ( DataType :: Timestamp ( _ , Some ( _ ) ) ) => Ok ( array ) ,
106106 _ => {
107107 // Not supported
108108 Err ( ArrowError :: CastError ( format ! (
@@ -117,9 +117,9 @@ pub fn array_with_timezone(
117117 assert ! ( !timezone. is_empty( ) ) ;
118118 match to_type {
119119 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- }
120+ // Pass through for Timestamp(_, Some(_)) targets: the cast_array dispatch arm
121+ // handles NTZ → TIMESTAMP conversion with correct "UTC" output annotation.
122+ Some ( DataType :: Timestamp ( _ , Some ( _ ) ) ) => Ok ( array ) ,
123123 _ => {
124124 // Not supported
125125 Err ( ArrowError :: CastError ( format ! (
@@ -179,7 +179,7 @@ fn datetime_cast_err(value: i64) -> ArrowError {
179179/// Parameters:
180180/// tz - timezone used to interpret local_datetime
181181/// local_datetime - a naive local datetime to resolve
182- fn resolve_local_datetime ( tz : & Tz , local_datetime : NaiveDateTime ) -> DateTime < Tz > {
182+ pub ( crate ) fn resolve_local_datetime ( tz : & Tz , local_datetime : NaiveDateTime ) -> DateTime < Tz > {
183183 match tz. from_local_datetime ( & local_datetime) {
184184 LocalResult :: Single ( dt) => dt,
185185 LocalResult :: Ambiguous ( dt, _) => dt,
@@ -210,7 +210,7 @@ fn resolve_local_datetime(tz: &Tz, local_datetime: NaiveDateTime) -> DateTime<Tz
210210/// array - input array of timestamp without timezone
211211/// tz - timezone of the values in the input array
212212/// to_timezone - timezone to change the input values to
213- fn timestamp_ntz_to_timestamp (
213+ pub ( crate ) fn timestamp_ntz_to_timestamp (
214214 array : ArrayRef ,
215215 tz : & str ,
216216 to_timezone : Option < & str > ,
@@ -259,6 +259,41 @@ fn timestamp_ntz_to_timestamp(
259259 }
260260}
261261
262+ /// Converts a `Timestamp(Microsecond, Some(_))` array to `Timestamp(Microsecond, None)`
263+ /// (TIMESTAMP_NTZ) by interpreting the UTC epoch value in the given session timezone and
264+ /// storing the resulting local datetime as epoch-relative microseconds without a TZ annotation.
265+ ///
266+ /// Matches Spark: `convertTz(ts, ZoneOffset.UTC, zoneId)`
267+ pub ( crate ) fn cast_timestamp_to_ntz (
268+ array : ArrayRef ,
269+ timezone : & str ,
270+ ) -> Result < ArrayRef , ArrowError > {
271+ assert ! ( !timezone. is_empty( ) ) ;
272+ let tz: Tz = timezone. parse ( ) ?;
273+ match array. data_type ( ) {
274+ DataType :: Timestamp ( TimeUnit :: Microsecond , Some ( _) ) => {
275+ let array = as_primitive_array :: < TimestampMicrosecondType > ( & array) ;
276+ let result: PrimitiveArray < TimestampMicrosecondType > = array. try_unary ( |value| {
277+ as_datetime :: < TimestampMicrosecondType > ( value)
278+ . ok_or_else ( || datetime_cast_err ( value) )
279+ . map ( |utc_naive| {
280+ // Convert UTC naive datetime → local datetime in session TZ
281+ let local_dt = tz. from_utc_datetime ( & utc_naive) ;
282+ // Re-encode as epoch-relative μs treating local time as UTC anchor.
283+ // This produces the NTZ representation (no offset applied).
284+ local_dt. naive_local ( ) . and_utc ( ) . timestamp_micros ( )
285+ } )
286+ } ) ?;
287+ // No timezone annotation on output = TIMESTAMP_NTZ
288+ Ok ( Arc :: new ( result) )
289+ }
290+ _ => Err ( ArrowError :: CastError ( format ! (
291+ "cast_timestamp_to_ntz: unexpected input type {:?}" ,
292+ array. data_type( )
293+ ) ) ) ,
294+ }
295+ }
296+
262297/// This takes for special pre-casting cases of Spark. E.g., Timestamp to String.
263298fn pre_timestamp_cast ( array : ArrayRef , timezone : String ) -> Result < ArrayRef , ArrowError > {
264299 assert ! ( !timezone. is_empty( ) ) ;
@@ -401,4 +436,55 @@ mod tests {
401436 micros_for( "2024-10-27 00:30:00" )
402437 ) ;
403438 }
439+
440+ // Helper: build a Timestamp(Microsecond, Some(tz)) array from a UTC datetime string
441+ fn ts_with_tz ( utc_datetime : & str , tz : & str ) -> ArrayRef {
442+ let dt = NaiveDateTime :: parse_from_str ( utc_datetime, "%Y-%m-%d %H:%M:%S" ) . unwrap ( ) ;
443+ let ts = dt. and_utc ( ) . timestamp_micros ( ) ;
444+ Arc :: new ( TimestampMicrosecondArray :: from ( vec ! [ ts] ) . with_timezone ( tz. to_string ( ) ) )
445+ }
446+
447+ #[ test]
448+ fn test_cast_timestamp_to_ntz_utc ( ) {
449+ // In UTC, local time == UTC time, so NTZ value == UTC epoch value
450+ let input = ts_with_tz ( "2024-01-15 10:30:00" , "UTC" ) ;
451+ let result = cast_timestamp_to_ntz ( input, "UTC" ) . unwrap ( ) ;
452+ let out = as_primitive_array :: < TimestampMicrosecondType > ( & result) ;
453+ // Expected NTZ value: epoch μs for "2024-01-15 10:30:00" as if it were UTC
454+ let expected = NaiveDateTime :: parse_from_str ( "2024-01-15 10:30:00" , "%Y-%m-%d %H:%M:%S" )
455+ . unwrap ( )
456+ . and_utc ( )
457+ . timestamp_micros ( ) ;
458+ assert_eq ! ( out. value( 0 ) , expected) ;
459+ assert_eq ! ( out. timezone( ) , None ) ; // no TZ annotation = NTZ
460+ }
461+
462+ #[ test]
463+ fn test_cast_timestamp_to_ntz_offset_timezone ( ) {
464+ // UTC epoch for "2024-01-15 15:30:00 UTC" cast to NTZ with session TZ = America/New_York (UTC-5)
465+ // Local time in NY = 10:30:00 → NTZ should store epoch μs for "2024-01-15 10:30:00"
466+ let input = ts_with_tz ( "2024-01-15 15:30:00" , "UTC" ) ;
467+ let result = cast_timestamp_to_ntz ( input, "America/New_York" ) . unwrap ( ) ;
468+ let out = as_primitive_array :: < TimestampMicrosecondType > ( & result) ;
469+ let expected = NaiveDateTime :: parse_from_str ( "2024-01-15 10:30:00" , "%Y-%m-%d %H:%M:%S" )
470+ . unwrap ( )
471+ . and_utc ( )
472+ . timestamp_micros ( ) ;
473+ assert_eq ! ( out. value( 0 ) , expected) ;
474+ assert_eq ! ( out. timezone( ) , None ) ;
475+ }
476+
477+ #[ test]
478+ fn test_cast_timestamp_to_ntz_dst ( ) {
479+ // During DST: UTC epoch for "2024-07-04 16:30:00 UTC", session TZ = America/New_York (UTC-4 in summer)
480+ // Local time in NY = 12:30:00 → NTZ stores epoch μs for "2024-07-04 12:30:00"
481+ let input = ts_with_tz ( "2024-07-04 16:30:00" , "UTC" ) ;
482+ let result = cast_timestamp_to_ntz ( input, "America/New_York" ) . unwrap ( ) ;
483+ let out = as_primitive_array :: < TimestampMicrosecondType > ( & result) ;
484+ let expected = NaiveDateTime :: parse_from_str ( "2024-07-04 12:30:00" , "%Y-%m-%d %H:%M:%S" )
485+ . unwrap ( )
486+ . and_utc ( )
487+ . timestamp_micros ( ) ;
488+ assert_eq ! ( out. value( 0 ) , expected) ;
489+ }
404490}
0 commit comments