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