Skip to main content

nodedb_sql/planner/
spatial_ctor.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Spatial geometry constructors that appear in value position (INSERT
4//! `VALUES`, WHERE equality) and synthesise a GeoJSON string literal directly
5//! rather than resolving as registered scalar functions.
6//!
7//! All constructors produce a `SqlValue::String` holding GeoJSON in the exact
8//! shape the spatial read path parses (`extract_geometry` →
9//! `sonic_rs::from_str::<Geometry>`), so an INSERTed geometry round-trips
10//! through a subsequent SELECT of the GEOMETRY column.
11
12use sqlparser::ast;
13
14use super::dml_helpers::expr_to_sql_value;
15use crate::error::{Result, SqlError};
16use crate::parser::normalize::normalize_ident;
17use crate::types::*;
18
19/// Attempt to interpret `func` as a spatial constructor. Returns `None` for
20/// any non-spatial function so the caller can fall through to the shared
21/// constant-folding pipeline.
22pub(super) fn try_spatial_constructor(func: &ast::Function) -> Option<Result<SqlValue>> {
23    SpatialConstructor::from_function(func).map(|ctor| spatial_constructor_to_value(ctor, func))
24}
25
26/// Spatial constructors that synthesise a GeoJSON string literal directly
27/// in value position (rather than going through the registered scalar
28/// evaluator). Closed set — adding a new constructor requires a new variant,
29/// which forces handling in `spatial_constructor_to_value`.
30#[derive(Copy, Clone)]
31enum SpatialConstructor {
32    Point,
33    MakePoint,
34    GeomFromGeoJson,
35    GeomFromText,
36    GeomFromWkb,
37}
38
39impl SpatialConstructor {
40    fn from_function(func: &ast::Function) -> Option<Self> {
41        let name = func
42            .name
43            .0
44            .iter()
45            .map(|p| match p {
46                ast::ObjectNamePart::Identifier(ident) => normalize_ident(ident),
47                _ => String::new(),
48            })
49            .collect::<Vec<_>>()
50            .join(".")
51            .to_lowercase();
52        match name.as_str() {
53            "st_point" => Some(Self::Point),
54            "st_makepoint" => Some(Self::MakePoint),
55            "st_geomfromgeojson" => Some(Self::GeomFromGeoJson),
56            "st_geomfromtext" => Some(Self::GeomFromText),
57            "st_geomfromwkb" => Some(Self::GeomFromWkb),
58            _ => None,
59        }
60    }
61
62    fn display_name(self) -> &'static str {
63        match self {
64            Self::Point => "ST_Point",
65            Self::MakePoint => "ST_MakePoint",
66            Self::GeomFromGeoJson => "ST_GeomFromGeoJSON",
67            Self::GeomFromText => "ST_GeomFromText",
68            Self::GeomFromWkb => "ST_GeomFromWKB",
69        }
70    }
71}
72
73fn spatial_constructor_to_value(
74    ctor: SpatialConstructor,
75    func: &ast::Function,
76) -> Result<SqlValue> {
77    let args = super::select::extract_func_args(func)?;
78    match ctor {
79        SpatialConstructor::Point => {
80            if args.len() < 2 {
81                return Err(SqlError::InvalidFunction {
82                    detail: format!(
83                        "{} requires 2 arguments (longitude, latitude), got {}",
84                        ctor.display_name(),
85                        args.len()
86                    ),
87                });
88            }
89            let lon = super::select::extract_float(&args[0])?;
90            let lat = super::select::extract_float(&args[1])?;
91            Ok(SqlValue::String(format!(
92                r#"{{"type":"Point","coordinates":[{lon},{lat}]}}"#
93            )))
94        }
95        SpatialConstructor::MakePoint => {
96            if args.len() < 2 {
97                return Err(SqlError::InvalidFunction {
98                    detail: format!(
99                        "{} requires at least 2 arguments (x, y[, z]), got {}",
100                        ctor.display_name(),
101                        args.len()
102                    ),
103                });
104            }
105            let x = super::select::extract_float(&args[0])?;
106            let y = super::select::extract_float(&args[1])?;
107            // 3-arg form carries an explicit z; preserve it rather than drop it.
108            if args.len() >= 3 {
109                let z = super::select::extract_float(&args[2])?;
110                Ok(SqlValue::String(format!(
111                    r#"{{"type":"Point","coordinates":[{x},{y},{z}]}}"#
112                )))
113            } else {
114                Ok(SqlValue::String(format!(
115                    r#"{{"type":"Point","coordinates":[{x},{y}]}}"#
116                )))
117            }
118        }
119        SpatialConstructor::GeomFromText => {
120            if args.is_empty() {
121                return Err(SqlError::InvalidFunction {
122                    detail: format!("{} requires a WKT string argument", ctor.display_name()),
123                });
124            }
125            // SRID (optional 2nd arg) is ignored, consistent with ST_Point.
126            let wkt = super::select::extract_string_literal(&args[0])?;
127            let geom = nodedb_spatial::geometry_from_wkt(&wkt).ok_or_else(|| {
128                SqlError::InvalidFunction {
129                    detail: format!("{}: invalid WKT geometry '{wkt}'", ctor.display_name()),
130                }
131            })?;
132            geometry_to_geojson_value(geom, ctor)
133        }
134        SpatialConstructor::GeomFromWkb => {
135            if args.is_empty() {
136                return Err(SqlError::InvalidFunction {
137                    detail: format!("{} requires a WKB bytes argument", ctor.display_name()),
138                });
139            }
140            // SRID (optional 2nd arg) is ignored, consistent with ST_Point.
141            let bytes = match expr_to_sql_value(&args[0])? {
142                SqlValue::Bytes(b) => b,
143                // A hex string (rather than an `X'...'` literal) is trivially
144                // decodable to the same bytes.
145                SqlValue::String(s) => hex_string_to_bytes(&s, ctor)?,
146                other => {
147                    return Err(SqlError::InvalidFunction {
148                        detail: format!(
149                            "{} requires a bytes argument, got {other:?}",
150                            ctor.display_name()
151                        ),
152                    });
153                }
154            };
155            let geom = nodedb_spatial::geometry_from_wkb(&bytes).ok_or_else(|| {
156                SqlError::InvalidFunction {
157                    detail: format!("{}: invalid WKB geometry", ctor.display_name()),
158                }
159            })?;
160            geometry_to_geojson_value(geom, ctor)
161        }
162        SpatialConstructor::GeomFromGeoJson => {
163            if args.is_empty() {
164                return Err(SqlError::InvalidFunction {
165                    detail: format!(
166                        "{} requires 1 argument (GeoJSON string)",
167                        ctor.display_name()
168                    ),
169                });
170            }
171            let s = super::select::extract_string_literal(&args[0])?;
172            Ok(SqlValue::String(s))
173        }
174    }
175}
176
177/// Serialize a parsed `Geometry` to the GeoJSON string form the spatial read
178/// path (`extract_geometry` → `sonic_rs::from_str`) parses back. `Geometry`
179/// derives serde with `tag = "type"`, so this yields the same
180/// `{"type":...,"coordinates":...}` shape ST_Point synthesises by hand.
181fn geometry_to_geojson_value(
182    geom: nodedb_types::geometry::Geometry,
183    ctor: SpatialConstructor,
184) -> Result<SqlValue> {
185    let json = sonic_rs::to_string(&geom).map_err(|e| SqlError::InvalidFunction {
186        detail: format!("{}: failed to serialize geometry: {e}", ctor.display_name()),
187    })?;
188    Ok(SqlValue::String(json))
189}
190
191/// Decode an even-length ASCII hex string into bytes (for a hex-string WKB
192/// argument passed as a plain string rather than an `X'...'` literal).
193/// Delegates the byte-parsing to the shared helper in `resolver::expr::value`
194/// so the two hex-decode surfaces don't drift.
195fn hex_string_to_bytes(s: &str, ctor: SpatialConstructor) -> Result<Vec<u8>> {
196    crate::resolver::expr::value::parse_hex_bytes(s).map_err(|reason| SqlError::InvalidFunction {
197        detail: format!("{}: hex WKB {reason}", ctor.display_name()),
198    })
199}