#[derive(Debug, Clone, Copy, PartialEq)]
pub struct RadiusBbox {
pub dlon: f64,
pub dlat: f64,
}
const METRES_PER_DEGREE: f64 = 111_320.0;
pub fn radius_bbox(lat_deg: f64, radius_m: f64) -> RadiusBbox {
let dlat = radius_m / METRES_PER_DEGREE;
let cos_lat = lat_deg.to_radians().cos().abs().max(1.0e-6);
let dlon = (radius_m / (METRES_PER_DEGREE * cos_lat)).min(180.0);
RadiusBbox { dlon, dlat }
}
pub fn dwithin_sphere_indexed_sql(
table: &str,
geom_column: &str,
probe: (f64, f64),
radius_m: f64,
select_cols: &str,
) -> diesel::query_builder::SqlQuery {
diesel::sql_query(dwithin_sphere_indexed_sql_string(
table,
geom_column,
probe,
radius_m,
select_cols,
))
}
pub fn dwithin_sphere_indexed_sql_string(
table: &str,
geom_column: &str,
probe: (f64, f64),
radius_m: f64,
select_cols: &str,
) -> String {
let (lon, lat) = probe;
let bbox = radius_bbox(lat, radius_m);
format!(
"SELECT {select_cols} \
FROM [{table}] t \
JOIN [{table}_{geom_column}_rtree] r ON t.rowid = r.id \
WHERE r.xmax >= {x_min} AND r.xmin <= {x_max} \
AND r.ymax >= {y_min} AND r.ymin <= {y_max} \
AND ST_DWithinSphere(t.[{geom_column}], \
ST_Point({lon}, {lat}, 4326), {radius_m})",
x_min = lon - bbox.dlon,
x_max = lon + bbox.dlon,
y_min = lat - bbox.dlat,
y_max = lat + bbox.dlat,
)
}
pub fn intersects_window_indexed_sql(
table: &str,
geom_column: &str,
window: (f64, f64, f64, f64),
select_cols: &str,
) -> diesel::query_builder::SqlQuery {
diesel::sql_query(intersects_window_indexed_sql_string(
table,
geom_column,
window,
select_cols,
))
}
pub fn intersects_window_indexed_sql_string(
table: &str,
geom_column: &str,
window: (f64, f64, f64, f64),
select_cols: &str,
) -> String {
let (xmin, ymin, xmax, ymax) = window;
format!(
"SELECT {select_cols} \
FROM [{table}] t \
JOIN [{table}_{geom_column}_rtree] r ON t.rowid = r.id \
WHERE r.xmax >= {xmin} AND r.xmin <= {xmax} \
AND r.ymax >= {ymin} AND r.ymin <= {ymax} \
AND ST_Intersects(t.[{geom_column}], \
ST_MakeEnvelope({xmin}, {ymin}, {xmax}, {ymax}, 4326))",
)
}
pub fn nearest_sphere_indexed_sql(
table: &str,
geom_column: &str,
probe: (f64, f64),
search_radius_m: f64,
limit: usize,
select_cols: &str,
) -> diesel::query_builder::SqlQuery {
diesel::sql_query(nearest_sphere_indexed_sql_string(
table,
geom_column,
probe,
search_radius_m,
limit,
select_cols,
))
}
pub fn nearest_sphere_indexed_sql_string(
table: &str,
geom_column: &str,
probe: (f64, f64),
search_radius_m: f64,
limit: usize,
select_cols: &str,
) -> String {
let (lon, lat) = probe;
let bbox = radius_bbox(lat, search_radius_m);
format!(
"SELECT {select_cols} \
FROM [{table}] t \
JOIN [{table}_{geom_column}_rtree] r ON t.rowid = r.id \
WHERE r.xmax >= {x_min} AND r.xmin <= {x_max} \
AND r.ymax >= {y_min} AND r.ymin <= {y_max} \
ORDER BY ST_DistanceSphere(t.[{geom_column}], \
ST_Point({lon}, {lat}, 4326)) \
LIMIT {limit}",
x_min = lon - bbox.dlon,
x_max = lon + bbox.dlon,
y_min = lat - bbox.dlat,
y_max = lat + bbox.dlat,
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn radius_bbox_constant_dlat() {
let r = 1_000_000.0;
let expected = r / METRES_PER_DEGREE;
for lat in [-89.0_f64, -45.0, 0.0, 45.0, 89.0] {
let bbox = radius_bbox(lat, r);
assert!(
(bbox.dlat - expected).abs() < 1e-9,
"dlat at lat={lat} was {dlat}, expected {expected}",
dlat = bbox.dlat,
);
}
}
#[test]
fn radius_bbox_dlon_grows_with_latitude() {
let r = 1_000_000.0;
let dlons: Vec<f64> = [0.0, 30.0, 45.0, 60.0, 80.0]
.iter()
.map(|&lat| radius_bbox(lat, r).dlon)
.collect();
for window in dlons.windows(2) {
assert!(
window[1] > window[0],
"dlon sequence should be increasing, got {dlons:?}",
);
}
let at_45 = radius_bbox(45.0, r).dlon;
let expected_at_45 = r / (METRES_PER_DEGREE * 45.0_f64.to_radians().cos());
assert!(
(at_45 - expected_at_45).abs() < 1e-6,
"dlon at 45 was {at_45}, expected {expected_at_45}",
);
}
#[test]
fn radius_bbox_clamps_near_pole() {
let bbox = radius_bbox(89.9999, 1_000_000.0);
assert!(
bbox.dlon.is_finite(),
"dlon must stay finite near the pole, got {}",
bbox.dlon,
);
assert_eq!(bbox.dlon, 180.0);
}
#[test]
fn dwithin_sphere_indexed_sql_shape() {
let sql = dwithin_sphere_indexed_sql_string(
"places",
"geom",
(13.4, 52.5),
1_000_000.0,
"t.id, t.name",
);
assert!(sql.contains("SELECT t.id, t.name"), "SQL was: {sql}");
assert!(sql.contains("FROM [places] t"), "SQL was: {sql}");
assert!(
sql.contains("JOIN [places_geom_rtree] r ON t.rowid = r.id"),
"SQL was: {sql}",
);
assert!(sql.contains("r.xmax >="), "SQL was: {sql}");
assert!(sql.contains("r.xmin <="), "SQL was: {sql}");
assert!(sql.contains("r.ymax >="), "SQL was: {sql}");
assert!(sql.contains("r.ymin <="), "SQL was: {sql}");
assert!(sql.contains("ST_DWithinSphere(t.[geom]"), "SQL was: {sql}");
assert!(sql.contains("ST_Point(13.4, 52.5, 4326)"), "SQL was: {sql}",);
assert!(sql.contains("1000000"), "SQL was: {sql}");
}
#[test]
fn intersects_window_indexed_sql_shape() {
let sql = intersects_window_indexed_sql_string(
"places",
"geom",
(-1.6, 37.5, 28.4, 67.5),
"t.id, t.name",
);
assert!(sql.contains("SELECT t.id, t.name"), "SQL was: {sql}");
assert!(sql.contains("FROM [places] t"), "SQL was: {sql}");
assert!(
sql.contains("JOIN [places_geom_rtree] r ON t.rowid = r.id"),
"SQL was: {sql}",
);
assert!(sql.contains("r.xmax >= -1.6"), "SQL was: {sql}");
assert!(sql.contains("r.xmin <= 28.4"), "SQL was: {sql}");
assert!(sql.contains("r.ymax >= 37.5"), "SQL was: {sql}");
assert!(sql.contains("r.ymin <= 67.5"), "SQL was: {sql}");
assert!(sql.contains("ST_Intersects(t.[geom]"), "SQL was: {sql}",);
assert!(
sql.contains("ST_MakeEnvelope(-1.6, 37.5, 28.4, 67.5, 4326)"),
"SQL was: {sql}",
);
}
#[test]
fn nearest_sphere_indexed_sql_shape() {
let sql = nearest_sphere_indexed_sql_string(
"places",
"geom",
(13.4, 52.5),
1_000_000.0,
10,
"t.id, t.name",
);
assert!(sql.contains("SELECT t.id, t.name"), "SQL was: {sql}");
assert!(sql.contains("FROM [places] t"), "SQL was: {sql}");
assert!(
sql.contains("JOIN [places_geom_rtree] r ON t.rowid = r.id"),
"SQL was: {sql}",
);
assert!(sql.contains("r.xmax >="), "SQL was: {sql}");
assert!(sql.contains("r.xmin <="), "SQL was: {sql}");
assert!(sql.contains("r.ymax >="), "SQL was: {sql}");
assert!(sql.contains("r.ymin <="), "SQL was: {sql}");
assert!(
sql.contains("ORDER BY ST_DistanceSphere(t.[geom]"),
"SQL was: {sql}",
);
assert!(sql.contains("ST_Point(13.4, 52.5, 4326)"), "SQL was: {sql}",);
assert!(sql.contains("LIMIT 10"), "SQL was: {sql}");
}
}