kenro 0.2.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
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
//! Spatial predicates, DE-9IM based via `geo::Relate` so boundary cases come
//! out PostGIS-correct (e.g. a point on a polygon boundary: intersects=true,
//! contains=false).

use geo::{Distance, Euclidean, Relate};
use geo_types::Geometry;

use crate::error::{Error, Result};
use crate::geom::{self, Geom};

pub fn st_intersects(a: &[u8], b: &[u8]) -> Result<bool> {
    relate_predicate("ST_Intersects", a, b, |m| m.is_intersects())
}

pub fn st_contains(a: &[u8], b: &[u8]) -> Result<bool> {
    relate_predicate("ST_Contains", a, b, |m| m.is_contains())
}

pub fn st_within(a: &[u8], b: &[u8]) -> Result<bool> {
    relate_predicate("ST_Within", a, b, |m| m.is_within())
}

/// The only predicate that is TRUE when an operand is empty (the DE-9IM
/// matrix degenerates to `FF*FF****`).
pub fn st_disjoint(a: &[u8], b: &[u8]) -> Result<bool> {
    let (ga, gb) = decode_pair("ST_Disjoint", a, b)?;
    if geom::is_empty(&ga.geometry) || geom::is_empty(&gb.geometry) {
        return Ok(true);
    }
    reject_collection("ST_Disjoint", &ga)?;
    reject_collection("ST_Disjoint", &gb)?;
    Ok(ga.geometry.relate(&gb.geometry).is_disjoint())
}

pub fn st_touches(a: &[u8], b: &[u8]) -> Result<bool> {
    relate_predicate("ST_Touches", a, b, |m| m.is_touches())
}

pub fn st_crosses(a: &[u8], b: &[u8]) -> Result<bool> {
    relate_predicate("ST_Crosses", a, b, |m| m.is_crosses())
}

pub fn st_overlaps(a: &[u8], b: &[u8]) -> Result<bool> {
    relate_predicate("ST_Overlaps", a, b, |m| m.is_overlaps())
}

/// Two empty geometries are equal in PostGIS (golden-verified), so the
/// empty short-circuit differs from the other predicates.
pub fn st_equals(a: &[u8], b: &[u8]) -> Result<bool> {
    let (ga, gb) = decode_pair("ST_Equals", a, b)?;
    let (ea, eb) = (geom::is_empty(&ga.geometry), geom::is_empty(&gb.geometry));
    if ea || eb {
        return Ok(ea && eb);
    }
    reject_collection("ST_Equals", &ga)?;
    reject_collection("ST_Equals", &gb)?;
    Ok(ga.geometry.relate(&gb.geometry).is_equal_topo())
}

pub fn st_covers(a: &[u8], b: &[u8]) -> Result<bool> {
    relate_predicate("ST_Covers", a, b, |m| m.is_covers())
}

pub fn st_covered_by(a: &[u8], b: &[u8]) -> Result<bool> {
    relate_predicate("ST_CoveredBy", a, b, |m| m.is_coveredby())
}

/// `ST_Relate(a, b)` → the 9-character DE-9IM matrix string.
pub fn st_relate(a: &[u8], b: &[u8]) -> Result<String> {
    de9im_string("ST_Relate", a, b)
}

/// `ST_Relate(a, b, pattern)` → whether the DE-9IM matrix matches the
/// pattern (`T` = any intersection, `F` = none, `0`/`1`/`2` = exact
/// dimension, `*` = anything).
pub fn st_relate_pattern(a: &[u8], b: &[u8], pattern: &str) -> Result<bool> {
    let matrix = de9im_string("ST_Relate", a, b)?;
    de9im_matches("ST_Relate", &matrix, pattern)
}

/// 2D cartesian minimum distance; `None` (SQL NULL) when either side is
/// empty, matching PostGIS.
pub fn st_distance(a: &[u8], b: &[u8]) -> Result<Option<f64>> {
    let (ga, gb) = decode_pair("ST_Distance", a, b)?;
    if geom::is_empty(&ga.geometry) || geom::is_empty(&gb.geometry) {
        return Ok(None);
    }
    reject_collection("ST_Distance", &ga)?;
    reject_collection("ST_Distance", &gb)?;
    Ok(Some(Euclidean.distance(&ga.geometry, &gb.geometry)))
}

/// `distance <= d` (PostGIS uses `<=` and raises on a negative tolerance).
pub fn st_dwithin(a: &[u8], b: &[u8], d: f64) -> Result<bool> {
    if d < 0.0 {
        return Err(Error::Unsupported {
            func: "ST_DWithin",
            reason: "tolerance cannot be less than zero".into(),
        });
    }
    Ok(st_distance(a, b)?.is_some_and(|dist| dist <= d))
}

