Skip to content

Commit 5d84290

Browse files
authored
Return errors for invalid temporal types and values (#11015)
## Summary Validates temporal dtypes and returns errors from fallible scalar and import paths for invalid metadata or out-of-range values. Keeps the current timestamp range limits. #10953 proposes a wider range, so the two approaches can be reviewed together. ## Changes Checks Date/Time storage and Timestamp metadata, uses fallible Span setters, and covers Arrow, DataFusion, Python, and JNI boundaries. Pinned formatting and five-crate Clippy pass, along with 6,412 Rust tests (1 skipped), 73 Python tests, and 38 Java tests. Python type checking, Ruff, and Spotless pass. ## API Changes Adds `Timestamp::try_new_with_tz` and rejects Timestamp `Days` and unresolved time zones at construction. Existing convenience constructors retain a documented panic contract. --------- Signed-off-by: Connor Tsui <connor.tsui20@gmail.com>
1 parent 4c39dfd commit 5d84290

18 files changed

Lines changed: 218 additions & 77 deletions

File tree

‎java/vortex-jni/src/test/java/dev/vortex/api/ExpressionTagParityTest.java‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ public void timestampAcceptsEveryUnitExceptDays() {
164164
RuntimeException exception =
165165
assertThrows(RuntimeException.class, () -> Expression.literalTimestamp(0L, TimeUnit.DAYS, "UTC"));
166166
assertTrue(
167-
exception.getMessage().contains("Timestamp does not support Days time unit"),
167+
exception.getMessage().contains("Timestamp time unit must be finer than days, got Days"),
168168
() -> "unexpected message: " + exception.getMessage());
169169
}
170170

‎java/vortex-jni/src/test/java/dev/vortex/api/ExpressionTest.java‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,25 @@ public static void loadLibrary() {
2121
NativeLoader.loadJni();
2222
}
2323

24+
@Test
25+
public void timestampLiteralsRejectInvalidMetadata() {
26+
assertThrows(
27+
RuntimeException.class,
28+
() -> Expression.literalTimestamp(0L, Expression.TimeUnit.SECONDS, "Not/A/Timezone"));
29+
assertThrows(
30+
RuntimeException.class,
31+
() -> Expression.nullLiteralTimestamp(Expression.TimeUnit.SECONDS, "Not/A/Timezone"));
32+
assertThrows(RuntimeException.class, () -> Expression.nullLiteralTimestamp(Expression.TimeUnit.DAYS, null));
33+
}
34+
35+
@Test
36+
public void timestampLiteralsRejectOutOfRangeValues() {
37+
assertThrows(
38+
RuntimeException.class,
39+
() -> Expression.literalTimestamp(Long.MAX_VALUE, Expression.TimeUnit.SECONDS, null));
40+
assertNotNull(Expression.literalTimestamp(0L, Expression.TimeUnit.SECONDS, "UTC"));
41+
}
42+
2443
@Test
2544
public void rowIdxBuildsAndComposes() {
2645
assertNotNull(Expression.rowIdx());

‎vortex-array/src/arrays/datetime/mod.rs‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -100,14 +100,12 @@ impl TemporalData {
100100
}
101101
}
102102

103-
/// Create a new `TemporalData` holding Arrow spec compliant Timestamp data, with an
104-
/// optional timezone.
103+
/// Creates a timestamp array with the given unit and optional time zone.
105104
///
106105
/// # Panics
107106
///
108-
/// If `array` does not hold Primitive i64 data, the function will panic.
109-
///
110-
/// If the time_unit is days, the function will panic.
107+
/// Panics if `array` does not have primitive i64 storage, or if
108+
/// [`Timestamp::try_new_with_tz`] rejects the unit or time zone.
111109
pub fn new_timestamp(
112110
array: ArrayRef,
113111
time_unit: TimeUnit,

‎vortex-array/src/dtype/dtype_impl.rs‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -606,7 +606,8 @@ mod tests {
606606
Timestamp::new_with_tz(TimeUnit::Seconds, Some("UTC".into()), Nullable).erased(),
607607
);
608608
let t2 = DType::Extension(
609-
Timestamp::new_with_tz(TimeUnit::Seconds, Some("ET".into()), Nullable).erased(),
609+
Timestamp::new_with_tz(TimeUnit::Seconds, Some("America/New_York".into()), Nullable)
610+
.erased(),
610611
);
611612
assert!(!t1.eq_ignore_nullability(&t2));
612613
}

‎vortex-array/src/dtype/serde/proto.rs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ mod tests {
456456
#[test]
457457
fn test_extension_round_trip() {
458458
let ext_dtype =
459-
DType::Extension(Timestamp::new(TimeUnit::Days, Nullability::Nullable).erased());
459+
DType::Extension(Timestamp::new(TimeUnit::Seconds, Nullability::Nullable).erased());
460460
let converted = round_trip_dtype(&ext_dtype);
461461
assert_eq!(ext_dtype, converted);
462462
}

‎vortex-array/src/extension/datetime/date.rs‎

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,12 +100,13 @@ impl ExtVTable for Date {
100100
let ptype = date_ptype(metadata)
101101
.ok_or_else(|| vortex_err!("Date type does not support time unit {}", metadata))?;
102102

103-
vortex_ensure_eq!(
104-
ext_dtype.storage_dtype().as_ptype(),
105-
ptype,
106-
"Date storage dtype for {} has the wrong ptype",
107-
metadata
108-
);
103+
let DType::Primitive(storage_ptype, _) = ext_dtype.storage_dtype() else {
104+
vortex_bail!(
105+
"Date storage dtype must be primitive, got {}",
106+
ext_dtype.storage_dtype()
107+
);
108+
};
109+
vortex_ensure_eq!(*storage_ptype, ptype);
109110

110111
Ok(())
111112
}
@@ -131,12 +132,18 @@ mod tests {
131132

132133
use crate::dtype::DType;
133134
use crate::dtype::Nullability::Nullable;
135+
use crate::dtype::extension::ExtDType;
134136
use crate::extension::datetime::Date;
135137
use crate::extension::datetime::TimeUnit;
136138
use crate::scalar::PValue;
137139
use crate::scalar::Scalar;
138140
use crate::scalar::ScalarValue;
139141

142+
#[test]
143+
fn test_reject_non_primitive_storage() {
144+
assert!(ExtDType::<Date>::try_new(TimeUnit::Days, DType::Utf8(Nullable),).is_err());
145+
}
146+
140147
#[test]
141148
fn validate_date_scalar() -> VortexResult<()> {
142149
let days_dtype = DType::Extension(Date::new(TimeUnit::Days, Nullable).erased());

‎vortex-array/src/extension/datetime/time.rs‎

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -101,12 +101,13 @@ impl ExtVTable for Time {
101101
let ptype = time_ptype(metadata)
102102
.ok_or_else(|| vortex_err!("Time type does not support time unit {}", metadata))?;
103103

104-
vortex_ensure_eq!(
105-
ext_dtype.storage_dtype().as_ptype(),
106-
ptype,
107-
"Time storage dtype for {} has the wrong ptype",
108-
metadata
109-
);
104+
let DType::Primitive(storage_ptype, _) = ext_dtype.storage_dtype() else {
105+
vortex_bail!(
106+
"Time storage dtype must be primitive, got {}",
107+
ext_dtype.storage_dtype()
108+
);
109+
};
110+
vortex_ensure_eq!(*storage_ptype, ptype);
110111

111112
Ok(())
112113
}
@@ -121,19 +122,19 @@ impl ExtVTable for Time {
121122
TimeUnit::Seconds => {
122123
let v = i32::try_from(length_of_time)
123124
.map_err(|e| vortex_err!("Time seconds value out of i32 range: {e}"))?;
124-
(Span::new().seconds(v), TimeValue::Seconds(v))
125+
(Span::new().try_seconds(v)?, TimeValue::Seconds(v))
125126
}
126127
TimeUnit::Milliseconds => {
127128
let v = i32::try_from(length_of_time)
128129
.map_err(|e| vortex_err!("Time milliseconds value out of i32 range: {e}"))?;
129-
(Span::new().milliseconds(v), TimeValue::Milliseconds(v))
130+
(Span::new().try_milliseconds(v)?, TimeValue::Milliseconds(v))
130131
}
131132
TimeUnit::Microseconds => (
132-
Span::new().microseconds(length_of_time),
133+
Span::new().try_microseconds(length_of_time)?,
133134
TimeValue::Microseconds(length_of_time),
134135
),
135136
TimeUnit::Nanoseconds => (
136-
Span::new().nanoseconds(length_of_time),
137+
Span::new().try_nanoseconds(length_of_time)?,
137138
TimeValue::Nanoseconds(length_of_time),
138139
),
139140
d @ TimeUnit::Days => {
@@ -152,16 +153,31 @@ impl ExtVTable for Time {
152153

153154
#[cfg(test)]
154155
mod tests {
156+
use rstest::rstest;
155157
use vortex_error::VortexResult;
156158

157159
use crate::dtype::DType;
158160
use crate::dtype::Nullability::Nullable;
161+
use crate::dtype::extension::ExtDType;
159162
use crate::extension::datetime::Time;
160163
use crate::extension::datetime::TimeUnit;
161164
use crate::scalar::PValue;
162165
use crate::scalar::Scalar;
163166
use crate::scalar::ScalarValue;
164167

168+
#[rstest]
169+
#[case::microseconds(TimeUnit::Microseconds, i64::MAX)]
170+
#[case::nanoseconds(TimeUnit::Nanoseconds, i64::MIN)]
171+
fn test_reject_time_span_out_of_range(#[case] unit: TimeUnit, #[case] value: i64) {
172+
let dtype = DType::Extension(Time::new(unit, Nullable).erased());
173+
assert!(Scalar::try_new(dtype, Some(ScalarValue::from(value))).is_err());
174+
}
175+
176+
#[test]
177+
fn test_reject_non_primitive_storage() {
178+
assert!(ExtDType::<Time>::try_new(TimeUnit::Seconds, DType::Utf8(Nullable),).is_err());
179+
}
180+
165181
#[test]
166182
fn validate_time_scalar() -> VortexResult<()> {
167183
// 3661 seconds = 1 hour, 1 minute, 1 second.

‎vortex-array/src/extension/datetime/timestamp.rs‎

Lines changed: 62 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -31,31 +31,56 @@ use crate::scalar::ScalarValue;
3131
pub struct Timestamp;
3232

3333
impl Timestamp {
34-
/// Creates a new Timestamp extension =dtype with the given time unit and nullability.
34+
/// Creates a timestamp dtype with the given time unit and nullability.
35+
///
36+
/// # Panics
37+
///
38+
/// Panics if `time_unit` is [`TimeUnit::Days`].
3539
pub fn new(time_unit: TimeUnit, nullability: Nullability) -> ExtDType<Self> {
3640
Self::new_with_tz(time_unit, None, nullability)
3741
}
3842

39-
/// Creates a new Timestamp extension dtype with the given time unit, timezone, and nullability.
40-
pub fn new_with_tz(
43+
/// Creates a timestamp dtype with the given time unit, time zone, and nullability.
44+
///
45+
/// # Errors
46+
///
47+
/// Returns an error for [`TimeUnit::Days`] or an unresolved time zone. IANA names and fixed
48+
/// UTC offsets are accepted.
49+
pub fn try_new_with_tz(
4150
time_unit: TimeUnit,
4251
timezone: Option<Arc<str>>,
4352
nullability: Nullability,
44-
) -> ExtDType<Self> {
53+
) -> VortexResult<ExtDType<Self>> {
4554
ExtDType::try_new(
4655
TimestampOptions {
4756
unit: time_unit,
4857
tz: timezone,
4958
},
5059
DType::Primitive(PType::I64, nullability),
5160
)
52-
.vortex_expect("failed to create timestamp dtype")
5361
}
5462

55-
/// Creates a new `Timestamp` extension dtype with the given options and nullability.
63+
/// Creates a timestamp dtype with the given time unit, time zone, and nullability.
64+
///
65+
/// # Panics
66+
///
67+
/// Panics if [`Self::try_new_with_tz`] rejects the unit or time zone.
68+
pub fn new_with_tz(
69+
time_unit: TimeUnit,
70+
timezone: Option<Arc<str>>,
71+
nullability: Nullability,
72+
) -> ExtDType<Self> {
73+
Self::try_new_with_tz(time_unit, timezone, nullability)
74+
.vortex_expect("timestamp unit and time zone must be valid")
75+
}
76+
77+
/// Creates a timestamp dtype with the given options and nullability.
78+
///
79+
/// # Panics
80+
///
81+
/// Panics if [`Self::try_new_with_tz`] rejects the unit or time zone.
5682
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")
83+
Self::new_with_tz(options.unit, options.tz, nullability)
5984
}
6085
}
6186

@@ -200,6 +225,13 @@ impl ExtVTable for Timestamp {
200225
matches!(ext_dtype.storage_dtype(), DType::Primitive(PType::I64, _)),
201226
"Timestamp storage dtype must be i64"
202227
);
228+
vortex_ensure!(
229+
ext_dtype.metadata().unit != TimeUnit::Days,
230+
"Timestamp time unit must be finer than days, got {:?}",
231+
ext_dtype.metadata().unit
232+
);
233+
resolve_time_zone(ext_dtype.metadata().tz.as_deref())?;
234+
203235
Ok(())
204236
}
205237

@@ -213,19 +245,19 @@ impl ExtVTable for Timestamp {
213245

214246
let (span, value) = match metadata.unit {
215247
TimeUnit::Nanoseconds => (
216-
Span::new().nanoseconds(ts_value),
248+
Span::new().try_nanoseconds(ts_value)?,
217249
TimestampValue::Nanoseconds(ts_value, tz),
218250
),
219251
TimeUnit::Microseconds => (
220-
Span::new().microseconds(ts_value),
252+
Span::new().try_microseconds(ts_value)?,
221253
TimestampValue::Microseconds(ts_value, tz),
222254
),
223255
TimeUnit::Milliseconds => (
224-
Span::new().milliseconds(ts_value),
256+
Span::new().try_milliseconds(ts_value)?,
225257
TimestampValue::Milliseconds(ts_value, tz),
226258
),
227259
TimeUnit::Seconds => (
228-
Span::new().seconds(ts_value),
260+
Span::new().try_seconds(ts_value)?,
229261
TimestampValue::Seconds(ts_value, tz),
230262
),
231263
TimeUnit::Days => {
@@ -238,11 +270,6 @@ impl ExtVTable for Timestamp {
238270
.checked_add(span)
239271
.map_err(|e| vortex_err!("Invalid timestamp scalar: {}", e))?;
240272

241-
if let Some(tz) = tz {
242-
resolve_time_zone(Some(tz))
243-
.map_err(|e| e.with_context("Invalid timezone for timestamp scalar"))?;
244-
}
245-
246273
Ok(value)
247274
}
248275
}
@@ -289,16 +316,29 @@ mod tests {
289316
#[cfg_attr(miri, ignore)]
290317
#[test]
291318
fn reject_timestamp_with_invalid_timezone() {
292-
let dtype = DType::Extension(
293-
Timestamp::new_with_tz(
319+
assert!(
320+
Timestamp::try_new_with_tz(
294321
TimeUnit::Seconds,
295322
Some(Arc::from("Not/A/Timezone")),
296323
Nullable,
297324
)
298-
.erased(),
325+
.is_err()
299326
);
300-
let result = Scalar::try_new(dtype, Some(ScalarValue::Primitive(PValue::I64(0))));
301-
assert!(result.is_err());
327+
}
328+
329+
#[test]
330+
fn test_reject_timestamp_days() {
331+
assert!(Timestamp::try_new_with_tz(TimeUnit::Days, None, Nullable).is_err());
332+
}
333+
334+
#[rstest]
335+
#[case::seconds(TimeUnit::Seconds, i64::MAX)]
336+
#[case::milliseconds(TimeUnit::Milliseconds, i64::MAX)]
337+
#[case::microseconds(TimeUnit::Microseconds, i64::MAX)]
338+
#[case::nanoseconds(TimeUnit::Nanoseconds, i64::MIN)]
339+
fn test_reject_timestamp_span_out_of_range(#[case] unit: TimeUnit, #[case] value: i64) {
340+
let dtype = DType::Extension(Timestamp::new(unit, Nullable).erased());
341+
assert!(Scalar::try_new(dtype, Some(ScalarValue::from(value))).is_err());
302342
}
303343

304344
#[cfg_attr(miri, ignore)]

‎vortex-array/src/scalar/tests/nullability.rs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,15 @@ mod tests {
2626
DType::Bool(Nullability::Nullable),
2727
DType::Primitive(PType::I32, Nullability::Nullable),
2828
DType::Extension(Date::new(TimeUnit::Days, Nullability::Nullable).erased()),
29-
DType::Extension(Timestamp::new(TimeUnit::Days, Nullability::Nullable).erased()),
29+
DType::Extension(Timestamp::new(TimeUnit::Seconds, Nullability::Nullable).erased()),
3030
)]
3131
source_dtype: DType,
3232
#[values(
3333
DType::Null,
3434
DType::Bool(Nullability::Nullable),
3535
DType::Primitive(PType::I32, Nullability::Nullable),
3636
DType::Extension(Date::new(TimeUnit::Days, Nullability::Nullable).erased()),
37-
DType::Extension(Timestamp::new(TimeUnit::Days, Nullability::Nullable).erased()),
37+
DType::Extension(Timestamp::new(TimeUnit::Seconds, Nullability::Nullable).erased()),
3838
)]
3939
target_dtype: DType,
4040
) {

0 commit comments

Comments
 (0)