Skip to main content

vortex_geo/extension/
rect.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! The [`Rect`] bounding-box extension type (`vortex.geo.box`): an axis-aligned envelope stored
5//! columnarly as `Struct<xmin, ymin[, zmin][, mmin], xmax, ymax[, zmax][, mmax]>` of non-nullable
6//! `f64` — the lower corner's ordinates followed by the upper corner's — tagged with
7//! [`GeoMetadata`] (CRS). Its GeoArrow wire type is `geoarrow.box`.
8//!
9//! Decoding to `geo_types` yields a 2D [`Geometry::Rect`]; any `z`/`m`
10//! bounds are dropped, as for the other geometry types.
11
12use std::sync::Arc;
13
14use arrow_array::ArrayRef as ArrowArrayRef;
15use arrow_schema::DataType;
16use arrow_schema::Field;
17use arrow_schema::extension::ExtensionType;
18use geo_traits::to_geo::ToGeoGeometry;
19use geo_types::Geometry;
20use geoarrow::array::GeoArrowArrayAccessor;
21use geoarrow::array::IntoArrow;
22use geoarrow::array::RectArray;
23use geoarrow::datatypes::BoxType;
24use prost::Message;
25use vortex_array::ArrayRef;
26use vortex_array::ExecutionCtx;
27use vortex_array::IntoArray;
28use vortex_array::arrays::ExtensionArray;
29use vortex_array::arrays::StructArray;
30use vortex_array::arrays::extension::ExtensionArrayExt;
31use vortex_array::dtype::DType;
32use vortex_array::dtype::FieldNames;
33use vortex_array::dtype::Nullability;
34use vortex_array::dtype::PType;
35use vortex_array::dtype::StructFields;
36use vortex_array::dtype::extension::ExtDType;
37use vortex_array::dtype::extension::ExtId;
38use vortex_array::dtype::extension::ExtVTable;
39use vortex_array::scalar::ScalarValue;
40use vortex_array::validity::Validity;
41use vortex_arrow::ArrowExport;
42use vortex_arrow::ArrowExportVTable;
43use vortex_arrow::ArrowImport;
44use vortex_arrow::ArrowImportVTable;
45use vortex_arrow::ArrowSession;
46use vortex_arrow::ArrowSessionExt;
47use vortex_arrow::FromArrowArray;
48use vortex_arrow::FromArrowType;
49use vortex_error::VortexResult;
50use vortex_error::vortex_bail;
51use vortex_error::vortex_ensure;
52use vortex_error::vortex_err;
53use vortex_session::registry::CachedId;
54use vortex_session::registry::Id;
55
56use super::GeoMetadata;
57use super::coordinate::Dimension;
58use super::geo_metadata_from_arrow;
59use super::geoarrow_metadata;
60
61/// An axis-aligned bounding box (`geoarrow.box`), stored as `Struct<xmin, ymin[, ..], xmax, ymax[, ..]>`.
62// Named `Rect`, not `Box`: matches `geo::Rect` / geoarrow-rs `RectArray`, and `Box` is a std name.
63#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
64pub struct Rect;
65
66impl ExtVTable for Rect {
67    type Metadata = GeoMetadata;
68    // No cheap owned value; expose the raw storage scalar like `Polygon`.
69    type NativeValue<'a> = &'a ScalarValue;
70
71    fn id(&self) -> ExtId {
72        static ID: CachedId = CachedId::new("vortex.geo.box");
73        *ID
74    }
75
76    fn serialize_metadata(&self, metadata: &Self::Metadata) -> VortexResult<Vec<u8>> {
77        Ok(metadata.encode_to_vec())
78    }
79
80    fn deserialize_metadata(&self, metadata: &[u8]) -> VortexResult<Self::Metadata> {
81        Ok(GeoMetadata::decode(metadata)?)
82    }
83
84    fn validate_dtype(ext_dtype: &ExtDType<Self>) -> VortexResult<()> {
85        box_dimension(ext_dtype.storage_dtype()).map(|_| ())
86    }
87
88    fn unpack_native<'a>(
89        _ext_dtype: &'a ExtDType<Self>,
90        storage_value: &'a ScalarValue,
91    ) -> VortexResult<&'a ScalarValue> {
92        Ok(storage_value)
93    }
94}
95
96/// The `geoarrow.box` storage field names for `dim`: the lower corner's ordinates followed by the
97/// upper corner's.
98pub(crate) fn box_field_names(dim: Dimension) -> &'static [&'static str] {
99    match dim {
100        Dimension::Xy => &["xmin", "ymin", "xmax", "ymax"],
101        Dimension::Xyz => &["xmin", "ymin", "zmin", "xmax", "ymax", "zmax"],
102        Dimension::Xym => &["xmin", "ymin", "mmin", "xmax", "ymax", "mmax"],
103        Dimension::Xyzm => &[
104            "xmin", "ymin", "zmin", "mmin", "xmax", "ymax", "zmax", "mmax",
105        ],
106    }
107}
108
109/// The dimension whose box field names match `names`, or `None` if none do.
110fn box_dimension_from_names(names: &FieldNames) -> Option<Dimension> {
111    [
112        Dimension::Xy,
113        Dimension::Xyz,
114        Dimension::Xym,
115        Dimension::Xyzm,
116    ]
117    .into_iter()
118    .find(|&dim| {
119        names
120            .iter()
121            .map(AsRef::as_ref)
122            .eq(box_field_names(dim).iter().copied())
123    })
124}
125
126/// Canonical box storage: a `Struct` of non-nullable `f64` min/max fields, with `nullability` at
127/// the struct (per-box) level.
128pub(crate) fn box_storage_dtype(dim: Dimension, nullability: Nullability) -> DType {
129    let names = box_field_names(dim);
130    let fields = std::iter::repeat_n(
131        DType::Primitive(PType::F64, Nullability::NonNullable),
132        names.len(),
133    )
134    .collect::<Vec<_>>();
135    DType::Struct(
136        StructFields::new(FieldNames::from(names), fields),
137        nullability,
138    )
139}
140
141/// Validate `dtype` is a box `Struct` of non-nullable `f64` min/max fields, returning its
142/// [`Dimension`].
143pub(crate) fn box_dimension(dtype: &DType) -> VortexResult<Dimension> {
144    let DType::Struct(fields, _) = dtype else {
145        vortex_bail!("box storage must be a Struct, was {dtype}");
146    };
147    for (name, field) in fields.names().iter().zip(fields.fields()) {
148        vortex_ensure!(
149            matches!(
150                field,
151                DType::Primitive(PType::F64, Nullability::NonNullable)
152            ),
153            "box field {name} must be non-nullable f64, was {field}"
154        );
155    }
156    box_dimension_from_names(fields.names())
157        .ok_or_else(|| vortex_err!("not a valid geoarrow.box dimension: {:?}", fields.names()))
158}
159
160/// Build a native [`Rect`] array from canonical min/max ordinate columns.
161pub(crate) fn build_rect_array(
162    ext_dtype: &ExtDType<Rect>,
163    corners: Vec<ArrayRef>,
164    len: usize,
165    validity: Validity,
166) -> VortexResult<ArrayRef> {
167    let dimension = box_dimension(ext_dtype.storage_dtype())?;
168    let storage = StructArray::try_new(
169        FieldNames::from(box_field_names(dimension)),
170        corners,
171        len,
172        validity,
173    )?
174    .into_array();
175    Ok(ExtensionArray::try_new(ext_dtype.clone().erased(), storage)?.into_array())
176}
177
178static ARROW_BOX: CachedId = CachedId::new(BoxType::NAME);
179
180/// The `geoarrow.box` extension type for `dimension`.
181fn box_type(geo_metadata: &GeoMetadata, dimension: Dimension) -> BoxType {
182    BoxType::new(dimension.into(), geoarrow_metadata(geo_metadata))
183}
184
185/// Build a geoarrow `RectArray` from a `Rect`'s box `Struct` storage.
186fn rect_array(storage: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<RectArray> {
187    let box_type = box_type(&GeoMetadata::default(), box_dimension(storage.dtype())?);
188    let session = ctx.session().clone();
189    let arrow = session.arrow().execute_arrow(storage.clone(), None, ctx)?;
190    RectArray::try_from((arrow.as_ref(), box_type))
191        .map_err(|e| vortex_err!("failed to construct RectArray: {e}"))
192}
193
194/// Decode `Rect` storage to `geo_types` (2D [`Geometry::Rect`]), for the geo scalar functions.
195pub(crate) fn rect_geometries(
196    storage: &ArrayRef,
197    ctx: &mut ExecutionCtx,
198) -> VortexResult<Vec<Geometry<f64>>> {
199    rect_array(storage, ctx)?
200        .iter()
201        .map(|geometry| -> VortexResult<Geometry<f64>> {
202            Ok(geometry
203                .ok_or_else(|| vortex_err!("geo: null geometry is not supported"))?
204                .map_err(|e| vortex_err!("geo: geometry access failed: {e}"))?
205                .to_geometry())
206        })
207        .collect()
208}
209
210impl ArrowExportVTable for Rect {
211    fn arrow_ext_id(&self) -> Id {
212        *ARROW_BOX
213    }
214
215    fn vortex_id(&self) -> Id {
216        self.id()
217    }
218
219    fn to_arrow_field(
220        &self,
221        name: &str,
222        dtype: &DType,
223        session: &ArrowSession,
224    ) -> VortexResult<Option<Field>> {
225        let ext_type = dtype.as_extension();
226        let geo_metadata = ext_type.metadata::<Rect>();
227        let dimension = box_dimension(ext_type.storage_dtype())?;
228
229        let mut field = session.to_arrow_field(name, ext_type.storage_dtype())?;
230        field.try_with_extension_type(box_type(geo_metadata, dimension))?;
231
232        Ok(Some(field))
233    }
234
235    fn execute_arrow(
236        &self,
237        array: ArrayRef,
238        target: &Field,
239        ctx: &mut ExecutionCtx,
240    ) -> VortexResult<ArrowExport> {
241        let is_box = array
242            .dtype()
243            .as_extension_opt()
244            .map(|ext| ext.is::<Rect>())
245            .unwrap_or(false);
246        if !is_box {
247            return Ok(ArrowExport::Unsupported(array));
248        }
249
250        let Ok(box_meta) = target.try_extension_type::<BoxType>() else {
251            return Ok(ArrowExport::Unsupported(array));
252        };
253
254        let executed = array.execute::<ExtensionArray>(ctx)?;
255        let storage = executed.storage_array().clone();
256
257        let storage_field = Field::new(
258            String::new(),
259            target.data_type().clone(),
260            target.is_nullable(),
261        );
262        let session = ctx.session().clone();
263        let arrow_storage = session
264            .arrow()
265            .execute_arrow(storage, Some(&storage_field), ctx)?;
266
267        // Round-trip through GeoArrow's rect array; `into_arrow` is concrete, so wrap in `Arc`.
268        let rects = RectArray::try_from((arrow_storage.as_ref(), box_meta))
269            .map_err(|e| vortex_err!("failed to construct RectArray: {e}"))?;
270
271        Ok(ArrowExport::Exported(Arc::new(rects.into_arrow())))
272    }
273}
274
275impl ArrowImportVTable for Rect {
276    fn arrow_ext_id(&self) -> Id {
277        *ARROW_BOX
278    }
279
280    /// Import a `geoarrow.box` field as the [`Rect`] dtype. Keyed off the standard GeoArrow name,
281    /// so any producer resolves here. Accepts the full `BoxType` extension, or — for a
282    /// metadata-less literal — the name alone, inferring the dimension from the field names.
283    fn from_arrow_field(&self, field: &Field) -> VortexResult<Option<DType>> {
284        let (dimension, metadata) = if let Ok(box_meta) = field.try_extension_type::<BoxType>() {
285            (
286                box_meta.dimension().into(),
287                geo_metadata_from_arrow(box_meta.metadata()),
288            )
289        } else {
290            if field.extension_type_name() != Some(BoxType::NAME) {
291                return Ok(None);
292            }
293            // Infer from field names, not the strict `box_dimension` check: a literal's fields may
294            // be nullable, which that check rejects.
295            let DType::Struct(fields, _) = DType::from_arrow(field) else {
296                return Ok(None);
297            };
298            let Some(dimension) = box_dimension_from_names(fields.names()) else {
299                return Ok(None);
300            };
301            (dimension, GeoMetadata::default())
302        };
303
304        let storage_dtype = box_storage_dtype(dimension, field.is_nullable().into());
305        Ok(Some(DType::Extension(
306            ExtDType::try_with_vtable(Rect, metadata, storage_dtype)?.erased(),
307        )))
308    }
309
310    fn from_arrow_array(
311        &self,
312        array: ArrowArrayRef,
313        field: &Field,
314        dtype: &DType,
315    ) -> VortexResult<ArrowImport> {
316        let Some(ext_dtype) = dtype.as_extension_opt() else {
317            return Ok(ArrowImport::Unsupported(array));
318        };
319        if !ext_dtype.is::<Rect>()
320            || field.try_extension_type::<BoxType>().is_err()
321            || !matches!(array.data_type(), DataType::Struct(_))
322        {
323            return Ok(ArrowImport::Unsupported(array));
324        }
325
326        let storage = ArrayRef::from_arrow(array.as_ref(), field.is_nullable())?;
327        Ok(ArrowImport::Imported(
328            ExtensionArray::try_new(ext_dtype.clone(), storage)?.into_array(),
329        ))
330    }
331}
332
333#[cfg(test)]
334mod tests {
335    use geo_types::Geometry;
336    use rstest::rstest;
337    use vortex_array::VortexSessionExecute;
338    use vortex_array::arrays::ExtensionArray;
339    use vortex_array::arrays::extension::ExtensionArrayExt;
340    use vortex_array::dtype::DType;
341    use vortex_array::dtype::Nullability;
342    use vortex_array::dtype::PType;
343    use vortex_array::dtype::extension::ExtDType;
344    use vortex_error::VortexResult;
345
346    use super::Rect;
347    use super::box_dimension;
348    use super::box_storage_dtype;
349    use super::rect_geometries;
350    use crate::extension::GeoMetadata;
351    use crate::extension::coordinate::Dimension;
352
353    fn geo_meta() -> GeoMetadata {
354        GeoMetadata {
355            crs: Some("EPSG:4326".to_string()),
356        }
357    }
358
359    /// `Rect` accepts the canonical box storage of every GeoArrow dimension, and the storage dtype
360    /// round-trips back to that dimension.
361    #[rstest]
362    #[case::xy(Dimension::Xy)]
363    #[case::xyz(Dimension::Xyz)]
364    #[case::xym(Dimension::Xym)]
365    #[case::xyzm(Dimension::Xyzm)]
366    fn box_validates_every_dimension(#[case] dim: Dimension) -> VortexResult<()> {
367        let storage = box_storage_dtype(dim, Nullability::NonNullable);
368        ExtDType::<Rect>::try_new(geo_meta(), storage.clone())?;
369        assert_eq!(box_dimension(&storage)?, dim);
370        Ok(())
371    }
372
373    /// Invalid storage is rejected at dtype construction: non-struct storage, a struct with the
374    /// wrong field names, and a struct with a nullable field.
375    #[test]
376    fn box_rejects_invalid_storage() -> VortexResult<()> {
377        let primitive = DType::Primitive(PType::F64, Nullability::NonNullable);
378        assert!(ExtDType::<Rect>::try_new(geo_meta(), primitive).is_err());
379
380        let point_like = box_storage_dtype(Dimension::Xy, Nullability::NonNullable);
381        let DType::Struct(fields, _) = &point_like else {
382            unreachable!("box storage is a struct");
383        };
384        // Rename a field so it no longer matches the geoarrow.box layout.
385        let renamed = DType::Struct(
386            vortex_array::dtype::StructFields::new(
387                ["x", "ymin", "xmax", "ymax"].into(),
388                fields.fields().collect(),
389            ),
390            Nullability::NonNullable,
391        );
392        assert!(ExtDType::<Rect>::try_new(geo_meta(), renamed).is_err());
393        Ok(())
394    }
395
396    /// A `Rect` column decodes to 2D `geo_types` rectangles.
397    #[test]
398    fn box_decodes_to_geo_rect() -> VortexResult<()> {
399        let session = vortex_array::array_session();
400        let mut ctx = session.create_execution_ctx();
401
402        let rects = crate::test_harness::rect_column(vec![(0.0, 0.0, 2.0, 3.0)])?;
403        let storage = rects
404            .execute::<ExtensionArray>(&mut ctx)?
405            .storage_array()
406            .clone();
407
408        let geometries = rect_geometries(&storage, &mut ctx)?;
409        assert!(matches!(geometries.as_slice(), [Geometry::Rect(_)]));
410        Ok(())
411    }
412}