Skip to content

Commit f2c669e

Browse files
committed
Change extension dtype nullability without validating again
An extension type cannot restrict whether its values can be null, so `ExtVTable::validate_dtype` must not depend on the top-level nullability of the storage dtype. `ExtDType::with_nullability` now relies on that contract and rebuilds the dtype without validating again, so nullability changes in `take`, `mask` and `min_max` cannot panic. Also fixes `Debug for ExtDTypeRef`, which printed the metadata before the struct and then `metadata: Ok(())`. Signed-off-by: Connor Tsui <connor.tsui20@gmail.com>
1 parent 493967c commit f2c669e

5 files changed

Lines changed: 142 additions & 11 deletions

File tree

‎vortex-array/src/dtype/extension/erased.rs‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -180,11 +180,9 @@ impl Hash for ExtDTypeRef {
180180

181181
impl fmt::Debug for ExtDTypeRef {
182182
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
183-
let metadata = self.0.metadata_debug(f);
184-
185183
f.debug_struct("ExtDType")
186184
.field("id", &self.id())
187-
.field("metadata", &metadata)
185+
.field("metadata", &MetadataDebug(&*self.0))
188186
.field("storage_dtype", &self.storage_dtype())
189187
.finish()
190188
}
@@ -206,6 +204,13 @@ impl fmt::Display for ExtDTypeRef {
206204

207205
// Private formatting helpers for Display and Debug impls.
208206

207+
struct MetadataDebug<'a>(&'a dyn DynExtDType);
208+
impl fmt::Debug for MetadataDebug<'_> {
209+
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
210+
self.0.metadata_debug(f)
211+
}
212+
}
213+
209214
struct MetadataDisplay<'a>(&'a dyn DynExtDType);
210215
impl fmt::Display for MetadataDisplay<'_> {
211216
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {

‎vortex-array/src/dtype/extension/mod.rs‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,6 @@ mod sealed {
5555
/// This can be the **only** implementor for [`super::typed::DynExtDType`].
5656
impl<V: ExtVTable> Sealed for ExtDType<V> {}
5757
}
58+
59+
#[cfg(test)]
60+
mod tests;
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
// SPDX-FileCopyrightText: Copyright the Vortex contributors
3+
4+
//! Regression tests for extension dtype nullability changes and debug output.
5+
6+
use std::sync::atomic::AtomicUsize;
7+
use std::sync::atomic::Ordering;
8+
9+
use rstest::rstest;
10+
use vortex_error::VortexResult;
11+
use vortex_error::vortex_err;
12+
use vortex_session::registry::CachedId;
13+
14+
use super::ExtDType;
15+
use super::ExtId;
16+
use super::ExtVTable;
17+
use crate::dtype::DType;
18+
use crate::dtype::Nullability;
19+
use crate::dtype::PType;
20+
use crate::extension::datetime::TimeUnit;
21+
use crate::extension::datetime::Timestamp;
22+
use crate::scalar::ScalarValue;
23+
24+
/// The number of times [`CountingValidator::validate_dtype`] ran.
25+
static VALIDATIONS: AtomicUsize = AtomicUsize::new(0);
26+
27+
/// An extension type that counts how often its dtype is validated.
28+
#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
29+
struct CountingValidator;
30+
31+
impl ExtVTable for CountingValidator {
32+
type Metadata = u32;
33+
type NativeValue<'a> = &'a ScalarValue;
34+
35+
fn id(&self) -> ExtId {
36+
static ID: CachedId = CachedId::new("test.counting_validator");
37+
*ID
38+
}
39+
40+
fn serialize_metadata(&self, metadata: &Self::Metadata) -> VortexResult<Vec<u8>> {
41+
Ok(metadata.to_le_bytes().to_vec())
42+
}
43+
44+
fn deserialize_metadata(&self, metadata: &[u8]) -> VortexResult<Self::Metadata> {
45+
let bytes = metadata
46+
.try_into()
47+
.map_err(|_| vortex_err!("Expected four metadata bytes, got {}", metadata.len()))?;
48+
49+
Ok(u32::from_le_bytes(bytes))
50+
}
51+
52+
fn validate_dtype(_ext_dtype: &ExtDType<Self>) -> VortexResult<()> {
53+
VALIDATIONS.fetch_add(1, Ordering::Relaxed);
54+
Ok(())
55+
}
56+
57+
fn unpack_native<'a>(
58+
_ext_dtype: &'a ExtDType<Self>,
59+
storage_value: &'a ScalarValue,
60+
) -> VortexResult<Self::NativeValue<'a>> {
61+
Ok(storage_value)
62+
}
63+
}
64+
65+
// One test owns `VALIDATIONS`, so test threads cannot race on the counter.
66+
#[test]
67+
fn test_nullability_changes_do_not_validate_again() -> VortexResult<()> {
68+
for initial in [Nullability::NonNullable, Nullability::Nullable] {
69+
VALIDATIONS.store(0, Ordering::Relaxed);
70+
let dtype =
71+
ExtDType::<CountingValidator>::try_new(42, DType::Primitive(PType::I64, initial))?;
72+
assert_eq!(VALIDATIONS.load(Ordering::Relaxed), 1);
73+
74+
for target in [Nullability::Nullable, Nullability::NonNullable] {
75+
let changed = dtype.with_nullability(target);
76+
assert_eq!(
77+
changed.storage_dtype(),
78+
&DType::Primitive(PType::I64, target)
79+
);
80+
assert_eq!(changed.metadata::<CountingValidator>(), &42);
81+
assert_eq!(changed.id(), dtype.id());
82+
}
83+
assert_eq!(VALIDATIONS.load(Ordering::Relaxed), 1);
84+
}
85+
86+
Ok(())
87+
}
88+
89+
#[rstest]
90+
#[case::compact(false)]
91+
#[case::pretty(true)]
92+
fn test_debug_preserves_metadata(#[case] pretty: bool) {
93+
let dtype = Timestamp::new_with_tz(
94+
TimeUnit::Milliseconds,
95+
Some("UTC".into()),
96+
Nullability::Nullable,
97+
)
98+
.erased();
99+
let output = if pretty {
100+
format!("{dtype:#?}")
101+
} else {
102+
format!("{dtype:?}")
103+
};
104+
105+
assert!(output.starts_with("ExtDType {"), "{output}");
106+
assert!(output.contains("metadata: TimestampOptions {"), "{output}");
107+
assert!(output.contains("unit: Milliseconds"), "{output}");
108+
assert_eq!(
109+
output.contains("metadata: TimestampOptions {\n"),
110+
pretty,
111+
"{output}"
112+
);
113+
assert_eq!(output.matches("UTC").count(), 1, "{output}");
114+
assert!(!output.contains("Ok(())"), "{output}");
115+
}

