1use std::fmt;
7use std::sync::Arc;
8
9use jiff::Timestamp as JiffTimestamp;
10use jiff::tz::AmbiguousOffset;
11use jiff::tz::TimeZone;
12use prost::Message;
13use vortex_buffer::BitBufferMut;
14use vortex_buffer::BufferMut;
15use vortex_error::VortexResult;
16use vortex_error::vortex_bail;
17use vortex_error::vortex_err;
18use vortex_session::VortexSession;
19use vortex_session::registry::CachedId;
20
21use crate::ArrayRef;
22use crate::ExecutionCtx;
23use crate::IntoArray;
24use crate::arrays::ConstantArray;
25use crate::arrays::ExtensionArray;
26use crate::arrays::PrimitiveArray;
27use crate::arrays::VarBinViewArray;
28use crate::arrays::extension::ExtensionArrayExt;
29use crate::dtype::DType;
30use crate::dtype::Nullability;
31use crate::dtype::PType;
32use crate::expr::BoundExpression;
33use crate::expr::bound;
34use crate::extension::datetime::TimeUnit;
35use crate::extension::datetime::Timestamp;
36use crate::extension::datetime::TimestampOptions;
37use crate::extension::datetime::resolve_time_zone as resolve_zone;
38use crate::proto::expr as pb;
39use crate::scalar::Scalar;
40use crate::scalar_fn::Arity;
41use crate::scalar_fn::ChildName;
42use crate::scalar_fn::ExecutionArgs;
43use crate::scalar_fn::ScalarFnId;
44use crate::scalar_fn::ScalarFnVTable;
45use crate::scalar_fn::fns::literal::Literal;
46use crate::validity::Validity;
47
48#[derive(Clone, Debug, PartialEq, Eq, Hash)]
50pub struct ReplaceTimeZoneOptions {
51 pub time_zone: Option<Arc<str>>,
53 pub null_on_non_existent: bool,
55}
56
57impl fmt::Display for ReplaceTimeZoneOptions {
58 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59 match &self.time_zone {
60 Some(tz) => write!(f, "tz={tz}")?,
61 None => write!(f, "tz=none")?,
62 }
63 write!(f, ", null_on_non_existent={}", self.null_on_non_existent)
64 }
65}
66
67#[derive(Clone)]
71pub struct ReplaceTimeZone;
72
73impl ScalarFnVTable for ReplaceTimeZone {
74 type Options = ReplaceTimeZoneOptions;
75
76 fn id(&self) -> ScalarFnId {
77 static ID: CachedId = CachedId::new("vortex.replace_time_zone");
78 *ID
79 }
80
81 fn serialize(&self, options: &Self::Options) -> VortexResult<Option<Vec<u8>>> {
82 Ok(Some(
83 pb::ReplaceTimeZoneOpts {
84 time_zone: options.time_zone.as_ref().map(ToString::to_string),
85 null_on_non_existent: options.null_on_non_existent,
86 }
87 .encode_to_vec(),
88 ))
89 }
90
91 fn deserialize(
92 &self,
93 metadata: &[u8],
94 _session: &VortexSession,
95 ) -> VortexResult<Self::Options> {
96 let options = pb::ReplaceTimeZoneOpts::decode(metadata)?;
97 Ok(ReplaceTimeZoneOptions {
98 time_zone: options.time_zone.map(Arc::from),
99 null_on_non_existent: options.null_on_non_existent,
100 })
101 }
102
103 fn arity(&self, _options: &Self::Options) -> Arity {
104 Arity::Exact(2)
105 }
106
107 fn child_name(&self, _options: &Self::Options, child_idx: usize) -> ChildName {
108 match child_idx {
109 0 => ChildName::from("input"),
110 1 => ChildName::from("ambiguous"),
111 _ => unreachable!("Invalid child index {child_idx} for replace_time_zone()"),
112 }
113 }
114
115 fn simplify(
116 &self,
117 options: &Self::Options,
118 expr: &BoundExpression,
119 ) -> VortexResult<Option<BoundExpression>> {
120 let (Some(value), Some(policy)) = (
121 expr.child(0).as_opt::<Literal>(),
122 expr.child(1).as_opt::<Literal>(),
123 ) else {
124 return Ok(None);
125 };
126 Ok(replace_scalar(options, value, policy).ok().map(bound::lit))
129 }
130
131 fn return_dtype(&self, options: &Self::Options, arg_dtypes: &[DType]) -> VortexResult<DType> {
132 let input = timestamp_options(&arg_dtypes[0])?;
133 if input.unit == TimeUnit::Days {
134 vortex_bail!("Timestamp cannot use day units");
135 }
136 if !matches!(arg_dtypes[1], DType::Utf8(_) | DType::Null) {
137 vortex_bail!(
138 "replace_time_zone() requires a UTF-8 ambiguity policy, got {}",
139 arg_dtypes[1]
140 );
141 }
142 resolve_zone(options.time_zone.as_deref())?;
143 Ok(DType::Extension(
145 Timestamp::new_with_tz(input.unit, options.time_zone.clone(), Nullability::Nullable)
146 .erased(),
147 ))
148 }
149
150 fn execute(
151 &self,
152 options: &Self::Options,
153 args: &dyn ExecutionArgs,
154 ctx: &mut ExecutionCtx,
155 ) -> VortexResult<ArrayRef> {
156 let input = args.get(0)?;
157 let ambiguous = args.get(1)?;
158 let dtype =
159 self.return_dtype(options, &[input.dtype().clone(), ambiguous.dtype().clone()])?;
160 let metadata = timestamp_options(input.dtype())?.clone();
161 let source = resolve_zone(metadata.tz.as_deref())?;
162 let target = resolve_zone(options.time_zone.as_deref())?;
163 let convert = |value, policy: &str| {
164 replace(
165 value,
166 metadata.unit,
167 &source,
168 &target,
169 policy,
170 options.null_on_non_existent,
171 )
172 };
173
174 if let (Some(value), Some(policy)) = (input.as_constant(), ambiguous.as_constant()) {
175 let scalar = replace_scalar(options, &value, &policy)?;
176 return Ok(ConstantArray::new(scalar, args.row_count()).into_array());
177 }
178
179 if matches!(ambiguous.dtype(), DType::Null) {
180 return Ok(ConstantArray::new(Scalar::null(dtype), args.row_count()).into_array());
181 }
182 let input = input.execute::<ExtensionArray>(ctx)?;
183 let storage = input
184 .storage_array()
185 .clone()
186 .execute::<PrimitiveArray>(ctx)?;
187 let values = storage.to_buffer::<i64>();
188 let valid = storage.validity()?.execute_mask(values.len(), ctx)?;
189 let policies = ambiguous.execute::<VarBinViewArray>(ctx)?;
190 let policies_valid = policies.validity()?.execute_mask(values.len(), ctx)?;
191 let mut output = BufferMut::<i64>::with_capacity(values.len());
192 let mut output_valid = BitBufferMut::with_capacity(values.len());
193 for (i, ((value, valid), policy_valid)) in values
194 .iter()
195 .zip(valid.iter())
196 .zip(policies_valid.iter())
197 .enumerate()
198 {
199 let replaced = if valid && policy_valid {
200 let bytes = policies.bytes_at(i);
201 let policy = unsafe { std::str::from_utf8_unchecked(&bytes) };
203 convert(*value, policy)?
204 } else {
205 None
206 };
207 output.push(replaced.unwrap_or_default());
208 output_valid.append(replaced.is_some());
209 }
210 let DType::Extension(ext) = dtype else {
211 unreachable!()
212 };
213 let storage = PrimitiveArray::new(
214 output.freeze(),
215 Validity::from_bit_buffer(output_valid.freeze(), Nullability::Nullable),
216 )
217 .into_array();
218 Ok(ExtensionArray::new(ext, storage).into_array())
219 }
220}
221
222fn replace_scalar(
224 options: &ReplaceTimeZoneOptions,
225 value: &Scalar,
226 policy: &Scalar,
227) -> VortexResult<Scalar> {
228 let DType::Extension(ext) =
229 ReplaceTimeZone.return_dtype(options, &[value.dtype().clone(), policy.dtype().clone()])?
230 else {
231 unreachable!("replace_time_zone() returns a timestamp")
232 };
233 let metadata = timestamp_options(value.dtype())?;
234 let result = if value.is_null() || policy.is_null() {
235 None
236 } else {
237 let storage = value.as_extension().to_storage_scalar();
238 let policy = policy
239 .as_utf8()
240 .value()
241 .ok_or_else(|| vortex_err!("Missing ambiguity policy"))?;
242 replace(
243 i64::try_from(&storage)?,
244 metadata.unit,
245 &resolve_zone(metadata.tz.as_deref())?,
246 &resolve_zone(options.time_zone.as_deref())?,
247 policy.as_str(),
248 options.null_on_non_existent,
249 )?
250 };
251 let storage = result.map_or_else(
252 || Scalar::null(DType::Primitive(PType::I64, Nullability::Nullable)),
253 |v| Scalar::primitive(v, Nullability::Nullable),
254 );
255 Ok(Scalar::extension_ref(ext, storage))
256}
257
258fn timestamp_options(dtype: &DType) -> VortexResult<&TimestampOptions> {
259 if let DType::Extension(ext) = dtype
260 && let Some(options) = ext.metadata_opt::<Timestamp>()
261 {
262 return Ok(options);
263 }
264 vortex_bail!("replace_time_zone() requires Timestamp, got {dtype}")
265}
266
267fn replace(
268 value: i64,
269 unit: TimeUnit,
270 source: &TimeZone,
271 target: &TimeZone,
272 policy: &str,
273 null_on_non_existent: bool,
274) -> VortexResult<Option<i64>> {
275 if !matches!(policy, "raise" | "earliest" | "latest" | "null") {
276 vortex_bail!(
277 "Invalid ambiguity policy {policy:?}: expected raise, earliest, latest, or null"
278 );
279 }
280 if source == target && policy == "raise" {
282 return Ok(Some(value));
283 }
284 let timestamp = match unit {
285 TimeUnit::Seconds => JiffTimestamp::from_second(value)?,
286 TimeUnit::Milliseconds => JiffTimestamp::from_millisecond(value)?,
287 TimeUnit::Microseconds => JiffTimestamp::from_microsecond(value)?,
288 TimeUnit::Nanoseconds => JiffTimestamp::from_nanosecond(i128::from(value))?,
289 TimeUnit::Days => vortex_bail!("Timestamp cannot use day units"),
290 };
291 let wall_time = source.to_datetime(timestamp);
292 let ambiguous = target.to_ambiguous_timestamp(wall_time);
293 let timestamp = match ambiguous.offset() {
294 AmbiguousOffset::Unambiguous { .. } => ambiguous.unambiguous()?,
295 AmbiguousOffset::Gap { .. } if null_on_non_existent => return Ok(None),
296 AmbiguousOffset::Gap { .. } => ambiguous.unambiguous()?,
297 AmbiguousOffset::Fold { .. } => match policy {
298 "earliest" => ambiguous.earlier()?,
299 "latest" => ambiguous.later()?,
300 "null" => return Ok(None),
301 _ => ambiguous.unambiguous()?,
302 },
303 };
304 Ok(Some(match unit {
305 TimeUnit::Seconds => timestamp.as_second(),
306 TimeUnit::Milliseconds => timestamp.as_millisecond(),
307 TimeUnit::Microseconds => timestamp.as_microsecond(),
308 TimeUnit::Nanoseconds => i64::try_from(timestamp.as_nanosecond())
309 .map_err(|e| vortex_err!("Timestamp overflow: {e}"))?,
310 TimeUnit::Days => unreachable!(),
311 }))
312}
313
314#[cfg(test)]
315mod tests {
316 use rstest::rstest;
317 use vortex_error::VortexResult;
318 use vortex_error::vortex_err;
319 use vortex_session::VortexSession;
320
321 use super::ReplaceTimeZone;
322 use super::ReplaceTimeZoneOptions;
323 use super::replace;
324 use super::resolve_zone;
325 use crate::IntoArray;
326 use crate::VortexSessionExecute;
327 use crate::array_session;
328 use crate::arrays::ExtensionArray;
329 use crate::arrays::PrimitiveArray;
330 use crate::arrays::StructArray;
331 use crate::arrays::VarBinViewArray;
332 use crate::assert_arrays_eq;
333 use crate::dtype::DType;
334 use crate::dtype::Nullability;
335 use crate::dtype::StructFields;
336 use crate::expr::get_item;
337 use crate::expr::lit;
338 use crate::expr::replace_time_zone;
339 use crate::expr::root;
340 use crate::extension::datetime::TimeUnit;
341 use crate::extension::datetime::Timestamp;
342 use crate::scalar::Scalar;
343 use crate::scalar_fn::ScalarFnVTable;
344 use crate::scalar_fn::fns::literal::Literal;
345
346 #[rstest]
347 #[case(TimeUnit::Seconds, 1)]
348 #[case(TimeUnit::Milliseconds, 1_000)]
349 #[case(TimeUnit::Microseconds, 1_000_000)]
350 #[case(TimeUnit::Nanoseconds, 1_000_000_000)]
351 fn preserves_units_and_fractional_values(
352 #[case] unit: TimeUnit,
353 #[case] scale: i64,
354 ) -> VortexResult<()> {
355 let utc = resolve_zone(None)?;
356 let new_york = resolve_zone(Some("America/New_York"))?;
357 let value = 1_705_320_000 * scale + scale / 2;
358 assert_eq!(
359 replace(value, unit, &utc, &new_york, "raise", false)?,
360 Some(value + 18_000 * scale)
361 );
362 assert_eq!(
363 replace(
364 value + 18_000 * scale,
365 unit,
366 &new_york,
367 &utc,
368 "raise",
369 false
370 )?,
371 Some(value)
372 );
373 Ok(())
374 }
375
376 #[test]
377 fn resolves_folds_and_gaps_separately() -> VortexResult<()> {
378 let utc = resolve_zone(None)?;
379 let new_york = resolve_zone(Some("America/New_York"))?;
380 let fold = "2024-11-03T01:30:00Z"
381 .parse::<jiff::Timestamp>()?
382 .as_second();
383 let gap = "2024-03-10T02:30:00Z"
384 .parse::<jiff::Timestamp>()?
385 .as_second();
386 let convert = |value, policy, null_on_non_existent| {
387 replace(
388 value,
389 TimeUnit::Seconds,
390 &utc,
391 &new_york,
392 policy,
393 null_on_non_existent,
394 )
395 };
396 assert_eq!(convert(fold, "earliest", false)?, Some(fold + 14_400));
397 assert_eq!(convert(fold, "latest", false)?, Some(fold + 18_000));
398 assert_eq!(convert(fold, "null", false)?, None);
399 assert!(convert(fold, "raise", false).is_err());
400 assert_eq!(convert(gap, "raise", true)?, None);
401 assert!(convert(gap, "earliest", false).is_err());
403 Ok(())
404 }
405
406 #[test]
407 fn rejects_invalid_policy_and_timestamp_overflow() -> VortexResult<()> {
408 let utc = resolve_zone(None)?;
409 assert!(replace(0, TimeUnit::Seconds, &utc, &utc, "invalid", false).is_err());
410 let new_york = resolve_zone(Some("America/New_York"))?;
411 assert!(
412 replace(
413 i64::MAX,
414 TimeUnit::Nanoseconds,
415 &utc,
416 &new_york,
417 "raise",
418 false
419 )
420 .is_err()
421 );
422 assert!(resolve_zone(Some("Not/A/Timezone")).is_err());
423 assert!(resolve_zone(Some("+99:00")).is_err());
424 assert!(resolve_zone(Some("-05:60")).is_err());
425 Ok(())
426 }
427
428 #[rstest]
429 #[case(None, false)]
430 #[case(Some("America/New_York"), true)]
431 fn options_round_trip(
432 #[case] time_zone: Option<&str>,
433 #[case] null_on_non_existent: bool,
434 ) -> VortexResult<()> {
435 let options = ReplaceTimeZoneOptions {
436 time_zone: time_zone.map(Into::into),
437 null_on_non_existent,
438 };
439 let metadata = ReplaceTimeZone.serialize(&options)?.unwrap();
440 assert_eq!(
441 ReplaceTimeZone.deserialize(&metadata, &VortexSession::empty())?,
442 options
443 );
444 Ok(())
445 }
446
447 #[rstest]
448 #[case(None, Some("UTC"), 0)]
449 #[case(Some("+01:00"), None, 3_600)]
450 #[case(Some("-05:30"), None, -19_800)]
451 #[case(None, Some("+05:30"), -19_800)]
452 #[case(Some("+01:00"), Some("-05:30"), 23_400)]
453 fn simplify_folds_literal_input(
454 #[case] source: Option<&str>,
455 #[case] target: Option<&str>,
456 #[case] offset_seconds: i64,
457 ) -> VortexResult<()> {
458 let timestamp = |tz: Option<&str>| {
459 Timestamp::new_with_tz(
460 TimeUnit::Microseconds,
461 tz.map(Into::into),
462 Nullability::Nullable,
463 )
464 .erased()
465 };
466 let micros = Scalar::primitive(1_704_153_600_000_000i64, Nullability::Nullable);
467 let expr = replace_time_zone(
468 lit(Scalar::extension_ref(timestamp(source), micros)),
469 lit("earliest"),
470 ReplaceTimeZoneOptions {
471 time_zone: target.map(Into::into),
472 null_on_non_existent: false,
473 },
474 );
475 let scope = DType::Struct(StructFields::empty(), Nullability::NonNullable);
476 let optimized = expr.bind(&scope)?.optimize()?;
477
478 let scalar = optimized
480 .as_opt::<Literal>()
481 .ok_or_else(|| vortex_err!("expected a bare literal, got {optimized}"))?;
482 assert_eq!(
483 scalar,
484 &Scalar::extension_ref(
485 timestamp(target),
486 Scalar::primitive(
487 1_704_153_600_000_000i64 + offset_seconds * 1_000_000,
488 Nullability::Nullable,
489 ),
490 )
491 );
492 assert_eq!(scalar.dtype(), &DType::Extension(timestamp(target)));
493 Ok(())
494 }
495
496 #[test]
497 fn applies_per_row_policies_and_nulls() -> VortexResult<()> {
498 let timestamp = |tz: Option<&str>| {
499 Timestamp::new_with_tz(
500 TimeUnit::Microseconds,
501 tz.map(Into::into),
502 Nullability::Nullable,
503 )
504 .erased()
505 };
506 let fold = 1_730_597_400_000_000i64;
508 let hour = 3_600_000_000i64;
509 let input = ExtensionArray::new(
510 timestamp(None),
511 PrimitiveArray::from_option_iter([
512 Some(fold),
513 Some(fold),
514 Some(fold),
515 Some(fold),
516 None,
517 ])
518 .into_array(),
519 );
520 let policies = VarBinViewArray::from_iter_nullable_str([
521 Some("earliest"),
522 Some("latest"),
523 Some("null"),
524 None,
525 Some("raise"),
526 ]);
527 let array =
528 StructArray::from_fields(&[("t", input.into_array()), ("p", policies.into_array())])?
529 .into_array();
530 let result = array.apply(&replace_time_zone(
531 get_item("t", root()),
532 get_item("p", root()),
533 ReplaceTimeZoneOptions {
534 time_zone: Some("America/New_York".into()),
535 null_on_non_existent: false,
536 },
537 ))?;
538
539 let expected = ExtensionArray::new(
540 timestamp(Some("America/New_York")),
541 PrimitiveArray::from_option_iter([
542 Some(fold + 4 * hour),
543 Some(fold + 5 * hour),
544 None,
545 None,
546 None,
547 ])
548 .into_array(),
549 );
550 let mut ctx = array_session().create_execution_ctx();
551 assert_arrays_eq!(result, expected, &mut ctx);
552 Ok(())
553 }
554}