Skip to content

Commit 90e4548

Browse files
feat(vortex-geo): null-propagating scalar functions
Make the binary geo scalar functions (ST_Distance, ST_Intersects, ST_Contains) null-propagating, matching SQL/OGC semantics and the other Vortex binary kernels: a nullable geometry operand is now allowed, and any row whose geometry input is null yields a null result (output nullable iff an operand is nullable). A geo kernel decodes each operand into a geo_types geometry, and a null row has no geometry to decode, so it can't compute over every row and mask the nulls afterwards the way numeric kernels do. The shared execute_null_propagating dispatch in scalar_fn/mod.rs instead filters the null rows out up front, computes over the rows valid in both operands, and scatters the results back under the combined null mask. - validate_geometry_operands no longer rejects nullable operands - return_dtype mirrors operand nullability - tests for nullable columns, constant-null operands, and column/column nulls Signed-off-by: Nemo Yu <zyu379@wisc.edu>
1 parent 17f843a commit 90e4548

8 files changed

Lines changed: 449 additions & 174 deletions

File tree

Cargo.lock

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

vortex-geo/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,9 @@ geoarrow-cast = { workspace = true }
2424
prost = { workspace = true }
2525
vortex-array = { workspace = true }
2626
vortex-arrow = { workspace = true }
27+
vortex-buffer = { workspace = true }
2728
vortex-error = { workspace = true }
29+
vortex-mask = { workspace = true }
2830
vortex-session = { workspace = true }
2931
wkb = { workspace = true }
3032

vortex-geo/src/extension/mod.rs

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -72,18 +72,15 @@ pub(crate) fn is_native_geometry(dtype: &DType) -> bool {
7272
})
7373
}
7474

75-
/// Validate the operands of a geo scalar function: each must be a native geometry type (so the
76-
/// kernel can decode it) and non-nullable (geometry arrays never carry nulls).
75+
/// 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.
7778
pub(crate) fn validate_geometry_operands(dtypes: &[DType]) -> VortexResult<()> {
7879
for dtype in dtypes {
7980
vortex_ensure!(
8081
is_native_geometry(dtype),
8182
"geo: operand {dtype} is not a native geometry type"
8283
);
83-
vortex_ensure!(
84-
!dtype.is_nullable(),
85-
"geo: nullable operand {dtype} is unsupported"
86-
);
8784
}
8885
Ok(())
8986
}

vortex-geo/src/scalar_fn/contains.rs

Lines changed: 53 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,9 @@
66
use geo::Contains;
77
use vortex_array::ArrayRef;
88
use vortex_array::ExecutionCtx;
9-
use vortex_array::IntoArray;
10-
use vortex_array::arrays::BoolArray;
11-
use vortex_array::arrays::Constant;
12-
use vortex_array::arrays::ConstantArray;
139
use vortex_array::arrays::ScalarFnArray;
1410
use vortex_array::dtype::DType;
1511
use vortex_array::dtype::Nullability;
16-
use vortex_array::scalar::Scalar;
1712
use vortex_array::scalar_fn::Arity;
1813
use vortex_array::scalar_fn::ChildName;
1914
use vortex_array::scalar_fn::EmptyOptions;
@@ -22,13 +17,11 @@ use vortex_array::scalar_fn::ScalarFnId;
2217
use vortex_array::scalar_fn::ScalarFnVTable;
2318
use vortex_array::scalar_fn::TypedScalarFnInstance;
2419
use vortex_error::VortexResult;
25-
use vortex_error::vortex_ensure_eq;
2620
use vortex_session::VortexSession;
2721
use vortex_session::registry::CachedId;
2822

29-
use crate::extension::geometries;
30-
use crate::extension::single_geometry;
3123
use crate::extension::validate_geometry_operands;
24+
use crate::scalar_fn::execute_null_propagating;
3225

