kenro 0.4.0

SpatiaLite-style spatial SQL for SQLite in pure Rust — PostGIS-compatible ST_ functions, GeoPackage R-tree, CRS transform, H3, MVT. Use via rusqlite, loadable extension, or WASM
Documentation
//! Golden tests for the Tier B measure/processing functions vs the
//! reference PostGIS. Scalars compare by value with tolerance;
//! geometry-returning functions compare geometrically.

mod common;

use common::Vector;
use kenro::functions::{affine, io, measures, processing};
use rusqlite::Connection;
use rusqlite::types::Value;

fn check(id: &str, func: &str, got: &serde_json::Value, want: &serde_json::Value) {
    let geometric = matches!(
        func,
        "closestpoint"
            | "lineinterpolate"
            | "pointonsurface"
            | "simplifyvw"
            | "chaikin"
            | "removerepeated"
            | "rotate"
            | "rotate4"
            | "translate"
            | "scale"
    );
    // Convex rings compare as vertex sets (ring rotation/direction differ
    // legitimately between implementations).
    let vertex_set = matches!(func, "convexhull" | "orientedenv");
    match (got, want) {
        (serde_json::Value::Null, serde_json::Value::Null) => {}
        (serde_json::Value::Number(g), serde_json::Value::Number(w)) => {
            common::assert_number(id, g.as_f64().unwrap(), w.as_f64().unwrap())
        }
        (serde_json::Value::String(g), serde_json::Value::String(w)) if vertex_set => {
            assert!(
                common::geoms_same_vertex_set(g, w, 1e-9),
                "{id}: got {g}, want {w}"
            )
        }
        (serde_json::Value::String(g), serde_json::Value::String(w)) if geometric => {
            assert!(
                common::geoms_approx_equal(g, w, 1e-9),
                "{id}: got {g}, want {w}"
            )
        }
        (serde_json::Value::String(g), serde_json::Value::String(w)) => {
            assert_eq!(g, w, "{id}")
        }
        (got, want) => panic!("{id}: type mismatch: got {got}, want {want}"),
    }
}

fn opt_astext(blob: Option<Vec<u8>>) -> Result<serde_json::Value, kenro::Error> {
    Ok(match blob {
        Some(b) => io::st_as_text(&b)?.into(),
        None => serde_json::Value::Null,
    })
}

fn run_pure(v: &Vector) -> Result<serde_json::Value, kenro::Error> {
    let a = io::st_geom_from_text(v.a.as_ref().unwrap(), None)?;
    let b = || io::st_geom_from_text(v.b.as_ref().unwrap(), None);
    Ok(match v.func.as_str() {
        "closestpoint" => opt_astext(measures::st_closest_point(&a, &b()?)?)?,
        "lineinterpolate" => {
            io::st_as_text(&measures::st_line_interpolate_point(&a, v.arg.unwrap())?)?.into()
        }
        "linelocate" => measures::st_line_locate_point(&a, &b()?)?.into(),
        "hausdorff" => measures::st_hausdorff_distance(&a, &b()?)?.into(),
        "frechet" => measures::st_frechet_distance(&a, &b()?)?.into(),
        "azimuth" => match measures::st_azimuth(&a, &b()?)? {
            Some(az) => serde_json::json!(az),
            None => serde_json::Value::Null,
        },
        "convexhull" => io::st_as_text(&processing::st_convex_hull(&a)?)?.into(),
        "pointonsurface" => io::st_as_text(&processing::st_point_on_surface(&a)?)?.into(),
        "simplifyvw" => io::st_as_text(&processing::st_simplify_vw(&a, v.arg.unwrap())?)?.into(),
        "chaikin" => io::st_as_text(&processing::st_chaikin_smoothing(
            &a,
            v.arg.unwrap() as i64,
        )?)?
        .into(),
        "removerepeated" => io::st_as_text(&processing::st_remove_repeated_points(&a)?)?.into(),
        "orientedenv" => io::st_as_text(&processing::st_oriented_envelope(&a)?)?.into(),
        "rotate" => io::st_as_text(&affine::st_rotate(&a, v.arg.unwrap())?)?.into(),
        "rotate4" => {
            let args = v.args.clone().unwrap();
            io::st_as_text(&affine::st_rotate_xy(&a, v.arg.unwrap(), args[0], args[1])?)?.into()
        }
        "translate" => io::st_as_text(&affine::st_translate(&a, 10.0, -2.0)?)?.into(),
        "scale" => io::st_as_text(&affine::st_scale(&a, 2.0, 3.0)?)?.into(),
        other => panic!("{}: unknown fn {other}", v.id),
    })
}

