Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/znippy-zoomies

osm-katana/src/metadata.rs

#![allow(clippy::float_arithmetic)]

//! The GeoParquet **`geo` file metadata** document.
//!
//! Per the [GeoParquet specification](https://geoparquet.org/releases/v1.1.0/) a
//! GeoParquet file "MUST include a `geo` key in the Parquet metadata" whose value
//! is a JSON-encoded UTF-8 string. Three fields are required — `version`,
//! `primary_column`, `columns` — and each geometry column MUST carry `encoding`
//! and `geometry_types`.
//!
//! Two spec details this module gets deliberately right:
//!
//! * **`crs` is PROJJSON, not a string.** The spec says the CRS "should be
//!   provided in PROJJSON format" and that omitting it means the default
//!   `OGC:CRS84` (longitude/latitude on WGS84). OSM coordinates *are* OGC:CRS84,
//!   so the correct encoding is to **omit the key**. Emitting the bare string
//!   `"EPSG:4326"` (as this module used to) is not valid PROJJSON and readers are
//!   entitled to reject it.
//! * **`bbox` is an array or absent.** `"bbox": null` is not a legal value; a
//!   file-level bbox we do not know is simply left out.
//!
//! [`GeoMeta`] additionally emits the 1.1 **`covering`** object, which is what
//! lets a reader prune row groups by min/max statistics on four plain numeric
//! columns instead of decoding WKB. See `.nornir/geoparquet-spatial-layout-design.md`.

/// Name of the file-level Parquet key-value metadata entry the spec mandates.
pub const GEO_KEY: &str = "geo";

/// The GeoParquet version this crate writes.
pub const GEO_VERSION: &str = "1.1.0";

/// Default name of the covering bounding-box struct column (`bbox`), matching
/// the spec's own example and Overture Maps' published layout.
pub const BBOX_COLUMN: &str = "bbox";

