diff --git a/CHANGELOG.md b/CHANGELOG.md index e8033fbb00..fb9f519791 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,20 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### New Features +* Support built-in PostgreSQL geometric types (`point`, `line`, `lseg`, `box`, `path`, `polygon`, `circle`) https://github.com/SeaQL/sea-orm/issues/282 + + Behind the new `postgres-geometry` feature, geometric columns can be mapped + onto entity fields via a thin `Geo` newtype wrapping sqlx's `Pg*` types + (`PgPoint`, `PgPolygon`, …). These are the core Postgres geometric types — no + PostGIS extension required. Values are stored/loaded as canonical Postgres + text using `save_as` / `select_as` casts, so the core `Value` enum is + unchanged. `Geo` derefs to the inner sqlx type for direct field access. + + ```rust + #[sea_orm(column_type = r#"custom("point")"#, select_as = "text", save_as = "point")] + pub location: Geo, + ``` + * Split `belongs_to` from `has_one` with a new `BelongsTo` relation type https://github.com/SeaQL/sea-orm/pull/3118 A `belongs_to` relation can now be typed `BelongsTo` (required) or diff --git a/Cargo.toml b/Cargo.toml index 144fecb0ea..91be6da7cc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ features = [ "runtime-tokio-native-tls", "postgres-array", "postgres-vector", + "postgres-geometry", "with-ipnetwork", "with-arrow", "stream", @@ -209,6 +210,7 @@ with-ipnetwork = [ "sea-query/with-ipnetwork", "sea-query-sqlx?/with-ipnetwork", ] +postgres-geometry = ["sqlx-postgres"] with-json = [ "dep:serde", "serde_json", diff --git a/src/entity/prelude.rs b/src/entity/prelude.rs index a45b988a14..7b10fd6b08 100644 --- a/src/entity/prelude.rs +++ b/src/entity/prelude.rs @@ -101,6 +101,11 @@ pub use uuid::Uuid; #[cfg(feature = "with-uuid")] pub use crate::value::TextUuid; +#[cfg(feature = "postgres-geometry")] +pub use crate::value::{Geo, PgGeometry}; +#[cfg(feature = "postgres-geometry")] +pub use sqlx::postgres::types::{PgBox, PgCircle, PgLSeg, PgLine, PgPath, PgPoint, PgPolygon}; + #[cfg(feature = "postgres-vector")] pub use pgvector::Vector as PgVector; diff --git a/src/value.rs b/src/value.rs index edb896ee33..8744790eb3 100644 --- a/src/value.rs +++ b/src/value.rs @@ -76,6 +76,11 @@ mod text_uuid; #[cfg(feature = "with-uuid")] pub use text_uuid::*; +#[cfg(feature = "postgres-geometry")] +mod postgres_geometry; +#[cfg(feature = "postgres-geometry")] +pub use postgres_geometry::*; + /// Default value for `T`. pub trait DefaultActiveValue { /// `Default::default()` if implemented, dummy value otherwise. diff --git a/src/value/postgres_geometry.rs b/src/value/postgres_geometry.rs new file mode 100644 index 0000000000..5a1a3153dc --- /dev/null +++ b/src/value/postgres_geometry.rs @@ -0,0 +1,214 @@ +//! Support for the built-in PostgreSQL geometric types (issue #282): +//! `point`, `line`, `lseg`, `box`, `path`, `polygon`, `circle`. +//! +//! These are the *core Postgres* geometric types (Postgres manual §8.8) — **not** +//! PostGIS. No extension is required. The underlying representation is sqlx's own +//! [`PgPoint`], [`PgLine`], [`PgLSeg`], [`PgBox`], [`PgPath`], [`PgPolygon`] and +//! [`PgCircle`], wrapped in a thin [`Geo`] newtype so the required sea-orm / +//! sea-query traits can be implemented (Rust's orphan rules forbid implementing +//! them directly on the foreign sqlx types). +//! +//! `Geo` derefs to the inner sqlx type, so all its fields/methods are +//! available directly. +//! +//! ## How values cross the DB boundary +//! +//! sea-query's [`Value`] has no geometric variant, so values are bound as their +//! canonical Postgres **text** form and cast on both sides: +//! +//! ```ignore +//! use sea_orm::entity::prelude::*; +//! +//! #[sea_orm( +//! column_type = r#"custom("point")"#, +//! select_as = "text", // CAST(col AS text) -> "(x,y)" +//! save_as = "point" // CAST($1 AS point) +//! )] +//! pub location: Geo, +//! ``` + +use std::{ + ops::{Deref, DerefMut}, + str::FromStr, +}; + +use sea_query::{ArrayType, Nullable, ValueType, ValueTypeErr}; +use sqlx::postgres::types::{PgBox, PgCircle, PgLSeg, PgLine, PgPath, PgPoint, PgPolygon}; + +use crate::{self as sea_orm, ColumnType, DbErr, TryFromU64, TryGetError, TryGetable, Value}; + +/// A PostgreSQL geometric value wrapping one of sqlx's `Pg*` geometric types. +/// +/// See the module docs for usage. +#[derive(Clone, Debug, PartialEq)] +pub struct Geo(pub T); + +impl Geo { + /// Wrap an sqlx geometric value. + pub fn new(value: T) -> Self { + Geo(value) + } + + /// Unwrap into the inner sqlx geometric value. + pub fn into_inner(self) -> T { + self.0 + } +} + +/// Trait implemented for each supported sqlx geometric type, describing how it +/// converts to/from its canonical PostgreSQL text form. +pub trait PgGeometry: Sized + Clone { + /// The PostgreSQL type name (used for the `Custom(..)` column type). + const PG_TYPE: &'static str; + + /// Render to the canonical PostgreSQL text input form. + fn to_pg_text(&self) -> String; + + /// Parse from PostgreSQL text output. + fn from_pg_text(s: &str) -> Result; +} + +/// Format a coordinate pair as `(x,y)`. +fn pt(x: f64, y: f64) -> String { + format!("({x},{y})") +} + +fn join_points(points: &[PgPoint]) -> String { + points + .iter() + .map(|p| pt(p.x, p.y)) + .collect::>() + .join(",") +} + +macro_rules! impl_pg_geometry { + ($ty:ty, $name:literal, $to_text:expr) => { + impl PgGeometry for $ty { + const PG_TYPE: &'static str = $name; + + fn to_pg_text(&self) -> String { + let f: &dyn Fn(&$ty) -> String = &$to_text; + f(self) + } + + fn from_pg_text(s: &str) -> Result { + <$ty>::from_str(s).map_err(|e| { + DbErr::Type(format!(concat!("Failed to parse ", $name, ": {}"), e)) + }) + } + } + }; +} + +impl_pg_geometry!(PgPoint, "point", |p| pt(p.x, p.y)); +impl_pg_geometry!(PgLine, "line", |l| format!("{{{},{},{}}}", l.a, l.b, l.c)); +impl_pg_geometry!(PgLSeg, "lseg", |l| format!( + "[{},{}]", + pt(l.start_x, l.start_y), + pt(l.end_x, l.end_y) +)); +impl_pg_geometry!(PgBox, "box", |b| format!( + "({},{})", + pt(b.upper_right_x, b.upper_right_y), + pt(b.lower_left_x, b.lower_left_y) +)); +impl_pg_geometry!(PgCircle, "circle", |c| format!( + "<{},{}>", + pt(c.x, c.y), + c.radius +)); +impl_pg_geometry!(PgPath, "path", |p: &PgPath| { + let inner = join_points(&p.points); + if p.closed { + format!("({inner})") + } else { + format!("[{inner}]") + } +}); +impl_pg_geometry!(PgPolygon, "polygon", |p: &PgPolygon| format!( + "({})", + join_points(&p.points) +)); + +// ---- sea-orm / sea-query trait impls for the local `Geo` newtype ---- + +impl From> for Value { + fn from(value: Geo) -> Self { + Value::String(Some(value.0.to_pg_text())) + } +} + +impl TryGetable for Geo { + fn try_get_by( + res: &sea_orm::QueryResult, + index: I, + ) -> Result { + // Column is selected with `select_as = "text"`, so we read the canonical + // text form and parse it with sqlx's own `FromStr`. Read as `Option` so a + // SQL NULL surfaces as `TryGetError::Null` (catchable by `Option>`). + let text: Option = res.try_get_by(index)?; + match text { + Some(text) => T::from_pg_text(&text).map(Geo).map_err(TryGetError::DbErr), + None => Err(TryGetError::Null(format!("{index:?}"))), + } + } +} + +impl ValueType for Geo { + fn try_from(v: Value) -> Result { + match v { + Value::String(Some(s)) => T::from_pg_text(&s).map(Geo).map_err(|_| ValueTypeErr), + _ => Err(ValueTypeErr), + } + } + + fn type_name() -> String { + format!("Geo<{}>", T::PG_TYPE) + } + + fn array_type() -> ArrayType { + ArrayType::String + } + + fn column_type() -> ColumnType { + ColumnType::custom(T::PG_TYPE) + } +} + +impl Nullable for Geo { + fn null() -> Value { + Value::String(None) + } +} + +impl TryFromU64 for Geo { + fn try_from_u64(_n: u64) -> Result { + Err(DbErr::ConvertFromU64("Geo")) + } +} + +impl sea_orm::IntoActiveValue> for Geo { + fn into_active_value(self) -> crate::ActiveValue> { + crate::ActiveValue::Set(self) + } +} + +impl From for Geo { + fn from(value: T) -> Self { + Geo(value) + } +} + +impl Deref for Geo { + type Target = T; + + fn deref(&self) -> &T { + &self.0 + } +} + +impl DerefMut for Geo { + fn deref_mut(&mut self) -> &mut T { + &mut self.0 + } +} diff --git a/tests/common/features/geo.rs b/tests/common/features/geo.rs new file mode 100644 index 0000000000..b119c33034 --- /dev/null +++ b/tests/common/features/geo.rs @@ -0,0 +1,63 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "geo")] +pub struct Model { + #[sea_orm(primary_key, auto_increment = false)] + pub id: i32, + pub name: String, + #[sea_orm( + column_type = r#"custom("point")"#, + select_as = "text", + save_as = "point" + )] + pub location: Geo, + #[sea_orm( + column_type = r#"custom("polygon")"#, + select_as = "text", + save_as = "polygon" + )] + pub boundary: Geo, + #[sea_orm( + column_type = r#"custom("box")"#, + select_as = "text", + save_as = "box" + )] + pub bounds: Geo, + #[sea_orm( + column_type = r#"custom("circle")"#, + select_as = "text", + save_as = "circle" + )] + pub area: Geo, + #[sea_orm( + column_type = r#"custom("lseg")"#, + select_as = "text", + save_as = "lseg" + )] + pub segment: Geo, + #[sea_orm( + column_type = r#"custom("line")"#, + select_as = "text", + save_as = "line" + )] + pub line: Geo, + #[sea_orm( + column_type = r#"custom("path")"#, + select_as = "text", + save_as = "path" + )] + pub route: Geo, + #[sea_orm( + column_type = r#"custom("point")"#, + select_as = "text", + save_as = "point", + nullable + )] + pub optional_point: Option>, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/tests/common/features/mod.rs b/tests/common/features/mod.rs index b9e5e4a7f7..6569c975eb 100644 --- a/tests/common/features/mod.rs +++ b/tests/common/features/mod.rs @@ -14,6 +14,8 @@ pub mod edit_log; #[cfg(feature = "postgres-vector")] pub mod embedding; pub mod event_trigger; +#[cfg(feature = "postgres-geometry")] +pub mod geo; #[cfg(feature = "with-ipnetwork")] pub mod host_network; pub mod insert_default; @@ -48,6 +50,8 @@ pub use edit_log::Entity as EditLog; #[cfg(feature = "postgres-vector")] pub use embedding::Entity as Embedding; pub use event_trigger::Entity as EventTrigger; +#[cfg(feature = "postgres-geometry")] +pub use geo::Entity as GeoEntity; pub use insert_default::Entity as InsertDefault; pub use json_struct::Entity as JsonStruct; pub use json_vec::Entity as JsonVec; diff --git a/tests/postgres_geometry_tests.rs b/tests/postgres_geometry_tests.rs new file mode 100644 index 0000000000..069f6f4e8a --- /dev/null +++ b/tests/postgres_geometry_tests.rs @@ -0,0 +1,144 @@ +#![allow(unused_imports, dead_code)] + +pub mod common; + +use common::features::*; +use pretty_assertions::assert_eq; +use sea_orm::{ + ConnectionTrait, DatabaseBackend, DatabaseConnection, Statement, entity::prelude::*, entity::*, +}; + +#[sea_orm_macros::test] +#[cfg(all(feature = "sqlx-postgres", feature = "postgres-geometry"))] +async fn main() -> Result<(), DbErr> { + let ctx = common::TestContext::new("postgres_geometry_tests").await; + + // Built-in geometric types — no PostGIS extension required. + ctx.db + .execute_unprepared( + r#"CREATE TABLE "geo" ( + "id" integer PRIMARY KEY NOT NULL, + "name" varchar NOT NULL, + "location" point NOT NULL, + "boundary" polygon NOT NULL, + "bounds" box NOT NULL, + "area" circle NOT NULL, + "segment" lseg NOT NULL, + "line" line NOT NULL, + "route" path NOT NULL, + "optional_point" point + )"#, + ) + .await?; + + round_trip(&ctx.db).await?; + spatial_query(&ctx.db).await?; + + ctx.delete().await; + Ok(()) +} + +#[cfg(all(feature = "sqlx-postgres", feature = "postgres-geometry"))] +async fn round_trip(db: &DatabaseConnection) -> Result<(), DbErr> { + let model = geo::Model { + id: 1, + name: "sleipner".to_owned(), + location: Geo::new(PgPoint { x: 1.5, y: 2.5 }), + boundary: Geo::new(PgPolygon { + points: vec![ + PgPoint { x: 0.0, y: 0.0 }, + PgPoint { x: 0.0, y: 1.0 }, + PgPoint { x: 1.0, y: 1.0 }, + PgPoint { x: 1.0, y: 0.0 }, + ], + }), + // Box normalizes to (upper-right),(lower-left); supply already-normalized. + bounds: Geo::new(PgBox { + upper_right_x: 2.0, + upper_right_y: 2.0, + lower_left_x: 0.0, + lower_left_y: 0.0, + }), + area: Geo::new(PgCircle { + x: 1.0, + y: 1.0, + radius: 5.0, + }), + segment: Geo::new(PgLSeg { + start_x: 0.0, + start_y: 0.0, + end_x: 3.0, + end_y: 4.0, + }), + line: Geo::new(PgLine { + a: 1.0, + b: -1.0, + c: 0.0, + }), + route: Geo::new(PgPath { + closed: true, + points: vec![ + PgPoint { x: 0.0, y: 0.0 }, + PgPoint { x: 1.0, y: 0.0 }, + PgPoint { x: 1.0, y: 1.0 }, + ], + }), + optional_point: Some(Geo::new(PgPoint { x: 9.0, y: 9.0 })), + }; + + let inserted = model.clone().into_active_model().insert(db).await?; + assert_eq!(inserted, model); + + let fetched = GeoEntity::find_by_id(1).one(db).await?.expect("row exists"); + assert_eq!(fetched, model); + // Deref lets us reach the sqlx type's fields directly. + assert_eq!(fetched.location.x, 1.5); + assert_eq!(fetched.location.y, 2.5); + + // NULL geometric round-trips. + let model2 = geo::Model { + id: 2, + optional_point: None, + ..model.clone() + }; + let m2 = geo::ActiveModel { + id: Set(2), + optional_point: Set(None), + ..model.clone().into_active_model() + }; + m2.insert(db).await?; + let fetched2 = GeoEntity::find_by_id(2).one(db).await?.expect("row exists"); + assert_eq!(fetched2.optional_point, None); + let _ = model2; + + Ok(()) +} + +#[cfg(all(feature = "sqlx-postgres", feature = "postgres-geometry"))] +async fn spatial_query(db: &DatabaseConnection) -> Result<(), DbErr> { + // Native geometric operator: `<->` planar distance, `@>` contains. + // Which points lie within the boundary polygon of row 1? + let stmt = Statement::from_string( + DatabaseBackend::Postgres, + r#"SELECT "id" FROM "geo" WHERE "boundary" @> point(0.5, 0.5) ORDER BY "id""#, + ); + let rows = db.query_all_raw(stmt).await?; + let ids: Vec = rows + .iter() + .map(|r| r.try_get::("", "id")) + .collect::>()?; + assert_eq!(ids, vec![1, 2], "(0.5,0.5) is inside both boundary polygons"); + + // Planar distance from location to a probe point. + let dist: f64 = db + .query_one_raw(Statement::from_string( + DatabaseBackend::Postgres, + r#"SELECT ("location" <-> point(4.5, 6.5)) AS d FROM "geo" WHERE "id" = 1"#, + )) + .await? + .expect("row") + .try_get::("", "d")?; + assert_eq!(dist, 5.0, "distance (1.5,2.5)->(4.5,6.5) = 5"); + + Ok(()) +}