#![cfg(feature = "spatial")]
use crate::model::Model;
use crate::pg::accumulator::SqlAccumulator;
use crate::query::grouped::{IntoGroupKeyTuple, sealed};
use std::marker::PhantomData;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct SpatialJoinSpec {
pub(crate) t_geo_col: &'static str,
pub(crate) r_table: &'static str,
pub(crate) r_geo_col: &'static str,
pub(crate) r_pk_col: &'static str,
}
#[derive(Debug, Clone, PartialEq)]
pub struct RegionKey<R: Model> {
pub region_pk: Option<R::Pk>,
pub(crate) r_pk_col: Option<&'static str>,
pub(crate) _phantom: PhantomData<fn() -> R>,
}
#[derive(Debug, Clone, Copy)]
pub struct ClusterRadius {
pub(crate) eps_degrees: f64,
pub(crate) minpoints: i32,
}
impl ClusterRadius {
pub fn meters(m: f64) -> Self {
const METERS_PER_DEGREE: f64 = 111_320.0;
Self {
eps_degrees: m / METERS_PER_DEGREE,
minpoints: 1,
}
}
pub fn degrees(d: f64) -> Self {
Self {
eps_degrees: d,
minpoints: 1,
}
}
pub fn min_points(mut self, n: i32) -> Self {
self.minpoints = n;
self
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum GeohashPrecision {
P1,
P2,
P3,
P4,
P5,
P6,
P7,
P8,
P9,
P10,
P11,
P12,
}
impl GeohashPrecision {
pub(crate) fn as_i32(self) -> i32 {
match self {
Self::P1 => 1,
Self::P2 => 2,
Self::P3 => 3,
Self::P4 => 4,
Self::P5 => 5,
Self::P6 => 6,
Self::P7 => 7,
Self::P8 => 8,
Self::P9 => 9,
Self::P10 => 10,
Self::P11 => 11,
Self::P12 => 12,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ClusterId(pub Option<i32>);
#[derive(Debug, Clone, PartialEq)]
pub struct GeohashKey(pub Option<String>);
#[derive(Debug, Clone)]
pub(crate) struct ClusterSpec {
pub(crate) t_geo_col: &'static str,
pub(crate) eps_degrees: f64,
pub(crate) minpoints: i32,
}
#[derive(Debug, Clone)]
pub(crate) struct GeohashSpec {
pub(crate) t_geo_col: &'static str,
pub(crate) precision: i32,
}
impl<R: Model> sealed::Sealed for RegionKey<R> {}
impl<R: Model> IntoGroupKeyTuple for RegionKey<R>
where
R::Pk: for<'a> postgres_types::FromSql<'a> + Send + Unpin + 'static,
{
type Decoded = RegionKey<R>;
fn push_group_by_columns(&self, acc: &mut SqlAccumulator) {
let col = self
.r_pk_col
.expect("RegionKey::push_group_by_columns called on a decoded key (r_pk_col is None)");
acc.push_sql("r.");
acc.push_sql(col);
}
fn push_select_columns(&self, acc: &mut SqlAccumulator) {
let col = self
.r_pk_col
.expect("RegionKey::push_select_columns called on a decoded key (r_pk_col is None)");
acc.push_sql("r.");
acc.push_sql(col);
acc.push_sql(" AS rk0");
}
fn decode_tuple(row: &tokio_postgres::Row) -> Result<Self::Decoded, tokio_postgres::Error> {
let region_pk: Option<R::Pk> = row.try_get::<_, Option<R::Pk>>(0)?;
Ok(RegionKey {
region_pk,
r_pk_col: None,
_phantom: PhantomData,
})
}
}
impl sealed::Sealed for ClusterId {}
impl IntoGroupKeyTuple for ClusterId {
type Decoded = ClusterId;
fn push_group_by_columns(&self, acc: &mut SqlAccumulator) {
acc.push_sql("cluster_id");
}
fn push_select_columns(&self, _acc: &mut SqlAccumulator) {
unreachable!(
"ClusterId::push_select_columns is never called — \
cluster_by_proximity emits its own SELECT via build_cluster_grouped_select"
);
}
fn decode_tuple(row: &tokio_postgres::Row) -> Result<Self::Decoded, tokio_postgres::Error> {
let id: Option<i32> = row.try_get::<_, Option<i32>>(0)?;
Ok(ClusterId(id))
}
}
impl sealed::Sealed for GeohashKey {}
impl IntoGroupKeyTuple for GeohashKey {
type Decoded = GeohashKey;
fn push_group_by_columns(&self, acc: &mut SqlAccumulator) {
acc.push_sql("geohash");
}
fn push_select_columns(&self, _acc: &mut SqlAccumulator) {
unreachable!(
"GeohashKey::push_select_columns is never called — \
bucket_by_cell emits its own SELECT via build_geohash_grouped_select"
);
}
fn decode_tuple(row: &tokio_postgres::Row) -> Result<Self::Decoded, tokio_postgres::Error> {
let hash: Option<String> = row.try_get::<_, Option<String>>(0)?;
Ok(GeohashKey(hash))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::descriptor::ModelDescriptor;
use crate::pg::accumulator::SqlAccumulator;
use crate::query::grouped::IntoGroupKeyTuple;
struct FakeRegion;
impl crate::model::__sealed::Sealed for FakeRegion {}
#[allow(clippy::manual_async_fn)]
impl Model for FakeRegion {
type Pk = i64;
type Fields = ();
fn table_name() -> &'static str {
"regions"
}
fn pk_value(&self) -> &i64 {
unreachable!()
}
fn descriptor() -> &'static ModelDescriptor {
unreachable!()
}
fn get(
_ctx: &mut crate::context::DjogiContext,
_id: i64,
) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send {
async { unreachable!() }
}
fn create(
_ctx: &mut crate::context::DjogiContext,
_v: Self,
) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send {
async { unreachable!() }
}
fn save<'ctx>(
&'ctx mut self,
_ctx: &'ctx mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<(), crate::DjogiError>> + Send + 'ctx
{
async { unreachable!() }
}
fn delete(
self,
_ctx: &mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<(), crate::DjogiError>> + Send {
async { unreachable!() }
}
fn refresh_from_db<'ctx>(
&'ctx self,
_ctx: &'ctx mut crate::context::DjogiContext,
) -> impl std::future::Future<Output = Result<Self, crate::DjogiError>> + Send + 'ctx
{
async { unreachable!() }
}
}
#[test]
fn region_key_implements_into_group_key_tuple() {
fn assert_bound<T: IntoGroupKeyTuple>() {}
assert_bound::<RegionKey<FakeRegion>>();
}
#[test]
fn push_select_columns_emits_qualified_alias() {
let key = RegionKey::<FakeRegion> {
region_pk: None,
r_pk_col: Some("id"),
_phantom: PhantomData,
};
let mut acc = SqlAccumulator::new("");
key.push_select_columns(&mut acc);
assert_eq!(acc.sql(), "r.id AS rk0");
}
#[test]
fn push_group_by_columns_emits_qualified_column() {
let key = RegionKey::<FakeRegion> {
region_pk: None,
r_pk_col: Some("id"),
_phantom: PhantomData,
};
let mut acc = SqlAccumulator::new("");
key.push_group_by_columns(&mut acc);
assert_eq!(acc.sql(), "r.id");
}
#[test]
fn spatial_join_spec_fields_are_readable() {
let spec = SpatialJoinSpec {
t_geo_col: "location",
r_table: "regions",
r_geo_col: "boundary",
r_pk_col: "id",
};
assert_eq!(spec.t_geo_col, "location");
assert_eq!(spec.r_table, "regions");
assert_eq!(spec.r_geo_col, "boundary");
assert_eq!(spec.r_pk_col, "id");
}
#[test]
fn cluster_radius_meters_converts_to_degrees() {
let r = ClusterRadius::meters(500.0);
let expected = 500.0_f64 / 111_320.0;
let diff = (r.eps_degrees - expected).abs();
assert!(
diff < 1e-12,
"eps_degrees should be 500/111320, got {}",
r.eps_degrees
);
assert_eq!(r.minpoints, 1, "default min_points should be 1");
}
#[test]
fn cluster_radius_min_points_builder() {
let r = ClusterRadius::meters(500.0).min_points(3);
assert_eq!(r.minpoints, 3);
}
#[test]
fn cluster_radius_degrees_constructor() {
let r = ClusterRadius::degrees(0.01);
let diff = (r.eps_degrees - 0.01_f64).abs();
assert!(
diff < 1e-12,
"expected eps_degrees = 0.01, got {}",
r.eps_degrees
);
assert_eq!(r.minpoints, 1);
}
#[test]
fn geohash_precision_as_i32_returns_correct_value() {
assert_eq!(GeohashPrecision::P1.as_i32(), 1);
assert_eq!(GeohashPrecision::P5.as_i32(), 5);
assert_eq!(GeohashPrecision::P12.as_i32(), 12);
}
#[test]
fn cluster_id_implements_into_group_key_tuple() {
fn assert_bound<T: IntoGroupKeyTuple>() {}
assert_bound::<ClusterId>();
}
#[test]
#[should_panic(expected = "ClusterId::push_select_columns is never called")]
fn cluster_id_push_select_panics_because_unreachable() {
let key = ClusterId(None);
let mut acc = SqlAccumulator::new("");
key.push_select_columns(&mut acc);
}
#[test]
fn cluster_id_push_group_by_emits_alias() {
let key = ClusterId(None);
let mut acc = SqlAccumulator::new("");
key.push_group_by_columns(&mut acc);
assert_eq!(acc.sql(), "cluster_id");
}
#[test]
fn geohash_key_implements_into_group_key_tuple() {
fn assert_bound<T: IntoGroupKeyTuple>() {}
assert_bound::<GeohashKey>();
}
#[test]
#[should_panic(expected = "GeohashKey::push_select_columns is never called")]
fn geohash_key_push_select_panics_because_unreachable() {
let key = GeohashKey(None);
let mut acc = SqlAccumulator::new("");
key.push_select_columns(&mut acc);
}
#[test]
fn geohash_key_push_group_by_emits_alias() {
let key = GeohashKey(None);
let mut acc = SqlAccumulator::new("");
key.push_group_by_columns(&mut acc);
assert_eq!(acc.sql(), "geohash");
}
}