This is a tracking issue for figuring out the semantics we want for Union type, which we need to help us inform our implementation of Union.
This is a subissue of tracking issue #7882 and epic #7705.
Motivation
Union type has some weird semantics wrt nullability. If we look at the Arrow specification, Arrow simply says that the nullability of the value is determined by the child array. I would equate it to something like this (in Rust):
enum ArrowUnion {
Foo(Option<i32>),
Bar(Option<String>),
Qux(Decimal),
}
assert_eq!(ArrowUnion::Foo(None), ArrowUnion::Bar(None));
So if we want to represent a null value with ArrowUnion, we must choose one of Foo and Bar to represent that null value. This also implies that in Arrow, ArrowUnion::Foo(None) is semantically equivalent to ArrowUnion::Bar(None), which means null union values may not necessarily roundtrip their physical representations.
The lack of a top-level null presents a problem in Vortex, as we have generally made the assumption that ALL dtypes store their nullability at the top-level (except for DType::Null, which is special). For example, DType::Struct can be null at the top level, but it can also have different nullabilities for each of its child fields.
Even if it were not the case that Vortex prefers a semantic difference between parent and child nulls, implementation of this null type will be somewhat confusing: needing to "choose" how to represent null / validity masks means that null-related compute (like mask and cast and take with null indices) is not immediately obvious how to implement.
Design
We propose designing Vortex's Union type to be a superset of the functionality provided by Arrow's union by distinguishing between top-level null Union and child nulls. Basically, instead of Foo, we will use Option<Foo> and say that None != Foo::Bar(None).
// Not real Rust, we cant define enums inline.
type VortexUnion = Option<enum Child {
Foo(Option<i32>),
Bar(Option<String>),
Qux(Decimal),
}>;
let top_level_null = None;
let inner_null = Some(Child::Foo(None))
assert_ne!(top_level_null, inner_null);
// Still the same logic here as in the previous example.
let inner_null2 = Some(Child::Bar(None))
assert_eq!(inner_null, inner_null2);
So instead of Union(UnionVariants) that has to decide nullability at runtime by looking at the children, we have Union(UnionVariants, Nullability). This also allows us to store a validity mask at the top-level of the UnionArray struct (that we will implement soon 🤞).
This does mean that we will not directly roundtrip with Arrow, but it should be quite easy / cheap to convert a Vortex Union to an Arrow Union. The ONLY case we are worried about (that Arrow Union cannot express) is a nullable Vortex Union with non-nullable children.
There are multiple ways to do this, and they can be configuration options in our Arrow exporter:
- Add an additional
validity child to the Arrow Union that is essentially just a validity mask
- Choose 1 of the children to be nullable and use that for storing nulls
- Force all of the children to be nullable (and maybe we can be clever here?)
Upstream to Arrow?
We could also propose that Arrow do a similar thing. Arrow could just add a validity mask to the top-level union to have the same logic as proposed above, and it would be backwards compatible because this is strictly more expressive.
Steps
TODO
Unresolved questions
Implementation history
This is a tracking issue for figuring out the semantics we want for
Uniontype, which we need to help us inform our implementation ofUnion.This is a subissue of tracking issue #7882 and epic #7705.
Motivation
Uniontype has some weird semantics wrt nullability. If we look at the Arrow specification, Arrow simply says that the nullability of the value is determined by the child array. I would equate it to something like this (in Rust):So if we want to represent a null value with
ArrowUnion, we must choose one ofFooandBarto represent that null value. This also implies that in Arrow,ArrowUnion::Foo(None)is semantically equivalent toArrowUnion::Bar(None), which means null union values may not necessarily roundtrip their physical representations.The lack of a top-level null presents a problem in Vortex, as we have generally made the assumption that ALL dtypes store their nullability at the top-level (except for
DType::Null, which is special). For example,DType::Structcan be null at the top level, but it can also have different nullabilities for each of its child fields.Even if it were not the case that Vortex prefers a semantic difference between parent and child nulls, implementation of this null type will be somewhat confusing: needing to "choose" how to represent null / validity masks means that null-related compute (like
maskandcastandtakewith null indices) is not immediately obvious how to implement.Design
We propose designing Vortex's
Uniontype to be a superset of the functionality provided by Arrow's union by distinguishing between top-level nullUnionand child nulls. Basically, instead ofFoo, we will useOption<Foo>and say thatNone != Foo::Bar(None).So instead of
Union(UnionVariants)that has to decide nullability at runtime by looking at the children, we haveUnion(UnionVariants, Nullability). This also allows us to store a validity mask at the top-level of theUnionArraystruct (that we will implement soon 🤞).This does mean that we will not directly roundtrip with Arrow, but it should be quite easy / cheap to convert a Vortex
Unionto an ArrowUnion. The ONLY case we are worried about (that ArrowUnioncannot express) is a nullable VortexUnionwith non-nullable children.There are multiple ways to do this, and they can be configuration options in our Arrow exporter:
validitychild to the ArrowUnionthat is essentially just a validity maskUpstream to Arrow?
We could also propose that Arrow do a similar thing. Arrow could just add a validity mask to the top-level union to have the same logic as proposed above, and it would be backwards compatible because this is strictly more expressive.
Steps
TODO
Unresolved questions
Implementation history
UnionScalar#8739Unionnullability to its variants #8768