Skip to main content

prax_sqlx/
row_ref.rs

1//! Bridge between SqlxRow and prax_query::row::RowRef.
2//!
3//! Decodes each column to a string-keyed snapshot so the prax-query
4//! `FromRow` pipeline works uniformly across SQLx's three backends
5//! (Postgres, MySQL, SQLite). Strings are materialized eagerly so
6//! `get_str` can hand back a borrowed slice.
7//!
8//! ## Type-dispatched decoding
9//!
10//! Cells are decoded by dispatching on the column's reported type
11//! (`Column::type_info()`) rather than trial-decoding a fixed list of
12//! Rust types. SQLx's `Decode<String>` only accepts TEXT-family wire
13//! types (and the numeric decoders only accept their exact Postgres
14//! OIDs), so the old probe chain silently collapsed TIMESTAMP, UUID,
15//! JSON, NUMERIC, DATE, and TIME cells into [`Value::Null`] — a
16//! non-null `DateTime` read into `Option<DateTime>` came back `None`.
17//! With type-dispatch, every recognised type lands in its proper
18//! [`Value`] variant, and cells we genuinely cannot decode surface
19//! [`RowError::TypeConversion`] instead of a fabricated NULL.
20//!
21//! The chrono/uuid/json/rust_decimal decoders below rely on the matching
22//! sqlx features, which `prax-sqlx`'s Cargo.toml enables unconditionally.
23
24use std::collections::HashMap;
25
26use prax_query::row::{RowError, RowRef};
27use sqlx::{Column, Row, TypeInfo};
28
29use crate::row::SqlxRow;
30
31// Crate-private on purpose: variants may be added freely as new column
32// types are mapped, without a semver break.
33enum Value {
34    Null,
35    Bool(bool),
36    I64(i64),
37    F64(f64),
38    Text(String),
39    Bytes(Vec<u8>),
40    /// Postgres TIMESTAMPTZ / MySQL TIMESTAMP — a timezone-aware instant.
41    DateTimeUtc(chrono::DateTime<chrono::Utc>),
42    /// Postgres TIMESTAMP / MySQL DATETIME / SQLite DATETIME — naive.
43    NaiveDateTime(chrono::NaiveDateTime),
44    NaiveDate(chrono::NaiveDate),
45    NaiveTime(chrono::NaiveTime),
46    Uuid(uuid::Uuid),
47    Json(serde_json::Value),
48    Decimal(rust_decimal::Decimal),
49}
50
51/// A driver-agnostic decoded row produced by the sqlx engine. Holds owned
52/// values keyed by column name so callers can access them after the row
53/// itself has been dropped.
54pub struct SqlxRowRef {
55    values: HashMap<String, Value>,
56}
57
58impl SqlxRowRef {
59    /// Decode a raw sqlx row into a driver-agnostic [`SqlxRowRef`].
60    ///
61    /// Returns [`RowError::TypeConversion`] if any column's wire type
62    /// cannot be decoded into a [`Value`] — a column is never silently
63    /// recorded as NULL unless it actually is NULL.
64    pub fn from_sqlx(row: &SqlxRow) -> Result<Self, RowError> {
65        let mut values = HashMap::new();
66        match row {
67            #[cfg(feature = "postgres")]
68            SqlxRow::Postgres(r) => {
69                for (i, col) in r.columns().iter().enumerate() {
70                    let name = col.name().to_string();
71                    let v = decode_pg_cell(r, i)?;
72                    values.insert(name, v);
73                }
74            }
75            #[cfg(feature = "mysql")]
76            SqlxRow::MySql(r) => {
77                for (i, col) in r.columns().iter().enumerate() {
78                    let name = col.name().to_string();
79                    let v = decode_mysql_cell(r, i)?;
80                    values.insert(name, v);
81                }
82            }
83            #[cfg(feature = "sqlite")]
84            SqlxRow::Sqlite(r) => {
85                for (i, col) in r.columns().iter().enumerate() {
86                    let name = col.name().to_string();
87                    let v = decode_sqlite_cell(r, i)?;
88                    values.insert(name, v);
89                }
90            }
91        }
92        Ok(Self { values })
93    }
94}
95
96fn tc(column: &str, msg: impl Into<String>) -> RowError {
97    RowError::TypeConversion {
98        column: column.into(),
99        message: msg.into(),
100    }
101}
102
103/// Decode one cell as `Option<$ty>` and fold it into a [`Value`].
104///
105/// SQL NULL maps to [`Value::Null`] (the Option-wrapped decode
106/// short-circuits before sqlx's type-compatibility check), a decoded
107/// value maps through `$wrap`, and a driver-level failure — type
108/// mismatch, out-of-range, malformed payload — propagates as
109/// [`RowError::TypeConversion`], never as `Null`.
110macro_rules! decode_cell {
111    ($row:expr, $idx:expr, $column:expr, $ty:ty, $wrap:expr) => {
112        match $row.try_get::<Option<$ty>, _>($idx) {
113            Ok(None) => Ok(Value::Null),
114            Ok(Some(v)) => Ok(($wrap)(v)),
115            Err(e) => Err(tc($column, e.to_string())),
116        }
117    };
118}
119
120/// Decode a Postgres cell by dispatching on the column's `PgTypeInfo`.
121/// Matched names are sqlx's display names ("INT4", "TIMESTAMPTZ", ...).
122#[cfg(feature = "postgres")]
123fn decode_pg_cell(r: &sqlx::postgres::PgRow, i: usize) -> Result<Value, RowError> {
124    let column = r.columns()[i].name();
125    match r.columns()[i].type_info().name() {
126        // "CHAR" is BPCHAR (character(n)); sqlx's str decoder also
127        // accepts NAME and UNKNOWN.
128        "TEXT" | "VARCHAR" | "CHAR" | "NAME" | "UNKNOWN" => {
129            decode_cell!(r, i, column, String, Value::Text)
130        }
131        "BOOL" => decode_cell!(r, i, column, bool, Value::Bool),
132        // sqlx's int decoders accept exactly their own OID, so decode
133        // each width natively and widen.
134        "INT2" => decode_cell!(r, i, column, i16, |v| Value::I64(v as i64)),
135        "INT4" => decode_cell!(r, i, column, i32, |v| Value::I64(v as i64)),
136        "INT8" => decode_cell!(r, i, column, i64, Value::I64),
137        "FLOAT4" => decode_cell!(r, i, column, f32, |v| Value::F64(v as f64)),
138        "FLOAT8" => decode_cell!(r, i, column, f64, Value::F64),
139        "NUMERIC" => decode_cell!(r, i, column, rust_decimal::Decimal, Value::Decimal),
140        "TIMESTAMPTZ" => {
141            decode_cell!(
142                r,
143                i,
144                column,
145                chrono::DateTime<chrono::Utc>,
146                Value::DateTimeUtc
147            )
148        }
149        "TIMESTAMP" => decode_cell!(r, i, column, chrono::NaiveDateTime, Value::NaiveDateTime),
150        "DATE" => decode_cell!(r, i, column, chrono::NaiveDate, Value::NaiveDate),
151        "TIME" => decode_cell!(r, i, column, chrono::NaiveTime, Value::NaiveTime),
152        "UUID" => decode_cell!(r, i, column, uuid::Uuid, Value::Uuid),
153        "JSON" | "JSONB" => decode_cell!(r, i, column, serde_json::Value, Value::Json),
154        "BYTEA" => decode_cell!(r, i, column, Vec<u8>, Value::Bytes),
155        other => {
156            // User-defined enums, domains, arrays, ranges, MONEY, etc.:
157            // try a text decode; if the driver rejects the pairing, fail
158            // loudly instead of fabricating a NULL.
159            match r.try_get::<Option<String>, _>(i) {
160                Ok(None) => Ok(Value::Null),
161                Ok(Some(s)) => Ok(Value::Text(s)),
162                Err(e) => Err(tc(
163                    column,
164                    format!("unsupported Postgres type {other}: {e}"),
165                )),
166            }
167        }
168    }
169}
170
171/// Decode a MySQL cell by dispatching on the column's `MySqlTypeInfo`.
172/// sqlx derives the name from the wire type plus column flags, e.g.
173/// `TINYINT(1)` reports as "BOOLEAN" and unsigned ints carry an
174/// " UNSIGNED" suffix.
175#[cfg(feature = "mysql")]
176fn decode_mysql_cell(r: &sqlx::mysql::MySqlRow, i: usize) -> Result<Value, RowError> {
177    let column = r.columns()[i].name();
178    let ty = r.columns()[i].type_info().name();
179    match ty {
180        "CHAR" | "VARCHAR" | "TINYTEXT" | "TEXT" | "MEDIUMTEXT" | "LONGTEXT" | "ENUM" => {
181            decode_cell!(r, i, column, String, Value::Text)
182        }
183        // BOOLEAN is TINYINT(1) on the wire; sqlx's bool decoder reads
184        // any integer width as `!= 0`.
185        "BOOLEAN" => decode_cell!(r, i, column, bool, Value::Bool),
186        // Genuine integer columns decode as i64 — NOT bool. (The old
187        // probe chain tried bool before i64 and collapsed every MySQL
188        // integer into a boolean.)
189        "TINYINT" | "SMALLINT" | "MEDIUMINT" | "INT" | "BIGINT" => {
190            decode_cell!(r, i, column, i64, Value::I64)
191        }
192        // sqlx only accepts unsigned ints (and YEAR/BIT) as u64; widen
193        // with an explicit overflow check.
194        _ if ty.ends_with(" UNSIGNED") || ty == "YEAR" || ty == "BIT" => {
195            match r.try_get::<Option<u64>, _>(i) {
196                Ok(None) => Ok(Value::Null),
197                Ok(Some(v)) => i64::try_from(v)
198                    .map(Value::I64)
199                    .map_err(|_| tc(column, "u64 value overflows i64")),
200                Err(e) => Err(tc(column, e.to_string())),
201            }
202        }
203        "FLOAT" | "DOUBLE" => decode_cell!(r, i, column, f64, Value::F64),
204        "DECIMAL" => decode_cell!(r, i, column, rust_decimal::Decimal, Value::Decimal),
205        // TIMESTAMP decodes as a UTC instant; DATETIME stays naive.
206        // (sqlx's NaiveDateTime decoder rejects TIMESTAMP columns.)
207        "TIMESTAMP" => {
208            decode_cell!(
209                r,
210                i,
211                column,
212                chrono::DateTime<chrono::Utc>,
213                Value::DateTimeUtc
214            )
215        }
216        "DATETIME" => decode_cell!(r, i, column, chrono::NaiveDateTime, Value::NaiveDateTime),
217        "DATE" => decode_cell!(r, i, column, chrono::NaiveDate, Value::NaiveDate),
218        "TIME" => decode_cell!(r, i, column, chrono::NaiveTime, Value::NaiveTime),
219        "JSON" => decode_cell!(r, i, column, serde_json::Value, Value::Json),
220        "BINARY" | "VARBINARY" | "TINYBLOB" | "BLOB" | "MEDIUMBLOB" | "LONGBLOB" => {
221            decode_cell!(r, i, column, Vec<u8>, Value::Bytes)
222        }
223        other => match r.try_get::<Option<String>, _>(i) {
224            Ok(None) => Ok(Value::Null),
225            Ok(Some(s)) => Ok(Value::Text(s)),
226            Err(e) => Err(tc(column, format!("unsupported MySQL type {other}: {e}"))),
227        },
228    }
229}
230
231/// Decode a SQLite cell by dispatching on the column's declared-type
232/// affinity. SQLite has no native datetime storage: sqlx maps declared
233/// DATE/TIME/DATETIME/TIMESTAMP columns onto chrono decoders that read
234/// ISO-8601 text or numeric unix timestamps. Declared types sqlx cannot
235/// map (e.g. JSON) fall back to the value's runtime storage class, so a
236/// JSON column arrives here as "TEXT" and stays usable via
237/// `get_json`'s string parse. Columns with no declared type at all
238/// (expressions, `SELECT NULL`) report "NULL" and are probed against the
239/// runtime storage class.
240#[cfg(feature = "sqlite")]
241fn decode_sqlite_cell(r: &sqlx::sqlite::SqliteRow, i: usize) -> Result<Value, RowError> {
242    let column = r.columns()[i].name();
243    match r.columns()[i].type_info().name() {
244        "TEXT" => decode_cell!(r, i, column, String, Value::Text),
245        "INTEGER" => decode_cell!(r, i, column, i64, Value::I64),
246        "REAL" => decode_cell!(r, i, column, f64, Value::F64),
247        "BLOB" => decode_cell!(r, i, column, Vec<u8>, Value::Bytes),
248        // BOOLEAN columns store 0/1 integers; sqlx's bool decoder
249        // accepts them and reads `!= 0`.
250        "BOOLEAN" => decode_cell!(r, i, column, bool, Value::Bool),
251        "DATETIME" => decode_cell!(r, i, column, chrono::NaiveDateTime, Value::NaiveDateTime),
252        "DATE" => decode_cell!(r, i, column, chrono::NaiveDate, Value::NaiveDate),
253        "TIME" => decode_cell!(r, i, column, chrono::NaiveTime, Value::NaiveTime),
254        _ => {
255            // A bytes probe is `Ok(None)` only for a genuine SQL NULL:
256            // any non-null value either decodes (text/blob) or fails the
257            // compatibility check (int/float).
258            if r.try_get::<Option<Vec<u8>>, _>(i)
259                .is_ok_and(|v| v.is_none())
260            {
261                return Ok(Value::Null);
262            }
263            if let Ok(Some(s)) = r.try_get::<Option<String>, _>(i) {
264                return Ok(Value::Text(s));
265            }
266            if let Ok(Some(n)) = r.try_get::<Option<i64>, _>(i) {
267                return Ok(Value::I64(n));
268            }
269            if let Ok(Some(f)) = r.try_get::<Option<f64>, _>(i) {
270                return Ok(Value::F64(f));
271            }
272            if let Ok(Some(b)) = r.try_get::<Option<Vec<u8>>, _>(i) {
273                return Ok(Value::Bytes(b));
274            }
275            Err(tc(column, "unsupported SQLite value"))
276        }
277    }
278}
279
280impl RowRef for SqlxRowRef {
281    fn get_i32(&self, c: &str) -> Result<i32, RowError> {
282        match self
283            .values
284            .get(c)
285            .ok_or_else(|| RowError::ColumnNotFound(c.into()))?
286        {
287            Value::I64(i) => i32::try_from(*i).map_err(|_| tc(c, "i64 overflow")),
288            Value::Null => Err(RowError::UnexpectedNull(c.into())),
289            _ => Err(tc(c, "not an integer")),
290        }
291    }
292    fn get_i32_opt(&self, c: &str) -> Result<Option<i32>, RowError> {
293        match self.values.get(c) {
294            None => Err(RowError::ColumnNotFound(c.into())),
295            Some(Value::Null) => Ok(None),
296            Some(Value::I64(i)) => i32::try_from(*i)
297                .map(Some)
298                .map_err(|_| tc(c, "i64 overflow")),
299            Some(_) => Err(tc(c, "not an integer")),
300        }
301    }
302    fn get_i64(&self, c: &str) -> Result<i64, RowError> {
303        match self
304            .values
305            .get(c)
306            .ok_or_else(|| RowError::ColumnNotFound(c.into()))?
307        {
308            Value::I64(i) => Ok(*i),
309            Value::Null => Err(RowError::UnexpectedNull(c.into())),
310            _ => Err(tc(c, "not an integer")),
311        }
312    }
313    fn get_i64_opt(&self, c: &str) -> Result<Option<i64>, RowError> {
314        match self.values.get(c) {
315            None => Err(RowError::ColumnNotFound(c.into())),
316            Some(Value::Null) => Ok(None),
317            Some(Value::I64(i)) => Ok(Some(*i)),
318            Some(_) => Err(tc(c, "not an integer")),
319        }
320    }
321    fn get_f64(&self, c: &str) -> Result<f64, RowError> {
322        match self
323            .values
324            .get(c)
325            .ok_or_else(|| RowError::ColumnNotFound(c.into()))?
326        {
327            Value::F64(f) => Ok(*f),
328            Value::I64(i) => Ok(*i as f64),
329            Value::Null => Err(RowError::UnexpectedNull(c.into())),
330            _ => Err(tc(c, "not a number")),
331        }
332    }
333    fn get_f64_opt(&self, c: &str) -> Result<Option<f64>, RowError> {
334        match self.values.get(c) {
335            None => Err(RowError::ColumnNotFound(c.into())),
336            Some(Value::Null) => Ok(None),
337            Some(Value::F64(f)) => Ok(Some(*f)),
338            Some(Value::I64(i)) => Ok(Some(*i as f64)),
339            Some(_) => Err(tc(c, "not a number")),
340        }
341    }
342    fn get_bool(&self, c: &str) -> Result<bool, RowError> {
343        match self
344            .values
345            .get(c)
346            .ok_or_else(|| RowError::ColumnNotFound(c.into()))?
347        {
348            Value::Bool(b) => Ok(*b),
349            Value::I64(i) => Ok(*i != 0),
350            Value::Null => Err(RowError::UnexpectedNull(c.into())),
351            _ => Err(tc(c, "not a boolean")),
352        }
353    }
354    fn get_bool_opt(&self, c: &str) -> Result<Option<bool>, RowError> {
355        match self.values.get(c) {
356            None => Err(RowError::ColumnNotFound(c.into())),
357            Some(Value::Null) => Ok(None),
358            Some(Value::Bool(b)) => Ok(Some(*b)),
359            Some(Value::I64(i)) => Ok(Some(*i != 0)),
360            Some(_) => Err(tc(c, "not a boolean")),
361        }
362    }
363    fn get_str(&self, c: &str) -> Result<&str, RowError> {
364        match self
365            .values
366            .get(c)
367            .ok_or_else(|| RowError::ColumnNotFound(c.into()))?
368        {
369            Value::Text(s) => Ok(s.as_str()),
370            Value::Null => Err(RowError::UnexpectedNull(c.into())),
371            _ => Err(tc(c, "not text")),
372        }
373    }
374    fn get_str_opt(&self, c: &str) -> Result<Option<&str>, RowError> {
375        match self.values.get(c) {
376            None => Err(RowError::ColumnNotFound(c.into())),
377            Some(Value::Null) => Ok(None),
378            Some(Value::Text(s)) => Ok(Some(s.as_str())),
379            Some(_) => Err(tc(c, "not text")),
380        }
381    }
382    /// Override the trait default (`get_str` + `to_string`) so
383    /// String-typed model fields can read columns decoded into a
384    /// non-text variant — most importantly `String @db.Uuid` schema
385    /// fields over native UUID columns, mirroring `PgRow::get_string`
386    /// in prax-postgres. Datetimes stringify as RFC 3339; decimals and
387    /// JSON use their canonical text forms.
388    fn get_string(&self, c: &str) -> Result<String, RowError> {
389        match self
390            .values
391            .get(c)
392            .ok_or_else(|| RowError::ColumnNotFound(c.into()))?
393        {
394            Value::Text(s) => Ok(s.clone()),
395            Value::Uuid(u) => Ok(u.to_string()),
396            Value::Decimal(d) => Ok(d.to_string()),
397            Value::Json(j) => Ok(j.to_string()),
398            Value::DateTimeUtc(d) => Ok(d.to_rfc3339()),
399            Value::NaiveDateTime(d) => Ok(d.to_string()),
400            Value::NaiveDate(d) => Ok(d.to_string()),
401            Value::NaiveTime(t) => Ok(t.to_string()),
402            Value::Null => Err(RowError::UnexpectedNull(c.into())),
403            _ => Err(tc(c, "not text")),
404        }
405    }
406    fn get_string_opt(&self, c: &str) -> Result<Option<String>, RowError> {
407        match self.values.get(c) {
408            None => Err(RowError::ColumnNotFound(c.into())),
409            Some(Value::Null) => Ok(None),
410            Some(Value::Text(s)) => Ok(Some(s.clone())),
411            Some(Value::Uuid(u)) => Ok(Some(u.to_string())),
412            Some(Value::Decimal(d)) => Ok(Some(d.to_string())),
413            Some(Value::Json(j)) => Ok(Some(j.to_string())),
414            Some(Value::DateTimeUtc(d)) => Ok(Some(d.to_rfc3339())),
415            Some(Value::NaiveDateTime(d)) => Ok(Some(d.to_string())),
416            Some(Value::NaiveDate(d)) => Ok(Some(d.to_string())),
417            Some(Value::NaiveTime(t)) => Ok(Some(t.to_string())),
418            Some(_) => Err(tc(c, "not text")),
419        }
420    }
421    fn get_bytes(&self, c: &str) -> Result<&[u8], RowError> {
422        match self
423            .values
424            .get(c)
425            .ok_or_else(|| RowError::ColumnNotFound(c.into()))?
426        {
427            Value::Bytes(b) => Ok(b.as_slice()),
428            Value::Text(s) => Ok(s.as_bytes()),
429            Value::Null => Err(RowError::UnexpectedNull(c.into())),
430            _ => Err(tc(c, "not bytes")),
431        }
432    }
433    fn get_bytes_opt(&self, c: &str) -> Result<Option<&[u8]>, RowError> {
434        match self.values.get(c) {
435            None => Err(RowError::ColumnNotFound(c.into())),
436            Some(Value::Null) => Ok(None),
437            Some(Value::Bytes(b)) => Ok(Some(b.as_slice())),
438            Some(Value::Text(s)) => Ok(Some(s.as_bytes())),
439            Some(_) => Err(tc(c, "not bytes")),
440        }
441    }
442    fn get_datetime_utc(&self, c: &str) -> Result<chrono::DateTime<chrono::Utc>, RowError> {
443        match self
444            .values
445            .get(c)
446            .ok_or_else(|| RowError::ColumnNotFound(c.into()))?
447        {
448            Value::DateTimeUtc(d) => Ok(*d),
449            // Naive timestamps (MySQL DATETIME, SQLite DATETIME) are
450            // assumed to be UTC, matching driver convention.
451            Value::NaiveDateTime(d) => Ok(d.and_utc()),
452            Value::Text(s) => chrono::DateTime::parse_from_rfc3339(s)
453                .map(|d| d.with_timezone(&chrono::Utc))
454                .map_err(|e| tc(c, e.to_string())),
455            Value::Null => Err(RowError::UnexpectedNull(c.into())),
456            _ => Err(tc(c, "not a datetime")),
457        }
458    }
459    fn get_datetime_utc_opt(
460        &self,
461        c: &str,
462    ) -> Result<Option<chrono::DateTime<chrono::Utc>>, RowError> {
463        match self.values.get(c) {
464            None => Err(RowError::ColumnNotFound(c.into())),
465            Some(Value::Null) => Ok(None),
466            Some(Value::DateTimeUtc(d)) => Ok(Some(*d)),
467            Some(Value::NaiveDateTime(d)) => Ok(Some(d.and_utc())),
468            Some(Value::Text(s)) => chrono::DateTime::parse_from_rfc3339(s)
469                .map(|d| Some(d.with_timezone(&chrono::Utc)))
470                .map_err(|e| tc(c, e.to_string())),
471            Some(_) => Err(tc(c, "not a datetime")),
472        }
473    }
474    fn get_naive_datetime(&self, c: &str) -> Result<chrono::NaiveDateTime, RowError> {
475        match self
476            .values
477            .get(c)
478            .ok_or_else(|| RowError::ColumnNotFound(c.into()))?
479        {
480            Value::NaiveDateTime(d) => Ok(*d),
481            Value::DateTimeUtc(d) => Ok(d.naive_utc()),
482            Value::Text(s) => chrono::DateTime::parse_from_rfc3339(s)
483                .map(|d| d.naive_utc())
484                .map_err(|e| tc(c, e.to_string())),
485            Value::Null => Err(RowError::UnexpectedNull(c.into())),
486            _ => Err(tc(c, "not a datetime")),
487        }
488    }
489    fn get_naive_datetime_opt(&self, c: &str) -> Result<Option<chrono::NaiveDateTime>, RowError> {
490        match self.values.get(c) {
491            None => Err(RowError::ColumnNotFound(c.into())),
492            Some(Value::Null) => Ok(None),
493            Some(Value::NaiveDateTime(d)) => Ok(Some(*d)),
494            Some(Value::DateTimeUtc(d)) => Ok(Some(d.naive_utc())),
495            Some(Value::Text(s)) => chrono::DateTime::parse_from_rfc3339(s)
496                .map(|d| Some(d.naive_utc()))
497                .map_err(|e| tc(c, e.to_string())),
498            Some(_) => Err(tc(c, "not a datetime")),
499        }
500    }
501    fn get_naive_date(&self, c: &str) -> Result<chrono::NaiveDate, RowError> {
502        match self
503            .values
504            .get(c)
505            .ok_or_else(|| RowError::ColumnNotFound(c.into()))?
506        {
507            Value::NaiveDate(d) => Ok(*d),
508            Value::Text(s) => s
509                .parse::<chrono::NaiveDate>()
510                .map_err(|e: chrono::ParseError| tc(c, e.to_string())),
511            Value::Null => Err(RowError::UnexpectedNull(c.into())),
512            _ => Err(tc(c, "not a date")),
513        }
514    }
515    fn get_naive_date_opt(&self, c: &str) -> Result<Option<chrono::NaiveDate>, RowError> {
516        match self.values.get(c) {
517            None => Err(RowError::ColumnNotFound(c.into())),
518            Some(Value::Null) => Ok(None),
519            Some(Value::NaiveDate(d)) => Ok(Some(*d)),
520            Some(Value::Text(s)) => s
521                .parse::<chrono::NaiveDate>()
522                .map(Some)
523                .map_err(|e: chrono::ParseError| tc(c, e.to_string())),
524            Some(_) => Err(tc(c, "not a date")),
525        }
526    }
527    fn get_naive_time(&self, c: &str) -> Result<chrono::NaiveTime, RowError> {
528        match self
529            .values
530            .get(c)
531            .ok_or_else(|| RowError::ColumnNotFound(c.into()))?
532        {
533            Value::NaiveTime(t) => Ok(*t),
534            Value::Text(s) => s
535                .parse::<chrono::NaiveTime>()
536                .map_err(|e: chrono::ParseError| tc(c, e.to_string())),
537            Value::Null => Err(RowError::UnexpectedNull(c.into())),
538            _ => Err(tc(c, "not a time")),
539        }
540    }
541    fn get_naive_time_opt(&self, c: &str) -> Result<Option<chrono::NaiveTime>, RowError> {
542        match self.values.get(c) {
543            None => Err(RowError::ColumnNotFound(c.into())),
544            Some(Value::Null) => Ok(None),
545            Some(Value::NaiveTime(t)) => Ok(Some(*t)),
546            Some(Value::Text(s)) => s
547                .parse::<chrono::NaiveTime>()
548                .map(Some)
549                .map_err(|e: chrono::ParseError| tc(c, e.to_string())),
550            Some(_) => Err(tc(c, "not a time")),
551        }
552    }
553    fn get_uuid(&self, c: &str) -> Result<uuid::Uuid, RowError> {
554        match self
555            .values
556            .get(c)
557            .ok_or_else(|| RowError::ColumnNotFound(c.into()))?
558        {
559            Value::Uuid(u) => Ok(*u),
560            Value::Text(s) => uuid::Uuid::parse_str(s).map_err(|e| tc(c, e.to_string())),
561            Value::Null => Err(RowError::UnexpectedNull(c.into())),
562            _ => Err(tc(c, "not a uuid")),
563        }
564    }
565    fn get_uuid_opt(&self, c: &str) -> Result<Option<uuid::Uuid>, RowError> {
566        match self.values.get(c) {
567            None => Err(RowError::ColumnNotFound(c.into())),
568            Some(Value::Null) => Ok(None),
569            Some(Value::Uuid(u)) => Ok(Some(*u)),
570            Some(Value::Text(s)) => uuid::Uuid::parse_str(s)
571                .map(Some)
572                .map_err(|e| tc(c, e.to_string())),
573            Some(_) => Err(tc(c, "not a uuid")),
574        }
575    }
576    fn get_json(&self, c: &str) -> Result<serde_json::Value, RowError> {
577        match self
578            .values
579            .get(c)
580            .ok_or_else(|| RowError::ColumnNotFound(c.into()))?
581        {
582            Value::Json(j) => Ok(j.clone()),
583            Value::Text(s) => serde_json::from_str(s).map_err(|e| tc(c, e.to_string())),
584            Value::Null => Err(RowError::UnexpectedNull(c.into())),
585            _ => Err(tc(c, "not json")),
586        }
587    }
588    fn get_json_opt(&self, c: &str) -> Result<Option<serde_json::Value>, RowError> {
589        match self.values.get(c) {
590            None => Err(RowError::ColumnNotFound(c.into())),
591            Some(Value::Null) => Ok(None),
592            Some(Value::Json(j)) => Ok(Some(j.clone())),
593            Some(Value::Text(s)) => serde_json::from_str(s)
594                .map(Some)
595                .map_err(|e| tc(c, e.to_string())),
596            Some(_) => Err(tc(c, "not json")),
597        }
598    }
599    fn get_decimal(&self, c: &str) -> Result<rust_decimal::Decimal, RowError> {
600        match self
601            .values
602            .get(c)
603            .ok_or_else(|| RowError::ColumnNotFound(c.into()))?
604        {
605            Value::Decimal(d) => Ok(*d),
606            Value::I64(i) => Ok(rust_decimal::Decimal::from(*i)),
607            Value::Text(s) => s
608                .parse::<rust_decimal::Decimal>()
609                .map_err(|e: rust_decimal::Error| tc(c, e.to_string())),
610            Value::Null => Err(RowError::UnexpectedNull(c.into())),
611            _ => Err(tc(c, "not a decimal")),
612        }
613    }
614    fn get_decimal_opt(&self, c: &str) -> Result<Option<rust_decimal::Decimal>, RowError> {
615        match self.values.get(c) {
616            None => Err(RowError::ColumnNotFound(c.into())),
617            Some(Value::Null) => Ok(None),
618            Some(Value::Decimal(d)) => Ok(Some(*d)),
619            Some(Value::I64(i)) => Ok(Some(rust_decimal::Decimal::from(*i))),
620            Some(Value::Text(s)) => s
621                .parse::<rust_decimal::Decimal>()
622                .map(Some)
623                .map_err(|e: rust_decimal::Error| tc(c, e.to_string())),
624            Some(_) => Err(tc(c, "not a decimal")),
625        }
626    }
627    /// Override the trait default (which delegates to `get_str_opt` and
628    /// therefore *errors* on any non-text, non-null cell — I64, F64,
629    /// Bool, Bytes, ...) with a direct snapshot probe. This is what the
630    /// blanket `impl<T: FromColumn> FromColumn for Option<T>` calls to
631    /// short-circuit nullable columns, so `Option<i32> = Some(5)` must
632    /// get `Ok(false)` here rather than a "not text" failure. Mirrors
633    /// the `NullProbe` override on `PgRow` in prax-postgres.
634    fn is_null(&self, c: &str) -> Result<bool, RowError> {
635        match self.values.get(c) {
636            None => Err(RowError::ColumnNotFound(c.into())),
637            Some(v) => Ok(matches!(v, Value::Null)),
638        }
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645    use prax_query::row::FromColumn;
646
647    fn row(pairs: Vec<(&str, Value)>) -> SqlxRowRef {
648        SqlxRowRef {
649            values: pairs.into_iter().map(|(k, v)| (k.to_string(), v)).collect(),
650        }
651    }
652
653    fn naive_datetime() -> chrono::NaiveDateTime {
654        chrono::NaiveDate::from_ymd_opt(2024, 1, 2)
655            .unwrap()
656            .and_hms_opt(3, 4, 5)
657            .unwrap()
658    }
659
660    #[test]
661    fn is_null_reports_null_variant_as_null() {
662        let r = row(vec![("a", Value::Null)]);
663        assert!(r.is_null("a").unwrap());
664    }
665
666    #[test]
667    fn is_null_reports_every_non_null_variant_as_not_null() {
668        let r = row(vec![
669            ("bool", Value::Bool(true)),
670            ("i64", Value::I64(5)),
671            ("f64", Value::F64(1.5)),
672            ("text", Value::Text("hello".into())),
673            ("bytes", Value::Bytes(vec![1, 2, 3])),
674            (
675                "dt_utc",
676                Value::DateTimeUtc(chrono::DateTime::from_timestamp(1_700_000_000, 0).unwrap()),
677            ),
678            ("ndt", Value::NaiveDateTime(naive_datetime())),
679            (
680                "nd",
681                Value::NaiveDate(chrono::NaiveDate::from_ymd_opt(2024, 1, 2).unwrap()),
682            ),
683            (
684                "nt",
685                Value::NaiveTime(chrono::NaiveTime::from_hms_opt(3, 4, 5).unwrap()),
686            ),
687            ("uuid", Value::Uuid(uuid::Uuid::nil())),
688            ("json", Value::Json(serde_json::json!({"a": 1}))),
689            ("decimal", Value::Decimal(rust_decimal::Decimal::new(42, 1))),
690        ]);
691        for col in [
692            "bool", "i64", "f64", "text", "bytes", "dt_utc", "ndt", "nd", "nt", "uuid", "json",
693            "decimal",
694        ] {
695            assert!(!r.is_null(col).unwrap(), "{col} should not be null");
696        }
697    }
698
699    #[test]
700    fn is_null_missing_column_is_column_not_found() {
701        let r = row(vec![]);
702        assert!(matches!(
703            r.is_null("missing"),
704            Err(RowError::ColumnNotFound(_))
705        ));
706    }
707
708    /// Regression for the missing `is_null` override: the blanket
709    /// `FromColumn for Option<T>` dispatches through `is_null`, which
710    /// used to delegate to `get_str_opt` and error on non-text cells,
711    /// failing the whole row for `Option<i32> = Some(5)` & friends.
712    #[test]
713    fn option_from_column_decodes_non_text_cells() {
714        let r = row(vec![
715            ("n", Value::I64(5)),
716            ("f", Value::F64(2.5)),
717            ("b", Value::Bool(true)),
718            ("null_i", Value::Null),
719        ]);
720        assert_eq!(Option::<i32>::from_column(&r, "n").unwrap(), Some(5));
721        assert_eq!(Option::<f64>::from_column(&r, "f").unwrap(), Some(2.5));
722        assert_eq!(Option::<bool>::from_column(&r, "b").unwrap(), Some(true));
723        assert_eq!(Option::<i32>::from_column(&r, "null_i").unwrap(), None);
724    }
725
726    #[test]
727    fn typed_variants_are_readable_through_their_getters() {
728        let uuid = uuid::Uuid::nil();
729        let ndt = naive_datetime();
730        let r = row(vec![
731            ("u", Value::Uuid(uuid)),
732            ("d", Value::Decimal(rust_decimal::Decimal::new(42, 1))),
733            ("j", Value::Json(serde_json::json!([1, 2]))),
734            ("t", Value::NaiveDateTime(ndt)),
735        ]);
736        assert_eq!(r.get_uuid("u").unwrap(), uuid);
737        // String-typed model fields over UUID columns (Prisma `String
738        // @db.Uuid`) read through `get_string`, like prax-postgres.
739        assert_eq!(r.get_string("u").unwrap(), uuid.to_string());
740        assert_eq!(
741            r.get_decimal("d").unwrap(),
742            rust_decimal::Decimal::new(42, 1)
743        );
744        assert_eq!(r.get_json("j").unwrap(), serde_json::json!([1, 2]));
745        assert_eq!(r.get_naive_datetime("t").unwrap(), ndt);
746        // The UTC getter assumes naive timestamps are UTC.
747        assert_eq!(r.get_datetime_utc("t").unwrap(), ndt.and_utc());
748    }
749
750    // NOTE: decoding correctness of `decode_*_cell` against real wire
751    // types requires live databases (Postgres/MySQL/SQLite) and is
752    // currently untested — prax-sqlx has no live-database integration
753    // tests.
754}