Skip to main content

chopin_pg/
row.rs

1//! Row abstraction for query results with inline small-value optimisation.
2//!
3//! Column values ≤ 24 bytes (which covers all binary-format scalars: bool,
4//! i16, i32, i64, f32, f64, UUID, date, time, timestamp) are stored
5//! **inline** — zero heap allocations per column.  Only values larger than
6//! 24 bytes (e.g. long text, bytea, jsonb) spill to the heap.
7
8use std::rc::Rc;
9
10use crate::codec::ColumnDesc;
11use crate::error::{PgError, PgResult};
12use crate::protocol::FormatCode;
13use crate::types::{FromSql, PgValue};
14
15/// Maximum number of bytes stored inline (no heap allocation).
16/// 24 bytes covers: bool(1), i16(2), i32(4), i64(8), f32(4), f64(8),
17/// UUID(16), Date(4), Time(8), Timestamp(8), Interval(16), MacAddr(6),
18/// Point(16).
19const INLINE_CAP: usize = 24;
20
21/// Compact byte storage: small values inline, large values on the heap.
22#[derive(Clone)]
23enum CompactBytes {
24    /// Value stored inline — no heap allocation.
25    Inline { data: [u8; INLINE_CAP], len: u8 },
26    /// Value too large for inline — heap allocated.
27    Heap(Vec<u8>),
28}
29
30impl std::fmt::Debug for CompactBytes {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        write!(f, "CompactBytes({}b)", self.as_slice().len())
33    }
34}
35
36impl CompactBytes {
37    /// Create from a byte slice.  Inline if ≤ INLINE_CAP, heap otherwise.
38    #[inline]
39    fn from_slice(data: &[u8]) -> Self {
40        if data.len() <= INLINE_CAP {
41            let mut buf = [0u8; INLINE_CAP];
42            buf[..data.len()].copy_from_slice(data);
43            CompactBytes::Inline {
44                data: buf,
45                len: data.len() as u8,
46            }
47        } else {
48            CompactBytes::Heap(data.to_vec())
49        }
50    }
51
52    /// Borrow the stored bytes.
53    #[inline]
54    fn as_slice(&self) -> &[u8] {
55        match self {
56            CompactBytes::Inline { data, len } => &data[..*len as usize],
57            CompactBytes::Heap(v) => v,
58        }
59    }
60}
61
62/// A row returned from a query. Contains column descriptions and raw data.
63///
64/// Column descriptors are shared via `Rc` across all rows in a result set,
65/// avoiding expensive deep clones on every `DataRow` message.
66///
67/// Column values use [`CompactBytes`] — values ≤ 24 bytes are stored inline
68/// (no heap allocation), which covers all binary-format scalar types.
69#[derive(Debug)]
70pub struct Row {
71    columns: Rc<Vec<ColumnDesc>>,
72    values: Vec<Option<CompactBytes>>,
73}
74
75impl Row {
76    /// Create a new row from shared column descriptions and raw column data.
77    ///
78    /// Small values (≤ 24 bytes) are stored inline with zero heap allocation.
79    pub fn new(columns: Rc<Vec<ColumnDesc>>, raw_values: Vec<Option<&[u8]>>) -> Self {
80        let values = raw_values
81            .into_iter()
82            .map(|v| v.map(CompactBytes::from_slice))
83            .collect();
84        Self { columns, values }
85    }
86
87    /// Safely create a mock row for testing, bypassing wire protocols.
88    pub fn mock(names: &[&str], values: &[PgValue]) -> Self {
89        let mut cols = Vec::new();
90        let mut raw_values: Vec<Option<CompactBytes>> = Vec::new();
91        for (i, name) in names.iter().enumerate() {
92            let (type_oid, data) = match &values[i] {
93                PgValue::Null => (25, None), // default to text
94                PgValue::Int4(v) => (23, Some(v.to_string().into_bytes())),
95                PgValue::Int8(v) => (20, Some(v.to_string().into_bytes())),
96                PgValue::Text(s) => (25, Some(s.clone().into_bytes())),
97                PgValue::Bool(b) => (16, Some(if *b { b"t".to_vec() } else { b"f".to_vec() })),
98                // A complete map isn't necessary for a lightweight mock just matching basic fields
99                _ => (25, values[i].to_text_bytes()),
100            };
101            cols.push(ColumnDesc {
102                name: name.to_string(),
103                table_oid: 0,
104                col_attr: 0,
105                type_oid,
106                type_size: -1,
107                type_modifier: -1,
108                format_code: FormatCode::Text,
109            });
110            raw_values.push(data.map(|d| CompactBytes::from_slice(&d)));
111        }
112        Self {
113            columns: Rc::new(cols),
114            values: raw_values,
115        }
116    }
117
118    /// Get the number of columns.
119    pub fn len(&self) -> usize {
120        self.columns.len()
121    }
122
123    /// Check if the row is empty.
124    pub fn is_empty(&self) -> bool {
125        self.columns.is_empty()
126    }
127
128    /// Get a column value by index as a PgValue.
129    pub fn get(&self, index: usize) -> PgResult<PgValue> {
130        if index >= self.columns.len() {
131            return Err(PgError::TypeConversion(format!(
132                "Column index {} out of range",
133                index
134            )));
135        }
136
137        let col = &self.columns[index];
138        match &self.values[index] {
139            None => Ok(PgValue::Null),
140            Some(data) => match col.format_code {
141                FormatCode::Text => PgValue::from_text(col.type_oid, data.as_slice()),
142                FormatCode::Binary => PgValue::from_binary(col.type_oid, data.as_slice()),
143            },
144        }
145    }
146
147    /// Get a column value by name as a PgValue.
148    pub fn get_by_name(&self, name: &str) -> PgResult<PgValue> {
149        let index = self
150            .columns
151            .iter()
152            .position(|c| c.name == name)
153            .ok_or_else(|| PgError::TypeConversion(format!("Column '{}' not found", name)))?;
154        self.get(index)
155    }
156
157    /// Get a typed value by column index using the `FromSql` trait.
158    ///
159    /// # Example
160    /// ```ignore
161    /// let name: String = row.get_typed(0)?;
162    /// let age: Option<i32> = row.get_typed(1)?;
163    /// ```
164    pub fn get_typed<T: FromSql>(&self, index: usize) -> PgResult<T> {
165        let value = self.get(index)?;
166        T::from_sql(&value)
167    }
168
169    /// Get a typed value by column name using the `FromSql` trait.
170    pub fn get_typed_by_name<T: FromSql>(&self, name: &str) -> PgResult<T> {
171        let value = self.get_by_name(name)?;
172        T::from_sql(&value)
173    }
174
175    /// Get a column as a string (text representation).
176    pub fn get_str(&self, index: usize) -> PgResult<Option<&str>> {
177        if index >= self.columns.len() {
178            return Err(PgError::TypeConversion(format!(
179                "Column index {} out of range",
180                index
181            )));
182        }
183        match &self.values[index] {
184            None => Ok(None),
185            Some(data) => std::str::from_utf8(data.as_slice())
186                .map(Some)
187                .map_err(|_| PgError::TypeConversion("Invalid UTF-8".to_string())),
188        }
189    }
190
191    /// Get a column as i32.
192    pub fn get_i32(&self, index: usize) -> PgResult<Option<i32>> {
193        match self.get(index)? {
194            PgValue::Null => Ok(None),
195            PgValue::Int4(v) => Ok(Some(v)),
196            PgValue::Int2(v) => Ok(Some(v as i32)),
197            PgValue::Text(s) => s
198                .parse()
199                .map(Some)
200                .map_err(|_| PgError::TypeConversion("Not an i32".to_string())),
201            _ => Err(PgError::TypeConversion("Cannot convert to i32".to_string())),
202        }
203    }
204
205    /// Get a column as i64.
206    pub fn get_i64(&self, index: usize) -> PgResult<Option<i64>> {
207        match self.get(index)? {
208            PgValue::Null => Ok(None),
209            PgValue::Int8(v) => Ok(Some(v)),
210            PgValue::Int4(v) => Ok(Some(v as i64)),
211            PgValue::Int2(v) => Ok(Some(v as i64)),
212            PgValue::Text(s) => s
213                .parse()
214                .map(Some)
215                .map_err(|_| PgError::TypeConversion("Not an i64".to_string())),
216            _ => Err(PgError::TypeConversion("Cannot convert to i64".to_string())),
217        }
218    }
219
220    /// Get a column as bool.
221    pub fn get_bool(&self, index: usize) -> PgResult<Option<bool>> {
222        match self.get(index)? {
223            PgValue::Null => Ok(None),
224            PgValue::Bool(v) => Ok(Some(v)),
225            _ => Err(PgError::TypeConversion(
226                "Cannot convert to bool".to_string(),
227            )),
228        }
229    }
230
231    /// Get a column as f64.
232    pub fn get_f64(&self, index: usize) -> PgResult<Option<f64>> {
233        match self.get(index)? {
234            PgValue::Null => Ok(None),
235            PgValue::Float8(v) => Ok(Some(v)),
236            PgValue::Float4(v) => Ok(Some(v as f64)),
237            PgValue::Int4(v) => Ok(Some(v as f64)),
238            PgValue::Text(s) => s
239                .parse()
240                .map(Some)
241                .map_err(|_| PgError::TypeConversion("Not an f64".to_string())),
242            _ => Err(PgError::TypeConversion("Cannot convert to f64".to_string())),
243        }
244    }
245
246    /// Get a column as a UUID string (xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).
247    pub fn get_uuid(&self, index: usize) -> PgResult<Option<[u8; 16]>> {
248        match self.get(index)? {
249            PgValue::Null => Ok(None),
250            PgValue::Uuid(bytes) => Ok(Some(bytes)),
251            _ => Err(PgError::TypeConversion(
252                "Cannot convert to UUID".to_string(),
253            )),
254        }
255    }
256
257    /// Get column descriptions.
258    pub fn columns(&self) -> &[ColumnDesc] {
259        &self.columns
260    }
261
262    /// Get a column name by index.
263    pub fn column_name(&self, index: usize) -> Option<&str> {
264        self.columns.get(index).map(|c| c.name.as_str())
265    }
266
267    /// Find a column index by name.
268    pub fn column_index(&self, name: &str) -> Option<usize> {
269        self.columns.iter().position(|c| c.name == name)
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::codec::ColumnDesc;
277    use crate::error::{PgError, PgResult};
278    use crate::protocol::FormatCode;
279    use crate::types::PgValue;
280    use std::rc::Rc;
281
282    // OID constants (text format values are parsed the same as DB text protocol)
283    const OID_TEXT: u32 = 25;
284    const OID_INT4: u32 = 23;
285    const OID_INT8: u32 = 20;
286    const OID_BOOL: u32 = 16;
287    const OID_FLOAT8: u32 = 701;
288
289    fn col(name: &str, type_oid: u32) -> ColumnDesc {
290        ColumnDesc {
291            name: name.to_string(),
292            table_oid: 0,
293            col_attr: 0,
294            type_oid,
295            type_size: -1,
296            type_modifier: -1,
297            format_code: FormatCode::Text,
298        }
299    }
300
301    fn make_row(cols: &[(&str, u32)], vals: &[Option<&[u8]>]) -> Row {
302        let descs = Rc::new(cols.iter().map(|(n, o)| col(n, *o)).collect::<Vec<_>>());
303        Row::new(descs, vals.to_vec())
304    }
305
306    // ─── Structural ───────────────────────────────────────────────────────────
307
308    #[test]
309    fn test_row_len_two_columns() {
310        let row = make_row(
311            &[("name", OID_TEXT), ("age", OID_INT4)],
312            &[Some(b"alice"), Some(b"30")],
313        );
314        assert_eq!(row.len(), 2);
315        assert!(!row.is_empty());
316    }
317
318    #[test]
319    fn test_row_empty_columns() {
320        let row = Row::new(Rc::new(vec![]), vec![]);
321        assert_eq!(row.len(), 0);
322        assert!(row.is_empty());
323    }
324
325    #[test]
326    fn test_row_len_matches_column_count() {
327        let row = make_row(
328            &[("a", OID_TEXT), ("b", OID_INT4), ("c", OID_BOOL)],
329            &[Some(b"x"), Some(b"1"), Some(b"t")],
330        );
331        assert_eq!(row.len(), row.columns().len());
332    }
333
334    // ─── get() by index ───────────────────────────────────────────────────────
335
336    #[test]
337    fn test_get_text_value() {
338        let row = make_row(&[("name", OID_TEXT)], &[Some(b"hello")]);
339        match row.get(0).unwrap() {
340            PgValue::Text(s) => assert_eq!(s, "hello"),
341            other => panic!("Expected Text, got {:?}", other),
342        }
343    }
344
345    #[test]
346    fn test_get_int4_value() {
347        let row = make_row(&[("n", OID_INT4)], &[Some(b"42")]);
348        match row.get(0).unwrap() {
349            PgValue::Int4(v) => assert_eq!(v, 42),
350            other => panic!("Expected Int4, got {:?}", other),
351        }
352    }
353
354    #[test]
355    fn test_get_bool_true() {
356        let row = make_row(&[("flag", OID_BOOL)], &[Some(b"t")]);
357        match row.get(0).unwrap() {
358            PgValue::Bool(v) => assert!(v),
359            other => panic!("Expected Bool, got {:?}", other),
360        }
361    }
362
363    #[test]
364    fn test_get_bool_false() {
365        let row = make_row(&[("flag", OID_BOOL)], &[Some(b"f")]);
366        match row.get(0).unwrap() {
367            PgValue::Bool(v) => assert!(!v),
368            other => panic!("Expected Bool, got {:?}", other),
369        }
370    }
371
372    #[test]
373    fn test_get_null_returns_pgvalue_null() {
374        let row = make_row(&[("name", OID_TEXT)], &[None]);
375        assert!(matches!(row.get(0).unwrap(), PgValue::Null));
376    }
377
378    #[test]
379    fn test_get_index_out_of_range_returns_error() {
380        let row = make_row(&[("name", OID_TEXT)], &[Some(b"alice")]);
381        let err = row.get(99);
382        assert!(err.is_err());
383        if let Err(PgError::TypeConversion(msg)) = err {
384            assert!(
385                msg.contains("out of range") || msg.contains("index"),
386                "Error should mention out-of-range: {}",
387                msg
388            );
389        } else {
390            panic!("Expected TypeConversion error");
391        }
392    }
393
394    // ─── get_by_name() ────────────────────────────────────────────────────────
395
396    #[test]
397    fn test_get_by_name_found() {
398        let row = make_row(
399            &[("id", OID_INT4), ("name", OID_TEXT)],
400            &[Some(b"1"), Some(b"charlie")],
401        );
402        match row.get_by_name("name").unwrap() {
403            PgValue::Text(s) => assert_eq!(s, "charlie"),
404            other => panic!("Expected Text, got {:?}", other),
405        }
406    }
407
408    #[test]
409    fn test_get_by_name_not_found_returns_error() {
410        let row = make_row(&[("id", OID_INT4)], &[Some(b"5")]);
411        let err = row.get_by_name("nonexistent");
412        assert!(err.is_err());
413        if let Err(PgError::TypeConversion(msg)) = err {
414            assert!(
415                msg.contains("not found") || msg.contains("nonexistent"),
416                "Error should mention missing column: {}",
417                msg
418            );
419        } else {
420            panic!("Expected TypeConversion error");
421        }
422    }
423
424    #[test]
425    fn test_get_by_name_null_column() {
426        let row = make_row(&[("data", OID_TEXT)], &[None]);
427        assert!(matches!(row.get_by_name("data").unwrap(), PgValue::Null));
428    }
429
430    // ─── Convenience accessors ────────────────────────────────────────────────
431
432    #[test]
433    fn test_get_str_valid_utf8() {
434        let row = make_row(&[("msg", OID_TEXT)], &[Some(b"hello world")]);
435        assert_eq!(row.get_str(0).unwrap(), Some("hello world"));
436    }
437
438    #[test]
439    fn test_get_str_null() {
440        let row = make_row(&[("msg", OID_TEXT)], &[None]);
441        assert_eq!(row.get_str(0).unwrap(), None);
442    }
443
444    #[test]
445    fn test_get_i32_value() {
446        let row = make_row(&[("n", OID_INT4)], &[Some(b"777")]);
447        assert_eq!(row.get_i32(0).unwrap(), Some(777));
448    }
449
450    #[test]
451    fn test_get_i32_null() {
452        let row = make_row(&[("n", OID_INT4)], &[None]);
453        assert_eq!(row.get_i32(0).unwrap(), None);
454    }
455
456    #[test]
457    fn test_get_i64_large_value() {
458        let row = make_row(&[("n", OID_INT8)], &[Some(b"9876543210")]);
459        assert_eq!(row.get_i64(0).unwrap(), Some(9_876_543_210_i64));
460    }
461
462    #[test]
463    fn test_get_i64_null() {
464        let row = make_row(&[("n", OID_INT8)], &[None]);
465        assert_eq!(row.get_i64(0).unwrap(), None);
466    }
467
468    #[test]
469    fn test_get_bool_null() {
470        let row = make_row(&[("flag", OID_BOOL)], &[None]);
471        assert_eq!(row.get_bool(0).unwrap(), None);
472    }
473
474    #[test]
475    fn test_get_f64_value() {
476        let pi = std::f64::consts::PI;
477        let text = pi.to_string();
478        let row = make_row(&[("score", OID_FLOAT8)], &[Some(text.as_bytes())]);
479        let v = row.get_f64(0).unwrap().unwrap();
480        assert!((v - pi).abs() < f64::EPSILON);
481    }
482
483    #[test]
484    fn test_get_f64_null() {
485        let row = make_row(&[("score", OID_FLOAT8)], &[None]);
486        assert_eq!(row.get_f64(0).unwrap(), None);
487    }
488
489    // ─── column_name / column_index ───────────────────────────────────────────
490
491    #[test]
492    fn test_column_name_by_index() {
493        let row = make_row(
494            &[("user_id", OID_INT4), ("email", OID_TEXT)],
495            &[Some(b"1"), Some(b"a@b.com")],
496        );
497        assert_eq!(row.column_name(0), Some("user_id"));
498        assert_eq!(row.column_name(1), Some("email"));
499        assert_eq!(row.column_name(99), None);
500    }
501
502    #[test]
503    fn test_column_index_by_name() {
504        let row = make_row(
505            &[("id", OID_INT4), ("name", OID_TEXT)],
506            &[Some(b"1"), Some(b"bob")],
507        );
508        assert_eq!(row.column_index("id"), Some(0));
509        assert_eq!(row.column_index("name"), Some(1));
510        assert_eq!(row.column_index("missing"), None);
511    }
512
513    #[test]
514    fn test_columns_slice() {
515        let row = make_row(
516            &[("a", OID_TEXT), ("b", OID_INT4)],
517            &[Some(b"x"), Some(b"1")],
518        );
519        let cols = row.columns();
520        assert_eq!(cols.len(), 2);
521        assert_eq!(cols[0].name, "a");
522        assert_eq!(cols[1].name, "b");
523    }
524
525    // ─── get_typed / get_typed_by_name ────────────────────────────────────────
526
527    #[test]
528    fn test_get_typed_i32() {
529        let row = make_row(&[("n", OID_INT4)], &[Some(b"77")]);
530        let v: i32 = row.get_typed(0).unwrap();
531        assert_eq!(v, 77);
532    }
533
534    #[test]
535    fn test_get_typed_option_null_is_none() {
536        let row = make_row(&[("n", OID_INT4)], &[None]);
537        let v: Option<i32> = row.get_typed(0).unwrap();
538        assert_eq!(v, None);
539    }
540
541    #[test]
542    fn test_get_typed_option_value_is_some() {
543        let row = make_row(&[("n", OID_INT4)], &[Some(b"55")]);
544        let v: Option<i32> = row.get_typed(0).unwrap();
545        assert_eq!(v, Some(55));
546    }
547
548    #[test]
549    fn test_get_typed_by_name_string() {
550        let row = make_row(&[("label", OID_TEXT)], &[Some(b"production")]);
551        let v: String = row.get_typed_by_name("label").unwrap();
552        assert_eq!(v, "production");
553    }
554
555    #[test]
556    fn test_get_typed_by_name_not_found() {
557        let row = make_row(&[("n", OID_INT4)], &[Some(b"1")]);
558        let r: PgResult<i32> = row.get_typed_by_name("missing");
559        assert!(r.is_err());
560    }
561
562    // ─── Rc sharing — performance characteristic ─────────────────────────────
563    // Verifies that column descriptors are NOT copied per row (O(1) sharing).
564
565    #[test]
566    fn test_rc_columns_shared_across_rows() {
567        let cols = Rc::new(vec![col("v", OID_INT4)]);
568        let initial_count = Rc::strong_count(&cols);
569
570        let row1 = Row::new(Rc::clone(&cols), vec![Some(b"1")]);
571        let row2 = Row::new(Rc::clone(&cols), vec![Some(b"2")]);
572        let row3 = Row::new(Rc::clone(&cols), vec![Some(b"3")]);
573
574        // cols + row1 + row2 + row3
575        assert_eq!(Rc::strong_count(&cols), initial_count + 3);
576
577        drop(row1);
578        assert_eq!(Rc::strong_count(&cols), initial_count + 2);
579
580        drop(row2);
581        drop(row3);
582        assert_eq!(Rc::strong_count(&cols), initial_count);
583    }
584
585    #[test]
586    fn test_rc_1000_rows_share_same_column_descriptor() {
587        // Scalability: 1000 rows share one column vec — no deep copies
588        let cols = Rc::new(vec![col("id", OID_INT4), col("name", OID_TEXT)]);
589        let rows: Vec<Row> = (0..1000)
590            .map(|i| {
591                let val = i.to_string();
592                // NOTE: raw bytes live in their own allocation per row, which is expected
593                Row::new(Rc::clone(&cols), vec![Some(val.as_bytes()), Some(b"x")])
594            })
595            .collect();
596
597        // Only 1 + 1000 strong refs, NOT 1001 deep copies of the column vec
598        assert_eq!(Rc::strong_count(&cols), 1001);
599
600        drop(rows);
601        assert_eq!(Rc::strong_count(&cols), 1);
602    }
603
604    // ─── Multiple columns edge cases ─────────────────────────────────────────
605
606    #[test]
607    fn test_get_mixed_null_and_non_null() {
608        let row = make_row(
609            &[("a", OID_TEXT), ("b", OID_INT4), ("c", OID_TEXT)],
610            &[Some(b"hello"), None, Some(b"world")],
611        );
612        assert!(matches!(row.get(0).unwrap(), PgValue::Text(_)));
613        assert!(matches!(row.get(1).unwrap(), PgValue::Null));
614        assert!(matches!(row.get(2).unwrap(), PgValue::Text(_)));
615    }
616
617    #[test]
618    fn test_get_first_and_last_column() {
619        let row = make_row(
620            &[
621                ("first", OID_INT4),
622                ("middle", OID_TEXT),
623                ("last", OID_BOOL),
624            ],
625            &[Some(b"1"), Some(b"mid"), Some(b"t")],
626        );
627        assert!(matches!(row.get(0).unwrap(), PgValue::Int4(1)));
628        assert!(matches!(row.get(2).unwrap(), PgValue::Bool(true)));
629    }
630}