Skip to main content

qail_pg/driver/
row.rs

1//! PostgreSQL Row Helpers
2//!
3//! Provides convenient methods to extract typed values from row data.
4//! PostgreSQL Simple Query protocol returns all values as text format.
5
6use super::PgRow;
7
8/// Trait for types that can be constructed from a database row.
9///
10/// Implement this trait on your structs to enable typed fetching:
11/// ```ignore
12/// impl QailRow for User {
13///     fn columns() -> &'static [&'static str] {
14///         &["id", "name", "email"]
15///     }
16///     
17///     fn from_row(row: &PgRow) -> Self {
18///         User {
19///             id: row.uuid_typed(0).unwrap_or_default(),
20///             name: row.text(1),
21///             email: row.get_string(2),
22///         }
23///     }
24/// }
25///
26/// // Then use:
27/// let users: Vec<User> = driver.fetch_typed::<User>(&query).await?;
28/// ```
29pub trait QailRow: Sized {
30    /// Return the column names this struct expects.
31    /// These are used to automatically build SELECT queries.
32    fn columns() -> &'static [&'static str];
33
34    /// Construct an instance from a PgRow.
35    /// Column indices match the order returned by `columns()`.
36    fn from_row(row: &PgRow) -> Self;
37}
38
39impl PgRow {
40    /// Get a column value as String.
41    /// Returns None if the value is NULL or invalid UTF-8.
42    pub fn get_string(&self, idx: usize) -> Option<String> {
43        self.columns
44            .get(idx)?
45            .as_ref()
46            .and_then(|bytes| String::from_utf8(bytes.clone()).ok())
47    }
48
49    /// Get a column value as i32.
50    pub fn get_i32(&self, idx: usize) -> Option<i32> {
51        let bytes = self.columns.get(idx)?.as_ref()?;
52        std::str::from_utf8(bytes).ok()?.parse().ok()
53    }
54
55    /// Get a column value as i64.
56    pub fn get_i64(&self, idx: usize) -> Option<i64> {
57        let bytes = self.columns.get(idx)?.as_ref()?;
58        std::str::from_utf8(bytes).ok()?.parse().ok()
59    }
60
61    /// Get a column value as f64.
62    pub fn get_f64(&self, idx: usize) -> Option<f64> {
63        let bytes = self.columns.get(idx)?.as_ref()?;
64        std::str::from_utf8(bytes).ok()?.parse().ok()
65    }
66
67    /// Get a column value as bool.
68    pub fn get_bool(&self, idx: usize) -> Option<bool> {
69        let bytes = self.columns.get(idx)?.as_ref()?;
70        let s = std::str::from_utf8(bytes).ok()?;
71        match s {
72            "t" | "true" | "1" => Some(true),
73            "f" | "false" | "0" => Some(false),
74            _ => None,
75        }
76    }
77
78    /// Check if a column is NULL.
79    pub fn is_null(&self, idx: usize) -> bool {
80        self.columns.get(idx).map(|v| v.is_none()).unwrap_or(true)
81    }
82
83    /// Get raw bytes of a column.
84    pub fn get_bytes(&self, idx: usize) -> Option<&[u8]> {
85        self.columns.get(idx)?.as_ref().map(|v| v.as_slice())
86    }
87
88    /// Get number of columns in the row.
89    pub fn len(&self) -> usize {
90        self.columns.len()
91    }
92
93    /// Check if the row has no columns.
94    pub fn is_empty(&self) -> bool {
95        self.columns.is_empty()
96    }
97
98    /// Get a column value as UUID string.
99    /// Handles both text format (36-char string) and binary format (16 bytes).
100    pub fn get_uuid(&self, idx: usize) -> Option<String> {
101        let bytes = self.columns.get(idx)?.as_ref()?;
102
103        if bytes.len() == 16 {
104            // Binary format - decode 16 bytes
105            use crate::protocol::types::decode_uuid;
106            decode_uuid(bytes).ok()
107        } else {
108            // Text format - return as-is
109            String::from_utf8(bytes.clone()).ok()
110        }
111    }
112
113    /// Get a column value as JSON string.
114    /// Handles both JSON (text) and JSONB (version byte prefix) formats.
115    pub fn get_json(&self, idx: usize) -> Option<String> {
116        let bytes = self.columns.get(idx)?.as_ref()?;
117
118        if bytes.is_empty() {
119            return Some(String::new());
120        }
121
122        // JSONB has version byte (1) as first byte
123        if bytes[0] == 1 && bytes.len() > 1 {
124            String::from_utf8(bytes[1..].to_vec()).ok()
125        } else {
126            String::from_utf8(bytes.clone()).ok()
127        }
128    }
129
130    /// Get a column value as timestamp string (ISO 8601 format).
131    pub fn get_timestamp(&self, idx: usize) -> Option<String> {
132        let bytes = self.columns.get(idx)?.as_ref()?;
133        String::from_utf8(bytes.clone()).ok()
134    }
135
136    /// Get a column value as text array.
137    pub fn get_text_array(&self, idx: usize) -> Option<Vec<String>> {
138        let bytes = self.columns.get(idx)?.as_ref()?;
139        let s = std::str::from_utf8(bytes).ok()?;
140        Some(crate::protocol::types::decode_text_array(s))
141    }
142
143    /// Get a column value as integer array.
144    pub fn get_int_array(&self, idx: usize) -> Option<Vec<i64>> {
145        let bytes = self.columns.get(idx)?.as_ref()?;
146        let s = std::str::from_utf8(bytes).ok()?;
147        crate::protocol::types::decode_int_array(s).ok()
148    }
149
150    // ==================== ERGONOMIC SHORTCUTS ====================
151    // These methods reduce boilerplate by providing sensible defaults
152
153    /// Get string, defaulting to empty string if NULL.
154    /// Ergonomic shortcut: `row.text(0)` instead of `row.get_string(0).unwrap_or_default()`
155    pub fn text(&self, idx: usize) -> String {
156        self.get_string(idx).unwrap_or_default()
157    }
158
159    /// Get string with custom default if NULL.
160    /// Example: `row.text_or(1, "Unknown")`
161    pub fn text_or(&self, idx: usize, default: &str) -> String {
162        self.get_string(idx).unwrap_or_else(|| default.to_string())
163    }
164
165    /// Get i64, defaulting to 0 if NULL.
166    /// Ergonomic shortcut: `row.int(4)` instead of `row.get_i64(4).unwrap_or(0)`
167    pub fn int(&self, idx: usize) -> i64 {
168        self.get_i64(idx).unwrap_or(0)
169    }
170
171    /// Get f64, defaulting to 0.0 if NULL.
172    pub fn float(&self, idx: usize) -> f64 {
173        self.get_f64(idx).unwrap_or(0.0)
174    }
175
176    /// Get bool, defaulting to false if NULL.
177    pub fn boolean(&self, idx: usize) -> bool {
178        self.get_bool(idx).unwrap_or(false)
179    }
180
181    /// Parse timestamp as DateTime<Utc>.
182    /// Handles PostgreSQL timestamp formats automatically.
183    #[cfg(feature = "chrono")]
184    pub fn datetime(&self, idx: usize) -> Option<chrono::DateTime<chrono::Utc>> {
185        let s = self.get_timestamp(idx)?;
186        // Try parsing various PostgreSQL timestamp formats
187        chrono::DateTime::parse_from_rfc3339(&s.replace(' ', "T"))
188            .ok()
189            .map(|dt| dt.with_timezone(&chrono::Utc))
190            .or_else(|| {
191                // Try PostgreSQL format: "2024-01-01 12:00:00.123456+00"
192                chrono::DateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S%.f%#z")
193                    .ok()
194                    .map(|dt| dt.with_timezone(&chrono::Utc))
195            })
196    }
197
198    /// Parse UUID column as uuid::Uuid type.
199    #[cfg(feature = "uuid")]
200    pub fn uuid_typed(&self, idx: usize) -> Option<uuid::Uuid> {
201        self.get_uuid(idx)
202            .and_then(|s| uuid::Uuid::parse_str(&s).ok())
203    }
204
205    // ==================== GET BY COLUMN NAME ====================
206
207    /// Get column index by name.
208    pub fn column_index(&self, name: &str) -> Option<usize> {
209        self.column_info.as_ref()?.name_to_index.get(name).copied()
210    }
211
212    /// Get a String column by name.
213    pub fn get_string_by_name(&self, name: &str) -> Option<String> {
214        self.get_string(self.column_index(name)?)
215    }
216
217    /// Get an i32 column by name.
218    pub fn get_i32_by_name(&self, name: &str) -> Option<i32> {
219        self.get_i32(self.column_index(name)?)
220    }
221
222    /// Get an i64 column by name.
223    pub fn get_i64_by_name(&self, name: &str) -> Option<i64> {
224        self.get_i64(self.column_index(name)?)
225    }
226
227    /// Get a f64 column by name.
228    pub fn get_f64_by_name(&self, name: &str) -> Option<f64> {
229        self.get_f64(self.column_index(name)?)
230    }
231
232    /// Get a bool column by name.
233    pub fn get_bool_by_name(&self, name: &str) -> Option<bool> {
234        self.get_bool(self.column_index(name)?)
235    }
236
237    /// Get a UUID column by name.
238    pub fn get_uuid_by_name(&self, name: &str) -> Option<String> {
239        self.get_uuid(self.column_index(name)?)
240    }
241
242    /// Get a JSON column by name.
243    pub fn get_json_by_name(&self, name: &str) -> Option<String> {
244        self.get_json(self.column_index(name)?)
245    }
246
247    /// Check if a column is NULL by name.
248    pub fn is_null_by_name(&self, name: &str) -> bool {
249        self.column_index(name)
250            .map(|idx| self.is_null(idx))
251            .unwrap_or(true)
252    }
253
254    /// Get a timestamp column by name.
255    pub fn get_timestamp_by_name(&self, name: &str) -> Option<String> {
256        self.get_timestamp(self.column_index(name)?)
257    }
258
259    /// Get a text array column by name.
260    pub fn get_text_array_by_name(&self, name: &str) -> Option<Vec<String>> {
261        self.get_text_array(self.column_index(name)?)
262    }
263
264    /// Get an integer array column by name.
265    pub fn get_int_array_by_name(&self, name: &str) -> Option<Vec<i64>> {
266        self.get_int_array(self.column_index(name)?)
267    }
268
269    // ==================== ERGONOMIC BY-NAME SHORTCUTS ====================
270    // These mirror the positional shortcuts (text, boolean, int, etc.)
271    // but use column names — safe with RETURNING * regardless of column order.
272
273    /// Get string by column name, defaulting to empty string.
274    /// Example: `row.text_by_name("name")` instead of `row.get_string_by_name("name").unwrap_or_default()`
275    pub fn text_by_name(&self, name: &str) -> String {
276        self.get_string_by_name(name).unwrap_or_default()
277    }
278
279    /// Get bool by column name, defaulting to false.
280    pub fn boolean_by_name(&self, name: &str) -> bool {
281        self.get_bool_by_name(name).unwrap_or(false)
282    }
283
284    /// Get i64 by column name, defaulting to 0.
285    pub fn int_by_name(&self, name: &str) -> i64 {
286        self.get_i64_by_name(name).unwrap_or(0)
287    }
288
289    /// Get f64 by column name, defaulting to 0.0.
290    pub fn float_by_name(&self, name: &str) -> f64 {
291        self.get_f64_by_name(name).unwrap_or(0.0)
292    }
293
294    /// Parse timestamp by column name as DateTime<Utc>.
295    #[cfg(feature = "chrono")]
296    pub fn datetime_by_name(&self, name: &str) -> Option<chrono::DateTime<chrono::Utc>> {
297        self.datetime(self.column_index(name)?)
298    }
299
300    /// Parse UUID by column name as uuid::Uuid type.
301    #[cfg(feature = "uuid")]
302    pub fn uuid_typed_by_name(&self, name: &str) -> Option<uuid::Uuid> {
303        self.uuid_typed(self.column_index(name)?)
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_get_string() {
313        let row = PgRow {
314            columns: vec![Some(b"hello".to_vec()), None, Some(b"world".to_vec())],
315            column_info: None,
316        };
317
318        assert_eq!(row.get_string(0), Some("hello".to_string()));
319        assert_eq!(row.get_string(1), None);
320        assert_eq!(row.get_string(2), Some("world".to_string()));
321    }
322
323    #[test]
324    fn test_get_i32() {
325        let row = PgRow {
326            columns: vec![
327                Some(b"42".to_vec()),
328                Some(b"-123".to_vec()),
329                Some(b"not_a_number".to_vec()),
330            ],
331            column_info: None,
332        };
333
334        assert_eq!(row.get_i32(0), Some(42));
335        assert_eq!(row.get_i32(1), Some(-123));
336        assert_eq!(row.get_i32(2), None);
337    }
338
339    #[test]
340    fn test_get_bool() {
341        let row = PgRow {
342            columns: vec![
343                Some(b"t".to_vec()),
344                Some(b"f".to_vec()),
345                Some(b"true".to_vec()),
346                Some(b"false".to_vec()),
347            ],
348            column_info: None,
349        };
350
351        assert_eq!(row.get_bool(0), Some(true));
352        assert_eq!(row.get_bool(1), Some(false));
353        assert_eq!(row.get_bool(2), Some(true));
354        assert_eq!(row.get_bool(3), Some(false));
355    }
356
357    #[test]
358    fn test_is_null() {
359        let row = PgRow {
360            columns: vec![Some(b"value".to_vec()), None],
361            column_info: None,
362        };
363
364        assert!(!row.is_null(0));
365        assert!(row.is_null(1));
366        assert!(row.is_null(99)); // Out of bounds
367    }
368}