Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` 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<T>` 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<PgPoint>,
```

* 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<Entity>` (required) or
Expand Down
2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ features = [
"runtime-tokio-native-tls",
"postgres-array",
"postgres-vector",
"postgres-geometry",
"with-ipnetwork",
"with-arrow",
"stream",
Expand Down Expand Up @@ -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",
Expand Down
5 changes: 5 additions & 0 deletions src/entity/prelude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
5 changes: 5 additions & 0 deletions src/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
214 changes: 214 additions & 0 deletions src/value/postgres_geometry.rs
Original file line number Diff line number Diff line change
@@ -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<T>`] 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<T>` 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<PgPoint>,
//! ```

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<T: PgGeometry>(pub T);

impl<T: PgGeometry> Geo<T> {
/// 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<Self, DbErr>;
}

/// 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::<Vec<_>>()
.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<Self, DbErr> {
<$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<T>` newtype ----

impl<T: PgGeometry> From<Geo<T>> for Value {
fn from(value: Geo<T>) -> Self {
Value::String(Some(value.0.to_pg_text()))
}
}

impl<T: PgGeometry> TryGetable for Geo<T> {
fn try_get_by<I: sea_orm::ColIdx>(
res: &sea_orm::QueryResult,
index: I,
) -> Result<Self, TryGetError> {
// 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<Geo<T>>`).
let text: Option<String> = 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<T: PgGeometry> ValueType for Geo<T> {
fn try_from(v: Value) -> Result<Self, ValueTypeErr> {
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<T: PgGeometry> Nullable for Geo<T> {
fn null() -> Value {
Value::String(None)
}
}

impl<T: PgGeometry> TryFromU64 for Geo<T> {
fn try_from_u64(_n: u64) -> Result<Self, DbErr> {
Err(DbErr::ConvertFromU64("Geo"))
}
}

impl<T: PgGeometry> sea_orm::IntoActiveValue<Geo<T>> for Geo<T> {
fn into_active_value(self) -> crate::ActiveValue<Geo<T>> {
crate::ActiveValue::Set(self)
}
}

impl<T: PgGeometry> From<T> for Geo<T> {
fn from(value: T) -> Self {
Geo(value)
}
}

impl<T: PgGeometry> Deref for Geo<T> {
type Target = T;

fn deref(&self) -> &T {
&self.0
}
}

impl<T: PgGeometry> DerefMut for Geo<T> {
fn deref_mut(&mut self) -> &mut T {
&mut self.0
}
}
63 changes: 63 additions & 0 deletions tests/common/features/geo.rs
Original file line number Diff line number Diff line change
@@ -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<PgPoint>,
#[sea_orm(
column_type = r#"custom("polygon")"#,
select_as = "text",
save_as = "polygon"
)]
pub boundary: Geo<PgPolygon>,
#[sea_orm(
column_type = r#"custom("box")"#,
select_as = "text",
save_as = "box"
)]
pub bounds: Geo<PgBox>,
#[sea_orm(
column_type = r#"custom("circle")"#,
select_as = "text",
save_as = "circle"
)]
pub area: Geo<PgCircle>,
#[sea_orm(
column_type = r#"custom("lseg")"#,
select_as = "text",
save_as = "lseg"
)]
pub segment: Geo<PgLSeg>,
#[sea_orm(
column_type = r#"custom("line")"#,
select_as = "text",
save_as = "line"
)]
pub line: Geo<PgLine>,
#[sea_orm(
column_type = r#"custom("path")"#,
select_as = "text",
save_as = "path"
)]
pub route: Geo<PgPath>,
#[sea_orm(
column_type = r#"custom("point")"#,
select_as = "text",
save_as = "point",
nullable
)]
pub optional_point: Option<Geo<PgPoint>>,
}

#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

impl ActiveModelBehavior for ActiveModel {}
4 changes: 4 additions & 0 deletions tests/common/features/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Loading