/// A builder for the `geo` metadata document.
///
/// ```
/// let json = osm_katana::metadata::GeoMeta::new(&["Point"])
///     .with_bbox([17.0, 59.0, 19.0, 60.0])
///     .with_covering("bbox")
///     .to_json();
/// assert!(json.contains("\"covering\""));
/// ```
#[derive(Clone, Debug)]
pub struct GeoMeta<'a> {
    primary_column: &'a str,
    geometry_types: &'a [&'a str],
    bbox: Option<[f64; 4]>,
    covering: Option<&'a str>,
    /// Extra WKB geometry columns and the types they may hold — see
    /// [`GeoMeta::with_secondary_wkb_columns`].
    secondary: Option<(&'a [String], &'a [&'a str])>,
}

impl<'a> GeoMeta<'a> {
    /// A minimal, spec-conformant document for a WKB `geometry` column holding
    /// `geometry_types`.
    pub fn new(geometry_types: &'a [&'a str]) -> Self {
        Self {
            primary_column: "geometry",
            geometry_types,
            bbox: None,
            covering: None,
            secondary: None,
        }
    }

    /// Declare the file-level bounding box `[xmin, ymin, xmax, ymax]`.
    pub fn with_bbox(mut self, bbox: [f64; 4]) -> Self {
        self.bbox = Some(bbox);
        self
    }

    /// Declare a GeoParquet 1.1 `covering.bbox` pointing at the struct column
    /// `column`, whose children MUST be named `xmin, ymin, xmax, ymax` in that
    /// order and be FLOAT or DOUBLE.
    pub fn with_covering(mut self, column: &'a str) -> Self {
        self.covering = Some(column);
        self
    }

    /// **Declare additional WKB geometry columns beside the primary one.**
    ///
    /// GeoParquet's `columns` is a MAP, and the spec is explicit that a file may carry
    /// several geometry columns; only `primary_column` is singular. The generalisation
    /// columns (`geometry_z6`/`z9`/`z12`) are geometry by every measure — they hold WKB,
    /// they carry `LogicalType::Geometry` in the schema — so leaving them out of this
    /// document would make a spec-following reader treat them as opaque binary while a
    /// schema-following reader saw geometry. One file, two answers.
    ///
    /// They get no `covering`: the covering column describes the FEATURE's extent, and a
    /// simplified copy of a feature has the same extent to within the epsilon. Pointing
    /// four columns at one `bbox` would be four claims that happen to be true; declaring
    /// it once, on the primary column, is one claim that is.
    pub fn with_secondary_wkb_columns(
        mut self,
        names: &'a [String],
        geometry_types: &'a [&'a str],
    ) -> Self {
        self.secondary = Some((names, geometry_types));
        self
    }

    /// Render the JSON document.
    pub fn to_json(&self) -> String {
        let types = self
            .geometry_types
            .iter()
            .map(|t| format!("\"{t}\""))
            .collect::<Vec<_>>()
            .join(",");
        let mut col = format!(r#""encoding":"WKB","geometry_types":[{types}]"#);
        if let Some([w, s, e, n]) = self.bbox {
            col.push_str(&format!(r#","bbox":[{w},{s},{e},{n}]"#));
        }
        if let Some(c) = self.covering {
            col.push_str(&format!(
                r#","covering":{{"bbox":{{"xmin":["{c}","xmin"],"ymin":["{c}","ymin"],"xmax":["{c}","xmax"],"ymax":["{c}","ymax"]}}}}"#
            ));
        }
        let mut cols = format!(r#""{}":{{{col}}}"#, self.primary_column);
        if let Some((names, types)) = self.secondary {
            let t = types
                .iter()
                .map(|t| format!("\"{t}\""))
                .collect::<Vec<_>>()
                .join(",");
            for n in names {
                cols.push_str(&format!(
                    r#","{n}":{{"encoding":"WKB","geometry_types":[{t}]}}"#
                ));
            }
        }
        format!(
            r#"{{"version":"{GEO_VERSION}","primary_column":"{}","columns":{{{cols}}}}}"#,
            self.primary_column,
        )
    }
}

/// **What `ways.parquet` may declare** — and does deliver. Measured on the
/// 2026-08-03 Sweden cut: 9 203 168 of 9 204 689 rows carry WKB geometry.
pub const WAY_GEOMETRY_TYPES: &[&str] = &["LineString", "Polygon"];

/// **What `relations.parquet` may declare: nothing.**
///
/// It used to declare `["MultiPolygon"]`. Measured on the 2026-08-03 Sweden cut
/// (`osm-katana verify ~/.korp/work/sweden`): **0 of 14 300 803 rows carried a
/// geometry — the column is 100 % NULL, 0.0 MB of a 1 011.7 MB file.** Nothing
/// in the producer ever assembles relation geometry; every writer path calls
/// `geoms.append_null()`.
///
/// So the declaration was not a description of the file, it was a promise about
/// a file that does not exist — and the cost of that promise is not abstract:
/// lakes with islands and forests with glades are OSM multipolygon RELATIONS,
/// a consumer that trusts the metadata reads the column, gets NULL, and draws
/// nothing. facett's WKB decoder can read all seven types since `b4ce305`; it is
/// the data that is missing.
///
/// The GeoParquet 1.1 spec makes the empty list mean exactly the right thing —
/// "no geometry types are present / unknown" — so an empty declaration is not a
/// weaker promise, it is the *true* one. Building the geometry is the better
/// answer and is a separate, sized piece of work (it needs a way-id → coords
/// join that pass 2 does not keep); until it lands, promising and delivering
/// NULL is the one option that was never acceptable.
///
/// Enforced by `writer::geo_declaration_tests` — a declaration must be non-empty
/// **iff** the file actually holds a non-null geometry.
pub const RELATION_GEOMETRY_TYPES: &[&str] = &[];

/// Back-compatible shorthand: the `geo` document for a WKB geometry column of
/// `geometry_types`, with an optional file-level bbox.
///
/// Kept as the existing call surface across `writer.rs`; new call sites that want
/// a `covering` use [`GeoMeta`] directly.
pub fn geo_metadata(geometry_types: &[&str], bbox: Option<[f64; 4]>) -> String {
    let mut m = GeoMeta::new(geometry_types);
    if let Some(b) = bbox {
        m = m.with_bbox(b);
    }
    m.to_json()
}

/// The `covering.bbox` column name declared in a `geo` document, if any.
///
/// Returns the struct column name (e.g. `"bbox"`) — the four leaf paths are then
/// `<name>.xmin`, `<name>.ymin`, `<name>.xmax`, `<name>.ymax`. Used by
/// [`crate::digest`] to exclude the *derived* covering column from the content
/// digest (it is a pure function of the geometry, so it carries no independent
/// content and including it would break comparability across a repack).
pub fn covering_column(geo_json: &str) -> Option<String> {
    let cov = geo_json.find("\"covering\"")?;
    let bbox = geo_json[cov..].find("\"xmin\"")?;
    let rest = &geo_json[cov + bbox..];
    let open = rest.find('[')?;
    let q1 = rest[open..].find('"')? + open + 1;
    let q2 = rest[q1..].find('"')? + q1;
    Some(rest[q1..q2].to_string())
}

#[cfg(test)]
mod tests {
    use super::{GeoMeta, covering_column, geo_metadata};
    use serde_json::{Value, json};

    /// Point layer, nothing optional: assert the full `geo` document parses to
    /// exactly the expected structure. RED if a field name, nesting or value
    /// drifts — in particular if the invalid `"crs":"EPSG:4326"` string or the
    /// invalid `"bbox":null` ever come back.
    #[test]
    fn point_minimal_matches_expected_json() {
        let s = geo_metadata(&["Point"], None);
        let got: Value = serde_json::from_str(&s).expect("geo_metadata must emit valid JSON");
        let expected = json!({
            "version": "1.1.0",
            "primary_column": "geometry",
            "columns": { "geometry": { "encoding": "WKB", "geometry_types": ["Point"] } }
        });
        assert_eq!(got, expected);
        // Spec conformance, stated as its own assertions so a regression names itself.
        assert!(
            got["columns"]["geometry"].get("crs").is_none(),
            "crs MUST be omitted (⇒ OGC:CRS84); a bare 'EPSG:4326' string is not PROJJSON"
        );
        assert!(
            got["columns"]["geometry"].get("bbox").is_none(),
            "bbox MUST be an array or absent — never null"
        );
    }

    /// Multi-geometry layer WITH a file-level bbox.
    #[test]
    fn multi_type_with_bbox_matches_expected_json() {
        let s = geo_metadata(
            &["LineString", "Polygon"],
            Some([10.5, -20.25, 30.75, 40.125]),
        );
        let got: Value = serde_json::from_str(&s).unwrap();
        assert_eq!(
            got["columns"]["geometry"]["geometry_types"],
            json!(["LineString", "Polygon"])
        );
        assert_eq!(
            got["columns"]["geometry"]["bbox"],
            json!([10.5, -20.25, 30.75, 40.125])
        );
    }

    /// The 1.1 `covering.bbox` object must have exactly the spec's shape:
    /// `{"xmin": ["<col>", "xmin"], …}` for all four corners, in that order.
    #[test]
    fn covering_matches_spec_shape() {
        let s = GeoMeta::new(&["Point"]).with_covering("bbox").to_json();
        let got: Value = serde_json::from_str(&s).expect("valid JSON");
        let cov = &got["columns"]["geometry"]["covering"]["bbox"];
        assert_eq!(cov["xmin"], json!(["bbox", "xmin"]));
        assert_eq!(cov["ymin"], json!(["bbox", "ymin"]));
        assert_eq!(cov["xmax"], json!(["bbox", "xmax"]));
        assert_eq!(cov["ymax"], json!(["bbox", "ymax"]));
        assert_eq!(got["version"], json!("1.1.0"));
        // …and the extractor the digest relies on must find it.
        assert_eq!(covering_column(&s).as_deref(), Some("bbox"));
        // RED-when-broken: a document WITHOUT a covering must yield None, or the
        // digest would silently skip a real column.
        assert_eq!(covering_column(&geo_metadata(&["Point"], None)), None);
    }
}