Skip to main content

vortex_array/extension/datetime/
timestamp.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! Temporal extension data types.
5
6use std::fmt;
7use std::sync::Arc;
8
9use jiff::Span;
10use jiff::fmt::temporal::DateTimeParser;
11use jiff::tz::TimeZone;
12use vortex_error::VortexExpect;
13use vortex_error::VortexResult;
14use vortex_error::vortex_bail;
15use vortex_error::vortex_ensure;
16use vortex_error::vortex_err;
17use vortex_error::vortex_panic;
18use vortex_session::registry::CachedId;
19
20use crate::dtype::DType;
21use crate::dtype::Nullability;
22use crate::dtype::PType;
23use crate::dtype::extension::ExtDType;
24use crate::dtype::extension::ExtId;
25use crate::dtype::extension::ExtVTable;
26use crate::extension::datetime::TimeUnit;
27use crate::scalar::ScalarValue;
28
29/// Timestamp DType.
30#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
31pub struct Timestamp;
32
33impl Timestamp {
34    /// Creates a new Timestamp extension =dtype with the given time unit and nullability.
35    pub fn new(time_unit: TimeUnit, nullability: Nullability) -> ExtDType<Self> {
36        Self::new_with_tz(time_unit, None, nullability)
37    }
38
39    /// Creates a new Timestamp extension dtype with the given time unit, timezone, and nullability.
40    pub fn new_with_tz(
41        time_unit: TimeUnit,
42        timezone: Option<Arc<str>>,
43        nullability: Nullability,
44    ) -> ExtDType<Self> {
45        ExtDType::try_new(
46            TimestampOptions {
47                unit: time_unit,
48                tz: timezone,
49            },
50            DType::Primitive(PType::I64, nullability),
51        )
52        .vortex_expect("failed to create timestamp dtype")
53    }
54
55    /// Creates a new `Timestamp` extension dtype with the given options and nullability.
56    pub fn new_with_options(options: TimestampOptions, nullability: Nullability) -> ExtDType<Self> {
57        ExtDType::try_new(options, DType::Primitive(PType::I64, nullability))
58            .vortex_expect("failed to create timestamp dtype")
59    }
60}
61
62/// Options for the Timestamp DType.
63#[derive(Clone, Debug, PartialEq, Eq, Hash)]
64pub struct TimestampOptions {
65    /// The time unit of the timestamp.
66    pub unit: TimeUnit,
67    /// The timezone of the timestamp, if any.
68    pub tz: Option<Arc<str>>,
69}
70
71impl fmt::Display for TimestampOptions {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match &self.tz {
74            Some(tz) => write!(f, "{}, tz={}", self.unit, tz),
75            None => write!(f, "{}", self.unit),
76        }
77    }
78}
79
80/// Resolve an Arrow timestamp timezone, accepting IANA names and fixed offsets.
81pub(crate) fn resolve_time_zone(name: Option<&str>) -> VortexResult<TimeZone> {
82    Ok(match name {
83        Some(name) if name.starts_with('+') || name.starts_with('-') => {
84            DateTimeParser::new().parse_time_zone(name)?
85        }
86        Some(name) => TimeZone::get(name)?,
87        None => TimeZone::UTC,
88    })
89}
90
91/// Unpacked value of a [`Timestamp`] extension scalar.
92///
93/// Each variant carries the raw storage value and an optional timezone.
94pub enum TimestampValue<'a> {
95    /// Seconds since the Unix epoch.
96    Seconds(i64, Option<&'a Arc<str>>),
97    /// Milliseconds since the Unix epoch.
98    Milliseconds(i64, Option<&'a Arc<str>>),
99    /// Microseconds since the Unix epoch.
100    Microseconds(i64, Option<&'a Arc<str>>),
101    /// Nanoseconds since the Unix epoch.
102    Nanoseconds(i64, Option<&'a Arc<str>>),
103}
104
105impl fmt::Display for TimestampValue<'_> {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        let (span, tz) = match self {
108            TimestampValue::Seconds(v, tz) => (Span::new().seconds(*v), *tz),
109            TimestampValue::Milliseconds(v, tz) => (Span::new().milliseconds(*v), *tz),
110            TimestampValue::Microseconds(v, tz) => (Span::new().microseconds(*v), *tz),
111            TimestampValue::Nanoseconds(v, tz) => (Span::new().nanoseconds(*v), *tz),
112        };
113        let ts = jiff::Timestamp::UNIX_EPOCH + span;
114
115        match tz {
116            None => write!(f, "{ts}"),
117            Some(tz) => {
118                let zone = resolve_time_zone(Some(tz)).vortex_expect("unknown timezone");
119                let adjusted_ts = ts.to_zoned(zone);
120                write!(f, "{adjusted_ts}",)
121            }
122        }
123    }
124}
125
126impl ExtVTable for Timestamp {
127    type Metadata = TimestampOptions;
128
129    type NativeValue<'a> = TimestampValue<'a>;
130
131    fn id(&self) -> ExtId {
132        static ID: CachedId = CachedId::new("vortex.timestamp");
133        *ID
134    }
135
136    // NOTE(ngates): unfortunately we're stuck with this hand-rolled serialization format for
137    //  backwards compatibility.
138    fn serialize_metadata(&self, metadata: &Self::Metadata) -> VortexResult<Vec<u8>> {
139        let mut bytes = Vec::with_capacity(4);
140        let unit_tag: u8 = metadata.unit.into();
141
142        bytes.push(unit_tag);
143
144        // Encode time_zone as u16 length followed by utf8 bytes.
145        match &metadata.tz {
146            None => bytes.extend_from_slice(0u16.to_le_bytes().as_slice()),
147            Some(tz) => {
148                let tz_bytes = tz.as_bytes();
149                let tz_len = u16::try_from(tz_bytes.len())
150                    .unwrap_or_else(|err| vortex_panic!("tz did not fit in u16: {}", err));
151                bytes.extend_from_slice(tz_len.to_le_bytes().as_slice());
152                bytes.extend_from_slice(tz_bytes);
153            }
154        }
155
156        Ok(bytes)
157    }
158
159    fn deserialize_metadata(&self, data: &[u8]) -> VortexResult<Self::Metadata> {
160        vortex_ensure!(
161            data.len() >= 3,
162            "Timestamp metadata must have at least 3 bytes, got {}",
163            data.len()
164        );
165
166        let tag = data[0];
167        let time_unit = TimeUnit::try_from(tag)?;
168        let tz_len_bytes: [u8; 2] = data[1..3]
169            .try_into()
170            .ok()
171            .vortex_expect("Verified to have two bytes");
172        let tz_len = u16::from_le_bytes(tz_len_bytes) as usize;
173        if tz_len == 0 {
174            return Ok(TimestampOptions {
175                unit: time_unit,
176                tz: None,
177            });
178        }
179
180        // Attempt to load from len-prefixed bytes
181        vortex_ensure!(
182            data.len() >= 3 + tz_len,
183            "Timestamp metadata is truncated: declared timezone length {} but only {} bytes available",
184            tz_len,
185            data.len() - 3
186        );
187        let tz_bytes = &data[3..3 + tz_len];
188        let tz: Arc<str> = str::from_utf8(tz_bytes)
189            .map_err(|e| vortex_err!("timezone is not valid utf8 string: {e}"))?
190            .to_string()
191            .into();
192        Ok(TimestampOptions {
193            unit: time_unit,
194            tz: Some(tz),
195        })
196    }
197
198    fn validate_dtype(ext_dtype: &ExtDType<Self>) -> VortexResult<()> {
199        vortex_ensure!(
200            matches!(ext_dtype.storage_dtype(), DType::Primitive(PType::I64, _)),
201            "Timestamp storage dtype must be i64"
202        );
203        Ok(())
204    }
205
206    fn unpack_native<'a>(
207        ext_dtype: &'a ExtDType<Self>,
208        storage_value: &'a ScalarValue,
209    ) -> VortexResult<Self::NativeValue<'a>> {
210        let metadata = ext_dtype.metadata();
211        let ts_value = storage_value.as_primitive().cast::<i64>()?;
212        let tz = metadata.tz.as_ref();
213
214        let (span, value) = match metadata.unit {
215            TimeUnit::Nanoseconds => (
216                Span::new().nanoseconds(ts_value),
217                TimestampValue::Nanoseconds(ts_value, tz),
218            ),
219            TimeUnit::Microseconds => (
220                Span::new().microseconds(ts_value),
221                TimestampValue::Microseconds(ts_value, tz),
222            ),
223            TimeUnit::Milliseconds => (
224                Span::new().milliseconds(ts_value),
225                TimestampValue::Milliseconds(ts_value, tz),
226            ),
227            TimeUnit::Seconds => (
228                Span::new().seconds(ts_value),
229                TimestampValue::Seconds(ts_value, tz),
230            ),
231            TimeUnit::Days => vortex_bail!("Timestamp does not support Days time unit"),
232        };
233
234        // Validate the storage value is within the valid range for Timestamp.
235        jiff::Timestamp::UNIX_EPOCH
236            .checked_add(span)
237            .map_err(|e| vortex_err!("Invalid timestamp scalar: {}", e))?;
238
239        if let Some(tz) = tz {
240            resolve_time_zone(Some(tz))
241                .map_err(|e| vortex_err!("Invalid timezone for timestamp scalar: {}", e))?;
242        }
243
244        Ok(value)
245    }
246}
247
248#[cfg(test)]
249mod tests {
250    use std::sync::Arc;
251
252    use rstest::rstest;
253    use vortex_error::VortexResult;
254
255    use crate::dtype::DType;
256    use crate::dtype::Nullability::Nullable;
257    use crate::extension::datetime::TimeUnit;
258    use crate::extension::datetime::Timestamp;
259    use crate::scalar::PValue;
260    use crate::scalar::Scalar;
261    use crate::scalar::ScalarValue;
262
263    #[test]
264    fn validate_timestamp_scalar() -> VortexResult<()> {
265        let dtype = DType::Extension(Timestamp::new(TimeUnit::Seconds, Nullable).erased());
266        Scalar::try_new(dtype, Some(ScalarValue::Primitive(PValue::I64(0))))?;
267
268        Ok(())
269    }
270
271    #[cfg_attr(miri, ignore)]
272    #[rstest]
273    #[case("+01:00", "1970-01-01T01:00:00+01:00[+01:00]")]
274    #[case("-05:30", "1969-12-31T18:30:00-05:30[-05:30]")]
275    fn display_fixed_offset_timestamp(
276        #[case] timezone: &str,
277        #[case] expected: &str,
278    ) -> VortexResult<()> {
279        let dtype = DType::Extension(
280            Timestamp::new_with_tz(TimeUnit::Seconds, Some(timezone.into()), Nullable).erased(),
281        );
282        let scalar = Scalar::try_new(dtype, Some(ScalarValue::Primitive(PValue::I64(0))))?;
283        assert_eq!(format!("{}", scalar.as_extension()), expected);
284        Ok(())
285    }
286
287    #[cfg_attr(miri, ignore)]
288    #[test]
289    fn reject_timestamp_with_invalid_timezone() {
290        let dtype = DType::Extension(
291            Timestamp::new_with_tz(
292                TimeUnit::Seconds,
293                Some(Arc::from("Not/A/Timezone")),
294                Nullable,
295            )
296            .erased(),
297        );
298        let result = Scalar::try_new(dtype, Some(ScalarValue::Primitive(PValue::I64(0))));
299        assert!(result.is_err());
300    }
301
302    #[cfg_attr(miri, ignore)]
303    #[test]
304    fn display_timestamp_scalar() {
305        // Local (no timezone) timestamp.
306        let local_dtype = DType::Extension(Timestamp::new(TimeUnit::Seconds, Nullable).erased());
307        let scalar = Scalar::new(local_dtype, Some(ScalarValue::Primitive(PValue::I64(0))));
308        assert_eq!(format!("{}", scalar.as_extension()), "1970-01-01T00:00:00Z");
309
310        // Zoned timestamp.
311        let zoned_dtype = DType::Extension(
312            Timestamp::new_with_tz(
313                TimeUnit::Seconds,
314                Some(Arc::from("America/New_York")),
315                Nullable,
316            )
317            .erased(),
318        );
319        let scalar = Scalar::new(zoned_dtype, Some(ScalarValue::Primitive(PValue::I64(0))));
320        assert_eq!(
321            format!("{}", scalar.as_extension()),
322            "1969-12-31T19:00:00-05:00[America/New_York]"
323        );
324    }
325
326    #[test]
327    fn deserialize_empty_metadata_returns_error() {
328        use crate::dtype::extension::ExtVTable;
329
330        let vtable = Timestamp;
331        assert!(vtable.deserialize_metadata(&[]).is_err());
332    }
333
334    #[test]
335    fn deserialize_too_short_metadata_returns_error() {
336        use crate::dtype::extension::ExtVTable;
337
338        let vtable = Timestamp;
339        // Only 2 bytes - too short for the required 3-byte header.
340        assert!(vtable.deserialize_metadata(&[0x00, 0x01]).is_err());
341    }
342
343    #[test]
344    fn deserialize_truncated_timezone_returns_error() {
345        use crate::dtype::extension::ExtVTable;
346
347        let vtable = Timestamp;
348        // Valid tag (0x00 = Nanoseconds), tz_len = 10 (little-endian: [0x0A, 0x00]),
349        // but only 3 bytes of timezone data instead of the declared 10.
350        let data = [0x00u8, 0x0A, 0x00, b'U', b'T', b'C'];
351        assert!(vtable.deserialize_metadata(&data).is_err());
352    }
353}