Skip to content

Commit 0716c2c

Browse files
refactor(vortex-geo): address null-propagation review
- Add validity() + is_null_sensitive()=false vtable overrides to the three geo kernels so the planner can derive the output null mask symbolically (mask pushdown), matching vortex-tensor's binary functions. Shared via a binary_result_validity helper. is_fallible is left at the default since geometry decoding can genuinely fail. - Cover the two-column paths for the asymmetric ST_Contains: nulls on either side, plus the all-null-column and empty-combined-mask early returns. - Drop the unreachable AllOr::None arm in build_array; callers short-circuit the fully-null case to all_null_array. - Document that operands may differ in geometry type, and that the nullable test column's validity mirrors its storage. Signed-off-by: Nemo Yu <zyu379@wisc.edu>
1 parent 90e4548 commit 0716c2c

5 files changed

Lines changed: 133 additions & 8 deletions

File tree

vortex-geo/src/extension/mod.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,8 +73,10 @@ pub(crate) fn is_native_geometry(dtype: &DType) -> bool {
7373
}
7474

7575
/// Validate the operands of a geo scalar function: each must be a native geometry type so the
76-
/// kernel can decode it. Nullable operands are allowed; the kernels propagate nulls (a null
77-
/// geometry input yields a null result) rather than decoding null rows.
76+
/// kernel can decode it. The two operands need not share a geometry type — e.g. a `Point` against
77+
/// a `Polygon` is valid, since distance/containment/intersection across types is meaningful.
78+
/// Nullable operands are allowed; the kernels propagate nulls (a null geometry input yields a null
79+
/// result) rather than decoding null rows.
7880
pub(crate) fn validate_geometry_operands(dtypes: &[DType]) -> VortexResult<()> {
7981
for dtype in dtypes {
8082
vortex_ensure!(

vortex-geo/src/scalar_fn/contains.rs

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use vortex_array::ExecutionCtx;
99
use vortex_array::arrays::ScalarFnArray;
1010
use vortex_array::dtype::DType;
1111
use vortex_array::dtype::Nullability;
12+
use vortex_array::expr::Expression;
1213
use vortex_array::scalar_fn::Arity;
1314
use vortex_array::scalar_fn::ChildName;
1415
use vortex_array::scalar_fn::EmptyOptions;
@@ -21,6 +22,7 @@ use vortex_session::VortexSession;
2122
use vortex_session::registry::CachedId;
2223

2324
use crate::extension::validate_geometry_operands;
25+
use crate::scalar_fn::binary_result_validity;
2426
use crate::scalar_fn::execute_null_propagating;
2527

2628
/// OGC `ST_Contains` between two native geometry operands, each a column or a constant
@@ -85,6 +87,18 @@ impl ScalarFnVTable for GeoContains {
8587
// Containment is not symmetric: `a` is always the container and `b` the contained.
8688
execute_null_propagating(&a, &b, |a, b| a.contains(b), ctx)
8789
}
90+
91+
fn validity(
92+
&self,
93+
_: &Self::Options,
94+
expression: &Expression,
95+
) -> VortexResult<Option<Expression>> {
96+
binary_result_validity(expression)
97+
}
98+
99+
fn is_null_sensitive(&self, _: &Self::Options) -> bool {
100+
false
101+
}
88102
}
89103

90104
#[cfg(test)]
@@ -285,6 +299,71 @@ mod tests {
285299
Ok(())
286300
}
287301

302+
/// Both operands nullable columns: containment (asymmetric) is null wherever either the
303+
/// container or the contained row is null, and computed on the rows valid in both.
304+
#[test]
305+
fn contains_propagates_column_pair_nulls() -> VortexResult<()> {
306+
let session = vortex_array::array_session();
307+
let mut ctx = session.create_execution_ctx();
308+
309+
// A point contains another point only when they are equal.
310+
let container = nullable_point_column(vec![
311+
Some((1.0, 1.0)),
312+
None,
313+
Some((2.0, 2.0)),
314+
Some((3.0, 3.0)),
315+
])?;
316+
let contained = nullable_point_column(vec![
317+
Some((1.0, 1.0)),
318+
Some((5.0, 5.0)),
319+
None,
320+
Some((4.0, 4.0)),
321+
])?;
322+
let contains = GeoContains::try_new_array(container, contained)?.into_array();
323+
324+
let expected = BoolArray::new(
325+
BitBuffer::from_iter([true, false, false, false]),
326+
Validity::from_iter([true, false, false, true]),
327+
)
328+
.into_array();
329+
assert_arrays_eq!(contains, expected, &mut ctx);
330+
Ok(())
331+
}
332+
333+
/// An entirely-null geometry column yields an all-null output (the fully-null fast path in
334+
/// `eval_column`).
335+
#[test]
336+
fn contains_all_null_column_is_all_null() -> VortexResult<()> {
337+
let session = vortex_array::array_session();
338+
let mut ctx = session.create_execution_ctx();
339+
340+
let container = geometry_constant(&Geometry::Polygon(rect_polygon(0.0, 0.0, 4.0, 4.0)), 2)?;
341+
let points = nullable_point_column(vec![None, None])?;
342+
let contains = GeoContains::try_new_array(container, points)?.into_array();
343+
344+
let expected =
345+
BoolArray::new(BitBuffer::from_iter([false, false]), Validity::AllInvalid).into_array();
346+
assert_arrays_eq!(contains, expected, &mut ctx);
347+
Ok(())
348+
}
349+
350+
/// Two nullable columns whose nulls never line up: the combined mask is empty, so the output
351+
/// is all null (the `valid.all_false()` early return in `eval_column_pair`).
352+
#[test]
353+
fn contains_column_pair_all_null() -> VortexResult<()> {
354+
let session = vortex_array::array_session();
355+
let mut ctx = session.create_execution_ctx();
356+
357+
let container = nullable_point_column(vec![Some((1.0, 1.0)), None])?;
358+
let contained = nullable_point_column(vec![None, Some((2.0, 2.0))])?;
359+
let contains = GeoContains::try_new_array(container, contained)?.into_array();
360+
361+
let expected =
362+
BoolArray::new(BitBuffer::from_iter([false, false]), Validity::AllInvalid).into_array();
363+
assert_arrays_eq!(contains, expected, &mut ctx);
364+
Ok(())
365+
}
366+
288367
/// A non-geometry operand dtype is rejected up front, before execution.
289368
#[test]
290369
fn non_geometry_operand_is_rejected() -> VortexResult<()> {

vortex-geo/src/scalar_fn/distance.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use vortex_array::arrays::ScalarFnArray;
1111
use vortex_array::dtype::DType;
1212
use vortex_array::dtype::Nullability;
1313
use vortex_array::dtype::PType;
14+
use vortex_array::expr::Expression;
1415
use vortex_array::scalar_fn::Arity;
1516
use vortex_array::scalar_fn::ChildName;
1617
use vortex_array::scalar_fn::EmptyOptions;
@@ -23,6 +24,7 @@ use vortex_session::VortexSession;
2324
use vortex_session::registry::CachedId;
2425

2526
use crate::extension::validate_geometry_operands;
27+
use crate::scalar_fn::binary_result_validity;
2628
use crate::scalar_fn::execute_null_propagating;
2729

2830
/// Planar (Euclidean) `ST_Distance` (no geodesic correction) between two native geometry
@@ -85,6 +87,18 @@ impl ScalarFnVTable for GeoDistance {
8587
let b = args.get(1)?;
8688
execute_null_propagating(&a, &b, |x, y| Euclidean.distance(x, y), ctx)
8789
}
90+
91+
fn validity(
92+
&self,
93+
_: &Self::Options,
94+
expression: &Expression,
95+
) -> VortexResult<Option<Expression>> {
96+
binary_result_validity(expression)
97+
}
98+
99+
fn is_null_sensitive(&self, _: &Self::Options) -> bool {
100+
false
101+
}
88102
}
89103

90104
#[cfg(test)]

vortex-geo/src/scalar_fn/intersects.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ use vortex_array::ExecutionCtx;
99
use vortex_array::arrays::ScalarFnArray;
1010
use vortex_array::dtype::DType;
1111
use vortex_array::dtype::Nullability;
12+
use vortex_array::expr::Expression;
1213
use vortex_array::scalar_fn::Arity;
1314
use vortex_array::scalar_fn::ChildName;
1415
use vortex_array::scalar_fn::EmptyOptions;
@@ -21,6 +22,7 @@ use vortex_session::VortexSession;
2122
use vortex_session::registry::CachedId;
2223

2324
use crate::extension::validate_geometry_operands;
25+
use crate::scalar_fn::binary_result_validity;
2426
use crate::scalar_fn::execute_null_propagating;
2527

2628
/// OGC `ST_Intersects` (not disjoint; boundary contact counts) between two native geometry
@@ -83,6 +85,18 @@ impl ScalarFnVTable for GeoIntersects {
8385
let b = args.get(1)?;
8486
execute_null_propagating(&a, &b, |x, y| x.intersects(y), ctx)
8587
}
88+
89+
fn validity(
90+
&self,
91+
_: &Self::Options,
92+
expression: &Expression,
93+
) -> VortexResult<Option<Expression>> {
94+
binary_result_validity(expression)
95+
}
96+
97+
fn is_null_sensitive(&self, _: &Self::Options) -> bool {
98+
false
99+
}
86100
}
87101

88102
#[cfg(test)]

vortex-geo/src/scalar_fn/mod.rs

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ use vortex_array::arrays::PrimitiveArray;
1818
use vortex_array::dtype::DType;
1919
use vortex_array::dtype::Nullability;
2020
use vortex_array::dtype::PType;
21+
use vortex_array::expr::Expression;
22+
use vortex_array::expr::and;
2123
use vortex_array::scalar::Scalar;
2224
use vortex_array::validity::Validity;
2325
use vortex_buffer::BitBuffer;
@@ -71,8 +73,6 @@ impl GeoOutput for f64 {
7173
match valid.indices() {
7274
// No nulls: `values` already lines up one-to-one with the rows.
7375
AllOr::All => PrimitiveArray::new(values, validity).into_array(),
74-
// No valid rows: the whole output is null.
75-
AllOr::None => PrimitiveArray::new(vec![0.0f64; len], validity).into_array(),
7676
// Some nulls: scatter each computed value back to the row it came from.
7777
AllOr::Some(rows) => {
7878
let mut data = vec![0.0f64; len];
@@ -81,6 +81,11 @@ impl GeoOutput for f64 {
8181
}
8282
PrimitiveArray::new(data, validity).into_array()
8383
}
84+
// The all-null case never reaches here, `eval_column` / `eval_column_pair` return
85+
// `all_null_array` when the combined mask is empty.
86+
AllOr::None => {
87+
unreachable!("empty masks are handled by all_null_array in eval_column(_pair)")
88+
}
8489
}
8590
}
8691
}
@@ -104,10 +109,6 @@ impl GeoOutput for bool {
104109
match valid.indices() {
105110
// No nulls: `values` already lines up one-to-one with the rows.
106111
AllOr::All => BoolArray::new(BitBuffer::from_iter(values), validity).into_array(),
107-
// No valid rows: the whole output is null.
108-
AllOr::None => {
109-
BoolArray::new(BitBuffer::from_iter(vec![false; len]), validity).into_array()
110-
}
111112
// Some nulls: scatter each computed value back to the row it came from.
112113
AllOr::Some(rows) => {
113114
let mut data = vec![false; len];
@@ -116,6 +117,11 @@ impl GeoOutput for bool {
116117
}
117118
BoolArray::new(BitBuffer::from_iter(data), validity).into_array()
118119
}
120+
// The all-null case never reaches here — `eval_column` / `eval_column_pair` return
121+
// `all_null_array` when the combined mask is empty.
122+
AllOr::None => {
123+
unreachable!("empty masks are handled by all_null_array in eval_column(_pair)")
124+
}
119125
}
120126
}
121127
}
@@ -125,6 +131,16 @@ fn all_null_array<T: GeoOutput>(len: usize) -> ArrayRef {
125131
ConstantArray::new(Scalar::null(T::null_dtype()), len).into_array()
126132
}
127133

134+
/// The validity expression for a binary geo kernel: the result is null iff either operand is
135+
/// null. Returning this lets the planner derive the output's null mask symbolically (mask
136+
/// pushdown) instead of executing the whole kernel.
137+
pub(crate) fn binary_result_validity(expression: &Expression) -> VortexResult<Option<Expression>> {
138+
Ok(Some(and(
139+
expression.child(0).validity()?,
140+
expression.child(1).validity()?,
141+
)))
142+
}
143+
128144
/// Run a binary geo kernel over operands `a` and `b`, each a column or a constant literal.
129145
///
130146
/// The output is null wherever either operand is null, and its type is nullable if either operand

0 commit comments

Comments
 (0)