fn relate_predicate(
    func: &'static str,
    a: &[u8],
    b: &[u8],
    pred: impl Fn(&geo::relate::IntersectionMatrix) -> bool,
) -> Result<bool> {
    let (ga, gb) = decode_pair(func, a, b)?;
    relate_geoms(func, &ga, &gb, pred)
}

/// The predicate body, over geometries that are already decoded.
///
/// Callers that hold a decoded geometry across many calls — a host that
/// keeps one search window and tests thousands of candidates against it —
/// reach the same semantics through [`decoded`] without re-decoding a blob
/// per call. Everything below the decode is shared, so the two paths cannot
/// drift.
fn relate_geoms(
    func: &'static str,
    a: &Geom,
    b: &Geom,
    pred: impl Fn(&geo::relate::IntersectionMatrix) -> bool,
) -> Result<bool> {
    check_pair(func, a, b)?;
    if geom::is_empty(&a.geometry) || geom::is_empty(&b.geometry) {
        return Ok(false);
    }
    reject_collection(func, a)?;
    reject_collection(func, b)?;
    let matrix = a.geometry.relate(&b.geometry);
    Ok(pred(&matrix))
}

/// Predicates and measures over already-decoded geometries.
///
/// Same contract as the blob functions of the same name — this *is* their
/// implementation — for hosts that can hold a decoded geometry between
/// calls. In SQLite that is not possible (a UDF receives bytes), but a
/// wasm host handing back a handle can.
pub mod decoded {
    use super::*;

    pub fn st_intersects(a: &Geom, b: &Geom) -> Result<bool> {
        relate_geoms("ST_Intersects", a, b, |m| m.is_intersects())
    }

    pub fn st_contains(a: &Geom, b: &Geom) -> Result<bool> {
        relate_geoms("ST_Contains", a, b, |m| m.is_contains())
    }

    pub fn st_within(a: &Geom, b: &Geom) -> Result<bool> {
        relate_geoms("ST_Within", a, b, |m| m.is_within())
    }

    pub fn st_covers(a: &Geom, b: &Geom) -> Result<bool> {
        relate_geoms("ST_Covers", a, b, |m| m.is_covers())
    }

    /// `None` (SQL NULL) when either side is empty, matching PostGIS.
    pub fn st_distance(a: &Geom, b: &Geom) -> Result<Option<f64>> {
        const FUNC: &str = "ST_Distance";
        check_pair(FUNC, a, b)?;
        if geom::is_empty(&a.geometry) || geom::is_empty(&b.geometry) {
            return Ok(None);
        }
        reject_collection(FUNC, a)?;
        reject_collection(FUNC, b)?;
        Ok(Some(Euclidean.distance(&a.geometry, &b.geometry)))
    }

    pub fn st_dwithin(a: &Geom, b: &Geom, d: f64) -> Result<bool> {
        if d < 0.0 {
            return Err(Error::Unsupported {
                func: "ST_DWithin",
                reason: "tolerance cannot be less than zero".into(),
            });
        }
        Ok(st_distance(a, b)?.is_some_and(|dist| dist <= d))
    }
}

/// The 9-character DE-9IM string in row order (Interior, Boundary,
/// Exterior of a) × (Interior, Boundary, Exterior of b). Empty operands
/// bypass `relate` (their matrix is fully determined by the other side's
/// dimensions), everything else reads the computed matrix cell by cell.
fn de9im_string(func: &'static str, a: &[u8], b: &[u8]) -> Result<String> {
    use geo::HasDimensions;
    use geo::algorithm::dimensions::Dimensions;
    use geo::coordinate_position::CoordPos;

    let (ga, gb) = decode_pair(func, a, b)?;
    reject_collection(func, &ga)?;
    reject_collection(func, &gb)?;

    let dim_char = |d: Dimensions| match d {
        Dimensions::Empty => 'F',
        Dimensions::ZeroDimensional => '0',
        Dimensions::OneDimensional => '1',
        Dimensions::TwoDimensional => '2',
    };

    let a_empty = geom::is_empty(&ga.geometry);
    let b_empty = geom::is_empty(&gb.geometry);
    if a_empty || b_empty {
        // An empty side's interior/boundary rows and columns are all F; its
        // exterior is everything, so it meets the other side's interior and
        // boundary at their full dimensions. Exterior × exterior is always 2.
        let (ai, ab) = if a_empty {
            (Dimensions::Empty, Dimensions::Empty)
        } else {
            (ga.geometry.dimensions(), ga.geometry.boundary_dimensions())
        };
        let (bi, bb) = if b_empty {
            (Dimensions::Empty, Dimensions::Empty)
        } else {
            (gb.geometry.dimensions(), gb.geometry.boundary_dimensions())
        };
        let cells = [
            ['F', 'F', dim_char(ai)],
            ['F', 'F', dim_char(ab)],
            [dim_char(bi), dim_char(bb), '2'],
        ];
        return Ok(cells.iter().flatten().collect());
    }

    let matrix = ga.geometry.relate(&gb.geometry);
    let positions = [CoordPos::Inside, CoordPos::OnBoundary, CoordPos::Outside];
    Ok(positions
        .iter()
        .flat_map(|pa| positions.iter().map(|pb| dim_char(matrix.get(*pa, *pb))))
        .collect())
}

