Skip to content

Commit 1e93238

Browse files
connortsui20claude
andcommitted
Make union as_nullable propagate to all variants
A union has no top-level nullability of its own; its nullability is derived from its variants. So DType::with_nullability on a union now propagates the requested nullability into every variant instead of being a no-op, via a new UnionVariants::with_nullability helper. This also lets Scalar::into_nullable work on non-nullable unions (it previously had to assert against the no-nullable-representation case), so the assertion and its panic path are removed. Signed-off-by: Connor Tsui <connor.tsui20@gmail.com> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8ee294c commit 1e93238

4 files changed

Lines changed: 64 additions & 54 deletions

File tree

vortex-array/src/dtype/dtype_impl.rs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,8 +82,9 @@ impl DType {
8282

8383
/// Get a new DType with the given nullability (but otherwise the same as `self`).
8484
///
85-
/// [`DType::Null`] and [`DType::Union`] have intrinsic nullability and are returned unchanged.
86-
/// To change a union's nullability, construct different [`UnionVariants`].
85+
/// [`DType::Null`] has intrinsic nullability and is returned unchanged. A [`DType::Union`] has
86+
/// no top-level nullability of its own — its nullability is derived from its variants — so
87+
/// `nullability` is propagated into every variant instead.
8788
pub fn with_nullability(&self, nullability: Nullability) -> Self {
8889
match self {
8990
Null => Null,
@@ -95,7 +96,7 @@ impl DType {
9596
List(edt, _) => List(Arc::clone(edt), nullability),
9697
FixedSizeList(edt, size, _) => FixedSizeList(Arc::clone(edt), *size, nullability),
9798
Struct(sf, _) => Struct(sf.clone(), nullability),
98-
Union(vs) => Union(vs.clone()),
99+
Union(vs) => Union(vs.with_nullability(nullability)),
99100
Variant(_) => Variant(nullability),
100101
Extension(ext) => Extension(ext.with_nullability(nullability)),
101102
}

vortex-array/src/dtype/union.rs

Lines changed: 34 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,21 @@ impl UnionVariants {
353353
pub fn derived_nullability(&self) -> Nullability {
354354
self.variants().any(|dtype| dtype.is_nullable()).into()
355355
}
356+
357+
/// Returns a copy of these variants with `nullability` applied to every variant [`DType`].
358+
///
359+
/// A union has no top-level nullability of its own; its nullability is derived from its
360+
/// variants (see [`Self::derived_nullability`]). Setting a union's nullability therefore means
361+
/// propagating `nullability` into each variant.
362+
pub fn with_nullability(&self, nullability: Nullability) -> Self {
363+
let dtypes: Vec<DType> = self
364+
.variants()
365+
.map(|variant| variant.with_nullability(nullability))
366+
.collect();
367+
368+
Self::try_new(self.names().clone(), dtypes, self.type_ids().to_vec())
369+
.vortex_expect("changing variant nullability preserves union validity")
370+
}
356371
}
357372

358373
#[cfg(test)]
@@ -526,16 +541,29 @@ mod tests {
526541
}
527542

528543
#[test]
529-
fn test_with_nullability_does_not_change_union_variants() {
544+
fn test_with_nullability_propagates_to_variants() {
530545
let nonnullable = DType::Union(i32_variants());
531-
assert_eq!(nonnullable.as_nullable(), nonnullable);
532546
assert_eq!(nonnullable.nullability(), Nullability::NonNullable);
533547

534-
let nullable = DType::Union(
535-
UnionVariants::new(["value"].into(), vec![DType::Utf8(Nullability::Nullable)]).unwrap(),
536-
);
537-
assert_eq!(nullable.as_nonnullable(), nullable);
548+
// `as_nullable` makes every variant nullable, since a union has no top-level nullability.
549+
let nullable = nonnullable.as_nullable();
538550
assert_eq!(nullable.nullability(), Nullability::Nullable);
551+
assert_eq!(
552+
nullable,
553+
DType::Union(
554+
UnionVariants::new(
555+
["int", "str"].into(),
556+
vec![
557+
DType::Primitive(PType::I32, Nullability::Nullable),
558+
DType::Utf8(Nullability::Nullable),
559+
],
560+
)
561+
.unwrap()
562+
)
563+
);
564+
565+
// `as_nonnullable` propagates the other direction, round-tripping back to the original.
566+
assert_eq!(nullable.as_nonnullable(), nonnullable);
539567
}
540568

541569
#[test]

vortex-array/src/scalar/cast.rs

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -76,19 +76,11 @@ impl Scalar {
7676

7777
/// Cast the scalar into a nullable version of its current type.
7878
///
79-
/// # Panics
80-
///
81-
/// Panics if the scalar's dtype has no nullable representation, such as a union dtype when all
82-
/// of its variants are non-nullable.
79+
/// For a union this makes every variant nullable, since a union has no top-level nullability of
80+
/// its own.
8381
pub fn into_nullable(self) -> Scalar {
8482
let (dtype, value) = self.into_parts();
85-
let nullable_dtype = dtype.as_nullable();
86-
assert!(
87-
nullable_dtype.is_nullable(),
88-
"dtype {dtype} cannot be made nullable"
89-
);
90-
91-
Self::try_new(nullable_dtype, value)
83+
Self::try_new(dtype.as_nullable(), value)
9284
.vortex_expect("Casting to nullable should always succeed")
9385
}
9486
}

vortex-array/src/scalar/tests/casting.rs

Lines changed: 23 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -416,50 +416,39 @@ mod tests {
416416
}
417417

418418
#[test]
419-
fn into_nullable_runtime_nullable_union_is_identity() -> VortexResult<()> {
420-
let variants = UnionVariants::try_new(
421-
["int", "string"].into(),
422-
vec![
423-
DType::Primitive(PType::I32, Nullability::Nullable),
424-
DType::Utf8(Nullability::NonNullable),
425-
],
426-
vec![5, 9],
427-
)?;
419+
fn into_nullable_makes_every_variant_nullable() -> VortexResult<()> {
428420
let scalar = Scalar::union(
429-
variants,
421+
UnionVariants::try_new(
422+
["int", "string"].into(),
423+
vec![
424+
DType::Primitive(PType::I32, Nullability::NonNullable),
425+
DType::Utf8(Nullability::NonNullable),
426+
],
427+
vec![5, 9],
428+
)?,
430429
5,
431-
Scalar::primitive(42_i32, Nullability::Nullable),
430+
Scalar::primitive(42_i32, Nullability::NonNullable),
432431
)?;
433-
let dtype = scalar.dtype().clone();
434432

435433
let nullable = scalar.into_nullable();
436434

437-
assert_eq!(nullable.dtype(), &dtype);
438-
assert!(nullable.dtype().is_nullable());
439-
assert_eq!(nullable.as_union().type_id(), Some(5));
440-
441-
Ok(())
442-
}
443-
444-
#[test]
445-
#[should_panic(expected = "cannot be made nullable")]
446-
fn into_nullable_runtime_nonnullable_union_panics() {
447-
let variants = UnionVariants::try_new(
435+
// A union has no top-level nullability, so `into_nullable` propagates into every variant.
436+
let expected = DType::Union(UnionVariants::try_new(
448437
["int", "string"].into(),
449438
vec![
450-
DType::Primitive(PType::I32, Nullability::NonNullable),
451-
DType::Utf8(Nullability::NonNullable),
439+
DType::Primitive(PType::I32, Nullability::Nullable),
440+
DType::Utf8(Nullability::Nullable),
452441
],
453442
vec![5, 9],
454-
)
455-
.vortex_expect("valid union variants");
456-
let scalar = Scalar::union(
457-
variants,
458-
5,
459-
Scalar::primitive(42_i32, Nullability::NonNullable),
460-
)
461-
.vortex_expect("valid union scalar");
443+
)?);
444+
assert_eq!(nullable.dtype(), &expected);
445+
assert!(nullable.dtype().is_nullable());
446+
assert_eq!(nullable.as_union().type_id(), Some(5));
447+
assert_eq!(
448+
nullable.as_union().value(),
449+
Some(Scalar::primitive(42_i32, Nullability::Nullable))
450+
);
462451

463-
scalar.into_nullable();
452+
Ok(())
464453
}
465454
}

0 commit comments

Comments
 (0)