geoarrow-schema 0.8.0

GeoArrow geometry type and metadata definitions.
Documentation
use arrow_schema::{ArrowError, Field};
use serde::{Deserialize, Serialize};

use crate::Edges;
use crate::crs::Crs;

/// GeoArrow extension metadata.
///
/// This follows the extension metadata [defined by the GeoArrow
/// specification](https://geoarrow.org/extension-types).
///
/// This struct is contained within all GeoArrow geometry type definitions, such as
/// [`PointType`][crate::PointType], [`GeometryType`][crate::GeometryType], or
/// [`WkbType`][crate::WkbType].
#[derive(Default, Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct Metadata {
    // Raise the underlying crs fields to this level.
    // https://serde.rs/attr-flatten.html
    #[serde(flatten)]
    crs: Crs,

    /// If present, instructs consumers that edges follow a spherical path rather than a planar
    /// one. If this value is omitted, edges will be interpreted as planar.
    #[serde(skip_serializing_if = "Option::is_none")]
    edges: Option<Edges>,
}

impl Metadata {
    /// Creates a new [`Metadata`] object.
    pub fn new(crs: Crs, edges: Option<Edges>) -> Self {
        Self { crs, edges }
    }

    /// Expose the underlying Coordinate Reference System information.
    pub fn crs(&self) -> &Crs {
        &self.crs
    }

    /// Expose the underlying edge interpolation
    pub fn edges(&self) -> Option<Edges> {
        self.edges
    }

    /// Serialize this metadata to a string.
    ///
    /// If `None`, no extension metadata should be written.
    pub(crate) fn serialize(&self) -> Option<String> {
        if self.crs.should_serialize() || self.edges.is_some() {
            Some(serde_json::to_string(&self).unwrap())
        } else {
            None
        }
    }

    /// Deserialize metadata from a string.
    pub(crate) fn deserialize<S: AsRef<str>>(metadata: Option<S>) -> Result<Self, ArrowError> {
        if let Some(ext_meta) = metadata {
            Ok(serde_json::from_str(ext_meta.as_ref())
                .map_err(|err| ArrowError::ExternalError(Box::new(err)))?)
        } else {
            Ok(Default::default())
        }
    }
}

impl TryFrom<&Field> for Metadata {
    type Error = ArrowError;

    fn try_from(value: &Field) -> Result<Self, Self::Error> {
        Self::deserialize(value.extension_type_metadata())
    }
}

#[cfg(test)]
mod test {
    use std::collections::HashMap;
    use std::str::FromStr;

    use arrow_schema::DataType;
    use serde_json::{Value, json};

    use super::*;

    const EPSG_4326_WKT: &str = r#"GEOGCRS["WGS 84",ENSEMBLE["World Geodetic System 1984 ensemble",MEMBER["World Geodetic System 1984 (Transit)"],MEMBER["World Geodetic System 1984 (G730)"],MEMBER["World Geodetic System 1984 (G873)"],MEMBER["World Geodetic System 1984 (G1150)"],MEMBER["World Geodetic System 1984 (G1674)"],MEMBER["World Geodetic System 1984 (G1762)"],MEMBER["World Geodetic System 1984 (G2139)"],ELLIPSOID["WGS 84",6378137,298.257223563,LENGTHUNIT["metre",1]],ENSEMBLEACCURACY[2.0]],PRIMEM["Greenwich",0,ANGLEUNIT["degree",0.0174532925199433]],CS[ellipsoidal,2],AXIS["geodetic latitude (Lat)",north,ORDER[1],ANGLEUNIT["degree",0.0174532925199433]],AXIS["geodetic longitude (Lon)",east,ORDER[2],ANGLEUNIT["degree",0.0174532925199433]],USAGE[SCOPE["Horizontal component of 3D system."],AREA["World."],BBOX[-90,-180,90,180]],ID["EPSG",4326]]"#;

