Skip to content

feat(postgres): add geometry path #3716

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 27 commits into from
Mar 2, 2025
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
883c398
feat: add geometry path
jayy-lmao Jan 30, 2025
d75a3fe
fix: paths to pg point
jayy-lmao Jan 30, 2025
c0f728a
test: remove array tests for path
jayy-lmao Jan 30, 2025
12e1d74
Fix readme: uuid feature is gating for all repos (#3720)
jthacker Jan 31, 2025
db649f9
Replace some futures_util APIs with std variants (#3721)
paolobarbolini Feb 2, 2025
4932c1c
feat(sqlx-cli): Add flag to disable automatic loading of .env files (…
benwilber Feb 4, 2025
81bfc89
chore: expose bstr feature (#3714)
joeydewaal Feb 4, 2025
218cb20
chore: replace rustls-pemfile with rustls-pki-types (#3725)
tottoto Feb 5, 2025
a27a10f
QueryBuilder: add `debug_assert` when `push_values` is passed an empt…
chanmaoganda Feb 18, 2025
8145fb9
fix: merge conflicts
jayy-lmao Mar 1, 2025
dfba902
chore(cli): remove unused async-trait crate from dependencies (#3754)
tottoto Feb 20, 2025
490145b
Update pull_request_template.md
abonander Feb 20, 2025
d31ecd0
Fix example calculation (#3741)
dns2utf8 Feb 20, 2025
4c3a474
Avoid privilege requirements by using an advisory lock in test setup …
kildrens Feb 21, 2025
a77f12b
Small doc correction. (#3755)
xvapx Feb 22, 2025
49fa59e
Update FAQ.md
abonander Feb 23, 2025
72e6826
refactor(cli): replace promptly with dialoguer (#3669)
paolobarbolini Feb 27, 2025
cb1dbe4
docs(pool): recommend actix-web ThinData over Data to avoid two Arcs …
jonasmalacofilho Mar 1, 2025
8d0d678
fix: merge conflicts
jayy-lmao Mar 1, 2025
7791740
fix: use types mod from main
jayy-lmao Mar 1, 2025
1d416ae
Merge branch 'launchbadge:main' into feat/add-geometry-path
jayy-lmao Mar 1, 2025
0021cf6
fix: merge conflicts
jayy-lmao Mar 1, 2025
28fba22
fix: merge conflicts
jayy-lmao Mar 1, 2025
29687a9
fix: merge conflicts
jayy-lmao Mar 1, 2025
12b0d14
fix: ordering of types mod
jayy-lmao Mar 1, 2025
78f3621
fix: path import
jayy-lmao Mar 1, 2025
51154ca
test: no array test for path
jayy-lmao Mar 1, 2025
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
2 changes: 2 additions & 0 deletions sqlx-postgres/src/type_checking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ impl_type_checking!(

sqlx::postgres::types::PgBox,

sqlx::postgres::types::PgPath,

#[cfg(feature = "uuid")]
sqlx::types::Uuid,

Expand Down
1 change: 1 addition & 0 deletions sqlx-postgres/src/types/geometry/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
pub mod r#box;
pub mod line;
pub mod line_segment;
pub mod path;
pub mod point;
372 changes: 372 additions & 0 deletions sqlx-postgres/src/types/geometry/path.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,372 @@
use crate::decode::Decode;
use crate::encode::{Encode, IsNull};
use crate::error::BoxDynError;
use crate::types::{PgPoint, Type};
use crate::{PgArgumentBuffer, PgHasArrayType, PgTypeInfo, PgValueFormat, PgValueRef, Postgres};
use sqlx_core::bytes::Buf;
use sqlx_core::Error;
use std::mem;
use std::str::FromStr;

const BYTE_WIDTH: usize = mem::size_of::<f64>();

/// ## Postgres Geometric Path type
///
/// Description: Open path or Closed path (similar to polygon)
/// Representation: Open `[(x1,y1),...]`, Closed `((x1,y1),...)`
///
/// Paths are represented by lists of connected points. Paths can be open, where the first and last points in the list are considered not connected, or closed, where the first and last points are considered connected.
/// Values of type path are specified using any of the following syntaxes:
/// ```text
/// [ ( x1 , y1 ) , ... , ( xn , yn ) ]
/// ( ( x1 , y1 ) , ... , ( xn , yn ) )
/// ( x1 , y1 ) , ... , ( xn , yn )
/// ( x1 , y1 , ... , xn , yn )
/// x1 , y1 , ... , xn , yn
/// ```
/// where the points are the end points of the line segments comprising the path. Square brackets `([])` indicate an open path, while parentheses `(())` indicate a closed path.
/// When the outermost parentheses are omitted, as in the third through fifth syntaxes, a closed path is assumed.
///
/// See https://www.postgresql.org/docs/16/datatype-geometric.html#DATATYPE-GEOMETRIC-PATHS
#[derive(Debug, Clone, PartialEq)]
pub struct PgPath {
pub closed: bool,
pub points: Vec<PgPoint>,
}

#[derive(Copy, Clone, Debug, PartialEq, Eq)]
struct Header {
is_closed: bool,
length: usize,
}

impl Type<Postgres> for PgPath {
fn type_info() -> PgTypeInfo {
PgTypeInfo::with_name("path")
}
}

impl PgHasArrayType for PgPath {
fn array_type_info() -> PgTypeInfo {
PgTypeInfo::with_name("_path")
}
}

impl<'r> Decode<'r, Postgres> for PgPath {
fn decode(value: PgValueRef<'r>) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
match value.format() {
PgValueFormat::Text => Ok(PgPath::from_str(value.as_str()?)?),
PgValueFormat::Binary => Ok(PgPath::from_bytes(value.as_bytes()?)?),
}
}
}

impl<'q> Encode<'q, Postgres> for PgPath {
fn produces(&self) -> Option<PgTypeInfo> {
Some(PgTypeInfo::with_name("path"))
}

fn encode_by_ref(&self, buf: &mut PgArgumentBuffer) -> Result<IsNull, BoxDynError> {
self.serialize(buf)?;
Ok(IsNull::No)
}
}

impl FromStr for PgPath {
type Err = Error;

fn from_str(s: &str) -> Result<Self, Self::Err> {
let closed = !s.contains('[');
let sanitised = s.replace(['(', ')', '[', ']', ' '], "");
let parts = sanitised.split(',').collect::<Vec<_>>();

let mut points = vec![];

if parts.len() % 2 != 0 {
return Err(Error::Decode(
format!("Unmatched pair in PATH: {}", s).into(),
));
}

for chunk in parts.chunks_exact(2) {
if let [x_str, y_str] = chunk {
let x = parse_float_from_str(x_str, "could not get x")?;
let y = parse_float_from_str(y_str, "could not get y")?;

let point = PgPoint { x, y };
points.push(point);
}
}

if !points.is_empty() {
return Ok(PgPath { points, closed });
}

Err(Error::Decode(
format!("could not get path from {}", s).into(),
))
}
}

impl PgPath {
fn header(&self) -> Header {
Header {
is_closed: self.closed,
length: self.points.len(),
}
}

fn from_bytes(mut bytes: &[u8]) -> Result<Self, BoxDynError> {
let header = Header::try_read(&mut bytes)?;

if bytes.len() != header.data_size() {
return Err(format!(
"expected {} bytes after header, got {}",
header.data_size(),
bytes.len()
)
.into());
}

if bytes.len() % BYTE_WIDTH * 2 != 0 {
return Err(format!(
"data length not divisible by pairs of {BYTE_WIDTH}: {}",
bytes.len()
)
.into());
}

let mut out_points = Vec::with_capacity(bytes.len() / (BYTE_WIDTH * 2));

while bytes.has_remaining() {
let point = PgPoint {
x: bytes.get_f64(),
y: bytes.get_f64(),
};
out_points.push(point)
}
Ok(PgPath {
closed: header.is_closed,
points: out_points,
})
}

fn serialize(&self, buff: &mut PgArgumentBuffer) -> Result<(), BoxDynError> {
let header = self.header();
buff.reserve(header.data_size());
header.try_write(buff)?;

for point in &self.points {
buff.extend_from_slice(&point.x.to_be_bytes());
buff.extend_from_slice(&point.y.to_be_bytes());
}
Ok(())
}

#[cfg(test)]
fn serialize_to_vec(&self) -> Vec<u8> {
let mut buff = PgArgumentBuffer::default();
self.serialize(&mut buff).unwrap();
buff.to_vec()
}
}

impl Header {
const HEADER_WIDTH: usize = mem::size_of::<i8>() + mem::size_of::<i32>();

fn data_size(&self) -> usize {
self.length * BYTE_WIDTH * 2
}

fn try_read(buf: &mut &[u8]) -> Result<Self, String> {
if buf.len() < Self::HEADER_WIDTH {
return Err(format!(
"expected PATH data to contain at least {} bytes, got {}",
Self::HEADER_WIDTH,
buf.len()
));
}

let is_closed = buf.get_i8();
let length = buf.get_i32();

let length = usize::try_from(length).ok().ok_or_else(|| {
format!(
"received PATH data length: {length}. Expected length between 0 and {}",
usize::MAX
)
})?;

Ok(Self {
is_closed: is_closed != 0,
length,
})
}

fn try_write(&self, buff: &mut PgArgumentBuffer) -> Result<(), String> {
let is_closed = self.is_closed as i8;

let length = i32::try_from(self.length).map_err(|_| {
format!(
"PATH length exceeds allowed maximum ({} > {})",
self.length,
i32::MAX
)
})?;

buff.extend(is_closed.to_be_bytes());
buff.extend(length.to_be_bytes());

Ok(())
}
}

fn parse_float_from_str(s: &str, error_msg: &str) -> Result<f64, Error> {
s.parse().map_err(|_| Error::Decode(error_msg.into()))
}

#[cfg(test)]
mod path_tests {

use std::str::FromStr;

use crate::types::PgPoint;

use super::PgPath;

const PATH_CLOSED_BYTES: &[u8] = &[
1, 0, 0, 0, 2, 63, 240, 0, 0, 0, 0, 0, 0, 64, 0, 0, 0, 0, 0, 0, 0, 64, 8, 0, 0, 0, 0, 0, 0,
64, 16, 0, 0, 0, 0, 0, 0,
];

const PATH_OPEN_BYTES: &[u8] = &[
0, 0, 0, 0, 2, 63, 240, 0, 0, 0, 0, 0, 0, 64, 0, 0, 0, 0, 0, 0, 0, 64, 8, 0, 0, 0, 0, 0, 0,
64, 16, 0, 0, 0, 0, 0, 0,
];

const PATH_UNEVEN_POINTS: &[u8] = &[
0, 0, 0, 0, 2, 63, 240, 0, 0, 0, 0, 0, 0, 64, 0, 0, 0, 0, 0, 0, 0, 64, 8, 0, 0, 0, 0, 0, 0,
64, 16, 0, 0,
];

#[test]
fn can_deserialise_path_type_bytes_closed() {
let path = PgPath::from_bytes(PATH_CLOSED_BYTES).unwrap();
assert_eq!(
path,
PgPath {
closed: true,
points: vec![PgPoint { x: 1.0, y: 2.0 }, PgPoint { x: 3.0, y: 4.0 }]
}
)
}

#[test]
fn cannot_deserialise_path_type_uneven_point_bytes() {
let path = PgPath::from_bytes(PATH_UNEVEN_POINTS);
assert!(path.is_err());

if let Err(err) = path {
assert_eq!(
err.to_string(),
format!("expected 32 bytes after header, got 28")
)
}
}

#[test]
fn can_deserialise_path_type_bytes_open() {
let path = PgPath::from_bytes(PATH_OPEN_BYTES).unwrap();
assert_eq!(
path,
PgPath {
closed: false,
points: vec![PgPoint { x: 1.0, y: 2.0 }, PgPoint { x: 3.0, y: 4.0 }]
}
)
}

#[test]
fn can_deserialise_path_type_str_first_syntax() {
let path = PgPath::from_str("[( 1, 2), (3, 4 )]").unwrap();
assert_eq!(
path,
PgPath {
closed: false,
points: vec![PgPoint { x: 1., y: 2. }, PgPoint { x: 3., y: 4. }]
}
);
}

#[test]
fn cannot_deserialise_path_type_str_uneven_points_first_syntax() {
let input_str = "[( 1, 2), (3)]";
let path = PgPath::from_str(input_str);

assert!(path.is_err());

if let Err(err) = path {
assert_eq!(
err.to_string(),
format!("error occurred while decoding: Unmatched pair in PATH: {input_str}")
)
}
}

#[test]
fn can_deserialise_path_type_str_second_syntax() {
let path = PgPath::from_str("(( 1, 2), (3, 4 ))").unwrap();
assert_eq!(
path,
PgPath {
closed: true,
points: vec![PgPoint { x: 1., y: 2. }, PgPoint { x: 3., y: 4. }]
}
);
}

#[test]
fn can_deserialise_path_type_str_third_syntax() {
let path = PgPath::from_str("(1, 2), (3, 4 )").unwrap();
assert_eq!(
path,
PgPath {
closed: true,
points: vec![PgPoint { x: 1., y: 2. }, PgPoint { x: 3., y: 4. }]
}
);
}

#[test]
fn can_deserialise_path_type_str_fourth_syntax() {
let path = PgPath::from_str("1, 2, 3, 4").unwrap();
assert_eq!(
path,
PgPath {
closed: true,
points: vec![PgPoint { x: 1., y: 2. }, PgPoint { x: 3., y: 4. }]
}
);
}

#[test]
fn can_deserialise_path_type_str_float() {
let path = PgPath::from_str("(1.1, 2.2), (3.3, 4.4)").unwrap();
assert_eq!(
path,
PgPath {
closed: true,
points: vec![PgPoint { x: 1.1, y: 2.2 }, PgPoint { x: 3.3, y: 4.4 }]
}
);
}

#[test]
fn can_serialise_path_type() {
let path = PgPath {
closed: true,
points: vec![PgPoint { x: 1., y: 2. }, PgPoint { x: 3., y: 4. }],
};
assert_eq!(path.serialize_to_vec(), PATH_CLOSED_BYTES,)
}
}
Loading