From 63bb6a21cac517b034fd551472c846037375fe6f Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 17 Jun 2026 00:49:33 +0800 Subject: [PATCH] fix(mysql): parse geometry WKB to WKT for display and editing --- crates/dbx-core/src/data_grid_sql.rs | 36 +++ crates/dbx-core/src/db/mod.rs | 1 + crates/dbx-core/src/db/mysql.rs | 7 +- crates/dbx-core/src/db/postgres.rs | 314 +-------------------------- crates/dbx-core/src/db/wkb.rs | 312 ++++++++++++++++++++++++++ 5 files changed, 362 insertions(+), 308 deletions(-) create mode 100644 crates/dbx-core/src/db/wkb.rs diff --git a/crates/dbx-core/src/data_grid_sql.rs b/crates/dbx-core/src/data_grid_sql.rs index 86719cedb..8136b998c 100644 --- a/crates/dbx-core/src/data_grid_sql.rs +++ b/crates/dbx-core/src/data_grid_sql.rs @@ -801,6 +801,13 @@ pub fn format_grid_sql_literal( if text.is_empty() { return if database_type == Some(DatabaseType::SqlServer) { "N''" } else { "''" }.to_string(); } + // MySQL geometry columns: wrap WKT text with ST_GeomFromText() + if is_mysql_geometry_literal_database(database_type) + && column_info.map(|column| is_geometry_column_type(&column.data_type)).unwrap_or(false) + { + let escaped = text.replace('\\', "\\\\").replace('\'', "''"); + return format!("ST_GeomFromText('{}')", escaped); + } let literal_text = if database_type == Some(DatabaseType::Tdengine) { format_tdengine_timestamp_literal_text(&text) } else if is_mysql_datetime_literal_database(database_type) @@ -832,6 +839,35 @@ fn is_bit_column_type(data_type: &str) -> bool { lower.split(|ch: char| !ch.is_ascii_alphanumeric()).any(|token| token == "bit") } +fn is_mysql_geometry_literal_database(database_type: Option) -> bool { + matches!( + database_type, + Some( + DatabaseType::Mysql + | DatabaseType::Doris + | DatabaseType::StarRocks + | DatabaseType::Goldendb + | DatabaseType::Sundb + ) + ) +} + +fn is_geometry_column_type(data_type: &str) -> bool { + let lower = data_type.to_ascii_lowercase(); + let base = lower.split('(').next().unwrap_or(&lower).trim(); + matches!( + base, + "geometry" + | "point" + | "linestring" + | "polygon" + | "multipoint" + | "multilinestring" + | "multipolygon" + | "geometrycollection" + ) +} + fn manticore_typed_attribute_value(text: &str, column_info: Option<&DataGridColumnInfo>) -> Option { let data_type = column_info?.data_type.to_ascii_lowercase(); if is_boolean_type(&data_type) && text.eq_ignore_ascii_case("true") { diff --git a/crates/dbx-core/src/db/mod.rs b/crates/dbx-core/src/db/mod.rs index a09ffbdf9..c15b36f7a 100644 --- a/crates/dbx-core/src/db/mod.rs +++ b/crates/dbx-core/src/db/mod.rs @@ -19,6 +19,7 @@ pub mod sqlserver; pub mod ssh_tunnel; pub mod transport_layer_tunnel; pub mod turso_driver; +pub mod wkb; use reqwest::ClientBuilder; use std::future::Future; diff --git a/crates/dbx-core/src/db/mysql.rs b/crates/dbx-core/src/db/mysql.rs index 2c41d7e8b..6a3ea6846 100644 --- a/crates/dbx-core/src/db/mysql.rs +++ b/crates/dbx-core/src/db/mysql.rs @@ -284,7 +284,12 @@ fn mysql_value_to_json(row: &mysql_async::Row, idx: usize) -> serde_json::Value return row_get::, _>(row, idx) .map(|bytes| { if matches!(column.column_type(), ColumnType::MYSQL_TYPE_GEOMETRY) { - mysql_blob_preview(&bytes, "GEOMETRY") + // MySQL prefixes geometry WKB with a 4-byte SRID. + // Strip it before passing to the WKB parser. + let wkb = if bytes.len() >= 4 { &bytes[4..] } else { &bytes }; + super::wkb::wkb_to_wkt(wkb) + .map(serde_json::Value::String) + .unwrap_or_else(|| super::binary_value_to_json(&bytes)) } else { mysql_bytes_to_json(bytes, column) } diff --git a/crates/dbx-core/src/db/postgres.rs b/crates/dbx-core/src/db/postgres.rs index f46b97e94..c49e87ffc 100644 --- a/crates/dbx-core/src/db/postgres.rs +++ b/crates/dbx-core/src/db/postgres.rs @@ -86,309 +86,6 @@ impl<'a> FromSql<'a> for PgRawBytes { } } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct WkbDimensions { - has_z: bool, - has_m: bool, -} - -impl WkbDimensions { - fn suffix(self) -> &'static str { - match (self.has_z, self.has_m) { - (false, false) => "", - (true, false) => " Z", - (false, true) => " M", - (true, true) => " ZM", - } - } - - fn coordinate_len(self) -> usize { - 2 + usize::from(self.has_z) + usize::from(self.has_m) - } -} - -#[derive(Debug, Clone, PartialEq)] -enum WkbGeometry { - Point { dims: WkbDimensions, coords: Option> }, - LineString { dims: WkbDimensions, points: Vec> }, - Polygon { dims: WkbDimensions, rings: Vec>> }, - MultiPoint { dims: WkbDimensions, points: Vec>> }, - MultiLineString { dims: WkbDimensions, lines: Vec>> }, - MultiPolygon { dims: WkbDimensions, polygons: Vec>>> }, - GeometryCollection { dims: WkbDimensions, geometries: Vec }, -} - -impl WkbGeometry { - fn to_wkt(&self) -> String { - match self { - Self::Point { dims, coords } => match coords { - Some(coords) => format!("POINT{}({})", dims.suffix(), format_wkb_coordinate(coords)), - None => format!("POINT{} EMPTY", dims.suffix()), - }, - Self::LineString { dims, points } => { - if points.is_empty() { - format!("LINESTRING{} EMPTY", dims.suffix()) - } else { - format!("LINESTRING{}({})", dims.suffix(), format_wkb_coordinate_sequence(points)) - } - } - Self::Polygon { dims, rings } => { - if rings.is_empty() { - format!("POLYGON{} EMPTY", dims.suffix()) - } else { - format!( - "POLYGON{}({})", - dims.suffix(), - rings - .iter() - .map(|ring| format!("({})", format_wkb_coordinate_sequence(ring))) - .collect::>() - .join(",") - ) - } - } - Self::MultiPoint { dims, points } => { - if points.is_empty() { - format!("MULTIPOINT{} EMPTY", dims.suffix()) - } else { - format!( - "MULTIPOINT{}({})", - dims.suffix(), - points - .iter() - .map(|point| match point { - Some(coords) => format!("({})", format_wkb_coordinate(coords)), - None => "EMPTY".to_string(), - }) - .collect::>() - .join(",") - ) - } - } - Self::MultiLineString { dims, lines } => { - if lines.is_empty() { - format!("MULTILINESTRING{} EMPTY", dims.suffix()) - } else { - format!( - "MULTILINESTRING{}({})", - dims.suffix(), - lines - .iter() - .map(|line| format!("({})", format_wkb_coordinate_sequence(line))) - .collect::>() - .join(",") - ) - } - } - Self::MultiPolygon { dims, polygons } => { - if polygons.is_empty() { - format!("MULTIPOLYGON{} EMPTY", dims.suffix()) - } else { - format!( - "MULTIPOLYGON{}({})", - dims.suffix(), - polygons - .iter() - .map(|polygon| { - format!( - "({})", - polygon - .iter() - .map(|ring| format!("({})", format_wkb_coordinate_sequence(ring))) - .collect::>() - .join(",") - ) - }) - .collect::>() - .join(",") - ) - } - } - Self::GeometryCollection { dims, geometries } => { - if geometries.is_empty() { - format!("GEOMETRYCOLLECTION{} EMPTY", dims.suffix()) - } else { - format!( - "GEOMETRYCOLLECTION{}({})", - dims.suffix(), - geometries.iter().map(WkbGeometry::to_wkt).collect::>().join(",") - ) - } - } - } - } -} - -fn format_wkb_coordinate(coords: &[f64]) -> String { - coords.iter().map(|value| value.to_string()).collect::>().join(" ") -} - -fn format_wkb_coordinate_sequence(points: &[Vec]) -> String { - points.iter().map(|point| format_wkb_coordinate(point)).collect::>().join(",") -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum WkbEndian { - Big, - Little, -} - -struct WkbReader<'a> { - raw: &'a [u8], - pos: usize, -} - -impl<'a> WkbReader<'a> { - fn new(raw: &'a [u8]) -> Self { - Self { raw, pos: 0 } - } - - fn read_u8(&mut self) -> Option { - let value = *self.raw.get(self.pos)?; - self.pos += 1; - Some(value) - } - - fn read_array(&mut self) -> Option<[u8; N]> { - let end = self.pos.checked_add(N)?; - let bytes: [u8; N] = self.raw.get(self.pos..end)?.try_into().ok()?; - self.pos = end; - Some(bytes) - } - - fn read_u32(&mut self, endian: WkbEndian) -> Option { - let bytes = self.read_array::<4>()?; - Some(match endian { - WkbEndian::Big => u32::from_be_bytes(bytes), - WkbEndian::Little => u32::from_le_bytes(bytes), - }) - } - - fn read_f64(&mut self, endian: WkbEndian) -> Option { - let bytes = self.read_array::<8>()?; - Some(match endian { - WkbEndian::Big => f64::from_be_bytes(bytes), - WkbEndian::Little => f64::from_le_bytes(bytes), - }) - } -} - -fn parse_wkb_dimensions(type_word: u32) -> (u32, WkbDimensions, bool) { - let mut base_type = type_word & 0x1FFF_FFFF; - let mut dims = WkbDimensions { has_z: (type_word & 0x8000_0000) != 0, has_m: (type_word & 0x4000_0000) != 0 }; - let has_srid = (type_word & 0x2000_0000) != 0; - - if base_type >= 3000 { - dims.has_z = true; - dims.has_m = true; - base_type -= 3000; - } else if base_type >= 2000 { - dims.has_m = true; - base_type -= 2000; - } else if base_type >= 1000 { - dims.has_z = true; - base_type -= 1000; - } - - (base_type, dims, has_srid) -} - -fn read_wkb_point_coords(reader: &mut WkbReader<'_>, dims: WkbDimensions, allow_empty_point: bool) -> Option> { - let endian = match reader.read_u8()? { - 0 => WkbEndian::Big, - 1 => WkbEndian::Little, - _ => return None, - }; - let type_word = reader.read_u32(endian)?; - let (base_type, parsed_dims, has_srid) = parse_wkb_dimensions(type_word); - if base_type != 1 || parsed_dims != dims { - return None; - } - if has_srid { - reader.read_u32(endian)?; - } - let coords = (0..parsed_dims.coordinate_len()).map(|_| reader.read_f64(endian)).collect::>>()?; - if allow_empty_point && coords.iter().all(|value| value.is_nan()) { - return None; - } - Some(coords) -} - -fn parse_wkb_points(reader: &mut WkbReader<'_>, endian: WkbEndian, dims: WkbDimensions) -> Option>> { - let count = usize::try_from(reader.read_u32(endian)?).ok()?; - (0..count) - .map(|_| (0..dims.coordinate_len()).map(|_| reader.read_f64(endian)).collect::>>()) - .collect::>>() -} - -fn parse_wkb_geometry(reader: &mut WkbReader<'_>) -> Option { - let endian = match reader.read_u8()? { - 0 => WkbEndian::Big, - 1 => WkbEndian::Little, - _ => return None, - }; - let type_word = reader.read_u32(endian)?; - let (base_type, dims, has_srid) = parse_wkb_dimensions(type_word); - if has_srid { - reader.read_u32(endian)?; - } - - match base_type { - 1 => { - let coords = (0..dims.coordinate_len()).map(|_| reader.read_f64(endian)).collect::>>()?; - let coords = if coords.iter().all(|value| value.is_nan()) { None } else { Some(coords) }; - Some(WkbGeometry::Point { dims, coords }) - } - 2 => Some(WkbGeometry::LineString { dims, points: parse_wkb_points(reader, endian, dims)? }), - 3 => { - let ring_count = usize::try_from(reader.read_u32(endian)?).ok()?; - let rings = (0..ring_count).map(|_| parse_wkb_points(reader, endian, dims)).collect::>>()?; - Some(WkbGeometry::Polygon { dims, rings }) - } - 4 => { - let count = usize::try_from(reader.read_u32(endian)?).ok()?; - let points = - (0..count).map(|_| Some(read_wkb_point_coords(reader, dims, true))).collect::>>()?; - Some(WkbGeometry::MultiPoint { dims, points }) - } - 5 => { - let count = usize::try_from(reader.read_u32(endian)?).ok()?; - let lines = (0..count) - .map(|_| match parse_wkb_geometry(reader)? { - WkbGeometry::LineString { points, .. } => Some(points), - _ => None, - }) - .collect::>>()?; - Some(WkbGeometry::MultiLineString { dims, lines }) - } - 6 => { - let count = usize::try_from(reader.read_u32(endian)?).ok()?; - let polygons = (0..count) - .map(|_| match parse_wkb_geometry(reader)? { - WkbGeometry::Polygon { rings, .. } => Some(rings), - _ => None, - }) - .collect::>>()?; - Some(WkbGeometry::MultiPolygon { dims, polygons }) - } - 7 => { - let count = usize::try_from(reader.read_u32(endian)?).ok()?; - let geometries = (0..count).map(|_| parse_wkb_geometry(reader)).collect::>>()?; - Some(WkbGeometry::GeometryCollection { dims, geometries }) - } - _ => None, - } -} - -fn ewkb_to_wkt(raw: &[u8]) -> Option { - let mut reader = WkbReader::new(raw); - let geometry = parse_wkb_geometry(&mut reader)?; - if reader.pos != raw.len() { - return None; - } - Some(geometry.to_wkt()) -} - /// Decode pgvector binary format into a Vec. /// /// pgvector binary layout (big-endian): @@ -575,7 +272,7 @@ fn pg_value_to_json(row: &Row, idx: usize, type_name: &str) -> serde_json::Value if upper == "GEOMETRY" || upper == "GEOGRAPHY" { if let Ok(PgRawBytes(raw)) = row.try_get::<_, PgRawBytes>(idx) { - return ewkb_to_wkt(&raw) + return super::wkb::wkb_to_wkt(&raw) .map(serde_json::Value::String) .unwrap_or_else(|| super::binary_value_to_json(&raw)); } @@ -2136,7 +1833,7 @@ mod tests { #[test] fn ewkb_point_with_srid_formats_as_wkt() { let raw = decode_hex("0101000020E6100000C520B07268195D404E62105839F44340"); - assert_eq!(ewkb_to_wkt(&raw), Some("POINT(116.397 39.908)".to_string())); + assert_eq!(super::super::wkb::wkb_to_wkt(&raw), Some("POINT(116.397 39.908)".to_string())); } #[test] @@ -2145,7 +1842,7 @@ mod tests { "0106000020E610000002000000010300000001000000050000000000000000005D4000000000000044400000000000405D4000000000000044400000000000405D4000000000008044400000000000005D4000000000008044400000000000005D400000000000004440010300000001000000050000000000000000805D4000000000008043400000000000C05D4000000000008043400000000000C05D4000000000000044400000000000805D4000000000000044400000000000805D400000000000804340", ); assert_eq!( - ewkb_to_wkt(&raw), + super::super::wkb::wkb_to_wkt(&raw), Some( "MULTIPOLYGON(((116 40,117 40,117 41,116 41,116 40)),((118 39,119 39,119 40,118 40,118 39)))" .to_string() @@ -2158,7 +1855,10 @@ mod tests { let raw = decode_hex( "0107000020E61000000200000001010000000000000000005D4000000000000044400102000000020000000000000000405D4000000000008044400000000000805D400000000000004540", ); - assert_eq!(ewkb_to_wkt(&raw), Some("GEOMETRYCOLLECTION(POINT(116 40),LINESTRING(117 41,118 42))".to_string())); + assert_eq!( + super::super::wkb::wkb_to_wkt(&raw), + Some("GEOMETRYCOLLECTION(POINT(116 40),LINESTRING(117 41,118 42))".to_string()) + ); } #[test] diff --git a/crates/dbx-core/src/db/wkb.rs b/crates/dbx-core/src/db/wkb.rs new file mode 100644 index 000000000..41bc5d6c8 --- /dev/null +++ b/crates/dbx-core/src/db/wkb.rs @@ -0,0 +1,312 @@ +/// Shared WKB (Well-Known Binary) geometry parsing shared by PostgreSQL (EWKB) +/// and MySQL (standard WKB). Parses WKB bytes into WKT (Well-Known Text) strings. +/// +/// Supports: POINT, LINESTRING, POLYGON, MULTIPOINT, MULTILINESTRING, +/// MULTIPOLYGON, GEOMETRYCOLLECTION, and their Z/M/ZM variants. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct WkbDimensions { + has_z: bool, + has_m: bool, +} + +impl WkbDimensions { + fn suffix(self) -> &'static str { + match (self.has_z, self.has_m) { + (false, false) => "", + (true, false) => " Z", + (false, true) => " M", + (true, true) => " ZM", + } + } + + fn coordinate_len(self) -> usize { + 2 + usize::from(self.has_z) + usize::from(self.has_m) + } +} + +#[derive(Debug, Clone, PartialEq)] +enum WkbGeometry { + Point { dims: WkbDimensions, coords: Option> }, + LineString { dims: WkbDimensions, points: Vec> }, + Polygon { dims: WkbDimensions, rings: Vec>> }, + MultiPoint { dims: WkbDimensions, points: Vec>> }, + MultiLineString { dims: WkbDimensions, lines: Vec>> }, + MultiPolygon { dims: WkbDimensions, polygons: Vec>>> }, + GeometryCollection { dims: WkbDimensions, geometries: Vec }, +} + +impl WkbGeometry { + fn to_wkt(&self) -> String { + match self { + Self::Point { dims, coords } => match coords { + Some(coords) => format!("POINT{}({})", dims.suffix(), format_wkb_coordinate(coords)), + None => format!("POINT{} EMPTY", dims.suffix()), + }, + Self::LineString { dims, points } => { + if points.is_empty() { + format!("LINESTRING{} EMPTY", dims.suffix()) + } else { + format!("LINESTRING{}({})", dims.suffix(), format_wkb_coordinate_sequence(points)) + } + } + Self::Polygon { dims, rings } => { + if rings.is_empty() { + format!("POLYGON{} EMPTY", dims.suffix()) + } else { + format!( + "POLYGON{}({})", + dims.suffix(), + rings + .iter() + .map(|ring| format!("({})", format_wkb_coordinate_sequence(ring))) + .collect::>() + .join(",") + ) + } + } + Self::MultiPoint { dims, points } => { + if points.is_empty() { + format!("MULTIPOINT{} EMPTY", dims.suffix()) + } else { + format!( + "MULTIPOINT{}({})", + dims.suffix(), + points + .iter() + .map(|point| match point { + Some(coords) => format!("({})", format_wkb_coordinate(coords)), + None => "EMPTY".to_string(), + }) + .collect::>() + .join(",") + ) + } + } + Self::MultiLineString { dims, lines } => { + if lines.is_empty() { + format!("MULTILINESTRING{} EMPTY", dims.suffix()) + } else { + format!( + "MULTILINESTRING{}({})", + dims.suffix(), + lines + .iter() + .map(|line| format!("({})", format_wkb_coordinate_sequence(line))) + .collect::>() + .join(",") + ) + } + } + Self::MultiPolygon { dims, polygons } => { + if polygons.is_empty() { + format!("MULTIPOLYGON{} EMPTY", dims.suffix()) + } else { + format!( + "MULTIPOLYGON{}({})", + dims.suffix(), + polygons + .iter() + .map(|polygon| { + format!( + "({})", + polygon + .iter() + .map(|ring| format!("({})", format_wkb_coordinate_sequence(ring))) + .collect::>() + .join(",") + ) + }) + .collect::>() + .join(",") + ) + } + } + Self::GeometryCollection { dims, geometries } => { + if geometries.is_empty() { + format!("GEOMETRYCOLLECTION{} EMPTY", dims.suffix()) + } else { + format!( + "GEOMETRYCOLLECTION{}({})", + dims.suffix(), + geometries.iter().map(WkbGeometry::to_wkt).collect::>().join(",") + ) + } + } + } + } +} + +fn format_wkb_coordinate(coords: &[f64]) -> String { + coords.iter().map(|value| value.to_string()).collect::>().join(" ") +} + +fn format_wkb_coordinate_sequence(points: &[Vec]) -> String { + points.iter().map(|point| format_wkb_coordinate(point)).collect::>().join(",") +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WkbEndian { + Big, + Little, +} + +struct WkbReader<'a> { + raw: &'a [u8], + pos: usize, +} + +impl<'a> WkbReader<'a> { + fn new(raw: &'a [u8]) -> Self { + Self { raw, pos: 0 } + } + + fn read_u8(&mut self) -> Option { + let value = *self.raw.get(self.pos)?; + self.pos += 1; + Some(value) + } + + fn read_array(&mut self) -> Option<[u8; N]> { + let end = self.pos.checked_add(N)?; + let bytes: [u8; N] = self.raw.get(self.pos..end)?.try_into().ok()?; + self.pos = end; + Some(bytes) + } + + fn read_u32(&mut self, endian: WkbEndian) -> Option { + let bytes = self.read_array::<4>()?; + Some(match endian { + WkbEndian::Big => u32::from_be_bytes(bytes), + WkbEndian::Little => u32::from_le_bytes(bytes), + }) + } + + fn read_f64(&mut self, endian: WkbEndian) -> Option { + let bytes = self.read_array::<8>()?; + Some(match endian { + WkbEndian::Big => f64::from_be_bytes(bytes), + WkbEndian::Little => f64::from_le_bytes(bytes), + }) + } +} + +fn parse_wkb_dimensions(type_word: u32) -> (u32, WkbDimensions, bool) { + let mut base_type = type_word & 0x1FFF_FFFF; + let mut dims = WkbDimensions { has_z: (type_word & 0x8000_0000) != 0, has_m: (type_word & 0x4000_0000) != 0 }; + let has_srid = (type_word & 0x2000_0000) != 0; + + if base_type >= 3000 { + dims.has_z = true; + dims.has_m = true; + base_type -= 3000; + } else if base_type >= 2000 { + dims.has_m = true; + base_type -= 2000; + } else if base_type >= 1000 { + dims.has_z = true; + base_type -= 1000; + } + + (base_type, dims, has_srid) +} + +fn read_wkb_point_coords(reader: &mut WkbReader<'_>, dims: WkbDimensions, allow_empty_point: bool) -> Option> { + let endian = match reader.read_u8()? { + 0 => WkbEndian::Big, + 1 => WkbEndian::Little, + _ => return None, + }; + let type_word = reader.read_u32(endian)?; + let (base_type, parsed_dims, has_srid) = parse_wkb_dimensions(type_word); + if base_type != 1 || parsed_dims != dims { + return None; + } + if has_srid { + reader.read_u32(endian)?; + } + let coords = (0..parsed_dims.coordinate_len()).map(|_| reader.read_f64(endian)).collect::>>()?; + if allow_empty_point && coords.iter().all(|value| value.is_nan()) { + return None; + } + Some(coords) +} + +fn parse_wkb_points(reader: &mut WkbReader<'_>, endian: WkbEndian, dims: WkbDimensions) -> Option>> { + let count = usize::try_from(reader.read_u32(endian)?).ok()?; + (0..count) + .map(|_| (0..dims.coordinate_len()).map(|_| reader.read_f64(endian)).collect::>>()) + .collect::>>() +} + +fn parse_wkb_geometry(reader: &mut WkbReader<'_>) -> Option { + let endian = match reader.read_u8()? { + 0 => WkbEndian::Big, + 1 => WkbEndian::Little, + _ => return None, + }; + let type_word = reader.read_u32(endian)?; + let (base_type, dims, has_srid) = parse_wkb_dimensions(type_word); + if has_srid { + reader.read_u32(endian)?; + } + + match base_type { + 1 => { + let coords = (0..dims.coordinate_len()).map(|_| reader.read_f64(endian)).collect::>>()?; + let coords = if coords.iter().all(|value| value.is_nan()) { None } else { Some(coords) }; + Some(WkbGeometry::Point { dims, coords }) + } + 2 => Some(WkbGeometry::LineString { dims, points: parse_wkb_points(reader, endian, dims)? }), + 3 => { + let ring_count = usize::try_from(reader.read_u32(endian)?).ok()?; + let rings = (0..ring_count).map(|_| parse_wkb_points(reader, endian, dims)).collect::>>()?; + Some(WkbGeometry::Polygon { dims, rings }) + } + 4 => { + let count = usize::try_from(reader.read_u32(endian)?).ok()?; + let points = + (0..count).map(|_| Some(read_wkb_point_coords(reader, dims, true))).collect::>>()?; + Some(WkbGeometry::MultiPoint { dims, points }) + } + 5 => { + let count = usize::try_from(reader.read_u32(endian)?).ok()?; + let lines = (0..count) + .map(|_| match parse_wkb_geometry(reader)? { + WkbGeometry::LineString { points, .. } => Some(points), + _ => None, + }) + .collect::>>()?; + Some(WkbGeometry::MultiLineString { dims, lines }) + } + 6 => { + let count = usize::try_from(reader.read_u32(endian)?).ok()?; + let polygons = (0..count) + .map(|_| match parse_wkb_geometry(reader)? { + WkbGeometry::Polygon { rings, .. } => Some(rings), + _ => None, + }) + .collect::>>()?; + Some(WkbGeometry::MultiPolygon { dims, polygons }) + } + 7 => { + let count = usize::try_from(reader.read_u32(endian)?).ok()?; + let geometries = (0..count).map(|_| parse_wkb_geometry(reader)).collect::>>()?; + Some(WkbGeometry::GeometryCollection { dims, geometries }) + } + _ => None, + } +} + +/// Parse WKB/EWKB bytes into a WKT (Well-Known Text) string. +/// +/// Supports both standard WKB (MySQL) and Extended WKB (PostgreSQL/PostGIS). +/// Returns `None` if the bytes cannot be parsed as valid WKB. +pub(crate) fn wkb_to_wkt(raw: &[u8]) -> Option { + let mut reader = WkbReader::new(raw); + let geometry = parse_wkb_geometry(&mut reader)?; + if reader.pos != raw.len() { + return None; + } + Some(geometry.to_wkt()) +}