‎vortex-array/src/dtype/extension/typed.rs‎

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ use std::hash::Hash;
1212
use std::hash::Hasher;
1313
use std::sync::Arc;
1414

15-
use vortex_error::VortexExpect;
1615
use vortex_error::VortexResult;
1716

1817
use crate::dtype::DType;
@@ -88,14 +87,16 @@ impl<V: ExtVTable> ExtDType<V> {
8887
}
8988

9089
/// Returns a new [`ExtDTypeRef`] with the given nullability.
90+
///
91+
/// This does not validate the dtype again, because [`ExtVTable::validate_dtype`] must not
92+
/// depend on the top-level nullability of the storage dtype.
9193
pub fn with_nullability(&self, nullability: Nullability) -> ExtDTypeRef {
92-
let storage_dtype = self.storage_dtype.with_nullability(nullability);
93-
ExtDType::<V>::try_with_vtable(self.vtable.clone(), self.metadata.clone(), storage_dtype)
94-
.vortex_expect(
95-
"Extension DType should not fail validation with the same storage type \
96-
but different nullability",
97-
)
98-
.erased()
94+
Self {
95+
vtable: self.vtable.clone(),
96+
metadata: self.metadata.clone(),
97+
storage_dtype: self.storage_dtype.with_nullability(nullability),
98+
}
99+
.erased()
99100
}
100101

101102
/// Serializes the metadata into a byte vector.

‎vortex-array/src/dtype/extension/vtable.rs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,13 @@ pub trait ExtVTable: 'static + Sized + Send + Sync + Clone + Debug + Eq + Hash {
4646
///
4747
/// This is called when constructing an [`ExtDType`] and should check both storage dtype and
4848
/// extension metadata.
49+
///
50+
/// The result **must not** depend on the top-level nullability of the storage dtype. An
51+
/// extension type cannot restrict whether its values can be null, and
52+
/// [`ExtDType::with_nullability`] changes the nullability without validating again. Nested
53+
/// nullability belongs to the extension type, so the validator can check it. A validator that
54+
/// breaks this rule does not cause undefined behavior, but `with_nullability` can then return a
55+
/// dtype that the validator rejects.
4956
fn validate_dtype(ext_dtype: &ExtDType<Self>) -> VortexResult<()>;
5057

5158
// Methods related to the extension scalar values.

0 commit comments

Comments
 (0)