| // Licensed to the Apache Software Foundation (ASF) under one |
| // or more contributor license agreements. See the NOTICE file |
| // distributed with this work for additional information |
| // regarding copyright ownership. The ASF licenses this file |
| // to you under the Apache License, Version 2.0 (the |
| // "License"); you may not use this file except in compliance |
| // with the License. You may obtain a copy of the License at |
| // |
| // http://www.apache.org/licenses/LICENSE-2.0 |
| // |
| // Unless required by applicable law or agreed to in writing, |
| // software distributed under the License is distributed on an |
| // "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY |
| // KIND, either express or implied. See the License for the |
| // specific language governing permissions and limitations |
| // under the License. |
| |
| //! temporal kernels |
| |
| use chrono::{ |
| DateTime, Datelike, Duration, LocalResult, NaiveDate, NaiveDateTime, TimeZone, Timelike, Utc, |
| }; |
| |
| use std::sync::Arc; |
| |
| use arrow::array::{ |
| downcast_dictionary_array, downcast_temporal_array, |
| temporal_conversions::*, |
| timezone::Tz, |
| types::{ArrowDictionaryKeyType, ArrowTemporalType, TimestampMicrosecondType}, |
| ArrowNumericType, |
| }; |
| use arrow::{ |
| array::*, |
| datatypes::{DataType, TimeUnit}, |
| }; |
| |
| use crate::SparkError; |
| |
| // Copied from arrow_arith/temporal.rs |
| macro_rules! return_compute_error_with { |
| ($msg:expr, $param:expr) => { |
| return { Err(SparkError::Internal(format!("{}: {:?}", $msg, $param))) } |
| }; |
| } |
| |
| // The number of days between the beginning of the proleptic gregorian calendar (0001-01-01) |
| // and the beginning of the Unix Epoch (1970-01-01) |
| const DAYS_TO_UNIX_EPOCH: i32 = 719_163; |
| |
| // Optimized date truncation functions that work directly with days since epoch |
| // These avoid the overhead of converting to/from NaiveDateTime |
| |
| /// Convert days since Unix epoch to NaiveDate |
| #[inline] |
| fn days_to_date(days: i32) -> Option<NaiveDate> { |
| NaiveDate::from_num_days_from_ce_opt(days + DAYS_TO_UNIX_EPOCH) |
| } |
| |
| /// Truncate date to first day of year - optimized version |
| /// Uses ordinal (day of year) to avoid creating a new date |
| #[inline] |
| fn trunc_days_to_year(days: i32) -> Option<i32> { |
| let date = days_to_date(days)?; |
| let day_of_year_offset = date.ordinal() as i32 - 1; |
| Some(days - day_of_year_offset) |
| } |
| |
| /// Truncate date to first day of quarter - optimized version |
| /// Computes offset from first day of quarter without creating a new date |
| #[inline] |
| fn trunc_days_to_quarter(days: i32) -> Option<i32> { |
| let date = days_to_date(days)?; |
| let month = date.month(); // 1-12 |
| let quarter = (month - 1) / 3; // 0-3 |
| let first_month_of_quarter = quarter * 3 + 1; // 1, 4, 7, or 10 |
| |
| // Find day of year for first day of quarter |
| let first_day_of_quarter = NaiveDate::from_ymd_opt(date.year(), first_month_of_quarter, 1)?; |
| let quarter_start_ordinal = first_day_of_quarter.ordinal() as i32; |
| let current_ordinal = date.ordinal() as i32; |
| |
| Some(days - (current_ordinal - quarter_start_ordinal)) |
| } |
| |
| /// Truncate date to first day of month - optimized version |
| /// Instead of creating a new date, just subtract day offset |
| #[inline] |
| fn trunc_days_to_month(days: i32) -> Option<i32> { |
| let date = days_to_date(days)?; |
| let day_offset = date.day() as i32 - 1; |
| Some(days - day_offset) |
| } |
| |
| /// Truncate date to first day of week (Monday) - optimized version |
| #[inline] |
| fn trunc_days_to_week(days: i32) -> Option<i32> { |
| let date = days_to_date(days)?; |
| // weekday().num_days_from_monday() gives 0 for Monday, 1 for Tuesday, etc. |
| let days_since_monday = date.weekday().num_days_from_monday() as i32; |
| Some(days - days_since_monday) |
| } |
| |
| // Based on arrow_arith/temporal.rs:extract_component_from_datetime_array |
| // Transforms an array of DateTime<Tz> to an array of TimestampMicrosecond after applying an |
| // operation. The output array carries the input timezone annotation so downstream operators |
| // (shuffle, sort, row converter) observe a matching schema. |
| fn as_timestamp_tz_with_op<A: ArrayAccessor<Item = T::Native>, T: ArrowTemporalType, F>( |
| iter: ArrayIter<A>, |
| mut builder: PrimitiveBuilder<TimestampMicrosecondType>, |
| tz_str: &str, |
| op: F, |
| ) -> Result<TimestampMicrosecondArray, SparkError> |
| where |
| F: Fn(DateTime<Tz>) -> i64, |
| i64: From<T::Native>, |
| { |
| let tz: Tz = tz_str.parse()?; |
| for value in iter { |
| match value { |
| Some(value) => match as_datetime_with_timezone::<T>(value.into(), tz) { |
| Some(time) => builder.append_value(op(time)), |
| _ => { |
| return Err(SparkError::Internal( |
| "Unable to read value as datetime".to_string(), |
| )); |
| } |
| }, |
| None => builder.append_null(), |
| } |
| } |
| Ok(builder.finish().with_timezone(tz_str)) |
| } |
| |
| fn as_timestamp_tz_with_op_single<T: ArrowTemporalType, F>( |
| value: Option<T::Native>, |
| builder: &mut PrimitiveBuilder<TimestampMicrosecondType>, |
| tz: &Tz, |
| op: F, |
| ) -> Result<(), SparkError> |
| where |
| F: Fn(DateTime<Tz>) -> i64, |
| i64: From<T::Native>, |
| { |
| match value { |
| Some(value) => match as_datetime_with_timezone::<T>(value.into(), *tz) { |
| Some(time) => builder.append_value(op(time)), |
| _ => { |
| return Err(SparkError::Internal( |
| "Unable to read value as datetime".to_string(), |
| )); |
| } |
| }, |
| None => builder.append_null(), |
| } |
| Ok(()) |
| } |
| |
| // Apply the Tz to the Naive Date Time, convert to UTC, and return as microseconds in Unix epoch. |
| // After truncation the carried UTC offset may be wrong if the truncated time falls in a different |
| // DST period than the original (e.g., truncating a December/PST timestamp to QUARTER yields |
| // October 1 which is in PDT). We re-resolve the naive local time through the timezone so that |
| // chrono picks the correct offset for the target date. |
| #[inline] |
| fn as_micros_from_unix_epoch_utc(dt: Option<DateTime<Tz>>) -> i64 { |
| let dt = dt.unwrap(); |
| let naive = dt.naive_local(); |
| let tz = dt.timezone(); |
| |
| match tz.from_local_datetime(&naive) { |
| LocalResult::Single(resolved) | LocalResult::Ambiguous(resolved, _) => { |
| resolved.with_timezone(&Utc).timestamp_micros() |
| } |
| LocalResult::None => dt.with_timezone(&Utc).timestamp_micros(), |
| } |
| } |
| |
| #[inline] |
| fn trunc_date_to_year<T: Datelike + Timelike>(dt: T) -> Option<T> { |
| Some(dt) |
| .and_then(|d| d.with_nanosecond(0)) |
| .and_then(|d| d.with_second(0)) |
| .and_then(|d| d.with_minute(0)) |
| .and_then(|d| d.with_hour(0)) |
| .and_then(|d| d.with_day0(0)) |
| .and_then(|d| d.with_month0(0)) |
| } |
| |
| /// returns the month of the beginning of the quarter |
| #[inline] |
| fn quarter_month<T: Datelike>(dt: &T) -> u32 { |
| 1 + 3 * ((dt.month() - 1) / 3) |
| } |
| |
| #[inline] |
| fn trunc_date_to_quarter<T: Datelike + Timelike>(dt: T) -> Option<T> { |
| Some(dt) |
| .and_then(|d| d.with_nanosecond(0)) |
| .and_then(|d| d.with_second(0)) |
| .and_then(|d| d.with_minute(0)) |
| .and_then(|d| d.with_hour(0)) |
| .and_then(|d| d.with_day0(0)) |
| .and_then(|d| d.with_month(quarter_month(&d))) |
| } |
| |
| #[inline] |
| fn trunc_date_to_month<T: Datelike + Timelike>(dt: T) -> Option<T> { |
| Some(dt) |
| .and_then(|d| d.with_nanosecond(0)) |
| .and_then(|d| d.with_second(0)) |
| .and_then(|d| d.with_minute(0)) |
| .and_then(|d| d.with_hour(0)) |
| .and_then(|d| d.with_day0(0)) |
| } |
| |
| #[inline] |
| fn trunc_date_to_week<T>(dt: T) -> Option<T> |
| where |
| T: Datelike + Timelike + std::ops::Sub<Duration, Output = T> + Copy, |
| { |
| Some(dt) |
| .map(|d| d - Duration::try_seconds(60 * 60 * 24 * d.weekday() as i64).unwrap()) |
| .and_then(|d| d.with_nanosecond(0)) |
| .and_then(|d| d.with_second(0)) |
| .and_then(|d| d.with_minute(0)) |
| .and_then(|d| d.with_hour(0)) |
| } |
| |
| #[inline] |
| fn trunc_date_to_day<T: Timelike>(dt: T) -> Option<T> { |
| Some(dt) |
| .and_then(|d| d.with_nanosecond(0)) |
| .and_then(|d| d.with_second(0)) |
| .and_then(|d| d.with_minute(0)) |
| .and_then(|d| d.with_hour(0)) |
| } |
| |
| #[inline] |
| fn trunc_date_to_hour<T: Timelike>(dt: T) -> Option<T> { |
| Some(dt) |
| .and_then(|d| d.with_nanosecond(0)) |
| .and_then(|d| d.with_second(0)) |
| .and_then(|d| d.with_minute(0)) |
| } |
| |
| #[inline] |
| fn trunc_date_to_minute<T: Timelike>(dt: T) -> Option<T> { |
| Some(dt) |
| .and_then(|d| d.with_nanosecond(0)) |
| .and_then(|d| d.with_second(0)) |
| } |
| |
| #[inline] |
| fn trunc_date_to_second<T: Timelike>(dt: T) -> Option<T> { |
| Some(dt).and_then(|d| d.with_nanosecond(0)) |
| } |
| |
| #[inline] |
| fn trunc_date_to_ms<T: Timelike>(dt: T) -> Option<T> { |
| Some(dt).and_then(|d| d.with_nanosecond(1_000_000 * (d.nanosecond() / 1_000_000))) |
| } |
| |
| #[inline] |
| fn trunc_date_to_microsec<T: Timelike>(dt: T) -> Option<T> { |
| Some(dt).and_then(|d| d.with_nanosecond(1_000 * (d.nanosecond() / 1_000))) |
| } |
| |
| /// |
| /// Implements the spark [TRUNC](https://spark.apache.org/docs/latest/api/sql/index.html#trunc) |
| /// function where the specified format is a scalar value |
| /// |
| /// array is an array of Date32 values. The array may be a dictionary array. |
| /// |
| /// format is a scalar string specifying the format to apply to the timestamp value. |
| pub fn date_trunc_dyn(array: &dyn Array, format: String) -> Result<ArrayRef, SparkError> { |
| match array.data_type().clone() { |
| DataType::Dictionary(_, _) => { |
| downcast_dictionary_array!( |
| array => { |
| let truncated_values = date_trunc_dyn(array.values(), format)?; |
| Ok(Arc::new(array.with_values(truncated_values))) |
| } |
| dt => return_compute_error_with!("date_trunc does not support", dt), |
| ) |
| } |
| _ => { |
| downcast_temporal_array!( |
| array => { |
| date_trunc(array, format) |
| .map(|a| Arc::new(a) as ArrayRef) |
| } |
| dt => return_compute_error_with!("date_trunc does not support", dt), |
| ) |
| } |
| } |
| } |
| |
| pub(crate) fn date_trunc<T>( |
| array: &PrimitiveArray<T>, |
| format: String, |
| ) -> Result<Date32Array, SparkError> |
| where |
| T: ArrowTemporalType + ArrowNumericType, |
| i64: From<T::Native>, |
| { |
| match array.data_type() { |
| DataType::Date32 => { |
| // Use optimized path for Date32 that works directly with days |
| date_trunc_date32( |
| array |
| .as_any() |
| .downcast_ref::<Date32Array>() |
| .expect("Date32 type mismatch"), |
| format, |
| ) |
| } |
| dt => return_compute_error_with!( |
| "Unsupported input type '{:?}' for function 'date_trunc'", |
| dt |
| ), |
| } |
| } |
| |
| /// Truncates a date expressed as days since the epoch, returning `None` if it is out of range. |
| type DateTruncFn = fn(i32) -> Option<i32>; |
| |
| /// The `date_trunc` formats Spark accepts, and the truncation each one selects. |
| const DATE_TRUNC_FORMATS: [(&str, DateTruncFn); 8] = [ |
| ("YEAR", trunc_days_to_year), |
| ("YYYY", trunc_days_to_year), |
| ("YY", trunc_days_to_year), |
| ("QUARTER", trunc_days_to_quarter), |
| ("MONTH", trunc_days_to_month), |
| ("MON", trunc_days_to_month), |
| ("MM", trunc_days_to_month), |
| ("WEEK", trunc_days_to_week), |
| ]; |
| |
| /// Resolve a `date_trunc` format string to the corresponding truncation function. |
| /// |
| /// Every supported format is ASCII, so `eq_ignore_ascii_case` on the input as-is is exactly |
| /// what Spark does: a non-ASCII input cannot match any ASCII table entry after ASCII case |
| /// folding, and Spark also only recognizes those ASCII literals. This is allocation-free. |
| fn date_trunc_fn_for_format(format: &str) -> Result<DateTruncFn, SparkError> { |
| DATE_TRUNC_FORMATS |
| .iter() |
| .find(|(name, _)| name.eq_ignore_ascii_case(format)) |
| .map(|(_, trunc_fn)| *trunc_fn) |
| .ok_or_else(|| { |
| SparkError::Internal(format!( |
| "Unsupported format: {format:?} for function 'date_trunc'" |
| )) |
| }) |
| } |
| |
| /// Optimized date truncation for Date32 arrays |
| /// Works directly with days since epoch instead of converting to/from NaiveDateTime |
| fn date_trunc_date32(array: &Date32Array, format: String) -> Result<Date32Array, SparkError> { |
| // Select the truncation function based on format |
| let trunc_fn = date_trunc_fn_for_format(&format)?; |
| |
| // Apply truncation to each element |
| let result: Date32Array = array |
| .iter() |
| .map(|opt_days| opt_days.and_then(trunc_fn)) |
| .collect(); |
| |
| Ok(result) |
| } |
| |
| /// |
| /// Implements the spark [TRUNC](https://spark.apache.org/docs/latest/api/sql/index.html#trunc) |
| /// function where the specified format may be an array |
| /// |
| /// array is an array of Date32 values. The array may be a dictionary array. |
| /// |
| /// format is an array of strings specifying the format to apply to the corresponding date value. |
| /// The array may be a dictionary array. |
| pub(crate) fn date_trunc_array_fmt_dyn( |
| array: &dyn Array, |
| formats: &dyn Array, |
| ) -> Result<ArrayRef, SparkError> { |
| match (array.data_type().clone(), formats.data_type().clone()) { |
| (DataType::Dictionary(_, v), DataType::Dictionary(_, f)) => { |
| if !matches!(*v, DataType::Date32) { |
| return_compute_error_with!("date_trunc does not support", v) |
| } |
| if !matches!(*f, DataType::Utf8) { |
| return_compute_error_with!("date_trunc does not support format type ", f) |
| } |
| downcast_dictionary_array!( |
| formats => { |
| downcast_dictionary_array!( |
| array => { |
| date_trunc_array_fmt_dict_dict( |
| &array.downcast_dict::<Date32Array>().unwrap(), |
| &formats.downcast_dict::<StringArray>().unwrap()) |
| .map(|a| Arc::new(a) as ArrayRef) |
| } |
| dt => return_compute_error_with!("date_trunc does not support", dt) |
| ) |
| } |
| fmt => return_compute_error_with!("date_trunc does not support format type", fmt), |
| ) |
| } |
| (DataType::Dictionary(_, v), DataType::Utf8) => { |
| if !matches!(*v, DataType::Date32) { |
| return_compute_error_with!("date_trunc does not support", v) |
| } |
| downcast_dictionary_array!( |
| array => { |
| date_trunc_array_fmt_dict_plain( |
| &array.downcast_dict::<Date32Array>().unwrap(), |
| formats.as_any().downcast_ref::<StringArray>() |
| .expect("Unexpected value type in formats")) |
| .map(|a| Arc::new(a) as ArrayRef) |
| } |
| dt => return_compute_error_with!("date_trunc does not support", dt), |
| ) |
| } |
| (DataType::Date32, DataType::Dictionary(_, f)) => { |
| if !matches!(*f, DataType::Utf8) { |
| return_compute_error_with!("date_trunc does not support format type ", f) |
| } |
| downcast_dictionary_array!( |
| formats => { |
| downcast_temporal_array!(array => { |
| date_trunc_array_fmt_plain_dict( |
| array.as_any().downcast_ref::<Date32Array>() |
| .expect("Unexpected error in casting date array"), |
| &formats.downcast_dict::<StringArray>().unwrap()) |
| .map(|a| Arc::new(a) as ArrayRef) |
| } |
| dt => return_compute_error_with!("date_trunc does not support", dt), |
| ) |
| } |
| fmt => return_compute_error_with!("date_trunc does not support format type", fmt), |
| ) |
| } |
| (DataType::Date32, DataType::Utf8) => date_trunc_array_fmt_plain_plain( |
| array |
| .as_any() |
| .downcast_ref::<Date32Array>() |
| .expect("Unexpected error in casting date array"), |
| formats |
| .as_any() |
| .downcast_ref::<StringArray>() |
| .expect("Unexpected value type in formats"), |
| ) |
| .map(|a| Arc::new(a) as ArrayRef), |
| (dt, fmt) => Err(SparkError::Internal(format!( |
| "Unsupported datatype: {dt:}, format: {fmt:?} for function 'date_trunc'" |
| ))), |
| } |
| } |
| |
| macro_rules! date_trunc_array_fmt_helper { |
| ($array: ident, $formats: ident, $datatype: ident) => {{ |
| let mut builder = Date32Builder::with_capacity($array.len()); |
| let iter = $array.into_iter(); |
| match $datatype { |
| DataType::Date32 => { |
| // Format columns are almost always constant or very low cardinality, so remember |
| // the last format seen and skip re-resolving it for every row. |
| let mut cached: Option<(&str, DateTruncFn)> = None; |
| for (index, val) in iter.enumerate() { |
| let format = $formats.value(index); |
| let trunc_fn = match cached { |
| Some((cached_format, trunc_fn)) if cached_format == format => trunc_fn, |
| _ => { |
| let trunc_fn = date_trunc_fn_for_format(format)?; |
| cached = Some((format, trunc_fn)); |
| trunc_fn |
| } |
| }; |
| match val.and_then(trunc_fn) { |
| Some(days) => builder.append_value(days), |
| None => builder.append_null(), |
| } |
| } |
| Ok(builder.finish()) |
| } |
| dt => return_compute_error_with!( |
| "Unsupported input type '{:?}' for function 'date_trunc'", |
| dt |
| ), |
| } |
| }}; |
| } |
| |
| fn date_trunc_array_fmt_plain_plain( |
| array: &Date32Array, |
| formats: &StringArray, |
| ) -> Result<Date32Array, SparkError> |
| where |
| { |
| let data_type = array.data_type(); |
| date_trunc_array_fmt_helper!(array, formats, data_type) |
| } |
| |
| fn date_trunc_array_fmt_plain_dict<K>( |
| array: &Date32Array, |
| formats: &TypedDictionaryArray<K, StringArray>, |
| ) -> Result<Date32Array, SparkError> |
| where |
| K: ArrowDictionaryKeyType, |
| { |
| let data_type = array.data_type(); |
| date_trunc_array_fmt_helper!(array, formats, data_type) |
| } |
| |
| fn date_trunc_array_fmt_dict_plain<K>( |
| array: &TypedDictionaryArray<K, Date32Array>, |
| formats: &StringArray, |
| ) -> Result<Date32Array, SparkError> |
| where |
| K: ArrowDictionaryKeyType, |
| { |
| let data_type = array.values().data_type(); |
| date_trunc_array_fmt_helper!(array, formats, data_type) |
| } |
| |
| fn date_trunc_array_fmt_dict_dict<K, F>( |
| array: &TypedDictionaryArray<K, Date32Array>, |
| formats: &TypedDictionaryArray<F, StringArray>, |
| ) -> Result<Date32Array, SparkError> |
| where |
| K: ArrowDictionaryKeyType, |
| F: ArrowDictionaryKeyType, |
| { |
| let data_type = array.values().data_type(); |
| date_trunc_array_fmt_helper!(array, formats, data_type) |
| } |
| |
| /// |
| /// Implements the spark [DATE_TRUNC](https://spark.apache.org/docs/latest/api/sql/index.html#date_trunc) |
| /// function where the specified format is a scalar value |
| /// |
| /// array is an array of Timestamp(Microsecond) values. Timestamp values must have a valid |
| /// timezone or no timezone. The array may be a dictionary array. |
| /// |
| /// format is a scalar string specifying the format to apply to the timestamp value. |
| pub(crate) fn timestamp_trunc_dyn( |
| array: &dyn Array, |
| format: String, |
| ) -> Result<ArrayRef, SparkError> { |
| match array.data_type().clone() { |
| DataType::Dictionary(_, _) => { |
| downcast_dictionary_array!( |
| array => { |
| let truncated_values = timestamp_trunc_dyn(array.values(), format)?; |
| Ok(Arc::new(array.with_values(truncated_values))) |
| } |
| dt => return_compute_error_with!("timestamp_trunc does not support", dt), |
| ) |
| } |
| _ => { |
| downcast_temporal_array!( |
| array => { |
| timestamp_trunc(array, format) |
| .map(|a| Arc::new(a) as ArrayRef) |
| } |
| dt => return_compute_error_with!("timestamp_trunc does not support", dt), |
| ) |
| } |
| } |
| } |
| |
| /// Convert microseconds since epoch to NaiveDateTime |
| #[inline] |
| fn micros_to_naive(micros: i64) -> Option<NaiveDateTime> { |
| DateTime::from_timestamp_micros(micros).map(|dt| dt.naive_utc()) |
| } |
| |
| /// Convert NaiveDateTime back to microseconds since epoch |
| #[inline] |
| fn naive_to_micros(dt: NaiveDateTime) -> i64 { |
| dt.and_utc().timestamp_micros() |
| } |
| |
| /// Truncates a `NaiveDateTime`, returning `None` if the result is out of range. |
| type NtzTruncFn = fn(NaiveDateTime) -> Option<NaiveDateTime>; |
| |
| /// Truncates a `DateTime<Tz>`, returning `None` if the result is out of range. |
| type TzTruncFn = fn(DateTime<Tz>) -> Option<DateTime<Tz>>; |
| |
| /// The `timestamp_trunc` formats Spark accepts for the NTZ path, and the truncation each one |
| /// selects. All entries are ASCII, so `eq_ignore_ascii_case` on the raw input matches Spark |
| /// without allocating. |
| const TIMESTAMP_TRUNC_FORMATS_NTZ: [(&str, NtzTruncFn); 15] = [ |
| ("YEAR", trunc_date_to_year), |
| ("YYYY", trunc_date_to_year), |
| ("YY", trunc_date_to_year), |
| ("QUARTER", trunc_date_to_quarter), |
| ("MONTH", trunc_date_to_month), |
| ("MON", trunc_date_to_month), |
| ("MM", trunc_date_to_month), |
| ("WEEK", trunc_date_to_week), |
| ("DAY", trunc_date_to_day), |
| ("DD", trunc_date_to_day), |
| ("HOUR", trunc_date_to_hour), |
| ("MINUTE", trunc_date_to_minute), |
| ("SECOND", trunc_date_to_second), |
| ("MILLISECOND", trunc_date_to_ms), |
| ("MICROSECOND", trunc_date_to_microsec), |
| ]; |
| |
| /// Same formats as `TIMESTAMP_TRUNC_FORMATS_NTZ`, monomorphized for the timezone-aware path. |
| const TIMESTAMP_TRUNC_FORMATS_TZ: [(&str, TzTruncFn); 15] = [ |
| ("YEAR", trunc_date_to_year), |
| ("YYYY", trunc_date_to_year), |
| ("YY", trunc_date_to_year), |
| ("QUARTER", trunc_date_to_quarter), |
| ("MONTH", trunc_date_to_month), |
| ("MON", trunc_date_to_month), |
| ("MM", trunc_date_to_month), |
| ("WEEK", trunc_date_to_week), |
| ("DAY", trunc_date_to_day), |
| ("DD", trunc_date_to_day), |
| ("HOUR", trunc_date_to_hour), |
| ("MINUTE", trunc_date_to_minute), |
| ("SECOND", trunc_date_to_second), |
| ("MILLISECOND", trunc_date_to_ms), |
| ("MICROSECOND", trunc_date_to_microsec), |
| ]; |
| |
| /// Resolve a truncation format string to the corresponding NaiveDateTime truncation function. |
| /// |
| /// All supported formats are ASCII, so `eq_ignore_ascii_case` on the input as-is is exactly what |
| /// Spark does and allocation-free. |
| fn ntz_trunc_fn_for_format(format: &str) -> Result<NtzTruncFn, SparkError> { |
| TIMESTAMP_TRUNC_FORMATS_NTZ |
| .iter() |
| .find(|(name, _)| name.eq_ignore_ascii_case(format)) |
| .map(|(_, trunc_fn)| *trunc_fn) |
| .ok_or_else(|| { |
| SparkError::Internal(format!( |
| "Unsupported format: {format:?} for function 'timestamp_trunc'" |
| )) |
| }) |
| } |
| |
| /// Timezone-aware sibling of `ntz_trunc_fn_for_format`. |
| fn tz_trunc_fn_for_format(format: &str) -> Result<TzTruncFn, SparkError> { |
| TIMESTAMP_TRUNC_FORMATS_TZ |
| .iter() |
| .find(|(name, _)| name.eq_ignore_ascii_case(format)) |
| .map(|(_, trunc_fn)| *trunc_fn) |
| .ok_or_else(|| { |
| SparkError::Internal(format!( |
| "Unsupported format: {format:?} for function 'timestamp_trunc'" |
| )) |
| }) |
| } |
| |
| /// Truncate a TimestampNTZ array without any timezone conversion. |
| /// NTZ values are timezone-independent; we treat the raw microseconds as a naive datetime. |
| fn timestamp_trunc_ntz<T>( |
| array: &PrimitiveArray<T>, |
| format: String, |
| ) -> Result<TimestampMicrosecondArray, SparkError> |
| where |
| T: ArrowTemporalType + ArrowNumericType, |
| i64: From<T::Native>, |
| { |
| let trunc_fn = ntz_trunc_fn_for_format(&format)?; |
| |
| let result: TimestampMicrosecondArray = array |
| .iter() |
| .map(|opt_val| { |
| opt_val.and_then(|v| { |
| let micros: i64 = v.into(); |
| micros_to_naive(micros) |
| .and_then(trunc_fn) |
| .map(naive_to_micros) |
| }) |
| }) |
| .collect(); |
| |
| Ok(result) |
| } |
| |
| /// Truncate a single NTZ value and append to builder |
| fn timestamp_trunc_ntz_single<F>( |
| value: Option<i64>, |
| builder: &mut PrimitiveBuilder<TimestampMicrosecondType>, |
| op: F, |
| ) -> Result<(), SparkError> |
| where |
| F: Fn(NaiveDateTime) -> Option<NaiveDateTime>, |
| { |
| match value { |
| Some(micros) => match micros_to_naive(micros).and_then(op) { |
| Some(truncated) => builder.append_value(naive_to_micros(truncated)), |
| None => { |
| return Err(SparkError::Internal( |
| "Unable to truncate NTZ timestamp".to_string(), |
| )) |
| } |
| }, |
| None => builder.append_null(), |
| } |
| Ok(()) |
| } |
| |
| pub(crate) fn timestamp_trunc<T>( |
| array: &PrimitiveArray<T>, |
| format: String, |
| ) -> Result<TimestampMicrosecondArray, SparkError> |
| where |
| T: ArrowTemporalType + ArrowNumericType, |
| i64: From<T::Native>, |
| { |
| let builder = TimestampMicrosecondBuilder::with_capacity(array.len()); |
| let iter = ArrayIter::new(array); |
| match array.data_type() { |
| DataType::Timestamp(TimeUnit::Microsecond, None) => { |
| // TimestampNTZ: operate directly on naive microsecond values without timezone |
| timestamp_trunc_ntz(array, format) |
| } |
| DataType::Timestamp(TimeUnit::Microsecond, Some(tz)) => { |
| let trunc_fn = tz_trunc_fn_for_format(&format)?; |
| as_timestamp_tz_with_op::<&PrimitiveArray<T>, T, _>(iter, builder, tz, |dt| { |
| as_micros_from_unix_epoch_utc(trunc_fn(dt)) |
| }) |
| } |
| dt => return_compute_error_with!( |
| "Unsupported input type '{:?}' for function 'timestamp_trunc'", |
| dt |
| ), |
| } |
| } |
| |
| /// |
| /// Implements the spark [DATE_TRUNC](https://spark.apache.org/docs/latest/api/sql/index.html#date_trunc) |
| /// function where the specified format may be an array |
| /// |
| /// array is an array of Timestamp(Microsecond) values. Timestamp values must have a valid |
| /// timezone or no timezone. The array may be a dictionary array. |
| /// |
| /// format is an array of strings specifying the format to apply to the corresponding timestamp |
| /// value. The array may be a dictionary array. |
| pub(crate) fn timestamp_trunc_array_fmt_dyn( |
| array: &dyn Array, |
| formats: &dyn Array, |
| ) -> Result<ArrayRef, SparkError> { |
| match (array.data_type().clone(), formats.data_type().clone()) { |
| (DataType::Dictionary(_, _), DataType::Dictionary(_, _)) => { |
| downcast_dictionary_array!( |
| formats => { |
| downcast_dictionary_array!( |
| array => { |
| timestamp_trunc_array_fmt_dict_dict( |
| &array.downcast_dict::<TimestampMicrosecondArray>().unwrap(), |
| &formats.downcast_dict::<StringArray>().unwrap()) |
| .map(|a| Arc::new(a) as ArrayRef) |
| } |
| dt => return_compute_error_with!("timestamp_trunc does not support", dt) |
| ) |
| } |
| fmt => return_compute_error_with!("timestamp_trunc does not support format type", fmt), |
| ) |
| } |
| (DataType::Dictionary(_, _), DataType::Utf8) => { |
| downcast_dictionary_array!( |
| array => { |
| timestamp_trunc_array_fmt_dict_plain( |
| &array.downcast_dict::<PrimitiveArray<TimestampMicrosecondType>>().unwrap(), |
| formats.as_any().downcast_ref::<StringArray>() |
| .expect("Unexpected value type in formats")) |
| .map(|a| Arc::new(a) as ArrayRef) |
| } |
| dt => return_compute_error_with!("timestamp_trunc does not support", dt), |
| ) |
| } |
| (DataType::Timestamp(TimeUnit::Microsecond, _), DataType::Dictionary(_, _)) => { |
| downcast_dictionary_array!( |
| formats => { |
| downcast_temporal_array!(array => { |
| timestamp_trunc_array_fmt_plain_dict( |
| array, |
| &formats.downcast_dict::<StringArray>().unwrap()) |
| .map(|a| Arc::new(a) as ArrayRef) |
| } |
| dt => return_compute_error_with!("timestamp_trunc does not support", dt), |
| ) |
| } |
| fmt => return_compute_error_with!("timestamp_trunc does not support format type", fmt), |
| ) |
| } |
| (DataType::Timestamp(TimeUnit::Microsecond, _), DataType::Utf8) => { |
| downcast_temporal_array!( |
| array => { |
| timestamp_trunc_array_fmt_plain_plain(array, |
| formats.as_any().downcast_ref::<StringArray>().expect("Unexpected value type in formats")) |
| .map(|a| Arc::new(a) as ArrayRef) |
| }, |
| dt => return_compute_error_with!("timestamp_trunc does not support", dt), |
| ) |
| } |
| (dt, fmt) => Err(SparkError::Internal(format!( |
| "Unsupported datatype: {dt:}, format: {fmt:?} for function 'timestamp_trunc'" |
| ))), |
| } |
| } |
| |
| macro_rules! timestamp_trunc_array_fmt_helper { |
| ($array: ident, $formats: ident, $datatype: ident) => {{ |
| let mut builder = TimestampMicrosecondBuilder::with_capacity($array.len()); |
| let iter = $array.into_iter(); |
| assert_eq!( |
| $array.len(), |
| $formats.len(), |
| "lengths of values array and format array must be the same" |
| ); |
| match $datatype { |
| DataType::Timestamp(TimeUnit::Microsecond, None) => { |
| // TimestampNTZ: operate directly on naive microsecond values |
| for (index, val) in iter.enumerate() { |
| let micros_val = val.map(|v| i64::from(v)); |
| let trunc_fn = ntz_trunc_fn_for_format($formats.value(index))?; |
| timestamp_trunc_ntz_single(micros_val, &mut builder, trunc_fn)?; |
| } |
| Ok(builder.finish()) |
| } |
| DataType::Timestamp(TimeUnit::Microsecond, Some(tz_str)) => { |
| let tz: Tz = tz_str.parse()?; |
| for (index, val) in iter.enumerate() { |
| let trunc_fn = tz_trunc_fn_for_format($formats.value(index))?; |
| as_timestamp_tz_with_op_single::<T, _>(val, &mut builder, &tz, |dt| { |
| as_micros_from_unix_epoch_utc(trunc_fn(dt)) |
| })?; |
| } |
| Ok(builder.finish().with_timezone(tz_str.as_ref())) |
| } |
| dt => { |
| return_compute_error_with!( |
| "Unsupported input type '{:?}' for function 'timestamp_trunc'", |
| dt |
| ) |
| } |
| } |
| }}; |
| } |
| |
| fn timestamp_trunc_array_fmt_plain_plain<T>( |
| array: &PrimitiveArray<T>, |
| formats: &StringArray, |
| ) -> Result<TimestampMicrosecondArray, SparkError> |
| where |
| T: ArrowTemporalType + ArrowNumericType, |
| i64: From<T::Native>, |
| { |
| let data_type = array.data_type(); |
| timestamp_trunc_array_fmt_helper!(array, formats, data_type) |
| } |
| fn timestamp_trunc_array_fmt_plain_dict<T, K>( |
| array: &PrimitiveArray<T>, |
| formats: &TypedDictionaryArray<K, StringArray>, |
| ) -> Result<TimestampMicrosecondArray, SparkError> |
| where |
| T: ArrowTemporalType + ArrowNumericType, |
| i64: From<T::Native>, |
| K: ArrowDictionaryKeyType, |
| { |
| let data_type = array.data_type(); |
| timestamp_trunc_array_fmt_helper!(array, formats, data_type) |
| } |
| |
| fn timestamp_trunc_array_fmt_dict_plain<T, K>( |
| array: &TypedDictionaryArray<K, PrimitiveArray<T>>, |
| formats: &StringArray, |
| ) -> Result<TimestampMicrosecondArray, SparkError> |
| where |
| T: ArrowTemporalType + ArrowNumericType, |
| i64: From<T::Native>, |
| K: ArrowDictionaryKeyType, |
| { |
| let data_type = array.values().data_type(); |
| timestamp_trunc_array_fmt_helper!(array, formats, data_type) |
| } |
| |
| fn timestamp_trunc_array_fmt_dict_dict<T, K, F>( |
| array: &TypedDictionaryArray<K, PrimitiveArray<T>>, |
| formats: &TypedDictionaryArray<F, StringArray>, |
| ) -> Result<TimestampMicrosecondArray, SparkError> |
| where |
| T: ArrowTemporalType + ArrowNumericType, |
| i64: From<T::Native>, |
| K: ArrowDictionaryKeyType, |
| F: ArrowDictionaryKeyType, |
| { |
| let data_type = array.values().data_type(); |
| timestamp_trunc_array_fmt_helper!(array, formats, data_type) |
| } |
| |
| #[cfg(test)] |
| mod tests { |
| use crate::kernels::temporal::{ |
| date_trunc, date_trunc_array_fmt_dyn, timestamp_trunc, timestamp_trunc_array_fmt_dyn, |
| }; |
| use arrow::array::{ |
| builder::{PrimitiveDictionaryBuilder, StringDictionaryBuilder}, |
| iterator::ArrayIter, |
| types::{Date32Type, Int32Type, TimestampMicrosecondType}, |
| Array, Date32Array, PrimitiveArray, StringArray, TimestampMicrosecondArray, |
| }; |
| use std::sync::Arc; |
| |
| #[test] |
| #[cfg_attr(miri, ignore)] // test takes too long with miri |
| fn test_date_trunc() { |
| let size = 1000; |
| let mut vec: Vec<i32> = Vec::with_capacity(size); |
| for i in 0..size { |
| vec.push(i as i32); |
| } |
| let array = Date32Array::from(vec); |
| for fmt in [ |
| "YEAR", "YYYY", "YY", "QUARTER", "MONTH", "MON", "MM", "WEEK", |
| ] { |
| match date_trunc(&array, fmt.to_string()) { |
| Ok(a) => { |
| for i in 0..size { |
| assert!(array.values().get(i) >= a.values().get(i)) |
| } |
| } |
| _ => unreachable!(), |
| } |
| } |
| } |
| |
| #[test] |
| // This test only verifies that the various input array types work. Actually correctness to |
| // ensure this produces the same results as spark is verified in the JVM tests |
| fn test_date_trunc_array_fmt_dyn() { |
| let size = 10; |
| let formats = [ |
| "YEAR", "YYYY", "YY", "QUARTER", "MONTH", "MON", "MM", "WEEK", |
| ]; |
| let mut vec: Vec<i32> = Vec::with_capacity(size * formats.len()); |
| let mut fmt_vec: Vec<&str> = Vec::with_capacity(size * formats.len()); |
| for i in 0..size { |
| for fmt_value in &formats { |
| vec.push(i as i32 * 1_000_001); |
| fmt_vec.push(fmt_value); |
| } |
| } |
| |
| // timestamp array |
| let array = Date32Array::from(vec); |
| |
| // formats array |
| let fmt_array = StringArray::from(fmt_vec); |
| |
| // timestamp dictionary array |
| let mut date_dict_builder = PrimitiveDictionaryBuilder::<Int32Type, Date32Type>::new(); |
| for v in array.iter() { |
| date_dict_builder |
| .append(v.unwrap()) |
| .expect("Error in building timestamp array"); |
| } |
| let mut array_dict = date_dict_builder.finish(); |
| // apply timezone |
| array_dict = array_dict.with_values(Arc::new( |
| array_dict |
| .values() |
| .as_any() |
| .downcast_ref::<Date32Array>() |
| .unwrap() |
| .clone(), |
| )); |
| |
| // formats dictionary array |
| let mut formats_dict_builder = StringDictionaryBuilder::<Int32Type>::new(); |
| for v in fmt_array.iter() { |
| formats_dict_builder |
| .append(v.unwrap()) |
| .expect("Error in building formats array"); |
| } |
| let fmt_dict = formats_dict_builder.finish(); |
| |
| // verify input arrays |
| let iter = ArrayIter::new(&array); |
| let mut dict_iter = array_dict |
| .downcast_dict::<PrimitiveArray<Date32Type>>() |
| .unwrap() |
| .into_iter(); |
| for val in iter { |
| assert_eq!( |
| dict_iter |
| .next() |
| .expect("array and dictionary array do not match"), |
| val |
| ) |
| } |
| |
| // verify input format arrays |
| let fmt_iter = ArrayIter::new(&fmt_array); |
| let mut fmt_dict_iter = fmt_dict.downcast_dict::<StringArray>().unwrap().into_iter(); |
| for val in fmt_iter { |
| assert_eq!( |
| fmt_dict_iter |
| .next() |
| .expect("formats and dictionary formats do not match"), |
| val |
| ) |
| } |
| |
| // test cases |
| if let Ok(a) = date_trunc_array_fmt_dyn(&array, &fmt_array) { |
| for i in 0..array.len() { |
| assert!( |
| array.value(i) >= a.as_any().downcast_ref::<Date32Array>().unwrap().value(i) |
| ) |
| } |
| } else { |
| unreachable!() |
| } |
| if let Ok(a) = date_trunc_array_fmt_dyn(&array_dict, &fmt_array) { |
| for i in 0..array.len() { |
| assert!( |
| array.value(i) >= a.as_any().downcast_ref::<Date32Array>().unwrap().value(i) |
| ) |
| } |
| } else { |
| unreachable!() |
| } |
| if let Ok(a) = date_trunc_array_fmt_dyn(&array, &fmt_dict) { |
| for i in 0..array.len() { |
| assert!( |
| array.value(i) >= a.as_any().downcast_ref::<Date32Array>().unwrap().value(i) |
| ) |
| } |
| } else { |
| unreachable!() |
| } |
| if let Ok(a) = date_trunc_array_fmt_dyn(&array_dict, &fmt_dict) { |
| for i in 0..array.len() { |
| assert!( |
| array.value(i) >= a.as_any().downcast_ref::<Date32Array>().unwrap().value(i) |
| ) |
| } |
| } else { |
| unreachable!() |
| } |
| } |
| |
| #[test] |
| #[cfg_attr(miri, ignore)] // test takes too long with miri |
| fn test_timestamp_trunc() { |
| let size = 1000; |
| let mut vec: Vec<i64> = Vec::with_capacity(size); |
| for i in 0..size { |
| vec.push(i as i64); |
| } |
| let array = TimestampMicrosecondArray::from(vec).with_timezone_utc(); |
| for fmt in [ |
| "YEAR", |
| "YYYY", |
| "YY", |
| "QUARTER", |
| "MONTH", |
| "MON", |
| "MM", |
| "WEEK", |
| "DAY", |
| "DD", |
| "HOUR", |
| "MINUTE", |
| "SECOND", |
| "MILLISECOND", |
| "MICROSECOND", |
| ] { |
| match timestamp_trunc(&array, fmt.to_string()) { |
| Ok(a) => { |
| for i in 0..size { |
| assert!(array.values().get(i) >= a.values().get(i)) |
| } |
| } |
| _ => unreachable!(), |
| } |
| } |
| } |
| |
| #[test] |
| // test takes too long with miri |
| #[cfg_attr(miri, ignore)] |
| // This test only verifies that the various input array types work. Actually correctness to |
| // ensure this produces the same results as spark is verified in the JVM tests |
| fn test_timestamp_trunc_array_fmt_dyn() { |
| let size = 10; |
| let formats = [ |
| "YEAR", |
| "YYYY", |
| "YY", |
| "QUARTER", |
| "MONTH", |
| "MON", |
| "MM", |
| "WEEK", |
| "DAY", |
| "DD", |
| "HOUR", |
| "MINUTE", |
| "SECOND", |
| "MILLISECOND", |
| "MICROSECOND", |
| ]; |
| let mut vec: Vec<i64> = Vec::with_capacity(size * formats.len()); |
| let mut fmt_vec: Vec<&str> = Vec::with_capacity(size * formats.len()); |
| for i in 0..size { |
| for fmt_value in &formats { |
| vec.push(i as i64 * 1_000_000_001); |
| fmt_vec.push(fmt_value); |
| } |
| } |
| |
| // timestamp array |
| let array = TimestampMicrosecondArray::from(vec).with_timezone_utc(); |
| |
| // formats array |
| let fmt_array = StringArray::from(fmt_vec); |
| |
| // timestamp dictionary array |
| let mut timestamp_dict_builder = |
| PrimitiveDictionaryBuilder::<Int32Type, TimestampMicrosecondType>::new(); |
| for v in array.iter() { |
| timestamp_dict_builder |
| .append(v.unwrap()) |
| .expect("Error in building timestamp array"); |
| } |
| let mut array_dict = timestamp_dict_builder.finish(); |
| // apply timezone |
| array_dict = array_dict.with_values(Arc::new( |
| array_dict |
| .values() |
| .as_any() |
| .downcast_ref::<TimestampMicrosecondArray>() |
| .unwrap() |
| .clone() |
| .with_timezone_utc(), |
| )); |
| |
| // formats dictionary array |
| let mut formats_dict_builder = StringDictionaryBuilder::<Int32Type>::new(); |
| for v in fmt_array.iter() { |
| formats_dict_builder |
| .append(v.unwrap()) |
| .expect("Error in building formats array"); |
| } |
| let fmt_dict = formats_dict_builder.finish(); |
| |
| // verify input arrays |
| let iter = ArrayIter::new(&array); |
| let mut dict_iter = array_dict |
| .downcast_dict::<PrimitiveArray<TimestampMicrosecondType>>() |
| .unwrap() |
| .into_iter(); |
| for val in iter { |
| assert_eq!( |
| dict_iter |
| .next() |
| .expect("array and dictionary array do not match"), |
| val |
| ) |
| } |
| |
| // verify input format arrays |
| let fmt_iter = ArrayIter::new(&fmt_array); |
| let mut fmt_dict_iter = fmt_dict.downcast_dict::<StringArray>().unwrap().into_iter(); |
| for val in fmt_iter { |
| assert_eq!( |
| fmt_dict_iter |
| .next() |
| .expect("formats and dictionary formats do not match"), |
| val |
| ) |
| } |
| |
| // test cases |
| if let Ok(a) = timestamp_trunc_array_fmt_dyn(&array, &fmt_array) { |
| for i in 0..array.len() { |
| assert!( |
| array.value(i) |
| >= a.as_any() |
| .downcast_ref::<TimestampMicrosecondArray>() |
| .unwrap() |
| .value(i) |
| ) |
| } |
| } else { |
| unreachable!() |
| } |
| if let Ok(a) = timestamp_trunc_array_fmt_dyn(&array_dict, &fmt_array) { |
| for i in 0..array.len() { |
| assert!( |
| array.value(i) |
| >= a.as_any() |
| .downcast_ref::<TimestampMicrosecondArray>() |
| .unwrap() |
| .value(i) |
| ) |
| } |
| } else { |
| unreachable!() |
| } |
| if let Ok(a) = timestamp_trunc_array_fmt_dyn(&array, &fmt_dict) { |
| for i in 0..array.len() { |
| assert!( |
| array.value(i) |
| >= a.as_any() |
| .downcast_ref::<TimestampMicrosecondArray>() |
| .unwrap() |
| .value(i) |
| ) |
| } |
| } else { |
| unreachable!() |
| } |
| if let Ok(a) = timestamp_trunc_array_fmt_dyn(&array_dict, &fmt_dict) { |
| for i in 0..array.len() { |
| assert!( |
| array.value(i) |
| >= a.as_any() |
| .downcast_ref::<TimestampMicrosecondArray>() |
| .unwrap() |
| .value(i) |
| ) |
| } |
| } else { |
| unreachable!() |
| } |
| } |
| |
| /// Truncating a November timestamp in `America/Denver` to QUARTER must land on the start of |
| /// Q4, which is October 1 — and October 1 is still MDT (UTC-6), not MST (UTC-7). The |
| /// pre-fix kernel reused the input's MST offset for the truncated date, producing a result |
| /// one hour late. Also verifies the output array carries the input timezone, which is what |
| /// allows the result to flow through shuffle/sort without a `RowConverter` schema mismatch. |
| #[test] |
| fn test_timestamp_trunc_dst_boundary() { |
| // 2023-11-15 18:30:00 UTC = 2023-11-15 11:30 MST |
| let ts_utc_micros: i64 = 1700069400 * 1_000_000; |
| let array = |
| TimestampMicrosecondArray::from(vec![ts_utc_micros]).with_timezone("America/Denver"); |
| |
| let result = timestamp_trunc(&array, "QUARTER".to_string()).unwrap(); |
| |
| // 2023-10-01 00:00:00 MDT = 2023-10-01 06:00:00 UTC |
| let expected_utc_micros: i64 = 1696140000 * 1_000_000; |
| assert_eq!(result.value(0), expected_utc_micros); |
| assert_eq!( |
| result.data_type(), |
| &arrow::datatypes::DataType::Timestamp( |
| arrow::datatypes::TimeUnit::Microsecond, |
| Some("America/Denver".into()) |
| ) |
| ); |
| } |
| } |