vortex_array/extension/datetime/
timestamp.rs1use 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#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
31pub struct Timestamp;
32
33impl Timestamp {
34 pub fn new(time_unit: TimeUnit, nullability: Nullability) -> ExtDType<Self> {
36 Self::new_with_tz(time_unit, None, nullability)
37 }
38
39 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 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#[derive(Clone, Debug, PartialEq, Eq, Hash)]
64pub struct TimestampOptions {
65 pub unit: TimeUnit,
67 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
80pub(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
91pub enum TimestampValue<'a> {
95 Seconds(i64, Option<&'a Arc<str>>),
97 Milliseconds(i64, Option<&'a Arc<str>>),
99 Microseconds(i64, Option<&'a Arc<str>>),
101 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 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 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 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 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 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 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 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 let data = [0x00u8, 0x0A, 0x00, b'U', b'T', b'C'];
351 assert!(vtable.deserialize_metadata(&data).is_err());
352 }
353}