diff --git a/Cargo.lock b/Cargo.lock index 56cde771a9..4f1dbf13d5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -140,6 +140,15 @@ version = "1.0.75" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4668cab20f66d8d020e1fbc0ebe47217433c1b6c8f2040faf858554e394ace6" +[[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" @@ -1474,6 +1483,31 @@ dependencies = [ "version_check", ] +[[package]] +name = "geo-types" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "567495020b114f1ce9bed679b29975aa0bfae06ac22beacd5cfde5dabe7b05d6" +dependencies = [ + "approx", + "num-traits", + "serde", +] + +[[package]] +name = "geozero" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61d25cc15c7e5b86cd8dadea56bb78c46f346d4fb09022e7cbba0839c890d0a1" +dependencies = [ + "geo-types", + "log", + "scroll", + "serde_json", + "thiserror", + "wkt", +] + [[package]] name = "getrandom" version = "0.2.11" @@ -2888,6 +2922,12 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scroll" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04c565b551bafbef4157586fa379538366e4385d42082f255bfd96e4fe8519da" + [[package]] name = "sct" version = "0.7.1" @@ -3156,6 +3196,7 @@ dependencies = [ "dotenvy", "env_logger", "futures", + "geo-types", "hex", "libsqlite3-sys", "paste", @@ -3443,6 +3484,8 @@ dependencies = [ "futures-io", "futures-util", "generic-array", + "geo-types", + "geozero", "hex", "hkdf", "hmac", @@ -4463,6 +4506,18 @@ dependencies = [ "memchr", ] +[[package]] +name = "wkt" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3c2252781f8927974e8ba6a67c965a759a2b88ea2b1825f6862426bbb1c8f41" +dependencies = [ + "geo-types", + "log", + "num-traits", + "thiserror", +] + [[package]] name = "wyz" version = "0.5.1" diff --git a/Cargo.toml b/Cargo.toml index 9a4dfdf5fe..6cc8626bea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -110,6 +110,7 @@ mac_address = ["sqlx-core/mac_address", "sqlx-macros?/mac_address", "sqlx-postgr rust_decimal = ["sqlx-core/rust_decimal", "sqlx-macros?/rust_decimal", "sqlx-mysql?/rust_decimal", "sqlx-postgres?/rust_decimal"] time = ["sqlx-core/time", "sqlx-macros?/time", "sqlx-mysql?/time", "sqlx-postgres?/time", "sqlx-sqlite?/time"] uuid = ["sqlx-core/uuid", "sqlx-macros?/uuid", "sqlx-mysql?/uuid", "sqlx-postgres?/uuid", "sqlx-sqlite?/uuid"] +geometry = ["sqlx-mysql?/geometry"] regexp = ["sqlx-sqlite?/regexp"] [workspace.dependencies] @@ -136,6 +137,8 @@ mac_address = "1.1.5" rust_decimal = "1.26.1" time = { version = "0.3.14", features = ["formatting", "parsing", "macros"] } uuid = "1.1.2" +geozero = { version = "0.12.0", default-features = false } +geo-types = "0.7.12" # Common utility crates dotenvy = { version = "0.15.0", default-features = false } @@ -170,6 +173,7 @@ sqlx-test = { path = "./sqlx-test" } paste = "1.0.6" serde = { version = "1.0.132", features = ["derive"] } serde_json = "1.0.73" +geo-types = { workspace = true } url = "2.2.2" rand = "0.8.4" rand_xoshiro = "0.6.0" diff --git a/README.md b/README.md index ecbf0137cc..3a4a67259b 100644 --- a/README.md +++ b/README.md @@ -195,6 +195,8 @@ be removed in the future. - `json`: Add support for `JSON` and `JSONB` (in postgres) using the `serde_json` crate. +- `geometry`: Add support for `GEOMETRY` using the `geozero` crate, currently only available for MySQL. + - Offline mode is now always enabled. See [sqlx-cli/README.md][readme-offline]. [readme-offline]: sqlx-cli/README.md#enable-building-in-offline-mode-with-query diff --git a/sqlx-mysql/Cargo.toml b/sqlx-mysql/Cargo.toml index d56a550f47..8031514f34 100644 --- a/sqlx-mysql/Cargo.toml +++ b/sqlx-mysql/Cargo.toml @@ -14,6 +14,7 @@ json = ["sqlx-core/json", "serde"] any = ["sqlx-core/any"] offline = ["sqlx-core/offline", "serde/derive"] migrate = ["sqlx-core/migrate"] +geometry = ["geozero", "geo-types"] [dependencies] sqlx-core = { workspace = true } @@ -41,6 +42,8 @@ chrono = { workspace = true, optional = true } rust_decimal = { workspace = true, optional = true } time = { workspace = true, optional = true } uuid = { workspace = true, optional = true } +geozero = { workspace = true, features = ["with-geo", "with-wkb"], optional = true } +geo-types = { workspace = true, optional = true } # Misc atoi = "2.0" diff --git a/sqlx-mysql/src/types/geometry.rs b/sqlx-mysql/src/types/geometry.rs new file mode 100644 index 0000000000..0851fa19e1 --- /dev/null +++ b/sqlx-mysql/src/types/geometry.rs @@ -0,0 +1,50 @@ +use geo_types::Geometry; +use geozero::wkb::{FromWkb, WkbDialect}; +use geozero::{GeozeroGeometry, ToWkb}; + +use crate::decode::Decode; +use crate::encode::{Encode, IsNull}; +use crate::error::BoxDynError; +use crate::io::MySqlBufMutExt; +use crate::protocol::text::ColumnType; +use crate::types::Type; +use crate::{MySql, MySqlTypeInfo, MySqlValueRef}; + +/// Define a type that can be used to represent a `GEOMETRY` field. +/// +/// Note: Only `Geometry` is implemented with geozero::GeozeroGeometry. +impl Type for Geometry { + fn type_info() -> MySqlTypeInfo { + // MySQL does not allow to execute with a Geometry parameter for now. + // MySQL reports: 1210 (HY000): Incorrect arguments to mysqld_stmt_execute + // MariaDB does not report errors but does not work properly. + // So we use the `Blob` type to pass Geometry parameters. + MySqlTypeInfo::binary(ColumnType::Blob) + } + + fn compatible(ty: &MySqlTypeInfo) -> bool { + ty.r#type == ColumnType::Geometry || <&[u8] as Type>::compatible(ty) + } +} + +const ENCODE_ERR: &str = "failed to encode value as Geometry to WKB; the most likely cause is that the value is not a valid geometry"; + +impl Encode<'_, MySql> for Geometry { + fn encode_by_ref(&self, buf: &mut Vec) -> IsNull { + // Encoding is supposed to be infallible, so we don't have much choice but to panic here. + // However, in most cases, a geometry being unable to serialize to WKB is most likely due to user error. + let bytes = self.to_mysql_wkb(self.srid()).expect(ENCODE_ERR); + + buf.put_bytes_lenenc(bytes.as_ref()); + + IsNull::No + } +} + +impl Decode<'_, MySql> for Geometry { + fn decode(value: MySqlValueRef<'_>) -> Result { + let mut bytes = value.as_bytes()?; + + Ok(FromWkb::from_wkb(&mut bytes, WkbDialect::MySQL)?) + } +} diff --git a/sqlx-mysql/src/types/mod.rs b/sqlx-mysql/src/types/mod.rs index 9b7ef29fc7..25c466dc4b 100644 --- a/sqlx-mysql/src/types/mod.rs +++ b/sqlx-mysql/src/types/mod.rs @@ -128,3 +128,6 @@ mod time; #[cfg(feature = "uuid")] mod uuid; + +#[cfg(feature = "geometry")] +mod geometry; diff --git a/tests/mysql/types.rs b/tests/mysql/types.rs index fad95b36a4..0d67a9f0d1 100644 --- a/tests/mysql/types.rs +++ b/tests/mysql/types.rs @@ -287,6 +287,206 @@ mod json_tests { )); } +#[cfg(feature = "geometry")] +mod geometry_tests { + use geo_types::{Coord, Geometry, GeometryCollection, LineString, Point, Polygon}; + use sqlx_test::test_type; + + use super::*; + + test_type!(geometry_point>( + MySql, + "SELECT CAST({0} <=> ? AS SIGNED INTEGER), CAST({0} AS BINARY) as _2, ? as _3", + "ST_GeomFromText('POINT(1 1)')" == Geometry::Point(Point(Coord { x: 1.0, y: 1.0 })), + )); + + test_type!(geometry_linestring>( + MySql, + "SELECT CAST({0} <=> ? AS SIGNED INTEGER), CAST({0} AS BINARY) as _2, ? as _3", + "ST_GeomFromText('LINESTRING(0 0, 1 1, 2 2)')" == Geometry::LineString(LineString(vec![ + Coord { x: 0.0, y: 0.0 }, + Coord { x: 1.0, y: 1.0 }, + Coord { x: 2.0, y: 2.0 }, + ])), + )); + + test_type!(geometry_polygon>( + MySql, + "SELECT CAST({0} <=> ? AS SIGNED INTEGER), CAST({0} AS BINARY) as _2, ? as _3", + "ST_GeomFromText('POLYGON((0 0, 1 1, 1 0, 0 0))')" == Geometry::Polygon(Polygon::new( + LineString(vec![ + Coord { x: 0.0, y: 0.0 }, + Coord { x: 1.0, y: 1.0 }, + Coord { x: 1.0, y: 0.0 }, + Coord { x: 0.0, y: 0.0 }, + ]), + vec![], + )), + )); + + test_type!(geometry_collection>( + MySql, + "SELECT CAST({0} <=> ? AS SIGNED INTEGER), CAST({0} AS BINARY) as _2, ? as _3", + "ST_GeomFromText('GEOMETRYCOLLECTION(POINT(1 1),LINESTRING(0 0, 1 1, 2 2),POLYGON((0 0, 1 1, 1 0, 0 0)))')" == Geometry::GeometryCollection(GeometryCollection(vec![ + Geometry::Point(Point(Coord { x: 1.0, y: 1.0 })), + Geometry::LineString(LineString(vec![ + Coord { x: 0.0, y: 0.0 }, + Coord { x: 1.0, y: 1.0 }, + Coord { x: 2.0, y: 2.0 }, + ])), + Geometry::Polygon(Polygon::new( + LineString(vec![ + Coord { x: 0.0, y: 0.0 }, + Coord { x: 1.0, y: 1.0 }, + Coord { x: 1.0, y: 0.0 }, + Coord { x: 0.0, y: 0.0 }, + ]), + vec![], + )), + ])), + )); + + test_type!(geometry_collection_empty>( + MySql, + "SELECT CAST({0} <=> ? AS SIGNED INTEGER), CAST({0} AS BINARY) as _2, ? as _3", + "ST_GeomFromText('GEOMETRYCOLLECTION EMPTY')" == Geometry::::GeometryCollection(GeometryCollection(vec![])), + )); + + macro_rules! geo_table { + (CREATE, $ty:literal) => { + format!( + r#" +CREATE TEMPORARY TABLE with_geometry ( + id INT PRIMARY KEY AUTO_INCREMENT, + geom {} NOT NULL +);"#, + $ty + ) + }; + (TRUNCATE) => { + "TRUNCATE TABLE with_geometry" + }; + } + + /// Test with a table that has a column which type is a subtype of `GEOMETRY` + /// + /// It tests that we can insert and select values from the table, including using + /// geometry literals in selection. + /// + /// Because of the limitations of MySQL, we have to use the `Blob` type to represent + /// the `Geometry` type, so use case testing in actual tables make more sense with + /// the actual use of users. + macro_rules! test_geo_table { + ($name:ident, $ty:literal, $($text:literal == $value:expr),+ $(,)?) => { + paste::item! { + #[sqlx_macros::test] + async fn [< test_geometry_table_ $name >] () -> anyhow::Result<()> { + use sqlx::Connection; + + let mut conn = sqlx_test::new::().await?; + let tdl = geo_table!(CREATE, $ty); + + conn.execute(tdl.as_str()).await?; + + $( + let expected = $value; + + println!("Insert with select {:?}", expected); + sqlx::query("INSERT INTO with_geometry (geom) VALUES (?)") + .bind(&expected) + .execute(&mut conn) + .await?; + + let row = sqlx::query("SELECT geom FROM with_geometry WHERE geom = ?") + .bind(&expected) + .fetch_one(&mut conn) + .await?; + let geom: Geometry = row.try_get(0)?; + + assert_eq!(geom, expected); + + let query = format!("SELECT geom FROM with_geometry WHERE geom = {}", $text); + println!("{query}"); + + let row = sqlx::query(&query) + .fetch_one(&mut conn) + .await?; + let geom: Geometry = row.try_get(0)?; + + assert_eq!(geom, expected); + conn.execute(geo_table!(TRUNCATE)).await?; + )+ + + conn.close().await?; + + Ok(()) + } + } + }; + } + + test_geo_table!( + point, + "POINT", + "ST_GeomFromText('Point(0 0)')" == Geometry::Point(Point(Coord { x: 0.0, y: 0.0 })), + "ST_GeomFromText('Point(-2 -3)')" == Geometry::Point(Point(Coord { x: -2.0, y: -3.0 })), + "ST_GeomFromText('Point(5.76814 12345)')" + == Geometry::Point(Point(Coord { + x: 5.76814, + y: 12345.0 + })), + ); + + test_geo_table!( + linestring, + "LINESTRING", + "ST_GeomFromText('LineString(0 0, 1 1, 2 2)')" + == Geometry::LineString(LineString(vec![ + Coord { x: 0.0, y: 0.0 }, + Coord { x: 1.0, y: 1.0 }, + Coord { x: 2.0, y: 2.0 }, + ])), + ); + + test_geo_table!( + polygon, + "POLYGON", + "ST_GeomFromText('Polygon((0 0, 1 1, 1 0, 0 0))')" + == Geometry::Polygon(Polygon::new( + LineString(vec![ + Coord { x: 0.0, y: 0.0 }, + Coord { x: 1.0, y: 1.0 }, + Coord { x: 1.0, y: 0.0 }, + Coord { x: 0.0, y: 0.0 }, + ]), + vec![], + )), + ); + + test_geo_table!( + geometry_collection, + "GEOMETRYCOLLECTION", + "ST_GeomFromText('GeometryCollection(Point(1 2),LineString(3 4, 5 6, 7 8),Polygon((0 0, 1 1, 1 0, 0 0)))')" + == Geometry::GeometryCollection(GeometryCollection(vec![ + Geometry::Point(Point(Coord { x: 1.0, y: 2.0 })), + Geometry::LineString(LineString(vec![ + Coord { x: 3.0, y: 4.0 }, + Coord { x: 5.0, y: 6.0 }, + Coord { x: 7.0, y: 8.0 }, + ])), + Geometry::Polygon(Polygon::new( + LineString(vec![ + Coord { x: 0.0, y: 0.0 }, + Coord { x: 1.0, y: 1.0 }, + Coord { x: 1.0, y: 0.0 }, + Coord { x: 0.0, y: 0.0 }, + ]), + vec![], + )), + ])), + ); +} + #[sqlx_macros::test] async fn test_bits() -> anyhow::Result<()> { let mut conn = new::().await?;