use sqlparser::ast;
use nodedb_query::geo_functions;
use nodedb_types::geometry::Geometry;
use crate::error::{Result, SqlError};
use crate::parser::normalize::normalize_ident;
use crate::types::*;
pub(crate) fn fold_geometry_function(func: &ast::Function) -> Option<Result<SqlValue>> {
let name = function_name(func);
if !geo_functions::returns_geometry(&name) {
return None;
}
let expr = ast::Expr::Function(func.clone());
Some(match resolve(&expr) {
Ok(Some(geom)) => serialize(&geom, &name),
Ok(None) => Err(invalid_geometry(&name)),
Err(e) => Err(e),
})
}
pub(crate) fn resolve_geometry_expr(expr: &ast::Expr) -> Result<Geometry> {
match resolve(expr)? {
Some(geom) => Ok(geom),
None => Err(SqlError::InvalidFunction {
detail: format!(
"expression in geometry position does not resolve to a geometry: {expr}"
),
}),
}
}
fn resolve(expr: &ast::Expr) -> Result<Option<Geometry>> {
let sql_expr =
crate::resolver::expr::convert_expr(expr, &crate::resolver::ColumnScope::Unchecked)?;
let Some(value) = crate::planner::const_fold::fold_constant_default(&sql_expr)? else {
return Ok(None);
};
Ok(geometry_from_value(&value))
}
fn geometry_from_value(value: &SqlValue) -> Option<Geometry> {
match value {
SqlValue::String(text) => geo_functions::geometry_from_text(text),
_ => None,
}
}
fn serialize(geom: &Geometry, name: &str) -> Result<SqlValue> {
sonic_rs::to_string(geom)
.map(SqlValue::String)
.map_err(|e| SqlError::InvalidFunction {
detail: format!("{name}: failed to serialize geometry: {e}"),
})
}
fn invalid_geometry(name: &str) -> SqlError {
SqlError::InvalidFunction {
detail: format!("{name}: arguments do not describe a valid geometry"),
}
}
fn function_name(func: &ast::Function) -> String {
func.name
.0
.iter()
.map(|part| match part {
ast::ObjectNamePart::Identifier(ident) => normalize_ident(ident),
_ => String::new(),
})
.collect::<Vec<_>>()
.join(".")
.to_lowercase()
}
#[cfg(test)]
mod tests {
use nodedb_types::geometry::Geometry;
use super::{fold_geometry_function, resolve_geometry_expr};
use crate::types::SqlValue;
fn expr(sql: &str) -> sqlparser::ast::Expr {
use sqlparser::ast::{SetExpr, Statement};
let dialect = sqlparser::dialect::PostgreSqlDialect {};
let mut statements =
sqlparser::parser::Parser::parse_sql(&dialect, &format!("SELECT {sql}"))
.expect("test expression must parse");
let Statement::Query(query) = statements.remove(0) else {
panic!("expected a query");
};
let SetExpr::Select(select) = *query.body else {
panic!("expected a select");
};
match select.projection.into_iter().next() {
Some(sqlparser::ast::SelectItem::UnnamedExpr(e)) => e,
other => panic!("expected an unnamed expression, got {other:?}"),
}
}
fn resolved(sql: &str) -> Geometry {
resolve_geometry_expr(&expr(sql))
.unwrap_or_else(|e| panic!("`{sql}` must resolve to a geometry: {e}"))
}
#[test]
fn every_constructor_resolves_in_geometry_position() {
let point = Geometry::point(1.0, 2.0);
assert_eq!(resolved("ST_Point(1, 2)"), point);
assert_eq!(resolved("ST_MakePoint(1, 2)"), point);
assert_eq!(resolved("ST_GeomFromText('POINT(1 2)')"), point);
assert_eq!(
resolved(r#"ST_GeomFromGeoJSON('{"type":"Point","coordinates":[1,2]}')"#),
point
);
assert_eq!(
resolved("ST_GeomFromWKB(X'0101000000000000000000F03F0000000000000040')"),
point
);
}
#[test]
fn nested_geometry_operations_resolve() {
let buffered = resolved("ST_Buffer(ST_Point(1, 2), 1000)");
assert!(
matches!(buffered, Geometry::Polygon { .. }),
"ST_Buffer must resolve to a polygon, got {buffered:?}"
);
assert!(matches!(
resolved("ST_Envelope(ST_GeomFromText('LINESTRING(0 0, 1 1)'))"),
Geometry::Polygon { .. }
));
assert_eq!(
resolved("ST_Centroid(ST_GeomFromText('LINESTRING(0 0, 0 0)'))"),
Geometry::point(0.0, 0.0)
);
}
#[test]
fn wkt_and_geojson_literals_resolve() {
let point = Geometry::point(1.0, 2.0);
assert_eq!(resolved("'POINT(1 2)'"), point);
assert_eq!(resolved(r#"'{"type":"Point","coordinates":[1,2]}'"#), point);
}
#[test]
fn non_geometry_argument_reports_a_geometry_error() {
for sql in ["42", "'not a geometry'", "TRUE"] {
let err = resolve_geometry_expr(&expr(sql))
.expect_err("`{sql}` must not resolve to a geometry")
.to_string();
assert!(
err.to_lowercase().contains("geometry"),
"error for `{sql}` must name the geometry position, got: {err}"
);
assert!(
!err.contains("Invalid JSON value") && !err.contains("line 1 column 1"),
"error for `{sql}` must not surface as a JSON parse failure, got: {err}"
);
}
}
#[test]
fn geometry_functions_fold_to_their_stored_geojson_form() {
let sqlparser::ast::Expr::Function(func) = expr("ST_GeomFromText('POINT(1 2)')") else {
panic!("expected a function");
};
let Some(Ok(SqlValue::String(geojson))) = fold_geometry_function(&func) else {
panic!("a geometry constructor must fold to its stored form");
};
assert_eq!(
nodedb_types::geometry::from_geojson_str(&geojson),
Some(Geometry::point(1.0, 2.0)),
"stored form must parse back through the spatial read path"
);
}
#[test]
fn non_geometry_function_falls_through() {
let sqlparser::ast::Expr::Function(func) = expr("ST_Area(ST_Point(1, 2))") else {
panic!("expected a function");
};
assert!(fold_geometry_function(&func).is_none());
}
#[test]
fn malformed_geometry_in_value_position_is_an_error_not_null() {
for sql in ["ST_GeomFromText('POINT(')", "ST_GeomFromText('nonsense')"] {
let sqlparser::ast::Expr::Function(func) = expr(sql) else {
panic!("expected a function");
};
let Some(result) = fold_geometry_function(&func) else {
panic!("`{sql}` is a geometry constructor and must be claimed");
};
assert!(
result.is_err(),
"`{sql}` must fail rather than fold to NULL, got {result:?}"
);
}
}
}