diff --git a/Cargo.lock b/Cargo.lock index 4b490a4a4c..6509cc9586 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,6 +119,15 @@ version = "1.0.95" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" +[[package]] +name = "approx" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6" +dependencies = [ + "num-traits", +] + [[package]] name = "argon2" version = "0.4.1" @@ -1513,6 +1522,26 @@ dependencies = [ "version_check", ] +[[package]] +name = "geo-traits" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e7c353d12a704ccfab1ba8bfb1a7fe6cb18b665bf89d37f4f7890edcd260206" +dependencies = [ + "geo-types", +] + +[[package]] +name = "geo-types" +version = "0.7.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62ddb1950450d67efee2bbc5e429c68d052a822de3aad010d28b351fbb705224" +dependencies = [ + "approx", + "num-traits", + "serde", +] + [[package]] name = "getrandom" version = "0.2.15" @@ -2364,6 +2393,27 @@ dependencies = [ "libm", ] +[[package]] +name = "num_enum" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e613fc340b2220f734a8595782c551f1250e969d87d3be1ae0579e8d4065179" +dependencies = [ + "num_enum_derive", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1844ef2428cc3e1cb900be36181049ef3d3193c63e43026cfe202983b27a56" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.96", +] + [[package]] name = "object" version = "0.36.7" @@ -3028,7 +3078,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.9.4", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3400,6 +3450,7 @@ dependencies = [ "dotenvy", "env_logger", "futures-util", + "geo-types", "hex", "libsqlite3-sys", "paste", @@ -3464,6 +3515,7 @@ dependencies = [ "futures-intrusive", "futures-io", "futures-util", + "geo-types", "hashbrown 0.15.2", "hashlink", "indexmap 2.7.0", @@ -3649,6 +3701,7 @@ dependencies = [ name = "sqlx-mysql" version = "0.9.0-alpha.1" dependencies = [ + "anyhow", "atoi", "base64 0.22.1", "bigdecimal", @@ -3665,6 +3718,8 @@ dependencies = [ "futures-io", "futures-util", "generic-array", + "geo-traits", + "geo-types", "hex", "hkdf", "hmac", @@ -3688,6 +3743,7 @@ dependencies = [ "tracing", "uuid", "whoami", + "wkb", ] [[package]] @@ -4840,6 +4896,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "wkb" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff9eff6aebac4c64f9c7c057a68f6359284e2a80acf102dffe041fe219b3a082" +dependencies = [ + "byteorder", + "geo-traits", + "num_enum", + "thiserror 1.0.69", +] + [[package]] name = "write16" version = "1.0.0" diff --git a/Cargo.toml b/Cargo.toml index f206eadd26..651a82b438 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -137,6 +137,7 @@ sqlx = { version = "=0.9.0-alpha.1", path = ".", default-features = false } # Common type integrations shared by multiple driver crates. # These are optional unless enabled in a workspace crate. bigdecimal = "0.4.0" +geo-types = { version = "0.7.16", default-features = false } bit-vec = "0.6.3" chrono = { version = "0.4.34", default-features = false, features = ["std", "clock"] } ipnet = "2.3.0" @@ -174,6 +175,7 @@ env_logger = "0.11" async-std = { workspace = true, features = ["attributes"] } tokio = { version = "1.15.0", features = ["full"] } dotenvy = "0.15.0" +geo-types = { workspace = true } # Added for tests trybuild = "1.0.53" sqlx-test = { path = "./sqlx-test" } paste = "1.0.6" diff --git a/sqlx-core/Cargo.toml b/sqlx-core/Cargo.toml index 1bba2d65bb..9d9fcf69d6 100644 --- a/sqlx-core/Cargo.toml +++ b/sqlx-core/Cargo.toml @@ -32,6 +32,8 @@ _tls-none = [] # support offline/decoupled building (enables serialization of `Describe`) offline = ["serde", "either/serde"] +geo-types = ["dep:geo-types"] + [dependencies] # Runtimes async-std = { workspace = true, optional = true } @@ -47,6 +49,7 @@ rustls-native-certs = { version = "0.8.0", optional = true } # Type Integrations bit-vec = { workspace = true, optional = true } bigdecimal = { workspace = true, optional = true } +geo-types = { workspace = true, optional = true } rust_decimal = { workspace = true, optional = true } time = { workspace = true, optional = true } ipnet = { workspace = true, optional = true } diff --git a/sqlx-mysql/Cargo.toml b/sqlx-mysql/Cargo.toml index 52717c4207..b1bf6d51df 100644 --- a/sqlx-mysql/Cargo.toml +++ b/sqlx-mysql/Cargo.toml @@ -19,6 +19,7 @@ migrate = ["sqlx-core/migrate"] bigdecimal = ["dep:bigdecimal", "sqlx-core/bigdecimal"] chrono = ["dep:chrono", "sqlx-core/chrono"] rust_decimal = ["dep:rust_decimal", "rust_decimal/maths", "sqlx-core/rust_decimal"] +spatial-data-types = ["sqlx-core/geo-types", "dep:geo-types", "dep:wkb", "dep:geo-traits"] time = ["dep:time", "sqlx-core/time"] uuid = ["dep:uuid", "sqlx-core/uuid"] @@ -45,9 +46,12 @@ sha2 = { version = "0.10.0", default-features = false } # Type Integrations (versions inherited from `[workspace.dependencies]`) bigdecimal = { workspace = true, optional = true } chrono = { workspace = true, optional = true } +geo-types = { workspace = true, optional = true } +geo-traits = { version = "0.3.0", optional = true } rust_decimal = { workspace = true, optional = true } time = { workspace = true, optional = true } uuid = { workspace = true, optional = true } +wkb = { version = "0.9.0", optional = true } # Misc atoi = "2.0" @@ -72,7 +76,8 @@ whoami = { version = "1.2.1", default-features = false } serde = { version = "1.0.144", optional = true } [dev-dependencies] -sqlx = { workspace = true, features = ["mysql"] } +sqlx = { workspace = true, features = ["mysql", "macros", "migrate", "runtime-tokio"] } +anyhow = "1.0" # For test results [lints] workspace = true diff --git a/sqlx-mysql/src/types/geo/geometry.rs b/sqlx-mysql/src/types/geo/geometry.rs new file mode 100644 index 0000000000..1027971ff6 --- /dev/null +++ b/sqlx-mysql/src/types/geo/geometry.rs @@ -0,0 +1,182 @@ +use sqlx_core::decode::Decode; +use sqlx_core::encode::{Encode, IsNull}; +use sqlx_core::error::BoxDynError; +use sqlx_core::types::Type; + +use crate::io::MySqlBufMutExt; +use crate::protocol::text::{ColumnFlags, ColumnType}; +use crate::{MySql, MySqlTypeInfo, MySqlValueRef}; + +use geo_traits::to_geo::ToGeoGeometry; +use geo_types::Geometry; +use wkb::reader; +use wkb::writer; + +impl Type for Geometry { + fn type_info() -> MySqlTypeInfo { + MySqlTypeInfo::binary(ColumnType::Geometry) + } + + fn compatible(ty: &MySqlTypeInfo) -> bool { + matches!( + ty.r#type, + ColumnType::Geometry + | ColumnType::Blob + | ColumnType::MediumBlob + | ColumnType::LongBlob + | ColumnType::TinyBlob + | ColumnType::VarString if ty.flags.contains(ColumnFlags::BINARY) + ) + } +} + +impl Encode<'_, MySql> for Geometry { + fn encode_by_ref(&self, buf: &mut Vec) -> Result { + let mut wkb_buffer = Vec::new(); + wkb_buffer.extend_from_slice(&0u32.to_le_bytes()); // SRID = 0, Little Endian + writer::write_geometry(&mut wkb_buffer, self, &writer::WriteOptions::default())?; + buf.put_bytes_lenenc(&wkb_buffer); + Ok(IsNull::No) + } +} + +impl<'r> Decode<'r, MySql> for Geometry { + fn decode(value: MySqlValueRef<'r>) -> Result { + let bytes = value.as_bytes()?; + if bytes.len() < 4 { + return Err(format!( + "Invalid GEOMETRY data for Geometry: received {} bytes, expected at least 4 for SRID prefix.", + bytes.len() + ) + .into()); + } + let wkb_data = &bytes[4..]; // Skip 4-byte SRID + + let wkb_reader_geom = reader::Wkb::try_new(wkb_data) + .map_err(|e| BoxDynError::from(format!("WKB parsing error for Geometry: {}", e)))?; + + Ok(wkb_reader_geom.to_geometry()) + } +} + +#[cfg(test)] +mod tests { + use geo_types::{coord, Geometry as TestableGeoType, LineString, Point, Polygon}; + use sqlx::mysql::{MySqlPool, MySqlRow}; + use sqlx::{Executor, Row}; + + #[sqlx::test] + async fn test_encode_decode_geometry_enum(pool: MySqlPool) -> anyhow::Result<()> { + let table_name = format!("test_geo_geometry_enum_table"); + pool.execute(format!("DROP TABLE IF EXISTS {}", table_name).as_str()) + .await?; + pool.execute( + format!( + "CREATE TABLE {} (id INT, geom_val GEOMETRY, geom_null GEOMETRY NULL)", + table_name + ) + .as_str(), + ) + .await?; + + let p1_geom = Point::new(1.0, 2.0); + let geom_enum1 = TestableGeoType::Point(p1_geom); + + let ls1_geom = LineString::new(vec![coord! {x: 3., y: 4.}, coord! {x: 5., y: 6.}]); + let geom_enum2 = TestableGeoType::LineString(ls1_geom); + + let ext_poly_geom = LineString::new(vec![ + coord! {x:0.,y:0.}, + coord! {x:1.,y:1.}, + coord! {x:1.,y:0.}, + coord! {x:0.,y:0.}, + ]); + let poly1_geom = Polygon::new(ext_poly_geom, vec![]); + let geom_enum3 = TestableGeoType::Polygon(poly1_geom); + + // Test non-nullable Point variant + sqlx::query(&format!( + "INSERT INTO {} (id, geom_val) VALUES (1, ?)", + table_name + )) + .bind(geom_enum1.clone()) + .execute(&pool) + .await?; + let row1: MySqlRow = + sqlx::query(&format!("SELECT geom_val FROM {} WHERE id = 1", table_name)) + .fetch_one(&pool) + .await?; + let decoded_val1: TestableGeoType = row1.try_get("geom_val")?; + assert_eq!(decoded_val1, geom_enum1); + + // Test non-nullable LineString variant + sqlx::query(&format!( + "INSERT INTO {} (id, geom_val) VALUES (2, ?)", + table_name + )) + .bind(geom_enum2.clone()) + .execute(&pool) + .await?; + let row2: MySqlRow = + sqlx::query(&format!("SELECT geom_val FROM {} WHERE id = 2", table_name)) + .fetch_one(&pool) + .await?; + let decoded_val2: TestableGeoType = row2.try_get("geom_val")?; + assert_eq!(decoded_val2, geom_enum2); + + // Test non-nullable Polygon variant + sqlx::query(&format!( + "INSERT INTO {} (id, geom_val) VALUES (3, ?)", + table_name + )) + .bind(geom_enum3.clone()) + .execute(&pool) + .await?; + let row3: MySqlRow = + sqlx::query(&format!("SELECT geom_val FROM {} WHERE id = 3", table_name)) + .fetch_one(&pool) + .await?; + let decoded_val3: TestableGeoType = row3.try_get("geom_val")?; + assert_eq!(decoded_val3, geom_enum3); + + // Test nullable Some(value) - using Point variant + let some_val: Option = Some(geom_enum1.clone()); + sqlx::query(&format!( + "INSERT INTO {} (id, geom_null) VALUES (4, ?)", + table_name + )) + .bind(some_val.clone()) + .execute(&pool) + .await?; + let row_some: MySqlRow = sqlx::query(&format!( + "SELECT geom_null FROM {} WHERE id = 4", + table_name + )) + .fetch_one(&pool) + .await?; + let decoded_some: Option = row_some.try_get("geom_null")?; + assert_eq!(decoded_some, some_val); + + // Test nullable None + let none_val: Option = None; + sqlx::query(&format!( + "INSERT INTO {} (id, geom_null) VALUES (5, ?)", + table_name + )) + .bind(none_val.clone()) + .execute(&pool) + .await?; + let row_none: MySqlRow = sqlx::query(&format!( + "SELECT geom_null FROM {} WHERE id = 5", + table_name + )) + .fetch_one(&pool) + .await?; + let decoded_none: Option = row_none.try_get("geom_null")?; + assert_eq!(decoded_none, none_val); + + pool.execute(format!("DROP TABLE IF EXISTS {}", table_name).as_str()) + .await?; + Ok(()) + } +} diff --git a/sqlx-mysql/src/types/geo/geometry_collection.rs b/sqlx-mysql/src/types/geo/geometry_collection.rs new file mode 100644 index 0000000000..e0f50c7697 --- /dev/null +++ b/sqlx-mysql/src/types/geo/geometry_collection.rs @@ -0,0 +1,164 @@ +use sqlx_core::decode::Decode; +use sqlx_core::encode::{Encode, IsNull}; +use sqlx_core::error::BoxDynError; +use sqlx_core::types::Type; + +use crate::io::MySqlBufMutExt; +use crate::protocol::text::{ColumnFlags, ColumnType}; +use crate::{MySql, MySqlTypeInfo, MySqlValueRef}; + +use std::convert::TryFrom; + +use geo_traits::to_geo::ToGeoGeometry; +use geo_types::GeometryCollection; +use wkb::reader; +use wkb::writer; + +impl Type for GeometryCollection { + fn type_info() -> MySqlTypeInfo { + MySqlTypeInfo::binary(ColumnType::Geometry) + } + + fn compatible(ty: &MySqlTypeInfo) -> bool { + matches!( + ty.r#type, + ColumnType::Geometry + | ColumnType::Blob + | ColumnType::MediumBlob + | ColumnType::LongBlob + | ColumnType::TinyBlob + | ColumnType::VarString if ty.flags.contains(ColumnFlags::BINARY) + ) + } +} + +impl Encode<'_, MySql> for GeometryCollection { + fn encode_by_ref(&self, buf: &mut Vec) -> Result { + let mut wkb_buffer = Vec::new(); + wkb_buffer.extend_from_slice(&0u32.to_le_bytes()); // SRID = 0, Little Endian + writer::write_geometry_collection(&mut wkb_buffer, self, &writer::WriteOptions::default())?; + buf.put_bytes_lenenc(&wkb_buffer); + Ok(IsNull::No) + } +} + +impl<'r> Decode<'r, MySql> for GeometryCollection { + fn decode(value: MySqlValueRef<'r>) -> Result { + let bytes = value.as_bytes()?; + if bytes.len() < 4 { + return Err(format!( + "Invalid GEOMETRY data for GeometryCollection: received {} bytes, expected at least 4 for SRID prefix.", + bytes.len() + ) + .into()); + } + let wkb_data = &bytes[4..]; // Skip 4-byte SRID + + let wkb_reader_geom = reader::Wkb::try_new(wkb_data).map_err(|e| { + BoxDynError::from(format!("WKB parsing error for GeometryCollection: {}", e)) + })?; + + let geo_geom: geo_types::Geometry = wkb_reader_geom.to_geometry(); + + GeometryCollection::try_from(geo_geom).map_err(|e| { + BoxDynError::from(format!( + "Failed to convert geo_types::Geometry to GeometryCollection: {:?}", + e + )) + }) + } +} + +#[cfg(test)] +mod tests { + use geo_types::{coord, Geometry, GeometryCollection as TestableGeoType, LineString, Point}; + use sqlx::mysql::{MySqlPool, MySqlRow}; + use sqlx::{Executor, Row}; + + #[sqlx::test] + async fn test_encode_decode_geometry_collection(pool: MySqlPool) -> anyhow::Result<()> { + let table_name = format!("test_geo_geometry_collection_table"); + pool.execute(format!("DROP TABLE IF EXISTS {}", table_name).as_str()) + .await?; + pool.execute( + format!( + "CREATE TABLE {} (id INT, geom GEOMETRY, geom_null GEOMETRY NULL)", + table_name + ) + .as_str(), + ) + .await?; + + let p1 = Point::new(0., 0.); + let ls1 = LineString::new(vec![coord! {x: 1., y: 1.}, coord! {x: 2., y: 2.}]); + let gc1 = TestableGeoType::new_from(vec![ + // Changed to new_from + Geometry::Point(p1.clone()), + Geometry::LineString(ls1.clone()), + ]); + + let p2 = Point::new(10., 10.); + let gc2 = TestableGeoType::new_from(vec![ + // Changed to new_from + Geometry::Point(p2.clone()), + ]); + + // Test non-nullable + sqlx::query(&format!( + "INSERT INTO {} (id, geom) VALUES (1, ?)", + table_name + )) + .bind(gc1.clone()) + .execute(&pool) + .await?; + + let row: MySqlRow = sqlx::query(&format!("SELECT geom FROM {} WHERE id = 1", table_name)) + .fetch_one(&pool) + .await?; + let decoded_val: TestableGeoType = row.try_get("geom")?; + assert_eq!(decoded_val.len(), gc1.len()); + assert_eq!(decoded_val, gc1); + + // Test nullable Some(value) + let some_val: Option = Some(gc2.clone()); + sqlx::query(&format!( + "INSERT INTO {} (id, geom_null) VALUES (2, ?)", + table_name + )) + .bind(some_val.clone()) + .execute(&pool) + .await?; + + let row_some: MySqlRow = sqlx::query(&format!( + "SELECT geom_null FROM {} WHERE id = 2", + table_name + )) + .fetch_one(&pool) + .await?; + let decoded_some: Option = row_some.try_get("geom_null")?; + assert_eq!(decoded_some, some_val); + + // Test nullable None + let none_val: Option = None; + sqlx::query(&format!( + "INSERT INTO {} (id, geom_null) VALUES (3, ?)", + table_name + )) + .bind(none_val.clone()) + .execute(&pool) + .await?; + + let row_none: MySqlRow = sqlx::query(&format!( + "SELECT geom_null FROM {} WHERE id = 3", + table_name + )) + .fetch_one(&pool) + .await?; + let decoded_none: Option = row_none.try_get("geom_null")?; + assert_eq!(decoded_none, none_val); + + pool.execute(format!("DROP TABLE IF EXISTS {}", table_name).as_str()) + .await?; + Ok(()) + } +} diff --git a/sqlx-mysql/src/types/geo/linestring.rs b/sqlx-mysql/src/types/geo/linestring.rs new file mode 100644 index 0000000000..fb8a679e80 --- /dev/null +++ b/sqlx-mysql/src/types/geo/linestring.rs @@ -0,0 +1,156 @@ +use sqlx_core::decode::Decode; +use sqlx_core::encode::{Encode, IsNull}; +use sqlx_core::error::BoxDynError; +use sqlx_core::types::Type; + +use crate::io::MySqlBufMutExt; +use crate::protocol::text::{ColumnFlags, ColumnType}; +use crate::{MySql, MySqlTypeInfo, MySqlValueRef}; + +use std::convert::TryFrom; + +use geo_traits::to_geo::ToGeoGeometry; +use geo_types::LineString; +use wkb::reader; +use wkb::writer; + +impl Type for LineString { + fn type_info() -> MySqlTypeInfo { + MySqlTypeInfo::binary(ColumnType::Geometry) + } + + fn compatible(ty: &MySqlTypeInfo) -> bool { + matches!( + ty.r#type, + ColumnType::Geometry + | ColumnType::Blob + | ColumnType::MediumBlob + | ColumnType::LongBlob + | ColumnType::TinyBlob + | ColumnType::VarString if ty.flags.contains(ColumnFlags::BINARY) + ) + } +} + +impl Encode<'_, MySql> for LineString { + fn encode_by_ref(&self, buf: &mut Vec) -> Result { + let mut wkb_buffer = Vec::new(); + wkb_buffer.extend_from_slice(&0u32.to_le_bytes()); // SRID = 0, Little Endian + writer::write_line_string(&mut wkb_buffer, self, &writer::WriteOptions::default())?; + buf.put_bytes_lenenc(&wkb_buffer); + Ok(IsNull::No) + } +} + +impl<'r> Decode<'r, MySql> for LineString { + fn decode(value: MySqlValueRef<'r>) -> Result { + let bytes = value.as_bytes()?; + if bytes.len() < 4 { + return Err(format!( + "Invalid GEOMETRY data for LineString: received {} bytes, expected at least 4 for SRID prefix.", + bytes.len() + ) + .into()); + } + let wkb_data = &bytes[4..]; // Skip 4-byte SRID + + let wkb_reader_geom = reader::Wkb::try_new(wkb_data) + .map_err(|e| BoxDynError::from(format!("WKB parsing error for LineString: {}", e)))?; + + let geo_geom: geo_types::Geometry = wkb_reader_geom.to_geometry(); + + LineString::try_from(geo_geom).map_err(|e| { + BoxDynError::from(format!( + "Failed to convert geo_types::Geometry to LineString: {:?}", + e + )) + }) + } +} + +#[cfg(test)] +mod tests { + use geo_types::coord; + use geo_types::LineString as TestableGeoType; + use sqlx::mysql::{MySqlPool, MySqlRow}; + use sqlx::{Executor, Row}; // For CONSTRUCTION_CODE + + #[sqlx::test] + async fn test_encode_decode_linestring(pool: MySqlPool) -> anyhow::Result<()> { + let table_name = format!("test_geo_linestring_table"); + pool.execute(format!("DROP TABLE IF EXISTS {}", table_name).as_str()) + .await?; + pool.execute( + format!( + "CREATE TABLE {} (id INT, geom GEOMETRY, geom_null GEOMETRY NULL)", + table_name + ) + .as_str(), + ) + .await?; + + let ls1 = TestableGeoType::new(vec![ + coord! { x: 0., y: 0. }, + coord! { x: 1., y: 1. }, + coord! { x: 2., y: 0. }, + ]); + let ls2 = TestableGeoType::new(vec![coord! { x: 10., y: 10. }, coord! { x: 20., y: 20. }]); + + // Test non-nullable + sqlx::query(&format!( + "INSERT INTO {} (id, geom) VALUES (1, ?)", + table_name + )) + .bind(ls1.clone()) + .execute(&pool) + .await?; + + let row: MySqlRow = sqlx::query(&format!("SELECT geom FROM {} WHERE id = 1", table_name)) + .fetch_one(&pool) + .await?; + let decoded_val: TestableGeoType = row.try_get("geom")?; + assert_eq!(decoded_val, ls1); + + // Test nullable Some(value) + let some_val: Option = Some(ls2.clone()); + sqlx::query(&format!( + "INSERT INTO {} (id, geom_null) VALUES (2, ?)", + table_name + )) + .bind(some_val.clone()) + .execute(&pool) + .await?; + + let row_some: MySqlRow = sqlx::query(&format!( + "SELECT geom_null FROM {} WHERE id = 2", + table_name + )) + .fetch_one(&pool) + .await?; + let decoded_some: Option = row_some.try_get("geom_null")?; + assert_eq!(decoded_some, some_val); + + // Test nullable None + let none_val: Option = None; + sqlx::query(&format!( + "INSERT INTO {} (id, geom_null) VALUES (3, ?)", + table_name + )) + .bind(none_val.clone()) + .execute(&pool) + .await?; + + let row_none: MySqlRow = sqlx::query(&format!( + "SELECT geom_null FROM {} WHERE id = 3", + table_name + )) + .fetch_one(&pool) + .await?; + let decoded_none: Option = row_none.try_get("geom_null")?; + assert_eq!(decoded_none, none_val); + + pool.execute(format!("DROP TABLE IF EXISTS {}", table_name).as_str()) + .await?; + Ok(()) + } +} diff --git a/sqlx-mysql/src/types/geo/mod.rs b/sqlx-mysql/src/types/geo/mod.rs new file mode 100644 index 0000000000..472a04cdae --- /dev/null +++ b/sqlx-mysql/src/types/geo/mod.rs @@ -0,0 +1,8 @@ +pub mod geometry; +pub mod geometry_collection; +pub mod linestring; +pub mod multi_line_string; +pub mod multi_point; +pub mod multi_polygon; +pub mod point; +pub mod polygon; diff --git a/sqlx-mysql/src/types/geo/multi_line_string.rs b/sqlx-mysql/src/types/geo/multi_line_string.rs new file mode 100644 index 0000000000..3305e518ec --- /dev/null +++ b/sqlx-mysql/src/types/geo/multi_line_string.rs @@ -0,0 +1,157 @@ +use sqlx_core::decode::Decode; +use sqlx_core::encode::{Encode, IsNull}; +use sqlx_core::error::BoxDynError; +use sqlx_core::types::Type; + +use crate::io::MySqlBufMutExt; +use crate::protocol::text::{ColumnFlags, ColumnType}; +use crate::{MySql, MySqlTypeInfo, MySqlValueRef}; + +use std::convert::TryFrom; + +use geo_traits::to_geo::ToGeoGeometry; +use geo_types::MultiLineString; +use wkb::reader; +use wkb::writer; + +impl Type for MultiLineString { + fn type_info() -> MySqlTypeInfo { + MySqlTypeInfo::binary(ColumnType::Geometry) + } + + fn compatible(ty: &MySqlTypeInfo) -> bool { + matches!( + ty.r#type, + ColumnType::Geometry + | ColumnType::Blob + | ColumnType::MediumBlob + | ColumnType::LongBlob + | ColumnType::TinyBlob + | ColumnType::VarString if ty.flags.contains(ColumnFlags::BINARY) + ) + } +} + +impl Encode<'_, MySql> for MultiLineString { + fn encode_by_ref(&self, buf: &mut Vec) -> Result { + let mut wkb_buffer = Vec::new(); + wkb_buffer.extend_from_slice(&0u32.to_le_bytes()); // SRID = 0, Little Endian + writer::write_multi_line_string(&mut wkb_buffer, self, &writer::WriteOptions::default())?; + buf.put_bytes_lenenc(&wkb_buffer); + Ok(IsNull::No) + } +} + +impl<'r> Decode<'r, MySql> for MultiLineString { + fn decode(value: MySqlValueRef<'r>) -> Result { + let bytes = value.as_bytes()?; + if bytes.len() < 4 { + return Err(format!( + "Invalid GEOMETRY data for MultiLineString: received {} bytes, expected at least 4 for SRID prefix.", + bytes.len() + ) + .into()); + } + let wkb_data = &bytes[4..]; // Skip 4-byte SRID + + let wkb_reader_geom = reader::Wkb::try_new(wkb_data).map_err(|e| { + BoxDynError::from(format!("WKB parsing error for MultiLineString: {}", e)) + })?; + + let geo_geom: geo_types::Geometry = wkb_reader_geom.to_geometry(); + + MultiLineString::try_from(geo_geom).map_err(|e| { + BoxDynError::from(format!( + "Failed to convert geo_types::Geometry to MultiLineString: {:?}", + e + )) + }) + } +} + +#[cfg(test)] +mod tests { + use geo_types::coord; + use geo_types::LineString; + use geo_types::MultiLineString as TestableGeoType; + use sqlx::mysql::{MySqlPool, MySqlRow}; + use sqlx::{Executor, Row}; + + #[sqlx::test] + async fn test_encode_decode_multilinestring(pool: MySqlPool) -> anyhow::Result<()> { + let table_name = format!("test_geo_multilinestring_table"); + pool.execute(format!("DROP TABLE IF EXISTS {}", table_name).as_str()) + .await?; + pool.execute( + format!( + "CREATE TABLE {} (id INT, geom GEOMETRY, geom_null GEOMETRY NULL)", + table_name + ) + .as_str(), + ) + .await?; + + let ls1_in = LineString::new(vec![coord! {x:0.,y:0.}, coord! {x:1.,y:1.}]); + let ls2_in = LineString::new(vec![coord! {x:2.,y:2.}, coord! {x:3.,y:3.}]); + let mls1 = TestableGeoType::new(vec![ls1_in, ls2_in]); + let ls3_in = LineString::new(vec![coord! {x:10.,y:10.}, coord! {x:20.,y:20.}]); + let mls2 = TestableGeoType::new(vec![ls3_in]); + + // Test non-nullable + sqlx::query(&format!( + "INSERT INTO {} (id, geom) VALUES (1, ?)", + table_name + )) + .bind(mls1.clone()) + .execute(&pool) + .await?; + + let row: MySqlRow = sqlx::query(&format!("SELECT geom FROM {} WHERE id = 1", table_name)) + .fetch_one(&pool) + .await?; + let decoded_val: TestableGeoType = row.try_get("geom")?; + assert_eq!(decoded_val, mls1); + + // Test nullable Some(value) + let some_val: Option = Some(mls2.clone()); + sqlx::query(&format!( + "INSERT INTO {} (id, geom_null) VALUES (2, ?)", + table_name + )) + .bind(some_val.clone()) + .execute(&pool) + .await?; + + let row_some: MySqlRow = sqlx::query(&format!( + "SELECT geom_null FROM {} WHERE id = 2", + table_name + )) + .fetch_one(&pool) + .await?; + let decoded_some: Option = row_some.try_get("geom_null")?; + assert_eq!(decoded_some, some_val); + + // Test nullable None + let none_val: Option = None; + sqlx::query(&format!( + "INSERT INTO {} (id, geom_null) VALUES (3, ?)", + table_name + )) + .bind(none_val.clone()) + .execute(&pool) + .await?; + + let row_none: MySqlRow = sqlx::query(&format!( + "SELECT geom_null FROM {} WHERE id = 3", + table_name + )) + .fetch_one(&pool) + .await?; + let decoded_none: Option = row_none.try_get("geom_null")?; + assert_eq!(decoded_none, none_val); + + pool.execute(format!("DROP TABLE IF EXISTS {}", table_name).as_str()) + .await?; + Ok(()) + } +} diff --git a/sqlx-mysql/src/types/geo/multi_point.rs b/sqlx-mysql/src/types/geo/multi_point.rs new file mode 100644 index 0000000000..7969e0f2ee --- /dev/null +++ b/sqlx-mysql/src/types/geo/multi_point.rs @@ -0,0 +1,152 @@ +use sqlx_core::decode::Decode; +use sqlx_core::encode::{Encode, IsNull}; +use sqlx_core::error::BoxDynError; +use sqlx_core::types::Type; + +use crate::io::MySqlBufMutExt; +use crate::protocol::text::{ColumnFlags, ColumnType}; +use crate::{MySql, MySqlTypeInfo, MySqlValueRef}; + +use std::convert::TryFrom; + +use geo_traits::to_geo::ToGeoGeometry; +use geo_types::MultiPoint; +use wkb::reader; +use wkb::writer; + +impl Type for MultiPoint { + fn type_info() -> MySqlTypeInfo { + MySqlTypeInfo::binary(ColumnType::Geometry) + } + + fn compatible(ty: &MySqlTypeInfo) -> bool { + matches!( + ty.r#type, + ColumnType::Geometry + | ColumnType::Blob + | ColumnType::MediumBlob + | ColumnType::LongBlob + | ColumnType::TinyBlob + | ColumnType::VarString if ty.flags.contains(ColumnFlags::BINARY) + ) + } +} + +impl Encode<'_, MySql> for MultiPoint { + fn encode_by_ref(&self, buf: &mut Vec) -> Result { + let mut wkb_buffer = Vec::new(); + wkb_buffer.extend_from_slice(&0u32.to_le_bytes()); // SRID = 0, Little Endian + writer::write_multi_point(&mut wkb_buffer, self, &writer::WriteOptions::default())?; + buf.put_bytes_lenenc(&wkb_buffer); + Ok(IsNull::No) + } +} + +impl<'r> Decode<'r, MySql> for MultiPoint { + fn decode(value: MySqlValueRef<'r>) -> Result { + let bytes = value.as_bytes()?; + if bytes.len() < 4 { + return Err(format!( + "Invalid GEOMETRY data for MultiPoint: received {} bytes, expected at least 4 for SRID prefix.", + bytes.len() + ) + .into()); + } + let wkb_data = &bytes[4..]; // Skip 4-byte SRID + + let wkb_reader_geom = reader::Wkb::try_new(wkb_data) + .map_err(|e| BoxDynError::from(format!("WKB parsing error for MultiPoint: {}", e)))?; + + let geo_geom: geo_types::Geometry = wkb_reader_geom.to_geometry(); + + MultiPoint::try_from(geo_geom).map_err(|e| { + BoxDynError::from(format!( + "Failed to convert geo_types::Geometry to MultiPoint: {:?}", + e + )) + }) + } +} + +#[cfg(test)] +mod tests { + use geo_types::MultiPoint as TestableGeoType; + use geo_types::Point; + use sqlx::mysql::{MySqlPool, MySqlRow}; + use sqlx::{Executor, Row}; + + #[sqlx::test] + async fn test_encode_decode_multipoint(pool: MySqlPool) -> anyhow::Result<()> { + let table_name = format!("test_geo_multipoint_table"); + pool.execute(format!("DROP TABLE IF EXISTS {}", table_name).as_str()) + .await?; + pool.execute( + format!( + "CREATE TABLE {} (id INT, geom GEOMETRY, geom_null GEOMETRY NULL)", + table_name + ) + .as_str(), + ) + .await?; + + let mp1 = TestableGeoType::new(vec![Point::new(0., 0.), Point::new(1., 1.)]); + let mp2 = TestableGeoType::new(vec![Point::new(10., 10.), Point::new(20., 20.)]); + + // Test non-nullable + sqlx::query(&format!( + "INSERT INTO {} (id, geom) VALUES (1, ?)", + table_name + )) + .bind(mp1.clone()) + .execute(&pool) + .await?; + + let row: MySqlRow = sqlx::query(&format!("SELECT geom FROM {} WHERE id = 1", table_name)) + .fetch_one(&pool) + .await?; + let decoded_val: TestableGeoType = row.try_get("geom")?; + assert_eq!(decoded_val, mp1); + + // Test nullable Some(value) + let some_val: Option = Some(mp2.clone()); + sqlx::query(&format!( + "INSERT INTO {} (id, geom_null) VALUES (2, ?)", + table_name + )) + .bind(some_val.clone()) + .execute(&pool) + .await?; + + let row_some: MySqlRow = sqlx::query(&format!( + "SELECT geom_null FROM {} WHERE id = 2", + table_name + )) + .fetch_one(&pool) + .await?; + let decoded_some: Option = row_some.try_get("geom_null")?; + assert_eq!(decoded_some, some_val); + + // Test nullable None + let none_val: Option = None; + sqlx::query(&format!( + "INSERT INTO {} (id, geom_null) VALUES (3, ?)", + table_name + )) + .bind(none_val.clone()) + .execute(&pool) + .await?; + + let row_none: MySqlRow = sqlx::query(&format!( + "SELECT geom_null FROM {} WHERE id = 3", + table_name + )) + .fetch_one(&pool) + .await?; + let decoded_none: Option = row_none.try_get("geom_null")?; + assert_eq!(decoded_none, none_val); + + pool.execute(format!("DROP TABLE IF EXISTS {}", table_name).as_str()) + .await?; + Ok(()) + } +} diff --git a/sqlx-mysql/src/types/geo/multi_polygon.rs b/sqlx-mysql/src/types/geo/multi_polygon.rs new file mode 100644 index 0000000000..9d4f42cbfa --- /dev/null +++ b/sqlx-mysql/src/types/geo/multi_polygon.rs @@ -0,0 +1,175 @@ +use sqlx_core::decode::Decode; +use sqlx_core::encode::{Encode, IsNull}; +use sqlx_core::error::BoxDynError; +use sqlx_core::types::Type; + +use crate::io::MySqlBufMutExt; +use crate::protocol::text::{ColumnFlags, ColumnType}; +use crate::{MySql, MySqlTypeInfo, MySqlValueRef}; + +use std::convert::TryFrom; + +use geo_traits::to_geo::ToGeoGeometry; +use geo_types::MultiPolygon; +use wkb::reader; +use wkb::writer; + +impl Type for MultiPolygon { + fn type_info() -> MySqlTypeInfo { + MySqlTypeInfo::binary(ColumnType::Geometry) + } + + fn compatible(ty: &MySqlTypeInfo) -> bool { + matches!( + ty.r#type, + ColumnType::Geometry + | ColumnType::Blob + | ColumnType::MediumBlob + | ColumnType::LongBlob + | ColumnType::TinyBlob + | ColumnType::VarString if ty.flags.contains(ColumnFlags::BINARY) + ) + } +} + +impl Encode<'_, MySql> for MultiPolygon { + fn encode_by_ref(&self, buf: &mut Vec) -> Result { + let mut wkb_buffer = Vec::new(); + wkb_buffer.extend_from_slice(&0u32.to_le_bytes()); // SRID = 0, Little Endian + writer::write_multi_polygon(&mut wkb_buffer, self, &writer::WriteOptions::default())?; + buf.put_bytes_lenenc(&wkb_buffer); + Ok(IsNull::No) + } +} + +impl<'r> Decode<'r, MySql> for MultiPolygon { + fn decode(value: MySqlValueRef<'r>) -> Result { + let bytes = value.as_bytes()?; + if bytes.len() < 4 { + return Err(format!( + "Invalid GEOMETRY data for MultiPolygon: received {} bytes, expected at least 4 for SRID prefix.", + bytes.len() + ) + .into()); + } + let wkb_data = &bytes[4..]; // Skip 4-byte SRID + + let wkb_reader_geom = reader::Wkb::try_new(wkb_data) + .map_err(|e| BoxDynError::from(format!("WKB parsing error for MultiPolygon: {}", e)))?; + + let geo_geom: geo_types::Geometry = wkb_reader_geom.to_geometry(); + + MultiPolygon::try_from(geo_geom).map_err(|e| { + BoxDynError::from(format!( + "Failed to convert geo_types::Geometry to MultiPolygon: {:?}", + e + )) + }) + } +} + +#[cfg(test)] +mod tests { + use geo_types::coord; + use geo_types::LineString; + use geo_types::MultiPolygon as TestableGeoType; + use geo_types::Polygon; + use sqlx::mysql::{MySqlPool, MySqlRow}; + use sqlx::{Executor, Row}; + + #[sqlx::test] + async fn test_encode_decode_multipolygon(pool: MySqlPool) -> anyhow::Result<()> { + let table_name = format!("test_geo_multipolygon_table"); + pool.execute(format!("DROP TABLE IF EXISTS {}", table_name).as_str()) + .await?; + pool.execute( + format!( + "CREATE TABLE {} (id INT, geom GEOMETRY, geom_null GEOMETRY NULL)", + table_name + ) + .as_str(), + ) + .await?; + + let ext1 = LineString::new(vec![ + coord! {x:0.,y:0.}, + coord! {x:1.,y:1.}, + coord! {x:1.,y:0.}, + coord! {x:0.,y:0.}, + ]); + let poly1 = Polygon::new(ext1, vec![]); + let ext2 = LineString::new(vec![ + coord! {x:10.,y:10.}, + coord! {x:11.,y:11.}, + coord! {x:11.,y:10.}, + coord! {x:10.,y:10.}, + ]); + let poly2 = Polygon::new(ext2, vec![]); + let mpoly1 = TestableGeoType::new(vec![poly1, poly2]); + let ext3 = LineString::new(vec![ + coord! {x:20.,y:20.}, + coord! {x:21.,y:21.}, + coord! {x:21.,y:20.}, + coord! {x:20.,y:20.}, + ]); + let poly3 = Polygon::new(ext3, vec![]); + let mpoly2 = TestableGeoType::new(vec![poly3]); + + // Test non-nullable + sqlx::query(&format!( + "INSERT INTO {} (id, geom) VALUES (1, ?)", + table_name + )) + .bind(mpoly1.clone()) + .execute(&pool) + .await?; + + let row: MySqlRow = sqlx::query(&format!("SELECT geom FROM {} WHERE id = 1", table_name)) + .fetch_one(&pool) + .await?; + let decoded_val: TestableGeoType = row.try_get("geom")?; + assert_eq!(decoded_val, mpoly1); + + // Test nullable Some(value) + let some_val: Option = Some(mpoly2.clone()); + sqlx::query(&format!( + "INSERT INTO {} (id, geom_null) VALUES (2, ?)", + table_name + )) + .bind(some_val.clone()) + .execute(&pool) + .await?; + + let row_some: MySqlRow = sqlx::query(&format!( + "SELECT geom_null FROM {} WHERE id = 2", + table_name + )) + .fetch_one(&pool) + .await?; + let decoded_some: Option = row_some.try_get("geom_null")?; + assert_eq!(decoded_some, some_val); + + // Test nullable None + let none_val: Option = None; + sqlx::query(&format!( + "INSERT INTO {} (id, geom_null) VALUES (3, ?)", + table_name + )) + .bind(none_val.clone()) + .execute(&pool) + .await?; + + let row_none: MySqlRow = sqlx::query(&format!( + "SELECT geom_null FROM {} WHERE id = 3", + table_name + )) + .fetch_one(&pool) + .await?; + let decoded_none: Option = row_none.try_get("geom_null")?; + assert_eq!(decoded_none, none_val); + + pool.execute(format!("DROP TABLE IF EXISTS {}", table_name).as_str()) + .await?; + Ok(()) + } +} diff --git a/sqlx-mysql/src/types/geo/point.rs b/sqlx-mysql/src/types/geo/point.rs new file mode 100644 index 0000000000..52ba2e5902 --- /dev/null +++ b/sqlx-mysql/src/types/geo/point.rs @@ -0,0 +1,151 @@ +use sqlx_core::decode::Decode; +use sqlx_core::encode::{Encode, IsNull}; +use sqlx_core::error::BoxDynError; +use sqlx_core::types::Type; + +use crate::io::MySqlBufMutExt; +use crate::protocol::text::{ColumnFlags, ColumnType}; +use crate::{MySql, MySqlTypeInfo, MySqlValueRef}; + +use std::convert::TryFrom; + +use geo_traits::to_geo::ToGeoGeometry; +use geo_types::Point; +use wkb::reader; +use wkb::writer; + +impl Type for Point { + fn type_info() -> MySqlTypeInfo { + MySqlTypeInfo::binary(ColumnType::Geometry) + } + + fn compatible(ty: &MySqlTypeInfo) -> bool { + matches!( + ty.r#type, + ColumnType::Geometry + | ColumnType::Blob + | ColumnType::MediumBlob + | ColumnType::LongBlob + | ColumnType::TinyBlob + | ColumnType::VarString if ty.flags.contains(ColumnFlags::BINARY) + ) + } +} + +impl Encode<'_, MySql> for Point { + fn encode_by_ref(&self, buf: &mut Vec) -> Result { + let mut wkb_buffer = Vec::new(); + wkb_buffer.extend_from_slice(&0u32.to_le_bytes()); // SRID = 0, Little Endian + writer::write_point(&mut wkb_buffer, self, &writer::WriteOptions::default())?; + buf.put_bytes_lenenc(&wkb_buffer); + Ok(IsNull::No) + } +} + +impl<'r> Decode<'r, MySql> for Point { + fn decode(value: MySqlValueRef<'r>) -> Result { + let bytes = value.as_bytes()?; + if bytes.len() < 4 { + return Err(format!( + "Invalid GEOMETRY data for Point: received {} bytes, expected at least 4 for SRID prefix.", + bytes.len() + ) + .into()); + } + let wkb_data = &bytes[4..]; // Skip 4-byte SRID + + let wkb_reader_geom = reader::Wkb::try_new(wkb_data) + .map_err(|e| BoxDynError::from(format!("WKB parsing error for Point: {}", e)))?; + + let geo_geom: geo_types::Geometry = wkb_reader_geom.to_geometry(); + + Point::try_from(geo_geom).map_err(|e| { + BoxDynError::from(format!( + "Failed to convert geo_types::Geometry to Point: {:?}", + e + )) + }) + } +} + +#[cfg(test)] +mod tests { + use geo_types::Point as TestableGeoType; + use sqlx::mysql::{MySqlPool, MySqlRow}; + use sqlx::{Executor, Row}; + + #[sqlx::test] + async fn test_encode_decode_point(pool: MySqlPool) -> anyhow::Result<()> { + let table_name = format!("test_geo_point_table"); + pool.execute(format!("DROP TABLE IF EXISTS {}", table_name).as_str()) + .await?; + pool.execute( + format!( + "CREATE TABLE {} (id INT, geom GEOMETRY, geom_null GEOMETRY NULL)", + table_name + ) + .as_str(), + ) + .await?; + + let p1 = TestableGeoType::new(10.0, 20.5); + let p2 = TestableGeoType::new(30.0, 40.0); + + // Test non-nullable + sqlx::query(&format!( + "INSERT INTO {} (id, geom) VALUES (1, ?)", + table_name + )) + .bind(p1.clone()) + .execute(&pool) + .await?; + + let row: MySqlRow = sqlx::query(&format!("SELECT geom FROM {} WHERE id = 1", table_name)) + .fetch_one(&pool) + .await?; + let decoded_val: TestableGeoType = row.try_get("geom")?; + assert_eq!(decoded_val, p1); + + // Test nullable Some(value) + let some_val: Option = Some(p2.clone()); + sqlx::query(&format!( + "INSERT INTO {} (id, geom_null) VALUES (2, ?)", + table_name + )) + .bind(some_val.clone()) + .execute(&pool) + .await?; + + let row_some: MySqlRow = sqlx::query(&format!( + "SELECT geom_null FROM {} WHERE id = 2", + table_name + )) + .fetch_one(&pool) + .await?; + let decoded_some: Option = row_some.try_get("geom_null")?; + assert_eq!(decoded_some, some_val); + + // Test nullable None + let none_val: Option = None; + sqlx::query(&format!( + "INSERT INTO {} (id, geom_null) VALUES (3, ?)", + table_name + )) + .bind(none_val.clone()) + .execute(&pool) + .await?; + + let row_none: MySqlRow = sqlx::query(&format!( + "SELECT geom_null FROM {} WHERE id = 3", + table_name + )) + .fetch_one(&pool) + .await?; + let decoded_none: Option = row_none.try_get("geom_null")?; + assert_eq!(decoded_none, none_val); + + pool.execute(format!("DROP TABLE IF EXISTS {}", table_name).as_str()) + .await?; + Ok(()) + } +} diff --git a/sqlx-mysql/src/types/geo/polygon.rs b/sqlx-mysql/src/types/geo/polygon.rs new file mode 100644 index 0000000000..07eb72aa99 --- /dev/null +++ b/sqlx-mysql/src/types/geo/polygon.rs @@ -0,0 +1,165 @@ +use sqlx_core::decode::Decode; +use sqlx_core::encode::{Encode, IsNull}; +use sqlx_core::error::BoxDynError; +use sqlx_core::types::Type; + +use crate::io::MySqlBufMutExt; +use crate::protocol::text::{ColumnFlags, ColumnType}; +use crate::{MySql, MySqlTypeInfo, MySqlValueRef}; + +use std::convert::TryFrom; + +use geo_traits::to_geo::ToGeoGeometry; +use geo_types::Polygon; +use wkb::reader; +use wkb::writer; + +impl Type for Polygon { + fn type_info() -> MySqlTypeInfo { + MySqlTypeInfo::binary(ColumnType::Geometry) + } + + fn compatible(ty: &MySqlTypeInfo) -> bool { + matches!( + ty.r#type, + ColumnType::Geometry + | ColumnType::Blob + | ColumnType::MediumBlob + | ColumnType::LongBlob + | ColumnType::TinyBlob + | ColumnType::VarString if ty.flags.contains(ColumnFlags::BINARY) + ) + } +} + +impl Encode<'_, MySql> for Polygon { + fn encode_by_ref(&self, buf: &mut Vec) -> Result { + let mut wkb_buffer = Vec::new(); + wkb_buffer.extend_from_slice(&0u32.to_le_bytes()); // SRID = 0, Little Endian + writer::write_polygon(&mut wkb_buffer, self, &writer::WriteOptions::default())?; + buf.put_bytes_lenenc(&wkb_buffer); + Ok(IsNull::No) + } +} + +impl<'r> Decode<'r, MySql> for Polygon { + fn decode(value: MySqlValueRef<'r>) -> Result { + let bytes = value.as_bytes()?; + if bytes.len() < 4 { + return Err(format!( + "Invalid GEOMETRY data for Polygon: received {} bytes, expected at least 4 for SRID prefix.", + bytes.len() + ) + .into()); + } + let wkb_data = &bytes[4..]; // Skip 4-byte SRID + + let wkb_reader_geom = reader::Wkb::try_new(wkb_data) + .map_err(|e| BoxDynError::from(format!("WKB parsing error for Polygon: {}", e)))?; + + let geo_geom: geo_types::Geometry = wkb_reader_geom.to_geometry(); + + Polygon::try_from(geo_geom).map_err(|e| { + BoxDynError::from(format!( + "Failed to convert geo_types::Geometry to Polygon: {:?}", + e + )) + }) + } +} + +#[cfg(test)] +mod tests { + use geo_types::coord; + use geo_types::LineString; + use geo_types::Polygon as TestableGeoType; + use sqlx::mysql::{MySqlPool, MySqlRow}; + use sqlx::{Executor, Row}; + + #[sqlx::test] + async fn test_encode_decode_polygon(pool: MySqlPool) -> anyhow::Result<()> { + let table_name = format!("test_geo_polygon_table"); + pool.execute(format!("DROP TABLE IF EXISTS {}", table_name).as_str()) + .await?; + pool.execute( + format!( + "CREATE TABLE {} (id INT, geom GEOMETRY, geom_null GEOMETRY NULL)", + table_name + ) + .as_str(), + ) + .await?; + + let exterior1 = LineString::new(vec![ + coord! {x: 0., y: 0.}, + coord! {x: 1., y: 1.}, + coord! {x: 1., y: 0.}, + coord! {x: 0., y: 0.}, + ]); + let poly1 = TestableGeoType::new(exterior1, vec![]); + let exterior2 = LineString::new(vec![ + coord! {x: 10., y: 10.}, + coord! {x: 20., y: 20.}, + coord! {x: 20., y: 10.}, + coord! {x: 10., y: 10.}, + ]); + let poly2 = TestableGeoType::new(exterior2, vec![]); + + // Test non-nullable + sqlx::query(&format!( + "INSERT INTO {} (id, geom) VALUES (1, ?)", + table_name + )) + .bind(poly1.clone()) + .execute(&pool) + .await?; + + let row: MySqlRow = sqlx::query(&format!("SELECT geom FROM {} WHERE id = 1", table_name)) + .fetch_one(&pool) + .await?; + let decoded_val: TestableGeoType = row.try_get("geom")?; + assert_eq!(decoded_val, poly1); + + // Test nullable Some(value) + let some_val: Option = Some(poly2.clone()); + sqlx::query(&format!( + "INSERT INTO {} (id, geom_null) VALUES (2, ?)", + table_name + )) + .bind(some_val.clone()) + .execute(&pool) + .await?; + + let row_some: MySqlRow = sqlx::query(&format!( + "SELECT geom_null FROM {} WHERE id = 2", + table_name + )) + .fetch_one(&pool) + .await?; + let decoded_some: Option = row_some.try_get("geom_null")?; + assert_eq!(decoded_some, some_val); + + // Test nullable None + let none_val: Option = None; + sqlx::query(&format!( + "INSERT INTO {} (id, geom_null) VALUES (3, ?)", + table_name + )) + .bind(none_val.clone()) + .execute(&pool) + .await?; + + let row_none: MySqlRow = sqlx::query(&format!( + "SELECT geom_null FROM {} WHERE id = 3", + table_name + )) + .fetch_one(&pool) + .await?; + let decoded_none: Option = row_none.try_get("geom_null")?; + assert_eq!(decoded_none, none_val); + + pool.execute(format!("DROP TABLE IF EXISTS {}", table_name).as_str()) + .await?; + Ok(()) + } +} diff --git a/sqlx-mysql/src/types/mod.rs b/sqlx-mysql/src/types/mod.rs index dc5105fa2f..8d29480560 100644 --- a/sqlx-mysql/src/types/mod.rs +++ b/sqlx-mysql/src/types/mod.rs @@ -189,3 +189,6 @@ mod time; #[cfg(feature = "uuid")] mod uuid; + +#[cfg(feature = "spatial-data-types")] +mod geo;