#[test]
fn golden_processing_through_pure_functions() {
    for v in common::load("processing") {
        let result = run_pure(&v);
        if v.expects_error() {
            assert!(result.is_err(), "{}: expected an error", v.id);
            continue;
        }
        let got = result.unwrap_or_else(|e| panic!("{}: {e}", v.id));
        check(&v.id, &v.func, &got, v.effective());
    }
}

#[test]
fn golden_processing_through_sql() {
    let conn = Connection::open_in_memory().unwrap();
    kenro::register(&conn).unwrap();
    for v in common::load("processing") {
        let sql = match v.func.as_str() {
            "closestpoint" => {
                "SELECT ST_AsText(ST_ClosestPoint(ST_GeomFromText(?1), ST_GeomFromText(?2)))"
            }
            "lineinterpolate" => {
                "SELECT ST_AsText(ST_LineInterpolatePoint(ST_GeomFromText(?1), ?2))"
            }
            "linelocate" => "SELECT ST_LineLocatePoint(ST_GeomFromText(?1), ST_GeomFromText(?2))",
            "hausdorff" => "SELECT ST_HausdorffDistance(ST_GeomFromText(?1), ST_GeomFromText(?2))",
            "frechet" => "SELECT ST_FrechetDistance(ST_GeomFromText(?1), ST_GeomFromText(?2))",
            "azimuth" => "SELECT ST_Azimuth(ST_GeomFromText(?1), ST_GeomFromText(?2))",
            "convexhull" => "SELECT ST_AsText(ST_ConvexHull(ST_GeomFromText(?1)))",
            "pointonsurface" => "SELECT ST_AsText(ST_PointOnSurface(ST_GeomFromText(?1)))",
            "simplifyvw" => "SELECT ST_AsText(ST_SimplifyVW(ST_GeomFromText(?1), ?2))",
            "chaikin" => "SELECT ST_AsText(ST_ChaikinSmoothing(ST_GeomFromText(?1), ?2))",
            "removerepeated" => "SELECT ST_AsText(ST_RemoveRepeatedPoints(ST_GeomFromText(?1)))",
            "orientedenv" => "SELECT ST_AsText(ST_OrientedEnvelope(ST_GeomFromText(?1)))",
            "rotate" => "SELECT ST_AsText(ST_Rotate(ST_GeomFromText(?1), ?2))",
            "rotate4" => "SELECT ST_AsText(ST_Rotate(ST_GeomFromText(?1), ?2, ?3, ?4))",
            "translate" => "SELECT ST_AsText(ST_Translate(ST_GeomFromText(?1), 10.0, -2.0))",
            "scale" => "SELECT ST_AsText(ST_Scale(ST_GeomFromText(?1), 2.0, 3.0))",
            other => panic!("{}: unknown fn {other}", v.id),
        };
        let result: rusqlite::Result<Value> = match v.func.as_str() {
            "lineinterpolate" | "simplifyvw" | "rotate" => conn.query_row(
                sql,
                rusqlite::params![v.a.as_ref().unwrap(), v.arg.unwrap()],
                |r| r.get(0),
            ),
            "chaikin" => conn.query_row(
                sql,
                rusqlite::params![v.a.as_ref().unwrap(), v.arg.unwrap() as i64],
                |r| r.get(0),
            ),
            "rotate4" => {
                let args = v.args.clone().unwrap();
                conn.query_row(
                    sql,
                    rusqlite::params![v.a.as_ref().unwrap(), v.arg.unwrap(), args[0], args[1]],
                    |r| r.get(0),
                )
            }
            "convexhull" | "pointonsurface" | "removerepeated" | "orientedenv" | "translate"
            | "scale" => conn.query_row(sql, [v.a.as_ref().unwrap()], |r| r.get(0)),
            _ => conn.query_row(sql, [v.a.as_ref().unwrap(), v.b.as_ref().unwrap()], |r| {
                r.get(0)
            }),
        };
        if v.expects_error() {
            assert!(result.is_err(), "{}: expected an error", v.id);
            continue;
        }
        let got = match result.unwrap_or_else(|e| panic!("{}: {e}", v.id)) {
            Value::Null => serde_json::Value::Null,
            Value::Real(f) => f.into(),
            Value::Integer(i) => i.into(),
            Value::Text(s) => s.into(),
            other => panic!("{}: unexpected SQL value {other:?}", v.id),
        };
        check(&v.id, &v.func, &got, v.effective());
    }
}