Skip to main content

prax_postgres/
row_ref.rs

1//! Bridge between `tokio_postgres::Row` and `prax_query::row::RowRef`.
2//!
3//! `RowRef` is defined in `prax-query` and `Row` is defined in `tokio-postgres`;
4//! both are foreign to this crate, so Rust's orphan rules forbid a direct
5//! `impl RowRef for Row`. We wrap the row in a `#[repr(transparent)]` newtype
6//! (`PgRow`) and implement the trait on the wrapper.
7//!
8//! ## Dual API surface
9//!
10//! `PgRow` derefs to the wrapped `Row`, so callers can use either API:
11//! * The generic `RowRef` interface (`pg_row.get_i32("id")`) — portable across
12//!   drivers and what generated `FromRow` impls use.
13//! * The native `tokio_postgres::Row` interface (`pg_row.try_get::<_, i32>("id")`)
14//!   — full access to the driver's type system for columns `RowRef` does not
15//!   model (arrays, range types, etc.).
16//!
17//! ## `rust_decimal` limitation
18//!
19//! `tokio-postgres` 0.7 has no `with-rust_decimal-*` feature gate and
20//! `rust_decimal::Decimal` therefore has no `FromSql` impl through the driver.
21//! `PgRow::get_decimal` and `PgRow::get_decimal_opt` fall back to the trait's
22//! default implementations, which return a `RowError::TypeConversion` marked
23//! "decimal not supported by this row type". Until we add a bridging
24//! `FromSql`/`ToSql` impl (or switch to a driver that exposes the feature),
25//! callers that need decimal values should cast the column to text
26//! (`amount::text`) and parse in application code.
27
28use std::error::Error as StdError;
29
30use prax_query::row::{RowError, RowRef, into_row_error};
31use tokio_postgres::Row;
32use tokio_postgres::types::{FromSql, Kind, Type};
33
34/// `FromSql` shim that accepts any column type and decodes its raw
35/// bytes as UTF-8.
36///
37/// Used to read postgres `ENUM` columns into a Rust `String`, since
38/// `&str: FromSql` only `accepts` TEXT/VARCHAR/BPCHAR/NAME/UNKNOWN
39/// (plus a few citext-shaped names). User-defined enums encode as
40/// raw UTF-8 on the wire — the same shape `text_from_sql` would
41/// produce — so this just runs the bytes through `str::from_utf8`.
42struct AnyText(String);
43
44impl<'a> FromSql<'a> for AnyText {
45    fn from_sql(_ty: &Type, raw: &'a [u8]) -> Result<Self, Box<dyn StdError + Sync + Send>> {
46        Ok(AnyText(std::str::from_utf8(raw)?.to_owned()))
47    }
48    fn accepts(_ty: &Type) -> bool {
49        true
50    }
51}
52
53/// `FromSql` shim used exclusively for null-probe logic.
54///
55/// `NullProbe` accepts every Postgres wire type and discards the bytes
56/// entirely — we only care whether the column is NULL, not what the
57/// value is. This avoids the UTF-8 conversion failure that `AnyText`
58/// would incur on binary-encoded types (UUID, INTEGER, BYTEA, etc.)
59/// when probing a non-null column.
60///
61/// `Option<NullProbe>` deserialises to `None` on NULL and `Some(NullProbe)`
62/// on any non-null value regardless of the column's OID, making it the
63/// correct type for `RowRef::is_null` overrides on drivers where the
64/// default `get_str_opt` fallback would reject non-text columns.
65struct NullProbe;
66
67impl<'a> FromSql<'a> for NullProbe {
68    fn from_sql(_ty: &Type, _raw: &'a [u8]) -> Result<Self, Box<dyn StdError + Sync + Send>> {
69        Ok(NullProbe)
70    }
71    fn accepts(_ty: &Type) -> bool {
72        true
73    }
74}
75
76/// Newtype wrapper around `tokio_postgres::Row` that implements
77/// `prax_query::row::RowRef`.
78///
79/// The inner row is private; use [`PgRow::into_inner`] when ownership is
80/// needed (e.g., forwarding to a tokio-postgres API that consumes `Row`).
81/// For read-only access, the `Deref<Target = Row>` impl lets you call
82/// any `Row` method directly.
83#[repr(transparent)]
84pub struct PgRow(Row);
85
86impl PgRow {
87    /// Move the wrapped `tokio_postgres::Row` out of this wrapper.
88    pub fn into_inner(self) -> Row {
89        self.0
90    }
91}
92
93impl std::ops::Deref for PgRow {
94    type Target = Row;
95    fn deref(&self) -> &Self::Target {
96        &self.0
97    }
98}
99
100impl From<Row> for PgRow {
101    fn from(row: Row) -> Self {
102        PgRow(row)
103    }
104}
105
106impl RowRef for PgRow {
107    fn get_i32(&self, c: &str) -> Result<i32, RowError> {
108        into_row_error(c, self.try_get::<_, i32>(c))
109    }
110    fn get_i32_opt(&self, c: &str) -> Result<Option<i32>, RowError> {
111        into_row_error(c, self.try_get::<_, Option<i32>>(c))
112    }
113    fn get_i64(&self, c: &str) -> Result<i64, RowError> {
114        into_row_error(c, self.try_get::<_, i64>(c))
115    }
116    fn get_i64_opt(&self, c: &str) -> Result<Option<i64>, RowError> {
117        into_row_error(c, self.try_get::<_, Option<i64>>(c))
118    }
119    fn get_f64(&self, c: &str) -> Result<f64, RowError> {
120        into_row_error(c, self.try_get::<_, f64>(c))
121    }
122    fn get_f64_opt(&self, c: &str) -> Result<Option<f64>, RowError> {
123        into_row_error(c, self.try_get::<_, Option<f64>>(c))
124    }
125    fn get_bool(&self, c: &str) -> Result<bool, RowError> {
126        into_row_error(c, self.try_get::<_, bool>(c))
127    }
128    fn get_bool_opt(&self, c: &str) -> Result<Option<bool>, RowError> {
129        into_row_error(c, self.try_get::<_, Option<bool>>(c))
130    }
131    fn get_str(&self, c: &str) -> Result<&str, RowError> {
132        into_row_error(c, self.try_get::<_, &str>(c))
133    }
134    fn get_str_opt(&self, c: &str) -> Result<Option<&str>, RowError> {
135        into_row_error(c, self.try_get::<_, Option<&str>>(c))
136    }
137    /// Override the trait default (which is `get_str + to_string`) so
138    /// we can decode columns whose Postgres-side type is *not* TEXT but
139    /// whose Rust-side codegen has emitted `pub field: String`:
140    ///
141    /// * `UUID` columns — emitted as `String` for Prisma `String @db.Uuid`
142    ///   fields. We decode via `uuid::Uuid::from_sql` and stringify.
143    /// * User-defined `ENUM` columns — also emitted as `String` via
144    ///   `FromColumn`'s `get_string` call (see codegen for `enum`
145    ///   variants). We decode via `AnyText` since `&str: FromSql` does
146    ///   not accept enum types.
147    ///
148    /// Without this override, the row decode fails with `"error
149    /// deserializing column N"` and the whole query bubbles up a
150    /// `[P6003] type conversion error`.
151    ///
152    /// A matching override on `get_string_opt` covers nullable variants.
153    fn get_string(&self, c: &str) -> Result<String, RowError> {
154        let columns = self.0.columns();
155        if let Some(col) = columns.iter().find(|col| col.name() == c) {
156            let ty = col.type_();
157            if *ty == Type::UUID {
158                return into_row_error(c, self.try_get::<_, ::uuid::Uuid>(c))
159                    .map(|u| u.to_string());
160            }
161            if matches!(ty.kind(), Kind::Enum(_)) {
162                return into_row_error(c, self.try_get::<_, AnyText>(c)).map(|t| t.0);
163            }
164        }
165        into_row_error(c, self.try_get::<_, &str>(c)).map(|s| s.to_string())
166    }
167    fn get_string_opt(&self, c: &str) -> Result<Option<String>, RowError> {
168        let columns = self.0.columns();
169        if let Some(col) = columns.iter().find(|col| col.name() == c) {
170            let ty = col.type_();
171            if *ty == Type::UUID {
172                return into_row_error(c, self.try_get::<_, Option<::uuid::Uuid>>(c))
173                    .map(|opt| opt.map(|u| u.to_string()));
174            }
175            if matches!(ty.kind(), Kind::Enum(_)) {
176                return into_row_error(c, self.try_get::<_, Option<AnyText>>(c))
177                    .map(|opt| opt.map(|t| t.0));
178            }
179        }
180        into_row_error(c, self.try_get::<_, Option<&str>>(c)).map(|opt| opt.map(|s| s.to_string()))
181    }
182    /// Override the trait default (which falls back to `get_str_opt`, and
183    /// therefore fails for non-TEXT column types like INTEGER, UUID, etc.)
184    /// with a type-agnostic null probe.
185    ///
186    /// `NullProbe: FromSql` accepts every Postgres wire type and discards
187    /// the payload entirely, so `Option<NullProbe>` deserialises to `None`
188    /// on NULL and `Some(NullProbe)` on any non-null value — regardless of
189    /// the column's OID — without attempting any byte interpretation. This
190    /// is exactly what the blanket `impl<T: FromColumn> FromColumn for
191    /// Option<T>` needs to short-circuit nullable columns of any type.
192    fn is_null(&self, c: &str) -> Result<bool, RowError> {
193        into_row_error(c, self.try_get::<_, Option<NullProbe>>(c)).map(|opt| opt.is_none())
194    }
195
196    fn get_bytes(&self, c: &str) -> Result<&[u8], RowError> {
197        into_row_error(c, self.try_get::<_, &[u8]>(c))
198    }
199    fn get_bytes_opt(&self, c: &str) -> Result<Option<&[u8]>, RowError> {
200        into_row_error(c, self.try_get::<_, Option<&[u8]>>(c))
201    }
202    fn get_datetime_utc(&self, c: &str) -> Result<chrono::DateTime<chrono::Utc>, RowError> {
203        into_row_error(c, self.try_get::<_, chrono::DateTime<chrono::Utc>>(c))
204    }
205    fn get_datetime_utc_opt(
206        &self,
207        c: &str,
208    ) -> Result<Option<chrono::DateTime<chrono::Utc>>, RowError> {
209        into_row_error(
210            c,
211            self.try_get::<_, Option<chrono::DateTime<chrono::Utc>>>(c),
212        )
213    }
214    fn get_naive_datetime(&self, c: &str) -> Result<chrono::NaiveDateTime, RowError> {
215        into_row_error(c, self.try_get::<_, chrono::NaiveDateTime>(c))
216    }
217    fn get_naive_datetime_opt(&self, c: &str) -> Result<Option<chrono::NaiveDateTime>, RowError> {
218        into_row_error(c, self.try_get::<_, Option<chrono::NaiveDateTime>>(c))
219    }
220    fn get_naive_date(&self, c: &str) -> Result<chrono::NaiveDate, RowError> {
221        into_row_error(c, self.try_get::<_, chrono::NaiveDate>(c))
222    }
223    fn get_naive_date_opt(&self, c: &str) -> Result<Option<chrono::NaiveDate>, RowError> {
224        into_row_error(c, self.try_get::<_, Option<chrono::NaiveDate>>(c))
225    }
226    fn get_naive_time(&self, c: &str) -> Result<chrono::NaiveTime, RowError> {
227        into_row_error(c, self.try_get::<_, chrono::NaiveTime>(c))
228    }
229    fn get_naive_time_opt(&self, c: &str) -> Result<Option<chrono::NaiveTime>, RowError> {
230        into_row_error(c, self.try_get::<_, Option<chrono::NaiveTime>>(c))
231    }
232    fn get_uuid(&self, c: &str) -> Result<uuid::Uuid, RowError> {
233        into_row_error(c, self.try_get::<_, uuid::Uuid>(c))
234    }
235    fn get_uuid_opt(&self, c: &str) -> Result<Option<uuid::Uuid>, RowError> {
236        into_row_error(c, self.try_get::<_, Option<uuid::Uuid>>(c))
237    }
238    fn get_json(&self, c: &str) -> Result<serde_json::Value, RowError> {
239        into_row_error(c, self.try_get::<_, serde_json::Value>(c))
240    }
241    fn get_json_opt(&self, c: &str) -> Result<Option<serde_json::Value>, RowError> {
242        into_row_error(c, self.try_get::<_, Option<serde_json::Value>>(c))
243    }
244    fn get_decimal(&self, c: &str) -> Result<rust_decimal::Decimal, RowError> {
245        Err(RowError::TypeConversion {
246            column: c.to_string(),
247            message: "decimal columns require tokio-postgres with \
248                      `with-rust_decimal-*` feature, which this workspace does not \
249                      currently enable. Cast NUMERIC columns to TEXT in your SQL \
250                      (e.g. amount::text) and decode as String, or upgrade \
251                      tokio-postgres."
252                .to_string(),
253        })
254    }
255    fn get_decimal_opt(&self, c: &str) -> Result<Option<rust_decimal::Decimal>, RowError> {
256        // NULL can't be distinguished from the underlying "unsupported" state
257        // at this layer; surface the same actionable message.
258        Err(RowError::TypeConversion {
259            column: c.to_string(),
260            message: "decimal columns require tokio-postgres with \
261                      `with-rust_decimal-*` feature, which this workspace does not \
262                      currently enable. Cast NUMERIC columns to TEXT in your SQL \
263                      (e.g. amount::text) and decode as String, or upgrade \
264                      tokio-postgres."
265                .to_string(),
266        })
267    }
268}