    const EPSG_4326_PROJJSON: &str = r#"{"$schema":"https://proj.org/schemas/v0.7/projjson.schema.json","type":"GeographicCRS","name":"WGS 84","datum_ensemble":{"name":"World Geodetic System 1984 ensemble","members":[{"name":"World Geodetic System 1984 (Transit)","id":{"authority":"EPSG","code":1166}},{"name":"World Geodetic System 1984 (G730)","id":{"authority":"EPSG","code":1152}},{"name":"World Geodetic System 1984 (G873)","id":{"authority":"EPSG","code":1153}},{"name":"World Geodetic System 1984 (G1150)","id":{"authority":"EPSG","code":1154}},{"name":"World Geodetic System 1984 (G1674)","id":{"authority":"EPSG","code":1155}},{"name":"World Geodetic System 1984 (G1762)","id":{"authority":"EPSG","code":1156}},{"name":"World Geodetic System 1984 (G2139)","id":{"authority":"EPSG","code":1309}}],"ellipsoid":{"name":"WGS 84","semi_major_axis":6378137,"inverse_flattening":298.257223563},"accuracy":"2.0","id":{"authority":"EPSG","code":6326}},"coordinate_system":{"subtype":"ellipsoidal","axis":[{"name":"Geodetic latitude","abbreviation":"Lat","direction":"north","unit":"degree"},{"name":"Geodetic longitude","abbreviation":"Lon","direction":"east","unit":"degree"}]},"scope":"Horizontal component of 3D system.","area":"World.","bbox":{"south_latitude":-90,"west_longitude":-180,"north_latitude":90,"east_longitude":180},"id":{"authority":"EPSG","code":4326}}"#;

    #[test]
    fn test_crs_authority_code() {
        let crs = Crs::from_authority_code("EPSG:4326".to_string());
        let metadata = Metadata::new(crs, Some(Edges::Spherical));

        let expected = r#"{"crs":"EPSG:4326","crs_type":"authority_code","edges":"spherical"}"#;
        let serialized = metadata.serialize();
        assert_eq!(serialized.as_deref(), Some(expected));

        assert_eq!(
            metadata,
            Metadata::deserialize(serialized.as_deref()).unwrap()
        );
    }

    #[test]
    fn test_crs_authority_code_no_edges() {
        let crs = Crs::from_authority_code("EPSG:4326".to_string());
        let metadata = Metadata::new(crs, None);

        let expected = r#"{"crs":"EPSG:4326","crs_type":"authority_code"}"#;

        let serialized = metadata.serialize();
        assert_eq!(serialized.as_deref(), Some(expected));

        assert_eq!(
            metadata,
            Metadata::deserialize(serialized.as_deref()).unwrap()
        );
    }

    #[test]
    fn test_crs_wkt() {
        let crs = Crs::from_wkt2_2019(EPSG_4326_WKT.to_string());
        let metadata = Metadata::new(crs, None);

        let expected = r#"{"crs":"GEOGCRS[\"WGS 84\",ENSEMBLE[\"World Geodetic System 1984 ensemble\",MEMBER[\"World Geodetic System 1984 (Transit)\"],MEMBER[\"World Geodetic System 1984 (G730)\"],MEMBER[\"World Geodetic System 1984 (G873)\"],MEMBER[\"World Geodetic System 1984 (G1150)\"],MEMBER[\"World Geodetic System 1984 (G1674)\"],MEMBER[\"World Geodetic System 1984 (G1762)\"],MEMBER[\"World Geodetic System 1984 (G2139)\"],ELLIPSOID[\"WGS 84\",6378137,298.257223563,LENGTHUNIT[\"metre\",1]],ENSEMBLEACCURACY[2.0]],PRIMEM[\"Greenwich\",0,ANGLEUNIT[\"degree\",0.0174532925199433]],CS[ellipsoidal,2],AXIS[\"geodetic latitude (Lat)\",north,ORDER[1],ANGLEUNIT[\"degree\",0.0174532925199433]],AXIS[\"geodetic longitude (Lon)\",east,ORDER[2],ANGLEUNIT[\"degree\",0.0174532925199433]],USAGE[SCOPE[\"Horizontal component of 3D system.\"],AREA[\"World.\"],BBOX[-90,-180,90,180]],ID[\"EPSG\",4326]]","crs_type":"wkt2:2019"}"#;

        let serialized = metadata.serialize();
        assert_eq!(serialized.as_deref(), Some(expected));

        assert_eq!(
            metadata,
            Metadata::deserialize(serialized.as_deref()).unwrap()
        );
    }

