Skip to content

Commit 2c3048c

Browse files
committed
Require matching dtypes for extension comparisons
Signed-off-by: Connor Tsui <connor.tsui20@gmail.com>
1 parent 7387df0 commit 2c3048c

17 files changed

Lines changed: 889 additions & 114 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎encodings/datetime-parts/src/compute/compare.rs‎

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use vortex_array::scalar_fn::fns::operators::CompareOperator;
1818
use vortex_array::scalar_fn::fns::operators::Operator;
1919
use vortex_array::validity::Validity;
2020
use vortex_error::VortexResult;
21+
use vortex_error::vortex_ensure;
2122
use vortex_mask::AllOr;
2223

2324
use crate::array::DateTimeParts;
@@ -31,6 +32,13 @@ impl CompareKernel for DateTimeParts {
3132
operator: CompareOperator,
3233
ctx: &mut ExecutionCtx,
3334
) -> VortexResult<Option<ArrayRef>> {
35+
vortex_ensure!(
36+
lhs.dtype().eq_ignore_nullability(rhs.dtype()),
37+
"Comparison requires equal dtypes ignoring nullability, got {} and {}",
38+
lhs.dtype(),
39+
rhs.dtype()
40+
);
41+
3442
let Some(rhs_const) = rhs.as_constant() else {
3543
return Ok(None);
3644
};
@@ -284,7 +292,7 @@ fn compare_dtp(
284292
}
285293

286294
#[cfg(test)]
287-
mod test {
295+
mod tests {
288296
use std::sync::LazyLock;
289297

290298
use rstest::rstest;
@@ -303,6 +311,8 @@ mod test {
303311
use vortex_array::extension::datetime::TimestampOptions;
304312
use vortex_array::scalar::Scalar;
305313
use vortex_array::scalar_fn::fns::binary::CompareKernel;
314+
use vortex_array::test_harness::ExpectedOutcome;
315+
use vortex_array::test_harness::assert_execution_paths;
306316
use vortex_array::validity::Validity;
307317
use vortex_buffer::buffer;
308318
use vortex_session::VortexSession;
@@ -683,6 +693,49 @@ mod test {
683693
assert_matches_temporal(nullable, unit, constants)
684694
}
685695

696+
#[rstest]
697+
#[case::storage(None)]
698+
#[case::different_unit(Some(TimeUnit::Nanoseconds))]
699+
fn test_comparison_rejects_mismatched_constant_dtype(
700+
#[case] unit: Option<TimeUnit>,
701+
) -> VortexResult<()> {
702+
let mut ctx = SESSION.create_execution_ctx();
703+
let temporal = TemporalArray::new_timestamp(
704+
buffer![1600i64, 1400].into_array(),
705+
TimeUnit::Milliseconds,
706+
None,
707+
);
708+
let array = DateTimeParts::try_from_temporal(temporal, &mut ctx)?;
709+
let scalar = match unit {
710+
Some(unit) => Scalar::extension::<Timestamp>(
711+
TimestampOptions { unit, tz: None },
712+
Scalar::from(1_500_000_000i64),
713+
),
714+
None => Scalar::from(1500i64),
715+
};
716+
let rhs = ConstantArray::new(scalar, array.len()).into_array();
717+
718+
for operator in ALL_OPERATORS {
719+
assert!(
720+
<DateTimeParts as CompareKernel>::compare(
721+
array.as_view(),
722+
&rhs,
723+
operator,
724+
&mut ctx,
725+
)
726+
.is_err()
727+
);
728+
assert_execution_paths(
729+
&[array.clone().into_array(), rhs.clone()],
730+
|inputs| inputs[0].binary(inputs[1].clone(), operator.into()),
731+
ExpectedOutcome::Error,
732+
&mut ctx,
733+
);
734+
}
735+
736+
Ok(())
737+
}
738+
686739
#[test]
687740
fn null_rows_stay_null_when_parts_differ() -> VortexResult<()> {
688741
let mut ctx = SESSION.create_execution_ctx();

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

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ use vortex_session::VortexSession;
1212
use crate::EqMode;
1313
use crate::IntoArray;
1414
use crate::VortexSessionExecute;
15-
use crate::arrays::BoolArray;
1615
use crate::arrays::PrimitiveArray;
1716
use crate::arrays::datetime::TemporalData;
1817
use crate::assert_arrays_eq;
@@ -218,7 +217,7 @@ fn test_validity_preservation(#[case] validity: Validity) {
218217
}
219218

220219
#[test]
221-
fn comparison_rejects_mismatched_timestamp_units() -> VortexResult<()> {
220+
fn test_comparison_rejects_mismatched_timestamp_units_at_binding() -> VortexResult<()> {
222221
let ts_array = PrimitiveArray::from_iter(vec![1704067200000i64, 1704153600000, 1704240000000])
223222
.into_array();
224223
let temporal = TemporalData::new_timestamp(ts_array, TimeUnit::Milliseconds, None);
@@ -236,13 +235,12 @@ fn comparison_rejects_mismatched_timestamp_units() -> VortexResult<()> {
236235

237236
let error = temporal
238237
.into_array()
239-
.apply(&filter_expr)?
240-
.execute::<BoolArray>(&mut SESSION.create_execution_ctx())
241-
.unwrap_err();
238+
.apply(&filter_expr)
239+
.expect_err("comparisons require matching timestamp units when the expression is bound");
242240
assert!(
243241
error
244242
.to_string()
245-
.contains("Cannot compare extension dtypes")
243+
.contains("Comparison requires equal dtypes ignoring nullability")
246244
);
247245

248246
Ok(())

‎vortex-array/src/arrays/extension/compute/rules.rs‎

Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ mod tests {
247247
}
248248

249249
#[test]
250-
fn test_scalar_fn_no_pushdown_different_ext_types() {
250+
fn test_comparison_rejects_different_extension_types_at_binding() {
251251
#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)]
252252
struct TestExt2;
253253
impl ExtVTable for TestExt2 {
@@ -289,21 +289,15 @@ mod tests {
289289
let storage = buffer![10i64, 20, 30].into_array();
290290
let ext_array = ExtensionArray::new(ext_dtype1, storage).into_array();
291291

292-
// Create constant with different extension type
293292
let const_scalar = Scalar::extension::<TestExt2>(EmptyMetadata, Scalar::from(25i64));
294293
let const_array = ConstantArray::new(const_scalar, 3).into_array();
295294

296-
let scalar_fn_array = Binary::try_new(ext_array, const_array, Operator::Lt)
297-
.unwrap()
298-
.into_array();
299-
300-
let optimized = scalar_fn_array.optimize().unwrap();
301-
302-
// The first child should still be an ExtensionArray (no pushdown happened)
303-
let scalar_fn = optimized.as_opt::<ScalarFn>().unwrap();
295+
let error = Binary::try_new(ext_array, const_array, Operator::Lt)
296+
.expect_err("comparisons require matching extension dtypes at binding");
304297
assert!(
305-
scalar_fn.children()[0].as_opt::<Extension>().is_some(),
306-
"Expected first child to remain ExtensionArray when ext types differ"
298+
error
299+
.to_string()
300+
.contains("Comparison requires equal dtypes ignoring nullability")
307301
);
308302
}
309303

@@ -333,25 +327,19 @@ mod tests {
333327
}
334328

335329
#[test]
336-
fn test_scalar_fn_no_pushdown_non_extension_constant() {
330+
fn test_comparison_rejects_storage_constant_at_binding() {
337331
let ext_dtype = test_ext_dtype();
338332
let storage = buffer![10i64, 20, 30].into_array();
339333
let ext_array = ExtensionArray::new(ext_dtype, storage).into_array();
340334

341-
// Create a non-extension constant (plain primitive)
342335
let const_array = ConstantArray::new(Scalar::from(25i64), 3).into_array();
343336

344-
let scalar_fn_array = Binary::try_new(ext_array, const_array, Operator::Lt)
345-
.unwrap()
346-
.into_array();
347-
348-
let optimized = scalar_fn_array.optimize().unwrap();
349-
350-
// No pushdown should happen because constant is not an extension scalar
351-
let scalar_fn = optimized.as_opt::<ScalarFn>().unwrap();
337+
let error = Binary::try_new(ext_array, const_array, Operator::Lt)
338+
.expect_err("an extension cannot be compared with its storage dtype at binding");
352339
assert!(
353-
scalar_fn.children()[0].as_opt::<Extension>().is_some(),
354-
"Expected first child to remain ExtensionArray when constant is not extension"
340+
error
341+
.to_string()
342+
.contains("Comparison requires equal dtypes ignoring nullability")
355343
);
356344
}
357345
}

‎vortex-array/src/scalar_fn/fns/binary/compare/mod.rs‎

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use std::cmp::Ordering;
1515

1616
use vortex_error::VortexResult;
1717
use vortex_error::vortex_bail;
18+
use vortex_error::vortex_ensure;
1819
use vortex_error::vortex_err;
1920

2021
use crate::ArrayRef;
@@ -101,6 +102,8 @@ where
101102
_ => return Ok(None),
102103
};
103104

105+
validate_comparison_dtypes(array.dtype(), other.dtype())?;
106+
104107
let len = array.len();
105108
let nullable = array.dtype().is_nullable() || other.dtype().is_nullable();
106109

@@ -133,6 +136,8 @@ pub(crate) fn execute_compare(
133136
op: CompareOperator,
134137
ctx: &mut ExecutionCtx,
135138
) -> VortexResult<ArrayRef> {
139+
validate_comparison_dtypes(lhs.dtype(), rhs.dtype())?;
140+
136141
let nullable = lhs.dtype().is_nullable() || rhs.dtype().is_nullable();
137142

138143
if lhs.len() != rhs.len() {
@@ -174,32 +179,14 @@ fn compare_arrays(
174179
) -> VortexResult<ArrayRef> {
175180
let nullability = Nullability::from(lhs.dtype().is_nullable() || rhs.dtype().is_nullable());
176181

177-
// Extension arrays compare through their storage. When both sides are extensions they must
178-
// agree on the full extension dtype (a timestamp in milliseconds must not compare its raw
179-
// storage against one in seconds); a lone extension side may compare against its raw storage.
180-
if lhs.dtype().is_extension() || rhs.dtype().is_extension() {
181-
if lhs.dtype().is_extension()
182-
&& rhs.dtype().is_extension()
183-
&& !lhs.dtype().eq_ignore_nullability(rhs.dtype())
184-
{
185-
vortex_bail!(
186-
"Cannot compare extension dtypes {} and {}",
187-
lhs.dtype(),
188-
rhs.dtype()
189-
);
190-
}
182+
if lhs.dtype().is_extension() {
191183
let lhs = extension_storage(lhs, ctx)?;
192184
let rhs = extension_storage(rhs, ctx)?;
185+
193186
return compare_arrays(&lhs, &rhs, op, ctx);
194187
}
195188

196-
if !lhs.dtype().eq_ignore_nullability(rhs.dtype()) {
197-
vortex_bail!(
198-
"Cannot compare different DTypes {} and {}",
199-
lhs.dtype(),
200-
rhs.dtype()
201-
);
202-
}
189+
validate_comparison_dtypes(lhs.dtype(), rhs.dtype())?;
203190

204191
match lhs.dtype() {
205192
// Every value of the null dtype is null, so every comparison result is null.
@@ -221,12 +208,10 @@ fn compare_arrays(
221208
}
222209
}
223210

224-
/// Replace an extension array (or extension constant) with its storage. Non-extension arrays are
225-
/// returned unchanged.
211+
/// Returns the storage of an extension array or constant.
212+
///
213+
/// The caller must first require matching extension dtypes, ignoring nullability.
226214
fn extension_storage(array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<ArrayRef> {
227-
if !array.dtype().is_extension() {
228-
return Ok(array.clone());
229-
}
230215
if let Some(constant) = array.as_opt::<Constant>() {
231216
return Ok(ConstantArray::new(
232217
constant.scalar().as_extension().to_storage_scalar(),
@@ -238,7 +223,12 @@ fn extension_storage(array: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<A
238223
Ok(ext.storage_array().clone())
239224
}
240225

226+
/// Compares scalars with matching dtypes, ignoring nullability.
227+
///
228+
/// Returns null if either operand is null, and an error if the operand dtypes differ.
241229
pub fn scalar_cmp(lhs: &Scalar, rhs: &Scalar, operator: CompareOperator) -> VortexResult<Scalar> {
230+
validate_comparison_dtypes(lhs.dtype(), rhs.dtype())?;
231+
242232
if lhs.is_null() | rhs.is_null() {
243233
return Ok(Scalar::null(DType::Bool(Nullability::Nullable)));
244234
}
@@ -266,6 +256,16 @@ pub fn scalar_cmp(lhs: &Scalar, rhs: &Scalar, operator: CompareOperator) -> Vort
266256
Ok(Scalar::bool(b, nullability))
267257
}
268258

259+
/// Checks the shared dtype requirement before comparison fast paths or encoding kernels run.
260+
pub(super) fn validate_comparison_dtypes(lhs: &DType, rhs: &DType) -> VortexResult<()> {
261+
vortex_ensure!(
262+
lhs.eq_ignore_nullability(rhs),
263+
"Comparison requires equal dtypes ignoring nullability, got {lhs} and {rhs}"
264+
);
265+
266+
Ok(())
267+
}
268+
269269
/// Returns the predicate `Ordering -> bool` for a comparison operator.
270270
#[inline]
271271
pub(super) fn ordering_predicate(op: CompareOperator) -> fn(Ordering) -> bool {

0 commit comments

Comments
 (0)