Skip to main content

vortex_geo/extension/
point.rs

1// SPDX-License-Identifier: Apache-2.0
2// SPDX-FileCopyrightText: Copyright the Vortex contributors
3
4//! The [`Point`] geometry extension type (`vortex.geo.point`): a location stored columnarly as
5//! `Struct<x, y[, z][, m]>` of non-nullable `f64` — the four GeoArrow dimensions XY, XYZ, XYM,
6//! XYZM — tagged with [`GeoMetadata`] (CRS). `z` is an optional elevation and `m` an optional
7//! measure: an arbitrary per-point value such as distance along a route or a timestamp.
8
9use arrow_array::ArrayRef as ArrowArrayRef;
10use arrow_schema::DataType;
11use arrow_schema::Field;
12use arrow_schema::extension::ExtensionType;
13use geo_traits::to_geo::ToGeoGeometry;
14use geo_types::Geometry;
15use geoarrow::array::GeoArrowArrayAccessor;
16use geoarrow::array::IntoArrow;
17use geoarrow::array::PointArray;
18use geoarrow::datatypes::CoordType;
19use geoarrow::datatypes::PointType;
20use prost::Message;
21use vortex_array::ArrayRef;
22use vortex_array::ExecutionCtx;
23use vortex_array::IntoArray;
24use vortex_array::arrays::ExtensionArray;
25use vortex_array::arrays::extension::ExtensionArrayExt;
26use vortex_array::arrow::ArrowExport;
27use vortex_array::arrow::ArrowExportVTable;
28use vortex_array::arrow::ArrowImport;
29use vortex_array::arrow::ArrowImportVTable;
30use vortex_array::arrow::ArrowSession;
31use vortex_array::arrow::ArrowSessionExt;
32use vortex_array::arrow::FromArrowArray;
33use vortex_array::dtype::DType;
34use vortex_array::dtype::arrow::FromArrowType;
35use vortex_array::dtype::extension::ExtDType;
36use vortex_array::dtype::extension::ExtId;
37use vortex_array::dtype::extension::ExtVTable;
38use vortex_array::scalar::Scalar;
39use vortex_array::scalar::ScalarValue;
40use vortex_error::VortexError;
41use vortex_error::VortexResult;
42use vortex_error::vortex_ensure;
43use vortex_error::vortex_err;
44use vortex_session::registry::CachedId;
45use vortex_session::registry::Id;
46
47use super::GeoMetadata;
48use super::coordinate::Coordinate;
49use super::coordinate::Dimension;
50use super::coordinate::coordinate_dimension;
51use super::coordinate::coordinate_from_struct;
52use super::coordinate::coordinate_storage_dtype;
53use super::geo_metadata_from_arrow;
54use super::geoarrow_metadata;
55use super::geoarrow_to_wkb;
56
57/// A single location: `geoarrow.point`, stored as `Struct<x, y[, z][, m]>` of non-nullable `f64`.
58#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
59pub struct Point;
60
61impl ExtVTable for Point {
62    type Metadata = GeoMetadata;
63    type NativeValue<'a> = Coordinate;
64
65    fn id(&self) -> ExtId {
66        static ID: CachedId = CachedId::new("vortex.geo.point");
67        *ID
68    }
69
70    fn serialize_metadata(&self, metadata: &Self::Metadata) -> VortexResult<Vec<u8>> {
71        Ok(metadata.encode_to_vec())
72    }
73
74    fn deserialize_metadata(&self, metadata: &[u8]) -> VortexResult<Self::Metadata> {
75        Ok(GeoMetadata::decode(metadata)?)
76    }
77
78    fn validate_dtype(ext_dtype: &ExtDType<Self>) -> VortexResult<()> {
79        coordinate_dimension(ext_dtype.storage_dtype()).map(|_| ())
80    }
81
82    fn unpack_native<'a>(
83        ext_dtype: &'a ExtDType<Self>,
84        storage_value: &'a ScalarValue,
85    ) -> VortexResult<Coordinate> {
86        let storage = Scalar::try_new(
87            ext_dtype.storage_dtype().clone(),
88            Some(storage_value.clone()),
89        )?;
90        coordinate_from_struct(&storage)
91    }
92}
93
94static ARROW_POINT: CachedId = CachedId::new(PointType::NAME);
95
96/// The `geoarrow.point` extension type for `dimension`, with separated (struct) coordinates
97/// matching `Point` storage.
98fn point_type(geo_metadata: &GeoMetadata, dimension: Dimension) -> PointType {
99    PointType::new(dimension.into(), geoarrow_metadata(geo_metadata))
100}
101
102pub struct PointData(ExtensionArray);
103
104impl TryFrom<ExtensionArray> for PointData {
105    type Error = VortexError;
106
107    fn try_from(ext: ExtensionArray) -> Result<Self, Self::Error> {
108        vortex_ensure!(
109            ext.ext_dtype().is::<Point>(),
110            "expected a Point extension array"
111        );
112        Ok(PointData(ext))
113    }
114}
115
116impl PointData {
117    /// Serialize points to WKB (a view array) — the form DuckDB `GEOMETRY` takes.
118    pub fn to_wkb(&self, ctx: &mut ExecutionCtx) -> VortexResult<ArrayRef> {
119        geoarrow_to_wkb(&point_array(self.0.storage_array(), ctx)?)
120    }
121}
122
123/// Build a geoarrow `PointArray` from a `Point`'s `Struct<x, y[, ..]>` storage, shared by WKB export
124/// and `geo_types` decoding.
125fn point_array(storage: &ArrayRef, ctx: &mut ExecutionCtx) -> VortexResult<PointArray> {
126    let point_type = point_type(
127        &GeoMetadata::default(),
128        coordinate_dimension(storage.dtype())?,
129    );
130    let session = ctx.session().clone();
131    let arrow = session.arrow().execute_arrow(storage.clone(), None, ctx)?;
132    PointArray::try_from((arrow.as_ref(), point_type))
133        .map_err(|e| vortex_err!("failed to construct PointArray: {e}"))
134}
135
136/// Decode `Point` storage to `geo_types` points, for the geo scalar functions.
137pub(crate) fn point_geometries(
138    storage: &ArrayRef,
139    ctx: &mut ExecutionCtx,
140) -> VortexResult<Vec<Geometry<f64>>> {
141    point_array(storage, ctx)?
142        .iter()
143        .map(|geometry| -> VortexResult<Geometry<f64>> {
144            Ok(geometry
145                .ok_or_else(|| vortex_err!("geo: null geometry is not supported"))?
146                .map_err(|e| vortex_err!("geo: geometry access failed: {e}"))?
147                .to_geometry())
148        })
149        .collect()
150}
151
152impl ArrowExportVTable for Point {
153    fn arrow_ext_id(&self) -> Id {
154        *ARROW_POINT
155    }
156
157    fn vortex_id(&self) -> Id {
158        self.id()
159    }
160
161    fn to_arrow_field(
162        &self,
163        name: &str,
164        dtype: &DType,
165        session: &ArrowSession,
166    ) -> VortexResult<Option<Field>> {
167        let ext_type = dtype.as_extension();
168        let geo_metadata = ext_type.metadata::<Point>();
169        let dimension = coordinate_dimension(ext_type.storage_dtype())?;
170
171        let mut field = session.to_arrow_field(name, ext_type.storage_dtype())?;
172        field.try_with_extension_type(point_type(geo_metadata, dimension))?;
173
174        Ok(Some(field))
175    }
176
177    fn execute_arrow(
178        &self,
179        array: ArrayRef,
180        target: &Field,
181        ctx: &mut ExecutionCtx,
182    ) -> VortexResult<ArrowExport> {
183        let is_point = array
184            .dtype()
185            .as_extension_opt()
186            .map(|ext| ext.is::<Point>())
187            .unwrap_or(false);
188        if !is_point {
189            return Ok(ArrowExport::Unsupported(array));
190        }
191
192        let Ok(point_meta) = target.try_extension_type::<PointType>() else {
193            return Ok(ArrowExport::Unsupported(array));
194        };
195        if point_meta.coord_type() != CoordType::Separated {
196            return Ok(ArrowExport::Unsupported(array));
197        }
198
199        let executed = array.execute::<ExtensionArray>(ctx)?;
200        let storage = executed.storage_array().clone();
201
202        let storage_field = Field::new(
203            String::new(),
204            target.data_type().clone(),
205            target.is_nullable(),
206        );
207        let session = ctx.session().clone();
208        let arrow_storage = session
209            .arrow()
210            .execute_arrow(storage, Some(&storage_field), ctx)?;
211
212        // Round-trip through the GeoArrow point array type: this validates that the storage is
213        // the separated-coordinate struct layout expected for a `PointType` extension field.
214        let points = PointArray::try_from((arrow_storage.as_ref(), point_meta))
215            .map_err(|e| vortex_err!("failed to construct PointArray: {e}"))?;
216
217        Ok(ArrowExport::Exported(points.into_arrow()))
218    }
219}
220
221impl ArrowImportVTable for Point {
222    fn arrow_ext_id(&self) -> Id {
223        *ARROW_POINT
224    }
225
226    /// Import a `geoarrow.point` field as the [`Point`] dtype. Keyed off the standard GeoArrow name,
227    /// so any producer (DataFusion, DuckDB, geoarrow-rs, …) resolves here. Accepts the full
228    /// `PointType` extension, or — for a metadata-less geometry literal — the name alone, inferring
229    /// the dimension from the coordinate field names.
230    fn from_arrow_field(&self, field: &Field) -> VortexResult<Option<DType>> {
231        let (dimension, metadata) = if let Ok(point_meta) = field.try_extension_type::<PointType>()
232        {
233            vortex_ensure!(
234                point_meta.coord_type() == CoordType::Separated,
235                "geoarrow.point with interleaved coordinates is not supported; \
236                 re-encode with separated (struct) coordinates"
237            );
238            (
239                point_meta.dimension().into(),
240                geo_metadata_from_arrow(point_meta.metadata()),
241            )
242        } else {
243            // Infer the dimension from the field names, not the canonical storage check: a literal's
244            // coordinate fields may be nullable, which that check rejects.
245            if field.extension_type_name() != Some(PointType::NAME) {
246                return Ok(None);
247            }
248            let DType::Struct(fields, _) = DType::from_arrow(field) else {
249                return Ok(None);
250            };
251            let Ok(dimension) = Dimension::from_field_names(fields.names()) else {
252                return Ok(None);
253            };
254            (dimension, GeoMetadata::default())
255        };
256
257        let storage_dtype = coordinate_storage_dtype(dimension, field.is_nullable().into());
258        Ok(Some(DType::Extension(
259            ExtDType::try_with_vtable(Point, metadata, storage_dtype)?.erased(),
260        )))
261    }
262
263    fn from_arrow_array(
264        &self,
265        array: ArrowArrayRef,
266        field: &Field,
267        dtype: &DType,
268    ) -> VortexResult<ArrowImport> {
269        let Some(ext_dtype) = dtype.as_extension_opt() else {
270            return Ok(ArrowImport::Unsupported(array));
271        };
272        if !ext_dtype.is::<Point>()
273            || field.try_extension_type::<PointType>().is_err()
274            || !matches!(array.data_type(), DataType::Struct(_))
275        {
276            return Ok(ArrowImport::Unsupported(array));
277        }
278
279        let storage = ArrayRef::from_arrow(array.as_ref(), field.is_nullable())?;
280        Ok(ArrowImport::Imported(
281            ExtensionArray::try_new(ext_dtype.clone(), storage)?.into_array(),
282        ))
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use rstest::rstest;
289    use vortex_array::IntoArray;
290    use vortex_array::VortexSessionExecute;
291    use vortex_array::arrays::PrimitiveArray;
292    use vortex_array::arrays::StructArray;
293    use vortex_array::dtype::DType;
294    use vortex_array::dtype::Nullability;
295    use vortex_array::dtype::PType;
296    use vortex_array::dtype::extension::ExtDType;
297    use vortex_error::VortexResult;
298
299    use super::Point;
300    use crate::extension::GeoMetadata;
301    use crate::extension::coordinate::Coordinate;
302    use crate::extension::coordinate::Dimension;
303    use crate::extension::coordinate::coordinate_storage_dtype;
304    use crate::test_harness::coordinate_from_scalar;
305    use crate::test_harness::point_column;
306
307    fn geo_meta() -> GeoMetadata {
308        GeoMetadata {
309            crs: Some("EPSG:4326".to_string()),
310        }
311    }
312
313    /// `Point` accepts the canonical coordinate storage of every GeoArrow dimension.
314    #[rstest]
315    #[case::xy(Dimension::Xy)]
316    #[case::xyz(Dimension::Xyz)]
317    #[case::xym(Dimension::Xym)]
318    #[case::xyzm(Dimension::Xyzm)]
319    fn point_validates_every_dimension(#[case] dim: Dimension) -> VortexResult<()> {
320        let storage = coordinate_storage_dtype(dim, Nullability::NonNullable);
321        ExtDType::<Point>::try_new(geo_meta(), storage)?;
322        Ok(())
323    }
324
325    /// Invalid storage is rejected at dtype construction: both non-struct storage and a struct whose
326    /// fields are not GeoArrow coordinates.
327    #[test]
328    fn point_rejects_invalid_storage() -> VortexResult<()> {
329        let primitive = DType::Primitive(PType::F64, Nullability::NonNullable);
330        assert!(ExtDType::<Point>::try_new(geo_meta(), primitive).is_err());
331
332        let wrong_fields = StructArray::from_fields(&[
333            ("a", PrimitiveArray::from_iter(vec![0.0f64]).into_array()),
334            ("b", PrimitiveArray::from_iter(vec![0.0f64]).into_array()),
335        ])?
336        .into_array();
337        assert!(ExtDType::<Point>::try_new(geo_meta(), wrong_fields.dtype().clone()).is_err());
338        Ok(())
339    }
340
341    /// A `Point` column round-trips through scalar execution back to the original coordinates.
342    #[test]
343    fn point_unpacks_coordinates() -> VortexResult<()> {
344        let session = vortex_array::array_session();
345        let mut ctx = session.create_execution_ctx();
346
347        let points = point_column(vec![1.0, -111.7610], vec![2.0, 34.8697])?;
348
349        assert_eq!(
350            coordinate_from_scalar(&points.execute_scalar(0, &mut ctx)?)?,
351            Coordinate::xy(1.0, 2.0)
352        );
353        assert_eq!(
354            coordinate_from_scalar(&points.execute_scalar(1, &mut ctx)?)?,
355            Coordinate::xy(-111.7610, 34.8697)
356        );
357        Ok(())
358    }
359}