    #[test]
    fn test_projjson() {
        let crs = Crs::from_projjson(Value::from_str(EPSG_4326_PROJJSON).unwrap());
        let metadata = Metadata::new(crs, None);

        let expected = r#"{"crs":{"$schema":"https://proj.org/schemas/v0.7/projjson.schema.json","type":"GeographicCRS","name":"WGS 84","datum_ensemble":{"name":"World Geodetic System 1984 ensemble","members":[{"name":"World Geodetic System 1984 (Transit)","id":{"authority":"EPSG","code":1166}},{"name":"World Geodetic System 1984 (G730)","id":{"authority":"EPSG","code":1152}},{"name":"World Geodetic System 1984 (G873)","id":{"authority":"EPSG","code":1153}},{"name":"World Geodetic System 1984 (G1150)","id":{"authority":"EPSG","code":1154}},{"name":"World Geodetic System 1984 (G1674)","id":{"authority":"EPSG","code":1155}},{"name":"World Geodetic System 1984 (G1762)","id":{"authority":"EPSG","code":1156}},{"name":"World Geodetic System 1984 (G2139)","id":{"authority":"EPSG","code":1309}}],"ellipsoid":{"name":"WGS 84","semi_major_axis":6378137,"inverse_flattening":298.257223563},"accuracy":"2.0","id":{"authority":"EPSG","code":6326}},"coordinate_system":{"subtype":"ellipsoidal","axis":[{"name":"Geodetic latitude","abbreviation":"Lat","direction":"north","unit":"degree"},{"name":"Geodetic longitude","abbreviation":"Lon","direction":"east","unit":"degree"}]},"scope":"Horizontal component of 3D system.","area":"World.","bbox":{"south_latitude":-90,"west_longitude":-180,"north_latitude":90,"east_longitude":180},"id":{"authority":"EPSG","code":4326}},"crs_type":"projjson"}"#;

        let serialized = metadata.serialize();

        // We use Value for equality checking because JSON string formatting is different
        assert_eq!(
            Value::from_str(serialized.as_deref().unwrap()).unwrap(),
            Value::from_str(expected).unwrap()
        );

        assert_eq!(
            metadata,
            Metadata::deserialize(serialized.as_deref()).unwrap()
        );
    }

    #[test]
    fn test_unknown_crs() {
        let crs = Crs::from_unknown_crs_type("CRS".to_string());
        let metadata = Metadata::new(crs, None);

        let expected = r#"{"crs":"CRS"}"#;

        let serialized = metadata.serialize();
        assert_eq!(serialized.as_deref(), Some(expected));

        assert_eq!(
            metadata,
            Metadata::deserialize(serialized.as_deref()).unwrap()
        );
    }

    #[test]
    fn test_empty_metadata() {
        let metadata = Metadata::default();
        let serialized = metadata.serialize();
        assert_eq!(serialized.as_deref(), None);

        assert_eq!(
            metadata,
            Metadata::deserialize(serialized.as_deref()).unwrap()
        );
    }

    #[test]
    fn from_field() {
        let field = Field::new("", DataType::Null, false).with_metadata(HashMap::from([(
            "ARROW:extension:metadata".to_string(),
            r#"{"crs": {}, "crs_type": "projjson", "edges": "spherical"}"#.to_string(),
        )]));

        let metadata = Metadata::try_from(&field).unwrap();
        assert_eq!(metadata.crs(), &Crs::from_projjson(json!({})));
        assert_eq!(metadata.edges(), Some(Edges::Spherical));

        let bad_field = Field::new("", DataType::Null, false).with_metadata(HashMap::from([(
            "ARROW:extension:metadata".to_string(),
            "not valid json".to_string(),
        )]));
        assert_eq!(
            Metadata::try_from(&bad_field).unwrap_err().to_string(),
            "External error: expected ident at line 1 column 2"
        );
    }
}