/// Match a computed DE-9IM string against a pattern.
fn de9im_matches(func: &'static str, matrix: &str, pattern: &str) -> Result<bool> {
    if pattern.len() != 9 {
        return Err(Error::Unsupported {
            func,
            reason: format!("DE-9IM pattern must be 9 characters, got {:?}", pattern),
        });
    }
    let mut result = true;
    for (m, p) in matrix.chars().zip(pattern.chars()) {
        let ok = match p.to_ascii_uppercase() {
            '*' => true,
            'T' => m != 'F',
            'F' => m == 'F',
            '0' | '1' | '2' => m == p,
            other => {
                return Err(Error::Unsupported {
                    func,
                    reason: format!("invalid DE-9IM pattern character {other:?}"),
                });
            }
        };
        result &= ok;
    }
    Ok(result)
}

fn decode_pair(func: &'static str, a: &[u8], b: &[u8]) -> Result<(Geom, Geom)> {
    let ga = geom::decode_auto(a)?;
    let gb = geom::decode_auto(b)?;
    check_pair(func, &ga, &gb)?;
    Ok((ga, gb))
}

fn check_pair(func: &'static str, a: &Geom, b: &Geom) -> Result<()> {
    // Mixed *known* SRIDs error (PostGIS behavior). If either side is
    // unknown (srid <= 0 — plain WKB, or ST_GeomFromText without srid),
    // proceed: the headline rtree+predicate query depends on this leniency.
    if a.srid > 0 && b.srid > 0 && a.srid != b.srid {
        return Err(Error::MixedSrid {
            func,
            a: a.srid,
            b: b.srid,
        });
    }
    Ok(())
}

