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).and_then(|s| uuid::Uuid::parse_str(&s).ok())
202    }
203
204    // ==================== GET BY COLUMN NAME ====================
205
206    /// Get column index by name.
207    pub fn column_index(&self, name: &str) -> Option<usize> {
208        self.column_info.as_ref()?.name_to_index.get(name).copied()
209    }
210
211    /// Get a String column by name.
212    pub fn get_string_by_name(&self, name: &str) -> Option<String> {
213        self.get_string(self.column_index(name)?)
214    }
215
216    /// Get an i32 column by name.
217    pub fn get_i32_by_name(&self, name: &str) -> Option<i32> {
218        self.get_i32(self.column_index(name)?)
219    }
220
221    /// Get an i64 column by name.
222    pub fn get_i64_by_name(&self, name: &str) -> Option<i64> {
223        self.get_i64(self.column_index(name)?)
224    }
225
226    /// Get a f64 column by name.
227    pub fn get_f64_by_name(&self, name: &str) -> Option<f64> {
228        self.get_f64(self.column_index(name)?)
229    }
230
231    /// Get a bool column by name.
232    pub fn get_bool_by_name(&self, name: &str) -> Option<bool> {
233        self.get_bool(self.column_index(name)?)
234    }
235
236    /// Get a UUID column by name.
237    pub fn get_uuid_by_name(&self, name: &str) -> Option<String> {
238        self.get_uuid(self.column_index(name)?)
239    }
240
241    /// Get a JSON column by name.
242    pub fn get_json_by_name(&self, name: &str) -> Option<String> {
243        self.get_json(self.column_index(name)?)
244    }
245
246    /// Check if a column is NULL by name.
247    pub fn is_null_by_name(&self, name: &str) -> bool {
248        self.column_index(name)
249            .map(|idx| self.is_null(idx))
250            .unwrap_or(true)
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_get_string() {
260        let row = PgRow {
261            columns: vec![Some(b"hello".to_vec()), None, Some(b"world".to_vec())],
262            column_info: None,
263        };
264
265        assert_eq!(row.get_string(0), Some("hello".to_string()));
266        assert_eq!(row.get_string(1), None);
267        assert_eq!(row.get_string(2), Some("world".to_string()));
268    }
269
270    #[test]
271    fn test_get_i32() {
272        let row = PgRow {
273            columns: vec![
274                Some(b"42".to_vec()),
275                Some(b"-123".to_vec()),
276                Some(b"not_a_number".to_vec()),
277            ],
278            column_info: None,
279        };
280
281        assert_eq!(row.get_i32(0), Some(42));
282        assert_eq!(row.get_i32(1), Some(-123));
283        assert_eq!(row.get_i32(2), None);
284    }
285
286    #[test]
287    fn test_get_bool() {
288        let row = PgRow {
289            columns: vec![
290                Some(b"t".to_vec()),
291                Some(b"f".to_vec()),
292                Some(b"true".to_vec()),
293                Some(b"false".to_vec()),
294            ],
295            column_info: None,
296        };
297
298        assert_eq!(row.get_bool(0), Some(true));
299        assert_eq!(row.get_bool(1), Some(false));
300        assert_eq!(row.get_bool(2), Some(true));
301        assert_eq!(row.get_bool(3), Some(false));
302    }
303
304    #[test]
305    fn test_is_null() {
306        let row = PgRow {
307            columns: vec![Some(b"value".to_vec()), None],
308            column_info: None,
309        };
310
311        assert!(!row.is_null(0));
312        assert!(row.is_null(1));
313        assert!(row.is_null(99)); // Out of bounds
314    }
315}