Skip to main content

prax_postgres/
types.rs

1//! Type conversions for PostgreSQL.
2
3use std::str::FromStr;
4
5use prax_query::filter::FilterValue;
6use tokio_postgres::types::{IsNull, Kind, ToSql, Type};
7
8use crate::error::{PgError, PgResult};
9
10/// Polymorphic integer binding. `FilterValue::Int` always carries an i64
11/// (the widest scalar variant), but Postgres strictly validates client
12/// bindings against column types: binding an i64 to an `INT4` column
13/// fails with `WrongType { postgres: Int4, rust: "i64" }`. This wrapper
14/// inspects the target column type at bind time and narrows to i16 /
15/// i32 / i64 with a bounds check before forwarding to tokio-postgres'
16/// own impls.
17#[derive(Debug)]
18struct PgInt(i64);
19
20impl ToSql for PgInt {
21    fn to_sql(
22        &self,
23        ty: &Type,
24        out: &mut bytes::BytesMut,
25    ) -> Result<IsNull, Box<dyn std::error::Error + Sync + Send>> {
26        match *ty {
27            Type::INT2 => {
28                let v: i16 = self
29                    .0
30                    .try_into()
31                    .map_err(|_| format!("value {} overflows INT2", self.0))?;
32                v.to_sql(ty, out)
33            }
34            Type::INT4 => {
35                let v: i32 = self
36                    .0
37                    .try_into()
38                    .map_err(|_| format!("value {} overflows INT4", self.0))?;
39                v.to_sql(ty, out)
40            }
41            Type::INT8 => self.0.to_sql(ty, out),
42            _ => Err(format!("cannot bind integer to postgres type {ty:?}").into()),
43        }
44    }
45
46    fn accepts(ty: &Type) -> bool {
47        matches!(*ty, Type::INT2 | Type::INT4 | Type::INT8)
48    }
49
50    tokio_postgres::types::to_sql_checked!();
51}
52
53/// Polymorphic string binding. `FilterValue::String` always carries a
54/// Rust `String`, but Postgres rejects a String bound to a UUID column
55/// (`WrongType { postgres: Uuid, rust: "alloc::string::String" }`).
56/// This wrapper inspects the target column type at bind time and
57/// converts as appropriate; for plain TEXT/VARCHAR/CHAR/NAME and other
58/// types it forwards the String directly.
59#[derive(Debug)]
60struct PgString(String);
61
62impl ToSql for PgString {
63    fn to_sql(
64        &self,
65        ty: &Type,
66        out: &mut bytes::BytesMut,
67    ) -> Result<IsNull, Box<dyn std::error::Error + Sync + Send>> {
68        match *ty {
69            Type::UUID => {
70                let parsed = ::uuid::Uuid::from_str(&self.0).map_err(|e| {
71                    format!("FilterValue::String('{}') is not a valid UUID: {e}", self.0)
72                })?;
73                parsed.to_sql(ty, out)
74            }
75            // chrono types round-trip through `FilterValue::String`
76            // (see `prax-query/src/filter.rs`). Postgres rejects a String
77            // bound to TIMESTAMPTZ/TIMESTAMP/DATE/TIME with WrongType,
78            // so re-parse and forward through tokio-postgres' chrono
79            // FromSql/ToSql impls.
80            Type::TIMESTAMPTZ => {
81                let parsed: ::chrono::DateTime<::chrono::Utc> =
82                    ::chrono::DateTime::parse_from_rfc3339(&self.0)
83                        .map_err(|e| {
84                            format!(
85                                "FilterValue::String('{}') is not a valid RFC3339 \
86                                 datetime for TIMESTAMPTZ: {e}",
87                                self.0
88                            )
89                        })?
90                        .with_timezone(&::chrono::Utc);
91                parsed.to_sql(ty, out)
92            }
93            Type::TIMESTAMP => {
94                let parsed =
95                    ::chrono::NaiveDateTime::parse_from_str(&self.0, "%Y-%m-%dT%H:%M:%S%.f")
96                        .map_err(|e| {
97                            format!(
98                                "FilterValue::String('{}') is not a valid \
99                         ISO-8601 naive datetime for TIMESTAMP: {e}",
100                                self.0
101                            )
102                        })?;
103                parsed.to_sql(ty, out)
104            }
105            Type::DATE => {
106                let parsed =
107                    ::chrono::NaiveDate::parse_from_str(&self.0, "%Y-%m-%d").map_err(|e| {
108                        format!(
109                            "FilterValue::String('{}') is not a valid \
110                             YYYY-MM-DD date for DATE: {e}",
111                            self.0
112                        )
113                    })?;
114                parsed.to_sql(ty, out)
115            }
116            Type::TIME => {
117                let parsed =
118                    ::chrono::NaiveTime::parse_from_str(&self.0, "%H:%M:%S%.f").map_err(|e| {
119                        format!(
120                            "FilterValue::String('{}') is not a valid \
121                                 HH:MM:SS time for TIME: {e}",
122                            self.0
123                        )
124                    })?;
125                parsed.to_sql(ty, out)
126            }
127            _ => {
128                // User-defined `ENUM` columns reach this arm; their
129                // wire format is just utf-8 bytes, so write the
130                // string body directly. Plain TEXT/VARCHAR/CHAR/NAME
131                // also takes this path through `String: ToSql`.
132                if matches!(ty.kind(), Kind::Enum(_)) {
133                    out.extend_from_slice(self.0.as_bytes());
134                    Ok(IsNull::No)
135                } else {
136                    self.0.to_sql(ty, out)
137                }
138            }
139        }
140    }
141
142    fn accepts(_ty: &Type) -> bool {
143        true
144    }
145
146    tokio_postgres::types::to_sql_checked!();
147}
148
149/// Untyped NULL binding. `FilterValue::Null` must bind successfully against
150/// a column of any type (nullable UUID, custom ENUM, TIMESTAMPTZ, INT4,
151/// ...), but `Option::<String>::None`'s `accepts()` delegates to
152/// `String::accepts()`, which only covers TEXT/VARCHAR/CHAR/NAME/UNKNOWN.
153/// tokio-postgres runs that `accepts()` check before `to_sql` is ever
154/// called, so a NULL bound against e.g. a nullable UUID column fails with
155/// "error serializing parameter N" even though NULL has no type-specific
156/// wire representation to get wrong. This wrapper accepts every type and
157/// always signals `IsNull::Yes`.
158#[derive(Debug)]
159struct PgNull;
160
161impl ToSql for PgNull {
162    fn to_sql(
163        &self,
164        _ty: &Type,
165        _out: &mut bytes::BytesMut,
166    ) -> Result<IsNull, Box<dyn std::error::Error + Sync + Send>> {
167        Ok(IsNull::Yes)
168    }
169
170    fn accepts(_ty: &Type) -> bool {
171        true
172    }
173
174    tokio_postgres::types::to_sql_checked!();
175}
176
177/// Convert a FilterValue to a type that can be used as a PostgreSQL parameter.
178pub fn filter_value_to_sql(value: &FilterValue) -> PgResult<Box<dyn ToSql + Sync + Send>> {
179    match value {
180        FilterValue::Null => Ok(Box::new(PgNull)),
181        FilterValue::Bool(b) => Ok(Box::new(*b)),
182        FilterValue::Int(i) => Ok(Box::new(PgInt(*i))),
183        FilterValue::Float(f) => Ok(Box::new(*f)),
184        FilterValue::String(s) => Ok(Box::new(PgString(s.clone()))),
185        FilterValue::Json(j) => Ok(Box::new(j.clone())),
186        FilterValue::List(_) => {
187            // Lists are deliberately unsupported in raw binds. Typed IN
188            // filters expand to scalar placeholders upstream
189            // (prax-query/src/filter.rs), so this arm only fires for
190            // hand-built raw queries via `Sql::bind`. Callers needing list
191            // binds should use `= ANY($n)` array binds or the typed filter
192            // path.
193            Err(PgError::type_conversion(
194                "list values should be handled specially",
195            ))
196        }
197    }
198}
199
200/// Convert filter values to PostgreSQL parameters.
201pub fn filter_values_to_params(
202    values: &[FilterValue],
203) -> PgResult<Vec<Box<dyn ToSql + Sync + Send>>> {
204    values.iter().map(filter_value_to_sql).collect()
205}
206
207/// PostgreSQL type mapping utilities.
208pub mod pg_types {
209    use super::*;
210
211    /// Get the PostgreSQL type for a Rust type name.
212    pub fn rust_type_to_pg(rust_type: &str) -> Option<Type> {
213        match rust_type {
214            "i16" => Some(Type::INT2),
215            "i32" => Some(Type::INT4),
216            "i64" => Some(Type::INT8),
217            "f32" => Some(Type::FLOAT4),
218            "f64" => Some(Type::FLOAT8),
219            "bool" => Some(Type::BOOL),
220            "String" | "&str" => Some(Type::TEXT),
221            "Vec<u8>" | "&[u8]" => Some(Type::BYTEA),
222            "chrono::NaiveDate" => Some(Type::DATE),
223            "chrono::NaiveTime" => Some(Type::TIME),
224            "chrono::NaiveDateTime" => Some(Type::TIMESTAMP),
225            "chrono::DateTime<chrono::Utc>" => Some(Type::TIMESTAMPTZ),
226            "uuid::Uuid" => Some(Type::UUID),
227            "serde_json::Value" => Some(Type::JSONB),
228            _ => None,
229        }
230    }
231
232    /// Get the Rust type for a PostgreSQL type.
233    pub fn pg_type_to_rust(pg_type: &Type) -> &'static str {
234        match *pg_type {
235            Type::BOOL => "bool",
236            Type::INT2 => "i16",
237            Type::INT4 => "i32",
238            Type::INT8 => "i64",
239            Type::FLOAT4 => "f32",
240            Type::FLOAT8 => "f64",
241            Type::TEXT | Type::VARCHAR | Type::CHAR | Type::NAME => "String",
242            Type::BYTEA => "Vec<u8>",
243            Type::DATE => "chrono::NaiveDate",
244            Type::TIME => "chrono::NaiveTime",
245            Type::TIMESTAMP => "chrono::NaiveDateTime",
246            Type::TIMESTAMPTZ => "chrono::DateTime<chrono::Utc>",
247            Type::UUID => "uuid::Uuid",
248            Type::JSON | Type::JSONB => "serde_json::Value",
249            _ => "unknown",
250        }
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_filter_value_to_sql() {
260        let result = filter_value_to_sql(&FilterValue::Int(42));
261        assert!(result.is_ok());
262
263        let result = filter_value_to_sql(&FilterValue::String("test".to_string()));
264        assert!(result.is_ok());
265
266        let result = filter_value_to_sql(&FilterValue::Bool(true));
267        assert!(result.is_ok());
268    }
269
270    #[test]
271    fn test_pg_type_mapping() {
272        use pg_types::*;
273
274        assert_eq!(rust_type_to_pg("i32"), Some(Type::INT4));
275        assert_eq!(rust_type_to_pg("String"), Some(Type::TEXT));
276        assert_eq!(rust_type_to_pg("bool"), Some(Type::BOOL));
277
278        assert_eq!(pg_type_to_rust(&Type::INT4), "i32");
279        assert_eq!(pg_type_to_rust(&Type::TEXT), "String");
280    }
281}