Skip to main content

vortex_geo/extension/
polygon.rs

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