diff --git a/datafusion/functions/src/datetime/common.rs b/datafusion/functions/src/datetime/common.rs index 118b6b371bc17..e1ceab2f3b529 100644 --- a/datafusion/functions/src/datetime/common.rs +++ b/datafusion/functions/src/datetime/common.rs @@ -584,3 +584,15 @@ fn scalar_value(dt: &DataType, r: Option) -> Result { t => Err(internal_datafusion_err!("Unsupported data type: {t:?}")), } } + +/// Parse the optional timezone string carried by [`DataType::Timestamp`] into a +/// [`Tz`]. +/// +/// Returns `Ok(None)` when the timestamp type is timezone-naive. +pub(crate) fn parse_tz(tz: Option<&Arc>) -> Result> { + tz.map(|tz| { + tz.parse::() + .map_err(|op| exec_datafusion_err!("failed on timezone {tz}: {op:?}")) + }) + .transpose() +} diff --git a/datafusion/functions/src/datetime/date_part.rs b/datafusion/functions/src/datetime/date_part.rs index 4bfadf7999906..2b73a1bbd172f 100644 --- a/datafusion/functions/src/datetime/date_part.rs +++ b/datafusion/functions/src/datetime/date_part.rs @@ -19,6 +19,7 @@ use std::iter::repeat_n; use std::str::FromStr; use std::sync::Arc; +use arrow::array::temporal_conversions::as_datetime_with_timezone; use arrow::array::timezone::Tz; use arrow::array::{Array, ArrayRef, Float64Array, Int32Array, Int64Array}; use arrow::compute::{DatePart, binary, date_part}; @@ -31,17 +32,19 @@ use arrow::datatypes::{ IntervalUnit as ArrowIntervalUnit, TimeUnit, TimestampMicrosecondType, TimestampMillisecondType, TimestampNanosecondType, TimestampSecondType, }; -use chrono::{Datelike, NaiveDate}; +use chrono::{Datelike, NaiveDate, Offset}; use datafusion_common::types::{NativeType, logical_date}; +use crate::datetime::common::parse_tz; use datafusion_common::{ Result, ScalarValue, cast::{ as_date32_array, as_date64_array, as_int32_array, as_interval_dt_array, - as_interval_mdn_array, as_interval_ym_array, as_time32_millisecond_array, - as_time32_second_array, as_time64_microsecond_array, as_time64_nanosecond_array, - as_timestamp_microsecond_array, as_timestamp_millisecond_array, - as_timestamp_nanosecond_array, as_timestamp_second_array, + as_interval_mdn_array, as_interval_ym_array, as_primitive_array, + as_time32_millisecond_array, as_time32_second_array, as_time64_microsecond_array, + as_time64_nanosecond_array, as_timestamp_microsecond_array, + as_timestamp_millisecond_array, as_timestamp_nanosecond_array, + as_timestamp_second_array, }, exec_err, internal_err, not_impl_err, types::logical_string, @@ -81,6 +84,11 @@ use datafusion_macros::user_doc; - doy (day of the year) - epoch (seconds since Unix epoch for timestamps/dates, total seconds for intervals) - isodow (ISO 8601 day of the week where Monday is 1 and Sunday is 7) + - timezone (UTC offset in seconds) + - timezone_hour (whole hours of the UTC offset) + - timezone_minute (whole minutes of the UTC offset, excluding the hours) + + The `timezone`, `timezone_hour` and `timezone_minute` parts are only defined for timestamps that carry a timezone; extracting them from a timezone-naive timestamp, a date, a time or an interval is an error. They report the offset that applies at that instant, so they follow daylight saving time: `Europe/Brussels` yields `3600` in January and `7200` in July. For a negative offset each non-zero part carries the sign, so `America/St_Johns` in January yields `-3` hours and `-30` minutes. An offset smaller than one hour has a zero hour part, which cannot show a sign: `Africa/Monrovia` before 1972 yields `0` hours and `-43` minutes. "# ), argument( @@ -100,6 +108,12 @@ use datafusion_macros::user_doc; +----------------------------------------------------+ | 1 | +----------------------------------------------------+ +> SELECT date_part('timezone', TIMESTAMP '2024-07-01T12:00:00' AT TIME ZONE 'Europe/Brussels') AS utc_offset_seconds; ++--------------------+ +| utc_offset_seconds | ++--------------------+ +| 7200 | ++--------------------+ ```"# )] #[derive(Debug, PartialEq, Eq, Hash)] @@ -222,7 +236,13 @@ impl ScalarUDFImpl for DatePartFunc { Ok(DatePart::Nanosecond) => seconds_ns(array.as_ref())?, Ok(part) => date_part(array.as_ref(), part)?, Err(_) if is_epoch(part_trim) => epoch(array.as_ref())?, - Err(_) => return exec_err!("Date part '{part}' not supported"), + // `timezone`, `timezone_hour` and `timezone_minute` have no + // `DatePart` equivalent, so they are resolved once `DatePart::from_str` + // has failed. `TimezonePart::parse` matches lowercase spellings only. + Err(_) => match TimezonePart::parse(&part_trim.to_lowercase()) { + Some(tz_part) => timezone_part(array.as_ref(), tz_part)?, + None => return exec_err!("Date part '{part}' not supported"), + }, }; Ok(if is_scalar { @@ -317,6 +337,100 @@ fn is_nanosecond(part: &str) -> bool { .unwrap_or(false) } +/// The `timezone`, `timezone_hour` and `timezone_minute` fields, which report +/// the UTC offset that applies to a timezone-aware timestamp *at that instant*. +/// +/// Because the offset of a named timezone changes with daylight saving time, +/// these are per-row values, not a property of the type alone. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TimezonePart { + /// `timezone`: the whole UTC offset, in seconds + Offset, + /// `timezone_hour`: whole hours of the UTC offset + Hour, + /// `timezone_minute`: whole minutes of the UTC offset, excluding the hours. + /// The sign follows the sign of the offset (PostgreSQL behaviour), so + /// `-03:30` yields `-3` hours and `-30` minutes. + Minute, +} + +impl TimezonePart { + fn parse(part: &str) -> Option { + match part { + "timezone" => Some(Self::Offset), + "timezone_hour" => Some(Self::Hour), + "timezone_minute" => Some(Self::Minute), + _ => None, + } + } + + fn name(&self) -> &'static str { + match self { + Self::Offset => "timezone", + Self::Hour => "timezone_hour", + Self::Minute => "timezone_minute", + } + } + + /// Extract this field from a UTC offset expressed in seconds. + /// + /// Rust integer division truncates towards zero, which is what makes the + /// sign of `timezone_minute` follow the sign of the offset, matching + /// PostgreSQL. + fn project(&self, offset_seconds: i32) -> i32 { + match self { + Self::Offset => offset_seconds, + Self::Hour => offset_seconds / 3_600, + Self::Minute => (offset_seconds % 3_600) / 60, + } + } +} + +/// Compute a [`TimezonePart`] for every element of a timezone-aware timestamp +/// array. +/// +/// Errors for any other input type, including timezone-naive timestamps, which +/// carry no offset at all. This matches PostgreSQL, which rejects +/// `date_part('timezone', )`. +fn timezone_part(array: &dyn Array, part: TimezonePart) -> Result { + let Timestamp(unit, tz_opt) = array.data_type() else { + return exec_err!( + "Date part '{}' is only supported for timestamps with a timezone, got {}", + part.name(), + array.data_type() + ); + }; + let Some(tz) = parse_tz(tz_opt.as_ref())? else { + return exec_err!( + "Date part '{}' is not supported for timezone-naive timestamps, got {}", + part.name(), + array.data_type() + ); + }; + + match unit { + Second => timezone_part_typed::(array, tz, part), + Millisecond => timezone_part_typed::(array, tz, part), + Microsecond => timezone_part_typed::(array, tz, part), + Nanosecond => timezone_part_typed::(array, tz, part), + } +} + +fn timezone_part_typed( + array: &dyn Array, + tz: Tz, + part: TimezonePart, +) -> Result { + let array = as_primitive_array::(array)?; + // `unary_opt` keeps the input nulls and additionally nulls out any value + // that cannot be represented as a `DateTime` (out of chrono's range). + let result: Int32Array = array.unary_opt(|value| { + as_datetime_with_timezone::(value, tz) + .map(|dt| part.project(dt.offset().fix().local_minus_utc())) + }); + Ok(Arc::new(result)) +} + fn date_to_scalar(date: NaiveDate, target_type: &DataType) -> Option { Some(match target_type { Date32 => ScalarValue::Date32(Some(Date32Type::from_naive_date(date))), @@ -565,3 +679,280 @@ fn seconds_ns(array: &dyn Array) -> Result { Ok(Arc::new(r)) } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::DatePartFunc; + use arrow::array::{ + Array, Int32Array, TimestampMicrosecondArray, TimestampMillisecondArray, + TimestampNanosecondArray, TimestampSecondArray, + }; + use arrow::compute::kernels::cast_utils::string_to_timestamp_nanos; + use arrow::datatypes::{DataType, Field, FieldRef, TimeUnit}; + use datafusion_common::ScalarValue; + use datafusion_common::cast::as_int32_array; + use datafusion_common::config::ConfigOptions; + use datafusion_expr::{ + ColumnarValue, ReturnFieldArgs, ScalarFunctionArgs, ScalarUDFImpl, + }; + + /// Invoke `date_part(part, value)` with a single (already typed) argument. + fn invoke_date_part( + part: &str, + value: ColumnarValue, + number_rows: usize, + ) -> datafusion_common::Result { + let value_field: FieldRef = Field::new("b", value.data_type(), true).into(); + let args = ScalarFunctionArgs { + args: vec![ColumnarValue::Scalar(ScalarValue::from(part)), value], + arg_fields: vec![Field::new("a", DataType::Utf8, false).into(), value_field], + number_rows, + return_field: Field::new("f", DataType::Int32, true).into(), + config_options: Arc::new(ConfigOptions::default()), + }; + DatePartFunc::new().invoke_with_args(args) + } + + fn ts_nanos(s: &str) -> i64 { + string_to_timestamp_nanos(s).unwrap() + } + + /// `date_part(part, )` over a single-element array. + fn tz_part(part: &str, tz: &str, utc_instant: &str) -> Option { + let array = TimestampNanosecondArray::from(vec![Some(ts_nanos(utc_instant))]) + .with_timezone(tz); + let result = invoke_date_part(part, ColumnarValue::Array(Arc::new(array)), 1) + .unwrap() + .to_array(1) + .unwrap(); + assert_eq!(result.data_type(), &DataType::Int32); + let result = as_int32_array(&result).unwrap(); + result.is_valid(0).then(|| result.value(0)) + } + + /// Expected values were cross-checked against PostgreSQL 17: + /// `SET TimeZone TO ''; SELECT date_part('timezone', ''::timestamptz);` + #[test] + fn timezone_parts_match_postgres() { + // (timezone, UTC instant, offset seconds, offset hours, offset minutes) + let cases = [ + // Fixed offsets carried directly by the Arrow type + ("+05:30", "2024-07-01T12:00:00Z", 19_800, 5, 30), + ("-03:30", "2024-07-01T12:00:00Z", -12_600, -3, -30), + ("+00:00", "2024-07-01T12:00:00Z", 0, 0, 0), + ("UTC", "2024-07-01T12:00:00Z", 0, 0, 0), + // Named timezone, standard time vs daylight saving time + ("Europe/Brussels", "2024-01-01T12:00:00Z", 3_600, 1, 0), + ("Europe/Brussels", "2024-07-01T12:00:00Z", 7_200, 2, 0), + ("America/Denver", "2024-01-01T12:00:00Z", -25_200, -7, 0), + ("America/Denver", "2024-07-01T12:00:00Z", -21_600, -6, 0), + // 45 minute offsets (southern hemisphere: January is DST) + ("Pacific/Chatham", "2024-01-01T12:00:00Z", 49_500, 13, 45), + ("Pacific/Chatham", "2024-07-01T12:00:00Z", 45_900, 12, 45), + // Negative offsets keep the sign on both hour and minute + ("America/St_Johns", "2024-01-01T12:00:00Z", -12_600, -3, -30), + ("America/St_Johns", "2024-07-01T12:00:00Z", -9_000, -2, -30), + ( + "Pacific/Marquesas", + "2024-07-01T12:00:00Z", + -34_200, + -9, + -30, + ), + ("Asia/Kolkata", "2024-07-01T12:00:00Z", 19_800, 5, 30), + // A sub-hour offset: the hour part is zero and so cannot show the + // sign, while the minute part still carries it. PostgreSQL 17 + // gives -2588 / 0 / -43 for this instant. + ("Africa/Monrovia", "1900-01-01T12:00:00Z", -2_588, 0, -43), + ("Asia/Kathmandu", "2024-07-01T12:00:00Z", 20_700, 5, 45), + ]; + + for (tz, instant, secs, hours, minutes) in cases { + assert_eq!( + tz_part("timezone", tz, instant), + Some(secs), + "timezone for {tz} at {instant}" + ); + assert_eq!( + tz_part("timezone_hour", tz, instant), + Some(hours), + "timezone_hour for {tz} at {instant}" + ); + assert_eq!( + tz_part("timezone_minute", tz, instant), + Some(minutes), + "timezone_minute for {tz} at {instant}" + ); + } + } + + #[test] + fn timezone_parts_are_case_insensitive() { + assert_eq!( + tz_part("TIMEZONE", "Europe/Brussels", "2024-07-01T12:00:00Z"), + Some(7_200) + ); + // The spelling produced by `EXTRACT(TIMEZONE_HOUR FROM ..)` + assert_eq!( + tz_part("TIMEZONE_HOUR", "Asia/Kolkata", "2024-07-01T12:00:00Z"), + Some(5) + ); + assert_eq!( + tz_part("Timezone_Minute", "Asia/Kolkata", "2024-07-01T12:00:00Z"), + Some(30) + ); + } + + #[test] + fn timezone_parts_over_array_with_dst_transition_and_nulls() { + // Two instants either side of the Brussels DST switch, plus a null + let array = TimestampNanosecondArray::from(vec![ + Some(ts_nanos("2024-01-15T00:00:00Z")), + Some(ts_nanos("2024-07-15T00:00:00Z")), + None, + Some(ts_nanos("2024-11-15T00:00:00Z")), + ]) + .with_timezone("Europe/Brussels"); + + let result = invoke_date_part( + "timezone", + ColumnarValue::Array(Arc::new(array.clone())), + 4, + ) + .unwrap() + .to_array(4) + .unwrap(); + assert_eq!( + as_int32_array(&result).unwrap(), + &Int32Array::from(vec![Some(3_600), Some(7_200), None, Some(3_600)]) + ); + + let result = + invoke_date_part("timezone_hour", ColumnarValue::Array(Arc::new(array)), 4) + .unwrap() + .to_array(4) + .unwrap(); + assert_eq!( + as_int32_array(&result).unwrap(), + &Int32Array::from(vec![Some(1), Some(2), None, Some(1)]) + ); + } + + #[test] + fn timezone_parts_for_all_time_units() { + // 2024-07-01T12:00:00Z in Kolkata (+05:30) regardless of precision + let second = + TimestampSecondArray::from(vec![1_719_835_200]).with_timezone("Asia/Kolkata"); + let milli = TimestampMillisecondArray::from(vec![1_719_835_200_000]) + .with_timezone("Asia/Kolkata"); + let micro = TimestampMicrosecondArray::from(vec![1_719_835_200_000_000]) + .with_timezone("Asia/Kolkata"); + let nano = TimestampNanosecondArray::from(vec![1_719_835_200_000_000_000]) + .with_timezone("Asia/Kolkata"); + + for array in [ + Arc::new(second) as Arc, + Arc::new(milli), + Arc::new(micro), + Arc::new(nano), + ] { + let dt = array.data_type().clone(); + let result = invoke_date_part("timezone", ColumnarValue::Array(array), 1) + .unwrap() + .to_array(1) + .unwrap(); + assert_eq!( + as_int32_array(&result).unwrap(), + &Int32Array::from(vec![19_800]), + "unexpected offset for {dt}" + ); + } + } + + #[test] + fn timezone_parts_on_scalar_input() { + let scalar = ColumnarValue::Scalar(ScalarValue::TimestampNanosecond( + Some(ts_nanos("2024-07-01T12:00:00Z")), + Some("Europe/Brussels".into()), + )); + let ColumnarValue::Scalar(result) = + invoke_date_part("timezone", scalar, 1).unwrap() + else { + panic!("expected a scalar result for a scalar input"); + }; + assert_eq!(result, ScalarValue::Int32(Some(7_200))); + } + + #[test] + fn timezone_parts_on_null_input() { + for part in ["timezone", "timezone_hour", "timezone_minute"] { + let scalar = ColumnarValue::Scalar(ScalarValue::TimestampNanosecond( + None, + Some("Europe/Brussels".into()), + )); + let ColumnarValue::Scalar(result) = + invoke_date_part(part, scalar, 1).unwrap() + else { + panic!("expected a scalar result for a scalar input"); + }; + assert_eq!(result, ScalarValue::Int32(None)); + } + } + + #[test] + fn timezone_parts_reject_timezone_naive_timestamps() { + for part in ["timezone", "timezone_hour", "timezone_minute"] { + let scalar = ColumnarValue::Scalar(ScalarValue::TimestampNanosecond( + Some(ts_nanos("2024-07-01T12:00:00Z")), + None, + )); + let err = invoke_date_part(part, scalar, 1).unwrap_err().to_string(); + assert!( + err.contains(&format!( + "Date part '{part}' is not supported for timezone-naive timestamps" + )), + "unexpected error for {part}: {err}" + ); + } + } + + #[test] + fn timezone_parts_reject_non_timestamp_input() { + let scalar = ColumnarValue::Scalar(ScalarValue::Date32(Some(19_875))); + let err = invoke_date_part("timezone", scalar, 1) + .unwrap_err() + .to_string(); + assert!( + err.contains( + "Date part 'timezone' is only supported for timestamps with a timezone" + ), + "unexpected error: {err}" + ); + } + + #[test] + fn timezone_parts_return_int32() { + let arg_fields: Vec = vec![ + Field::new("a", DataType::Utf8, false).into(), + Field::new( + "b", + DataType::Timestamp(TimeUnit::Nanosecond, Some("Europe/Brussels".into())), + true, + ) + .into(), + ]; + for part in ["timezone", "timezone_hour", "timezone_minute"] { + let part = ScalarValue::from(part); + let field = DatePartFunc::new() + .return_field_from_args(ReturnFieldArgs { + arg_fields: &arg_fields, + scalar_arguments: &[Some(&part), None], + }) + .unwrap(); + assert_eq!(field.data_type(), &DataType::Int32); + assert!(field.is_nullable()); + } + } +} diff --git a/datafusion/functions/src/datetime/date_trunc.rs b/datafusion/functions/src/datetime/date_trunc.rs index 22c8af262a4df..daf9b0e304fd0 100644 --- a/datafusion/functions/src/datetime/date_trunc.rs +++ b/datafusion/functions/src/datetime/date_trunc.rs @@ -20,6 +20,7 @@ use std::ops::{Add, Sub}; use std::str::FromStr; use std::sync::Arc; +use crate::datetime::common::parse_tz; use arrow::array::temporal_conversions::{ MICROSECONDS, MILLISECONDS, NANOSECONDS, as_datetime_with_timezone, }; @@ -843,14 +844,6 @@ fn general_date_trunc( Ok(result) } -fn parse_tz(tz: Option<&Arc>) -> Result> { - tz.map(|tz| { - Tz::from_str(tz) - .map_err(|op| exec_datafusion_err!("failed on timezone {tz}: {op:?}")) - }) - .transpose() -} - #[cfg(test)] mod tests { use std::sync::Arc; diff --git a/datafusion/sqllogictest/test_files/datetime/date_part.slt b/datafusion/sqllogictest/test_files/datetime/date_part.slt index c13ee264ef8fd..246f85342f847 100644 --- a/datafusion/sqllogictest/test_files/datetime/date_part.slt +++ b/datafusion/sqllogictest/test_files/datetime/date_part.slt @@ -1859,3 +1859,167 @@ logical_plan physical_plan 01)FilterExec: c@0 >= 2024-01-01 AND c@0 < 2025-01-01 02)--DataSourceExec: partitions=1, partition_sizes=[1] + +########## +## `timezone`, `timezone_hour` and `timezone_minute` +## +## These report the UTC offset that applies to a timezone-aware timestamp at +## that instant, so they follow daylight saving time. Expected values were +## cross-checked against PostgreSQL 17 for the corresponding instants. +########## + +# Fixed offsets carried by the Arrow type +query III +SELECT + date_part('timezone', arrow_cast(TIMESTAMP '2024-07-01T12:00:00', 'Timestamp(Nanosecond, Some("+05:30"))')), + date_part('timezone_hour', arrow_cast(TIMESTAMP '2024-07-01T12:00:00', 'Timestamp(Nanosecond, Some("+05:30"))')), + date_part('timezone_minute', arrow_cast(TIMESTAMP '2024-07-01T12:00:00', 'Timestamp(Nanosecond, Some("+05:30"))')) +---- +19800 5 30 + +# Negative fixed offset: the sign is carried by both the hour and the minute +query III +SELECT + date_part('timezone', arrow_cast(TIMESTAMP '2024-07-01T12:00:00', 'Timestamp(Nanosecond, Some("-03:30"))')), + date_part('timezone_hour', arrow_cast(TIMESTAMP '2024-07-01T12:00:00', 'Timestamp(Nanosecond, Some("-03:30"))')), + date_part('timezone_minute', arrow_cast(TIMESTAMP '2024-07-01T12:00:00', 'Timestamp(Nanosecond, Some("-03:30"))')) +---- +-12600 -3 -30 + +# The exact query from https://github.com/apache/datafusion/issues/10368 +query I +SELECT date_part('timezone', '2024-07-01T12:00:00Z'::timestamptz AT TIME ZONE 'Europe/Brussels') +---- +7200 + +# Named timezone: standard time in January, daylight saving time in July +query III +SELECT + date_part('timezone', '2024-01-01T12:00:00Z'::timestamptz AT TIME ZONE 'Europe/Brussels'), + date_part('timezone_hour', '2024-01-01T12:00:00Z'::timestamptz AT TIME ZONE 'Europe/Brussels'), + date_part('timezone_minute', '2024-01-01T12:00:00Z'::timestamptz AT TIME ZONE 'Europe/Brussels') +---- +3600 1 0 + +query III +SELECT + date_part('timezone', '2024-01-01T12:00:00Z'::timestamptz AT TIME ZONE 'America/Denver'), + date_part('timezone', '2024-07-01T12:00:00Z'::timestamptz AT TIME ZONE 'America/Denver'), + date_part('timezone_hour', '2024-07-01T12:00:00Z'::timestamptz AT TIME ZONE 'America/Denver') +---- +-25200 -21600 -6 + +# 45 minute offsets. Chatham is in the southern hemisphere, so January is DST +query III +SELECT + date_part('timezone', '2024-01-01T12:00:00Z'::timestamptz AT TIME ZONE 'Pacific/Chatham'), + date_part('timezone_hour', '2024-01-01T12:00:00Z'::timestamptz AT TIME ZONE 'Pacific/Chatham'), + date_part('timezone_minute', '2024-01-01T12:00:00Z'::timestamptz AT TIME ZONE 'Pacific/Chatham') +---- +49500 13 45 + +query III +SELECT + date_part('timezone', '2024-07-01T12:00:00Z'::timestamptz AT TIME ZONE 'Pacific/Chatham'), + date_part('timezone_hour', '2024-07-01T12:00:00Z'::timestamptz AT TIME ZONE 'Pacific/Chatham'), + date_part('timezone_minute', '2024-07-01T12:00:00Z'::timestamptz AT TIME ZONE 'Pacific/Chatham') +---- +45900 12 45 + +# Negative non-whole-hour offset, standard time and daylight saving time +query III +SELECT + date_part('timezone', '2024-01-01T12:00:00Z'::timestamptz AT TIME ZONE 'America/St_Johns'), + date_part('timezone_hour', '2024-01-01T12:00:00Z'::timestamptz AT TIME ZONE 'America/St_Johns'), + date_part('timezone_minute', '2024-01-01T12:00:00Z'::timestamptz AT TIME ZONE 'America/St_Johns') +---- +-12600 -3 -30 + +query III +SELECT + date_part('timezone', '2024-07-01T12:00:00Z'::timestamptz AT TIME ZONE 'America/St_Johns'), + date_part('timezone_hour', '2024-07-01T12:00:00Z'::timestamptz AT TIME ZONE 'America/St_Johns'), + date_part('timezone_minute', '2024-07-01T12:00:00Z'::timestamptz AT TIME ZONE 'America/St_Johns') +---- +-9000 -2 -30 + +# `EXTRACT(field FROM source)` is the alternative syntax for the same function +query III +SELECT + EXTRACT(TIMEZONE FROM '2024-07-01T12:00:00Z'::timestamptz AT TIME ZONE 'Asia/Kolkata'), + EXTRACT(TIMEZONE_HOUR FROM '2024-07-01T12:00:00Z'::timestamptz AT TIME ZONE 'Asia/Kolkata'), + EXTRACT(TIMEZONE_MINUTE FROM '2024-07-01T12:00:00Z'::timestamptz AT TIME ZONE 'Asia/Kolkata') +---- +19800 5 30 + +# The part name is case insensitive +query II +SELECT + date_part('TIMEZONE', '2024-07-01T12:00:00Z'::timestamptz AT TIME ZONE 'Asia/Kathmandu'), + date_part('Timezone_Minute', '2024-07-01T12:00:00Z'::timestamptz AT TIME ZONE 'Asia/Kathmandu') +---- +20700 45 + +# Like every other date_part field except `epoch`, these return Int32 +query TTT +SELECT + arrow_typeof(date_part('timezone', '2024-07-01T12:00:00Z'::timestamptz AT TIME ZONE 'UTC')), + arrow_typeof(date_part('timezone_hour', '2024-07-01T12:00:00Z'::timestamptz AT TIME ZONE 'UTC')), + arrow_typeof(date_part('timezone_minute', '2024-07-01T12:00:00Z'::timestamptz AT TIME ZONE 'UTC')) +---- +Int32 Int32 Int32 + +# NULL in, NULL out +query III +SELECT + date_part('timezone', arrow_cast(NULL, 'Timestamp(Nanosecond, Some("Europe/Brussels"))')), + date_part('timezone_hour', arrow_cast(NULL, 'Timestamp(Nanosecond, Some("Europe/Brussels"))')), + date_part('timezone_minute', arrow_cast(NULL, 'Timestamp(Nanosecond, Some("Europe/Brussels"))')) +---- +NULL NULL NULL + +# Column input, straddling the exact daylight saving time transition, with a +# NULL row. Casting a naive timestamp to a timezone-aware one reads it as a +# local wall clock time, and Brussels springs forward from 02:00 to 03:00 local +# on 2024-03-31, so 01:59:59 local is still CET and 03:00:00 local is CEST. +statement ok +CREATE TABLE tz_offsets AS +SELECT arrow_cast(column1, 'Timestamp(Nanosecond, Some("Europe/Brussels"))') AS ts +FROM (VALUES + (TIMESTAMP '2024-01-15T00:00:00'), + (TIMESTAMP '2024-03-31T01:59:59'), + (TIMESTAMP '2024-03-31T03:00:00'), + (TIMESTAMP '2024-07-15T00:00:00'), + (TIMESTAMP '2024-11-15T00:00:00'), + (CAST(NULL AS TIMESTAMP)) +); + +query III +SELECT + date_part('timezone', ts), + date_part('timezone_hour', ts), + date_part('timezone_minute', ts) +FROM tz_offsets +---- +3600 1 0 +3600 1 0 +7200 2 0 +7200 2 0 +3600 1 0 +NULL NULL NULL + +statement ok +DROP TABLE tz_offsets; + +# These parts are only defined for timestamps that carry a timezone +query error DataFusion error: Execution error: Date part 'timezone' is not supported for timezone\-naive timestamps, got Timestamp\(ns\) +SELECT date_part('timezone', TIMESTAMP '2024-07-01T12:00:00') + +query error DataFusion error: Execution error: Date part 'timezone_hour' is not supported for timezone\-naive timestamps, got Timestamp\(ns\) +SELECT EXTRACT(TIMEZONE_HOUR FROM TIMESTAMP '2024-07-01T12:00:00') + +query error DataFusion error: Execution error: Date part 'timezone' is only supported for timestamps with a timezone, got Date32 +SELECT date_part('timezone', DATE '2024-07-01') + +query error DataFusion error: Execution error: Date part 'timezone_minute' is only supported for timestamps with a timezone, got Interval\(MonthDayNano\) +SELECT date_part('timezone_minute', INTERVAL '1' DAY) diff --git a/docs/source/user-guide/sql/scalar_functions.md b/docs/source/user-guide/sql/scalar_functions.md index 6726b7e145524..9811e6371bd1c 100644 --- a/docs/source/user-guide/sql/scalar_functions.md +++ b/docs/source/user-guide/sql/scalar_functions.md @@ -2563,6 +2563,11 @@ date_part(part, expression) - doy (day of the year) - epoch (seconds since Unix epoch for timestamps/dates, total seconds for intervals) - isodow (ISO 8601 day of the week where Monday is 1 and Sunday is 7) + - timezone (UTC offset in seconds) + - timezone_hour (whole hours of the UTC offset) + - timezone_minute (whole minutes of the UTC offset, excluding the hours) + + The `timezone`, `timezone_hour` and `timezone_minute` parts are only defined for timestamps that carry a timezone; extracting them from a timezone-naive timestamp, a date, a time or an interval is an error. They report the offset that applies at that instant, so they follow daylight saving time: `Europe/Brussels` yields `3600` in January and `7200` in July. For a negative offset each non-zero part carries the sign, so `America/St_Johns` in January yields `-3` hours and `-30` minutes. An offset smaller than one hour has a zero hour part, which cannot show a sign: `Africa/Monrovia` before 1972 yields `0` hours and `-43` minutes. - **expression**: Time expression to operate on. Can be a constant, column, or function. @@ -2581,6 +2586,12 @@ date_part(part, expression) +----------------------------------------------------+ | 1 | +----------------------------------------------------+ +> SELECT date_part('timezone', TIMESTAMP '2024-07-01T12:00:00' AT TIME ZONE 'Europe/Brussels') AS utc_offset_seconds; ++--------------------+ +| utc_offset_seconds | ++--------------------+ +| 7200 | ++--------------------+ ``` #### Alternative Syntax