#[cfg(feature = "spatial")]
use crate::geo::GeoPoint;
#[cfg(feature = "spatial")]
use crate::pg::accumulator::SqlAccumulator;
#[cfg(feature = "spatial")]
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum SpatialExpr {
Within {
field_column: &'static str,
center: GeoPoint,
radius_meters: f64,
},
Distance {
field_column: &'static str,
center: GeoPoint,
},
Contains {
field_column: &'static str,
other_ewkb: Vec<u8>,
},
Intersects {
field_column: &'static str,
other_ewkb: Vec<u8>,
},
Touches {
field_column: &'static str,
other_ewkb: Vec<u8>,
},
WithinShape {
field_column: &'static str,
other_ewkb: Vec<u8>,
},
BoundedBy {
field_column: &'static str,
min_lat: f64,
min_lon: f64,
max_lat: f64,
max_lon: f64,
},
Area {
geom_ewkb: Vec<u8>,
},
Intersection {
a_ewkb: Vec<u8>,
b_ewkb: Vec<u8>,
},
AreaOfIntersection {
a_ewkb: Vec<u8>,
b_ewkb: Vec<u8>,
},
}
#[cfg(feature = "spatial")]
impl SpatialExpr {
pub(crate) fn emit(&self, acc: &mut SqlAccumulator) {
match self {
SpatialExpr::Within {
field_column,
center,
radius_meters,
} => {
acc.push_sql("ST_DWithin(");
acc.push_sql(field_column);
acc.push_sql(", ST_Point(");
acc.push_bind(center.lon);
acc.push_sql(", ");
acc.push_bind(center.lat);
acc.push_sql(")::geography, ");
acc.push_bind(*radius_meters);
acc.push_sql(")");
}
SpatialExpr::Distance {
field_column,
center,
} => {
acc.push_sql("ST_Distance(");
acc.push_sql(field_column);
acc.push_sql(", ST_Point(");
acc.push_bind(center.lon);
acc.push_sql(", ");
acc.push_bind(center.lat);
acc.push_sql(")::geography)");
}
SpatialExpr::Contains {
field_column,
other_ewkb,
} => {
emit_binary_predicate(acc, ShapePredicate::Contains, field_column, other_ewkb);
}
SpatialExpr::Intersects {
field_column,
other_ewkb,
} => {
emit_binary_predicate(acc, ShapePredicate::Intersects, field_column, other_ewkb);
}
SpatialExpr::Touches {
field_column,
other_ewkb,
} => {
emit_binary_predicate(acc, ShapePredicate::Touches, field_column, other_ewkb);
}
SpatialExpr::WithinShape {
field_column,
other_ewkb,
} => {
emit_binary_predicate(acc, ShapePredicate::Within, field_column, other_ewkb);
}
SpatialExpr::BoundedBy {
field_column,
min_lat,
min_lon,
max_lat,
max_lon,
} => {
acc.push_sql("ST_MakeEnvelope(");
acc.push_bind(*min_lon);
acc.push_sql(", ");
acc.push_bind(*min_lat);
acc.push_sql(", ");
acc.push_bind(*max_lon);
acc.push_sql(", ");
acc.push_bind(*max_lat);
acc.push_sql(", 4326)::geography && ");
acc.push_sql(field_column);
}
SpatialExpr::Area { geom_ewkb } => {
acc.push_sql("ST_Area(");
push_ewkb_arg(acc, geom_ewkb, EwkbCast::Geography);
acc.push_sql(")");
}
SpatialExpr::Intersection { a_ewkb, b_ewkb } => {
acc.push_sql("ST_Intersection(");
push_ewkb_arg(acc, a_ewkb, EwkbCast::Geometry);
acc.push_sql(", ");
push_ewkb_arg(acc, b_ewkb, EwkbCast::Geometry);
acc.push_sql(")::geography");
}
SpatialExpr::AreaOfIntersection { a_ewkb, b_ewkb } => {
acc.push_sql("ST_Area(ST_Intersection(");
push_ewkb_arg(acc, a_ewkb, EwkbCast::Geometry);
acc.push_sql(", ");
push_ewkb_arg(acc, b_ewkb, EwkbCast::Geometry);
acc.push_sql(")::geography)");
}
}
}
}
#[cfg(feature = "spatial")]
#[derive(Clone, Copy)]
enum EwkbCast {
Geometry,
Geography,
}
#[cfg(feature = "spatial")]
fn push_ewkb_arg(acc: &mut SqlAccumulator, ewkb: &[u8], cast: EwkbCast) {
acc.push_bind(ewkb.to_vec());
acc.push_sql(match cast {
EwkbCast::Geometry => "::bytea::geometry",
EwkbCast::Geography => "::bytea::geography",
});
}
#[cfg(feature = "spatial")]
#[derive(Clone, Copy)]
enum ShapePredicate {
Contains,
Intersects,
Touches,
Within,
}
#[cfg(feature = "spatial")]
impl ShapePredicate {
fn function_name(self) -> &'static str {
match self {
Self::Contains => "ST_Contains",
Self::Intersects => "ST_Intersects",
Self::Touches => "ST_Touches",
Self::Within => "ST_Within",
}
}
fn needs_geometry_cast(self) -> bool {
!matches!(self, Self::Intersects)
}
}
#[cfg(feature = "spatial")]
fn emit_binary_predicate(
acc: &mut SqlAccumulator,
predicate: ShapePredicate,
field_column: &'static str,
other_ewkb: &[u8],
) {
let use_geometry = predicate.needs_geometry_cast();
let col_cast = if use_geometry { "::geometry" } else { "" };
let bind_cast = if use_geometry {
EwkbCast::Geometry
} else {
EwkbCast::Geography
};
acc.push_sql(predicate.function_name());
acc.push_sql("(");
acc.push_sql(field_column);
acc.push_sql(col_cast);
acc.push_sql(", ");
push_ewkb_arg(acc, other_ewkb, bind_cast);
acc.push_sql(")");
}
#[cfg(all(test, feature = "spatial"))]
mod tests {
use super::*;
use crate::geo::GeoPoint;
use crate::pg::accumulator::SqlAccumulator;
#[test]
fn within_km_emits_st_dwithin() {
let center = GeoPoint::new(37.7749, -122.4194).unwrap();
let expr = SpatialExpr::Within {
field_column: "location",
center,
radius_meters: 5000.0,
};
let mut acc = SqlAccumulator::new("");
expr.emit(&mut acc);
let sql = acc.sql();
assert!(
sql.contains("ST_DWithin"),
"expected ST_DWithin in SQL; got: {sql}"
);
assert!(
sql.contains("location"),
"expected column name 'location' in SQL; got: {sql}"
);
assert_eq!(
acc.bind_count(),
3,
"Within must bind exactly 3 params (lon, lat, radius_meters); got {}",
acc.bind_count()
);
assert!(
sql.contains("$1") && sql.contains("$2") && sql.contains("$3"),
"expected $1, $2, $3 in SQL; got: {sql}"
);
}
#[test]
fn distance_emits_st_distance() {
let center = GeoPoint::new(37.7749, -122.4194).unwrap();
let expr = SpatialExpr::Distance {
field_column: "location",
center,
};
let mut acc = SqlAccumulator::new("");
expr.emit(&mut acc);
let sql = acc.sql();
assert!(
sql.contains("ST_Distance"),
"expected ST_Distance in SQL; got: {sql}"
);
assert!(
sql.contains("location"),
"expected column name 'location' in SQL; got: {sql}"
);
assert_eq!(
acc.bind_count(),
2,
"Distance must bind exactly 2 params (lon, lat); got {}",
acc.bind_count()
);
assert!(
sql.contains("$1") && sql.contains("$2"),
"expected $1 and $2 in SQL; got: {sql}"
);
}
#[test]
fn within_km_injection_safe() {
let center = GeoPoint::new(12.3456, -98.7654).unwrap();
let expr = SpatialExpr::Within {
field_column: "location",
center,
radius_meters: 1234.5,
};
let mut acc = SqlAccumulator::new("");
expr.emit(&mut acc);
let sql = acc.sql();
assert!(
!sql.contains("12.3456"),
"latitude appeared literally in SQL — bind discipline violated; got: {sql}"
);
assert!(
!sql.contains("98.7654"),
"longitude appeared literally in SQL — bind discipline violated; got: {sql}"
);
assert!(
!sql.contains("1234.5"),
"radius appeared literally in SQL — bind discipline violated; got: {sql}"
);
}
#[test]
fn both_variants_include_geography_cast() {
let center = GeoPoint::new(0.0, 0.0).unwrap();
let within = SpatialExpr::Within {
field_column: "loc",
center,
radius_meters: 100.0,
};
let mut acc = SqlAccumulator::new("");
within.emit(&mut acc);
assert!(
acc.sql().contains("::geography"),
"Within must include ::geography cast; got: {}",
acc.sql()
);
let distance = SpatialExpr::Distance {
field_column: "loc",
center,
};
let mut acc2 = SqlAccumulator::new("");
distance.emit(&mut acc2);
assert!(
acc2.sql().contains("::geography"),
"Distance must include ::geography cast; got: {}",
acc2.sql()
);
}
#[test]
fn contains_emits_st_contains_with_ewkb_bind() {
let other_poly_bytes = vec![0x01, 0x02, 0x03]; let expr = SpatialExpr::Contains {
field_column: "area",
other_ewkb: other_poly_bytes.clone(),
};
let mut acc = SqlAccumulator::new("");
expr.emit(&mut acc);
let sql = acc.sql();
assert!(
sql.contains("ST_Contains"),
"expected ST_Contains, got: {sql}"
);
assert!(
sql.contains("area::geometry"),
"expected column 'area::geometry', got: {sql}"
);
assert!(
sql.contains("::bytea::geometry"),
"expected ::bytea::geometry bind cast, got: {sql}"
);
assert!(
!sql.contains("::geography"),
"ST_Contains must not use ::geography (no such overload in PostGIS 3.x); got: {sql}"
);
assert_eq!(
acc.bind_count(),
1,
"expected 1 bind (the EWKB bytes), got {}",
acc.bind_count()
);
}
#[test]
fn intersects_emits_st_intersects_with_ewkb_bind() {
let ewkb = vec![0xDE, 0xAD, 0xBE, 0xEF];
let expr = SpatialExpr::Intersects {
field_column: "route",
other_ewkb: ewkb,
};
let mut acc = SqlAccumulator::new("");
expr.emit(&mut acc);
let sql = acc.sql();
assert!(
sql.contains("ST_Intersects"),
"expected ST_Intersects, got: {sql}"
);
assert!(sql.contains("route"), "expected column 'route', got: {sql}");
assert!(
sql.contains("::bytea::geography"),
"expected ::bytea::geography bind cast, got: {sql}"
);
assert!(
!sql.contains("route::geometry"),
"ST_Intersects must keep geography column (no ::geometry cast); got: {sql}"
);
assert_eq!(
acc.bind_count(),
1,
"expected 1 bind, got {}",
acc.bind_count()
);
}
#[test]
fn touches_emits_st_touches_with_ewkb_bind() {
let ewkb = vec![0xAA, 0xBB];
let expr = SpatialExpr::Touches {
field_column: "boundary",
other_ewkb: ewkb,
};
let mut acc = SqlAccumulator::new("");
expr.emit(&mut acc);
let sql = acc.sql();
assert!(
sql.contains("ST_Touches"),
"expected ST_Touches, got: {sql}"
);
assert!(
sql.contains("boundary::geometry"),
"expected column 'boundary::geometry', got: {sql}"
);
assert!(
sql.contains("::bytea::geometry"),
"expected ::bytea::geometry bind cast, got: {sql}"
);
assert!(
!sql.contains("::geography"),
"ST_Touches must not use ::geography (no such overload); got: {sql}"
);
assert_eq!(
acc.bind_count(),
1,
"expected 1 bind, got {}",
acc.bind_count()
);
}
#[test]
fn within_shape_emits_st_within_not_st_dwithin() {
let ewkb = vec![0x01, 0xFF];
let expr = SpatialExpr::WithinShape {
field_column: "zone",
other_ewkb: ewkb,
};
let mut acc = SqlAccumulator::new("");
expr.emit(&mut acc);
let sql = acc.sql();
assert!(sql.contains("ST_Within"), "expected ST_Within, got: {sql}");
assert!(
!sql.contains("ST_DWithin"),
"got ST_DWithin instead of ST_Within: {sql}"
);
assert!(
sql.contains("zone::geometry"),
"expected column 'zone::geometry', got: {sql}"
);
assert!(
sql.contains("::bytea::geometry"),
"expected ::bytea::geometry bind cast, got: {sql}"
);
assert!(
!sql.contains("::geography"),
"ST_Within must not use ::geography (no such overload); got: {sql}"
);
assert_eq!(
acc.bind_count(),
1,
"expected 1 bind, got {}",
acc.bind_count()
);
}
#[test]
fn contains_injection_safe() {
let ewkb = vec![0x01, 0x03, 0x00, 0x00, 0x20]; let expr = SpatialExpr::Contains {
field_column: "coverage",
other_ewkb: ewkb,
};
let mut acc = SqlAccumulator::new("");
expr.emit(&mut acc);
let sql = acc.sql();
assert_eq!(
acc.bind_count(),
1,
"EWKB must be a bind param, not embedded in SQL; bind_count={}",
acc.bind_count()
);
assert!(sql.contains("$1"), "expected $1 placeholder, got: {sql}");
}
#[test]
fn intersects_injection_safe() {
let ewkb = vec![0x01, 0x02, 0x00, 0x00, 0x20];
let expr = SpatialExpr::Intersects {
field_column: "coverage",
other_ewkb: ewkb,
};
let mut acc = SqlAccumulator::new("");
expr.emit(&mut acc);
let sql = acc.sql();
assert_eq!(acc.bind_count(), 1);
assert!(sql.contains("$1"), "expected $1 placeholder, got: {sql}");
}
#[test]
fn touches_injection_safe() {
let ewkb = vec![0x01, 0x05, 0x00, 0x00, 0x20];
let expr = SpatialExpr::Touches {
field_column: "coverage",
other_ewkb: ewkb,
};
let mut acc = SqlAccumulator::new("");
expr.emit(&mut acc);
let sql = acc.sql();
assert_eq!(acc.bind_count(), 1);
assert!(sql.contains("$1"), "expected $1 placeholder, got: {sql}");
}
#[test]
fn within_shape_injection_safe() {
let ewkb = vec![0x01, 0x06, 0x00, 0x00, 0x20];
let expr = SpatialExpr::WithinShape {
field_column: "coverage",
other_ewkb: ewkb,
};
let mut acc = SqlAccumulator::new("");
expr.emit(&mut acc);
let sql = acc.sql();
assert_eq!(acc.bind_count(), 1);
assert!(sql.contains("$1"), "expected $1 placeholder, got: {sql}");
}
#[test]
fn bounded_by_emits_st_makeenvelope_in_xy_order() {
let expr = SpatialExpr::BoundedBy {
field_column: "area",
min_lat: 37.0,
min_lon: -123.0,
max_lat: 38.0,
max_lon: -122.0,
};
let mut acc = SqlAccumulator::new("");
expr.emit(&mut acc);
let sql = acc.sql();
assert!(
sql.contains("ST_MakeEnvelope("),
"expected ST_MakeEnvelope in SQL; got: {sql}"
);
assert!(
sql.contains("::geography &&"),
"expected ::geography && in SQL; got: {sql}"
);
assert!(
sql.contains("area"),
"expected column name 'area' after &&; got: {sql}"
);
assert!(
sql.contains("$1") && sql.contains("$2") && sql.contains("$3") && sql.contains("$4"),
"expected $1 $2 $3 $4 in SQL; got: {sql}"
);
assert_eq!(
acc.bind_count(),
4,
"BoundedBy must bind exactly 4 params; got {}",
acc.bind_count()
);
}
#[test]
fn bounded_by_emits_all_four_coords_as_binds() {
let expr = SpatialExpr::BoundedBy {
field_column: "zone",
min_lat: 11.1111,
min_lon: 22.2222,
max_lat: 33.3333,
max_lon: 44.4444,
};
let mut acc = SqlAccumulator::new("");
expr.emit(&mut acc);
let sql = acc.sql();
assert!(
!sql.contains("11.1111"),
"min_lat leaked into SQL; got: {sql}"
);
assert!(
!sql.contains("22.2222"),
"min_lon leaked into SQL; got: {sql}"
);
assert!(
!sql.contains("33.3333"),
"max_lat leaked into SQL; got: {sql}"
);
assert!(
!sql.contains("44.4444"),
"max_lon leaked into SQL; got: {sql}"
);
assert_eq!(
acc.bind_count(),
4,
"expected 4 binds, got {}",
acc.bind_count()
);
}
#[test]
fn bounded_by_includes_srid_4326_literal() {
let expr = SpatialExpr::BoundedBy {
field_column: "coverage",
min_lat: 0.0,
min_lon: 0.0,
max_lat: 1.0,
max_lon: 1.0,
};
let mut acc = SqlAccumulator::new("");
expr.emit(&mut acc);
assert!(
acc.sql().contains("4326"),
"expected literal 4326 SRID in SQL; got: {}",
acc.sql()
);
}
#[test]
fn distance_emits_st_distance_with_correct_structure() {
let center = GeoPoint::new(37.7749, -122.4194).unwrap();
let expr = SpatialExpr::Distance {
field_column: "loc",
center,
};
let mut acc = SqlAccumulator::new("");
expr.emit(&mut acc);
let sql = acc.sql();
assert!(
sql.contains("ST_Distance"),
"expected ST_Distance; got: {sql}"
);
assert!(sql.contains("loc"), "expected column 'loc'; got: {sql}");
assert!(
sql.contains("::geography"),
"expected ::geography cast; got: {sql}"
);
assert_eq!(
acc.bind_count(),
2,
"Distance binds lon + lat (2 params); got {}",
acc.bind_count()
);
}
#[test]
fn sequential_predicates_increment_bind_counter() {
let ewkb_a = vec![0xAA];
let ewkb_b = vec![0xBB];
let expr_a = SpatialExpr::Contains {
field_column: "area",
other_ewkb: ewkb_a,
};
let expr_b = SpatialExpr::Intersects {
field_column: "route",
other_ewkb: ewkb_b,
};
let mut acc = SqlAccumulator::new("");
expr_a.emit(&mut acc);
acc.push_sql(" AND ");
expr_b.emit(&mut acc);
let sql = acc.sql();
assert_eq!(
acc.bind_count(),
2,
"expected 2 total binds, got {}",
acc.bind_count()
);
assert!(sql.contains("$1"), "first bind must be $1; got: {sql}");
assert!(sql.contains("$2"), "second bind must be $2; got: {sql}");
}
#[test]
fn area_emits_st_area_with_geography_cast() {
let expr = SpatialExpr::Area {
geom_ewkb: vec![0x01, 0x02, 0x03],
};
let mut acc = SqlAccumulator::new("");
expr.emit(&mut acc);
let sql = acc.sql();
assert!(sql.contains("ST_Area("), "expected ST_Area, got: {sql}");
assert!(
sql.contains("::bytea::geography"),
"expected ::bytea::geography cast for meters-units, got: {sql}"
);
assert_eq!(
acc.bind_count(),
1,
"Area binds exactly one EWKB param; got {}",
acc.bind_count()
);
}
#[test]
fn intersection_emits_st_intersection_with_geometry_cast() {
let expr = SpatialExpr::Intersection {
a_ewkb: vec![0xAA],
b_ewkb: vec![0xBB],
};
let mut acc = SqlAccumulator::new("");
expr.emit(&mut acc);
let sql = acc.sql();
assert!(
sql.contains("ST_Intersection("),
"expected ST_Intersection, got: {sql}"
);
assert!(
sql.contains("$1::bytea::geometry"),
"expected $1::bytea::geometry for input arg, got: {sql}"
);
assert!(
sql.contains("$2::bytea::geometry"),
"expected $2::bytea::geometry for input arg, got: {sql}"
);
assert!(
sql.ends_with("::geography"),
"expected output ::geography cast for Polygon decode, got: {sql}"
);
assert!(
!sql.contains("::bytea::geography"),
"ST_Intersection has no geography input overload; input args must use \
::bytea::geometry, not ::bytea::geography; got: {sql}"
);
assert_eq!(acc.bind_count(), 2);
}
#[test]
fn area_of_intersection_emits_composed_st_area_st_intersection() {
let expr = SpatialExpr::AreaOfIntersection {
a_ewkb: vec![0xCC],
b_ewkb: vec![0xDD],
};
let mut acc = SqlAccumulator::new("");
expr.emit(&mut acc);
let sql = acc.sql();
assert!(sql.contains("ST_Area("), "got: {sql}");
assert!(sql.contains("ST_Intersection("), "got: {sql}");
assert!(sql.contains("$1::bytea::geometry"), "got: {sql}");
assert!(sql.contains("$2::bytea::geometry"), "got: {sql}");
assert!(
sql.contains(")::geography)"),
"expected outer geography cast for meters-units, got: {sql}"
);
assert_eq!(acc.bind_count(), 2);
}
#[test]
fn intersection_of_constructor_emits_correct_sql() {
use crate::expr::{Expr, node::ExprNode};
use crate::geo::Polygon;
use crate::pg::accumulator::SqlAccumulator;
let pts = vec![
GeoPoint::new(0.0, 0.0).unwrap(),
GeoPoint::new(0.0, 1.0).unwrap(),
GeoPoint::new(1.0, 1.0).unwrap(),
GeoPoint::new(0.0, 0.0).unwrap(),
];
let poly_a = Polygon::with_ring(pts.clone()).unwrap();
let poly_b = Polygon::with_ring(pts).unwrap();
let expr: Expr<Polygon> = Expr::intersection_of(&poly_a, &poly_b);
let mut acc = SqlAccumulator::new("");
if let ExprNode::Spatial(spatial) = &expr.node {
spatial.emit(&mut acc);
} else {
panic!("intersection_of must wrap ExprNode::Spatial");
}
let sql = acc.sql();
assert!(
sql.contains("ST_Intersection("),
"constructor must emit ST_Intersection; got: {sql}"
);
assert!(
sql.contains("$1::bytea::geometry"),
"first arg must use ::bytea::geometry; got: {sql}"
);
assert!(
sql.contains("$2::bytea::geometry"),
"second arg must use ::bytea::geometry; got: {sql}"
);
assert!(
sql.ends_with("::geography"),
"output must be cast to ::geography; got: {sql}"
);
assert!(
!sql.contains("::bytea::geography"),
"input args must not use ::geography (no such overload); got: {sql}"
);
assert_eq!(
acc.bind_count(),
2,
"Intersection binds exactly 2 EWKB params; got {}",
acc.bind_count()
);
}
#[test]
fn intersection_of_injection_safe() {
use crate::expr::{Expr, node::ExprNode};
use crate::geo::Polygon;
use crate::pg::accumulator::SqlAccumulator;
let pts = vec![
GeoPoint::new(10.0, 20.0).unwrap(),
GeoPoint::new(10.0, 21.0).unwrap(),
GeoPoint::new(11.0, 21.0).unwrap(),
GeoPoint::new(10.0, 20.0).unwrap(),
];
let poly_a = Polygon::with_ring(pts.clone()).unwrap();
let poly_b = Polygon::with_ring(pts).unwrap();
let expr: Expr<Polygon> = Expr::intersection_of(&poly_a, &poly_b);
let mut acc = SqlAccumulator::new("");
if let ExprNode::Spatial(spatial) = &expr.node {
spatial.emit(&mut acc);
} else {
panic!("intersection_of must wrap ExprNode::Spatial");
}
let sql = acc.sql();
assert!(
!sql.contains("10.0") && !sql.contains("20.0") && !sql.contains("21.0"),
"coordinate values must not appear literally in SQL; got: {sql}"
);
assert_eq!(
acc.bind_count(),
2,
"EWKB bytes must be bind params, not embedded in SQL; got {}",
acc.bind_count()
);
assert!(
sql.contains("$1") && sql.contains("$2"),
"expected $1 and $2 placeholders; got: {sql}"
);
}
#[test]
fn cluster_c_sequential_emission_preserves_bind_counter() {
let area_a = SpatialExpr::Area {
geom_ewkb: vec![0x11],
};
let area_int = SpatialExpr::AreaOfIntersection {
a_ewkb: vec![0x22],
b_ewkb: vec![0x33],
};
let mut acc = SqlAccumulator::new("");
area_int.emit(&mut acc);
acc.push_sql(" / ");
area_a.emit(&mut acc);
let sql = acc.sql();
assert_eq!(acc.bind_count(), 3);
assert!(
sql.contains("$1") && sql.contains("$2") && sql.contains("$3"),
"expected $1 $2 $3, got: {sql}"
);
}
}