fn reject_collection(func: &'static str, g: &Geom) -> Result<()> {
    if matches!(g.geometry, Geometry::GeometryCollection(_)) {
        return Err(Error::Unsupported {
            func,
            reason: "GeometryCollection operands are not supported".into(),
        });
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::functions::io::st_geom_from_text;

    fn g(wkt: &str) -> Vec<u8> {
        st_geom_from_text(wkt, None).unwrap()
    }

    fn g_srid(wkt: &str, srid: i32) -> Vec<u8> {
        st_geom_from_text(wkt, Some(srid)).unwrap()
    }

    const SQUARE: &str = "POLYGON((0 0,10 0,10 10,0 10,0 0))";

    #[test]
    fn basic_predicates() {
        assert!(st_intersects(&g(SQUARE), &g("POINT(5 5)")).unwrap());
        assert!(!st_intersects(&g(SQUARE), &g("POINT(20 20)")).unwrap());
        assert!(st_contains(&g(SQUARE), &g("POINT(5 5)")).unwrap());
        assert!(st_within(&g("POINT(5 5)"), &g(SQUARE)).unwrap());
        assert!(!st_within(&g(SQUARE), &g("POINT(5 5)")).unwrap());
    }

    #[test]
    fn boundary_point_intersects_but_not_contained() {
        let pt = g("POINT(10 5)");
        assert!(st_intersects(&g(SQUARE), &pt).unwrap());
        assert!(!st_contains(&g(SQUARE), &pt).unwrap());
        assert!(!st_within(&pt, &g(SQUARE)).unwrap());
    }

    #[test]
    fn distance_and_dwithin() {
        let a = g("POINT(0 0)");
        let b = g("POINT(3 4)");
        assert_eq!(st_distance(&a, &b).unwrap(), Some(5.0));
        assert!(st_dwithin(&a, &b, 5.0).unwrap()); // <= boundary
        assert!(!st_dwithin(&a, &b, 4.999).unwrap());
        assert!(st_dwithin(&a, &b, -1.0).is_err()); // PostGIS raises too
    }

    #[test]
    fn empty_operands() {
        let e = g("LINESTRING EMPTY");
        assert!(!st_intersects(&g(SQUARE), &e).unwrap());
        assert!(!st_contains(&g(SQUARE), &e).unwrap());
        assert_eq!(st_distance(&g(SQUARE), &e).unwrap(), None);
        assert!(!st_dwithin(&g(SQUARE), &e, 100.0).unwrap());
    }

    #[test]
    fn srid_gate() {
        let a = g_srid("POINT(0 0)", 4326);
        let b = g_srid("POINT(0 0)", 3857);
        assert!(matches!(
            st_intersects(&a, &b),
            Err(Error::MixedSrid { .. })
        ));
        // Unknown srid on either side proceeds.
        assert!(st_intersects(&a, &g("POINT(0 0)")).unwrap());
    }

    #[test]
    fn predicate_family() {
        let square = g(SQUARE);
        let inner = g("POLYGON((2 2,8 2,8 8,2 8,2 2))");
        let adjacent = g("POLYGON((10 0,20 0,20 10,10 10,10 0))");
        let far = g("POLYGON((100 100,110 100,110 110,100 110,100 100))");
        let overlapping = g("POLYGON((5 5,15 5,15 15,5 15,5 5))");
        let crossing_line = g("LINESTRING(-5 5,15 5)");
        let boundary_pt = g("POINT(10 5)");

        assert!(st_disjoint(&square, &far).unwrap());
        assert!(!st_disjoint(&square, &inner).unwrap());
        assert!(st_touches(&square, &adjacent).unwrap());
        assert!(!st_touches(&square, &inner).unwrap());
        assert!(st_crosses(&square, &crossing_line).unwrap());
        assert!(!st_crosses(&square, &inner).unwrap());
        assert!(st_overlaps(&square, &overlapping).unwrap());
        assert!(!st_overlaps(&square, &inner).unwrap()); // containment is not overlap
        assert!(st_equals(&square, &g(SQUARE)).unwrap());
        assert!(!st_equals(&square, &inner).unwrap());
        // Covers vs Contains: a boundary point is covered but not contained.
        assert!(st_covers(&square, &boundary_pt).unwrap());
        assert!(!st_contains(&square, &boundary_pt).unwrap());
        assert!(st_covered_by(&boundary_pt, &square).unwrap());
    }

    #[test]
    fn disjoint_is_true_for_empty_operands() {
        let e = g("LINESTRING EMPTY");
        assert!(st_disjoint(&g(SQUARE), &e).unwrap());
        assert!(st_disjoint(&e, &e).unwrap());
        assert!(!st_touches(&g(SQUARE), &e).unwrap());
        assert!(st_equals(&e, &e).unwrap()); // PostGIS: two empties are equal
        assert!(!st_equals(&g(SQUARE), &e).unwrap());
    }

    #[test]
    fn relate_strings() {
        // Point strictly inside a polygon: the classic 0FFFFF212.
        assert_eq!(st_relate(&g(SQUARE), &g("POINT(5 5)")).unwrap().len(), 9);
        assert_eq!(
            st_relate(&g("POINT(5 5)"), &g(SQUARE)).unwrap(),
            "0FFFFF212"
        );
        // Empty vs polygon: manual matrix.
        assert_eq!(
            st_relate(&g("LINESTRING EMPTY"), &g(SQUARE)).unwrap(),
            "FFFFFF212"
        );
        assert_eq!(
            st_relate(&g(SQUARE), &g("POLYGON EMPTY")).unwrap(),
            "FF2FF1FF2"
        );
    }

    #[test]
    fn relate_pattern_matching() {
        let within_pattern = "T*F**F***";
        assert!(st_relate_pattern(&g("POINT(5 5)"), &g(SQUARE), within_pattern).unwrap());
        assert!(!st_relate_pattern(&g("POINT(50 50)"), &g(SQUARE), within_pattern).unwrap());
        // Case-insensitive pattern letters; invalid patterns error.
        assert!(st_relate_pattern(&g("POINT(5 5)"), &g(SQUARE), "t*f**f***").unwrap());
        assert!(st_relate_pattern(&g(SQUARE), &g(SQUARE), "*********").unwrap());
        assert!(st_relate_pattern(&g(SQUARE), &g(SQUARE), "TOOSHORT").is_err());
        assert!(st_relate_pattern(&g(SQUARE), &g(SQUARE), "X********").is_err());
    }

    #[test]
    fn geometry_collection_rejected() {
        let gc = g("GEOMETRYCOLLECTION(POINT(1 1))");
        assert!(matches!(
            st_contains(&g(SQUARE), &gc),
            Err(Error::Unsupported { .. })
        ));
        assert!(matches!(
            st_intersects(&gc, &g(SQUARE)),
            Err(Error::Unsupported { .. })
        ));
    }
}