3326
/// OGC `ST_Contains` between two native geometry operands, each a column or a constant
3427
/// literal: true where operand `b` lies completely inside operand `a` (boundary contact alone
@@ -77,7 +70,8 @@ impl ScalarFnVTable for GeoContains {
7770

7871
fn return_dtype(&self, _: &Self::Options, dtypes: &[DType]) -> VortexResult<DType> {
7972
validate_geometry_operands(dtypes)?;
80-
Ok(DType::Bool(Nullability::NonNullable))
73+
let nullability = Nullability::from(dtypes.iter().any(DType::is_nullable));
74+
Ok(DType::Bool(nullability))
8175
}
8276

8377
fn execute(
@@ -89,59 +83,10 @@ impl ScalarFnVTable for GeoContains {
8983
let a = args.get(0)?;
9084
let b = args.get(1)?;
9185
// Containment is not symmetric: `a` is always the container and `b` the contained.
92-
match (a.as_opt::<Constant>(), b.as_opt::<Constant>()) {
93-
(Some(qa), Some(qb)) => {
94-
let ga = single_geometry(qa.scalar(), ctx)?;
95-
let gb = single_geometry(qb.scalar(), ctx)?;
96-
Ok(ConstantArray::new(
97-
Scalar::bool(ga.contains(&gb), Nullability::NonNullable),
98-
a.len(),
99-
)
100-
.into_array())
101-
}
102-
(Some(qa), None) => constant_contains_column(qa.scalar(), &b, ctx),
103-
(None, Some(qb)) => column_contains_constant(&a, qb.scalar(), ctx),
104-
(None, None) => {
105-
vortex_ensure_eq!(
106-
a.len(),
107-
b.len(),
108-
"geo contains: operand length mismatch {} vs {}",
109-
a.len(),
110-
b.len()
111-
);
112-
let ag = geometries(&a, ctx)?;
113-
let bg = geometries(&b, ctx)?;
114-
let hits = ag.iter().zip(&bg).map(|(x, y)| x.contains(y));
115-
Ok(BoolArray::from_iter(hits).into_array())
116-
}
117-
}
86+
execute_null_propagating(&a, &b, |a, b| a.contains(b), ctx)
11887
}
11988
}
12089

121-
/// Whether the constant `container` contains each row of `contained`.
122-
fn constant_contains_column(
123-
container: &Scalar,
124-
contained: &ArrayRef,
125-
ctx: &mut ExecutionCtx,
126-
) -> VortexResult<ArrayRef> {
127-
let container = single_geometry(container, ctx)?;
128-
let geoms = geometries(contained, ctx)?;
129-
let hits = geoms.iter().map(|g| container.contains(g));
130-
Ok(BoolArray::from_iter(hits).into_array())
131-
}
132-
133-
/// Whether each row of `container` contains the constant `contained`.
134-
fn column_contains_constant(
135-
container: &ArrayRef,
136-
contained: &Scalar,
137-
ctx: &mut ExecutionCtx,
138-
) -> VortexResult<ArrayRef> {
139-
let contained = single_geometry(contained, ctx)?;
140-
let geoms = geometries(container, ctx)?;
141-
let hits = geoms.iter().map(|g| g.contains(&contained));
142-
Ok(BoolArray::from_iter(hits).into_array())
143-
}
144-
14590
#[cfg(test)]
14691
mod tests {
14792
use geo_types::Geometry;
@@ -160,13 +105,17 @@ mod tests {
160105
use vortex_array::dtype::DType;
161106
use vortex_array::dtype::Nullability;
162107
use vortex_array::dtype::PType;
108+
use vortex_array::scalar::Scalar;
163109
use vortex_array::scalar_fn::EmptyOptions;
164110
use vortex_array::scalar_fn::ScalarFnVTable;
111+
use vortex_array::validity::Validity;
112+
use vortex_buffer::BitBuffer;
165113
use vortex_error::VortexResult;
166114
use vortex_error::vortex_err;
167115
use wkb::writer::WriteOptions;
168116

169117
use super::GeoContains;
118+
use crate::test_harness::nullable_point_column;
170119
use crate::test_harness::point_column;
171120

172121
/// A rectangle polygon with corners `(x0, y0)` and `(x1, y1)`, no holes.
@@ -286,12 +235,53 @@ mod tests {
286235
assert_contains(polygons, points, [true, false])
287236
}
288237

289-
/// Geometry arrays are never nullable, so a nullable operand dtype is rejected.
238+
/// Output nullability mirrors the operands: nullable if any operand is nullable, otherwise
239+
/// non-nullable.
290240
#[test]
291-
fn nullable_operand_is_rejected() -> VortexResult<()> {
241+
fn output_nullability_mirrors_operands() -> VortexResult<()> {
292242
let dtype = point_column(vec![0.0], vec![0.0])?.dtype().clone();
293-
let result = GeoContains.return_dtype(&EmptyOptions, &[dtype.as_nullable(), dtype]);
294-
assert!(result.is_err());
243+
let non_nullable =
244+
GeoContains.return_dtype(&EmptyOptions, &[dtype.clone(), dtype.clone()])?;
245+
assert!(!non_nullable.is_nullable());
246+
let nullable = GeoContains.return_dtype(&EmptyOptions, &[dtype.as_nullable(), dtype])?;
247+
assert!(nullable.is_nullable());
248+
Ok(())
249+
}
250+
251+
/// A null row in the contained operand yields a null verdict; valid rows keep their verdict
252+
/// (a strictly interior point is contained, an outside point is not).
253+
#[test]
254+
fn contains_propagates_null_rows() -> VortexResult<()> {
255+
let session = vortex_array::array_session();
256+
let mut ctx = session.create_execution_ctx();
257+
258+
let container = geometry_constant(&Geometry::Polygon(rect_polygon(0.0, 0.0, 4.0, 4.0)), 3)?;
259+
let points = nullable_point_column(vec![Some((2.0, 2.0)), None, Some((10.0, 10.0))])?;
260+
let contains = GeoContains::try_new_array(container, points)?.into_array();
261+
262+
let expected = BoolArray::new(
263+
BitBuffer::from_iter([true, false, false]),
264+
Validity::from_iter([true, false, true]),
265+
)
266+
.into_array();
267+
assert_arrays_eq!(contains, expected, &mut ctx);
268+
Ok(())
269+
}
270+
271+
/// A constant-null operand produces an all-null output.
272+
#[test]
273+
fn contains_constant_null_is_all_null() -> VortexResult<()> {
274+
let session = vortex_array::array_session();
275+
let mut ctx = session.create_execution_ctx();
276+
277+
let point_dtype = point_column(vec![0.0], vec![0.0])?.dtype().as_nullable();
278+
let null_const = ConstantArray::new(Scalar::null(point_dtype), 2).into_array();
279+
let points = point_column(vec![2.0, 10.0], vec![2.0, 10.0])?;
280+
let contains = GeoContains::try_new_array(null_const, points)?.into_array();
281+
282+
let expected =
283+
BoolArray::new(BitBuffer::from_iter([false, false]), Validity::AllInvalid).into_array();
284+
assert_arrays_eq!(contains, expected, &mut ctx);
295285
Ok(())
296286
}
297287

0 commit comments

Comments
 (0)