Skip to main content

hyperdb_api/
row_accessor.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! [`RowAccessor`] — name-based column access for [`FromRow`] impls
5//! with cached column-name → index resolution.
6//!
7//! When a typed query is consumed via [`Connection::fetch_one_as`] /
8//! [`Connection::fetch_all_as`] (and the async equivalents), the engine
9//! resolves each column name to its position in the result schema
10//! **once per query** and hands a `RowAccessor` to every `FromRow` impl.
11//! Inside a `FromRow` impl, calling `accessor.get("col")` is a single
12//! `HashMap` lookup followed by typed access — no per-call linear scan
13//! over the schema.
14//!
15//! For one-off named access on a [`Row`] (outside `fetch_*_as`), use
16//! [`Row::get_by_name`] instead — it does a linear scan but doesn't
17//! require building a cache.
18//!
19//! [`Connection::fetch_one_as`]: crate::Connection::fetch_one_as
20//! [`Connection::fetch_all_as`]: crate::Connection::fetch_all_as
21//! [`Row::get_by_name`]: crate::Row::get_by_name
22
23use std::collections::HashMap;
24
25use crate::error::{ColumnErrorKind, Error, Result};
26use crate::result::{Row, RowValue};
27
28/// Storage for the column-name → index lookup behind a [`RowAccessor`].
29///
30/// `fetch_*_as` builds a zero-alloc `&str`-keyed map borrowing from the
31/// `ResultSchema` (the `Borrowed` variant). The streaming path
32/// (`Connection::stream_as`) needs to own its map across iterator steps,
33/// which a `&str`-keyed map can't do (its keys borrow the schema), so it
34/// uses an owned `String`-keyed map (the `Owned` variant). Both look up by
35/// `&str` via `HashMap`'s `Borrow` bound, so the getters are agnostic.
36#[derive(Debug)]
37enum Indices<'a> {
38    Borrowed(&'a HashMap<&'a str, usize>),
39    Owned(&'a HashMap<String, usize>),
40}
41
42impl Indices<'_> {
43    /// Resolves a column name to its index, agnostic to key ownership.
44    fn get(&self, name: &str) -> Option<usize> {
45        match self {
46            Indices::Borrowed(m) => m.get(name).copied(),
47            Indices::Owned(m) => m.get(name).copied(),
48        }
49    }
50}
51
52/// A view over a [`Row`] that supports name-based access via a
53/// pre-resolved column-name → index lookup table.
54///
55/// `RowAccessor` is the parameter type of [`crate::FromRow::from_row`]; it
56/// borrows the row and a shared lookup map built once per query in
57/// [`fetch_one_as`](crate::Connection::fetch_one_as) /
58/// [`fetch_all_as`](crate::Connection::fetch_all_as).
59///
60/// # Example
61///
62/// ```no_run
63/// use hyperdb_api::{FromRow, RowAccessor, Result};
64///
65/// struct User { id: i32, name: String, email: Option<String> }
66///
67/// impl FromRow for User {
68///     fn from_row(row: RowAccessor<'_>) -> Result<Self> {
69///         Ok(User {
70///             id: row.get("id")?,
71///             name: row.get("name")?,
72///             email: row.get_opt("email")?,
73///         })
74///     }
75/// }
76/// ```
77#[derive(Debug)]
78pub struct RowAccessor<'a> {
79    row: &'a Row,
80    indices: Indices<'a>,
81}
82
83impl<'a> RowAccessor<'a> {
84    /// Constructs a new `RowAccessor` over the given row and pre-built
85    /// lookup map. Crate-internal: callers go through `fetch_*_as` to
86    /// get a `RowAccessor`, never construct one directly.
87    pub(crate) fn new(row: &'a Row, indices: &'a HashMap<&'a str, usize>) -> Self {
88        Self {
89            row,
90            indices: Indices::Borrowed(indices),
91        }
92    }
93
94    /// Constructs a new `RowAccessor` over the given row and an owned
95    /// lookup map. Crate-internal: used by `stream_as` where the map must
96    /// persist across iterator steps.
97    pub(crate) fn new_owned(row: &'a Row, indices: &'a HashMap<String, usize>) -> Self {
98        Self {
99            row,
100            indices: Indices::Owned(indices),
101        }
102    }
103
104    /// Builds a `name → index` lookup table from a [`ResultSchema`].
105    ///
106    /// Used by `fetch_*_as` to resolve names once per query before
107    /// iterating rows. Consumes O(N) time and allocates one entry per
108    /// column.
109    ///
110    /// [`ResultSchema`]: crate::ResultSchema
111    pub(crate) fn build_indices(schema: &'a crate::ResultSchema) -> HashMap<&'a str, usize> {
112        let mut map = HashMap::with_capacity(schema.column_count());
113        for i in 0..schema.column_count() {
114            map.insert(schema.column(i).name(), i);
115        }
116        map
117    }
118
119    /// Builds an owned `name → index` lookup table from a [`ResultSchema`].
120    ///
121    /// Used by `stream_as` where the map must persist across iterator
122    /// steps, requiring owned keys. Consumes O(N) time and allocates one
123    /// entry per column plus string copies.
124    ///
125    /// [`ResultSchema`]: crate::ResultSchema
126    pub(crate) fn build_owned_indices(schema: &crate::ResultSchema) -> HashMap<String, usize> {
127        let mut map = HashMap::with_capacity(schema.column_count());
128        for i in 0..schema.column_count() {
129            map.insert(schema.column(i).name().to_string(), i);
130        }
131        map
132    }
133
134    /// Returns the named column's value, decoded as `T`.
135    ///
136    /// # Errors
137    ///
138    /// - [`Error::Column`] with [`ColumnErrorKind::Missing`] if `name`
139    ///   is not in the result schema.
140    /// - [`Error::Column`] with [`ColumnErrorKind::Null`] if the cell
141    ///   is SQL `NULL`.
142    /// - [`Error::Column`] with [`ColumnErrorKind::TypeMismatch`] if
143    ///   the cell value cannot be decoded as `T`.
144    pub fn get<T: RowValue>(&self, name: &str) -> Result<T> {
145        let idx = self
146            .indices
147            .get(name)
148            .ok_or_else(|| Error::column(name, ColumnErrorKind::Missing))?;
149        match self.row.get::<T>(idx) {
150            Some(v) => Ok(v),
151            None => {
152                // Disambiguate NULL from type-mismatch by re-checking
153                // the underlying cell. `row.is_null(idx)` is the source
154                // of truth for SQL NULL.
155                if self.row.is_null(idx) {
156                    Err(Error::column(name, ColumnErrorKind::Null))
157                } else {
158                    let actual = self
159                        .row
160                        .sql_type(idx)
161                        .map_or_else(|| "<unknown>".to_string(), |t| format!("{t:?}"));
162                    Err(Error::column(
163                        name,
164                        ColumnErrorKind::TypeMismatch {
165                            expected: std::any::type_name::<T>().to_string(),
166                            actual,
167                        },
168                    ))
169                }
170            }
171        }
172    }
173
174    /// Returns the named column's value as `Option<T>`. SQL `NULL`
175    /// becomes `None`; missing columns and type mismatches still error.
176    ///
177    /// # Errors
178    ///
179    /// - [`Error::Column`] with [`ColumnErrorKind::Missing`] if `name`
180    ///   is not in the result schema.
181    /// - [`Error::Column`] with [`ColumnErrorKind::TypeMismatch`] if
182    ///   the cell is non-NULL but cannot be decoded as `T`.
183    pub fn get_opt<T: RowValue>(&self, name: &str) -> Result<Option<T>> {
184        let idx = self
185            .indices
186            .get(name)
187            .ok_or_else(|| Error::column(name, ColumnErrorKind::Missing))?;
188        if self.row.is_null(idx) {
189            return Ok(None);
190        }
191        if let Some(v) = self.row.get::<T>(idx) {
192            Ok(Some(v))
193        } else {
194            let actual = self
195                .row
196                .sql_type(idx)
197                .map_or_else(|| "<unknown>".to_string(), |t| format!("{t:?}"));
198            Err(Error::column(
199                name,
200                ColumnErrorKind::TypeMismatch {
201                    expected: std::any::type_name::<T>().to_string(),
202                    actual,
203                },
204            ))
205        }
206    }
207
208    /// Positional escape hatch: returns the value at column `idx`,
209    /// decoded as `T`.
210    ///
211    /// # Errors
212    ///
213    /// - [`Error::ColumnIndexOutOfBounds`] if `idx` is past the row's
214    ///   column count.
215    /// - [`Error::Column`] with [`ColumnErrorKind::Null`] if the cell
216    ///   is SQL `NULL`. The synthesized name is `col[{idx}]`.
217    /// - [`Error::Column`] with [`ColumnErrorKind::TypeMismatch`] if
218    ///   the cell value cannot be decoded as `T`. Same synthesized name.
219    pub fn position<T: RowValue>(&self, idx: usize) -> Result<T> {
220        if idx >= self.row.column_count() {
221            return Err(Error::column_index_out_of_bounds(
222                idx,
223                self.row.column_count(),
224            ));
225        }
226        // Mirror the `get`/`get_opt` error shape so callers can match
227        // on `Error::Column { kind, .. }` uniformly across named and
228        // positional access. Synthesize a name for the error label.
229        if let Some(v) = self.row.get::<T>(idx) {
230            Ok(v)
231        } else if self.row.is_null(idx) {
232            Err(Error::column(format!("col[{idx}]"), ColumnErrorKind::Null))
233        } else {
234            let actual = self
235                .row
236                .sql_type(idx)
237                .map_or_else(|| "<unknown>".to_string(), |t| format!("{t:?}"));
238            Err(Error::column(
239                format!("col[{idx}]"),
240                ColumnErrorKind::TypeMismatch {
241                    expected: std::any::type_name::<T>().to_string(),
242                    actual,
243                },
244            ))
245        }
246    }
247
248    /// Positional optional access: returns `Option<T>` for the cell at
249    /// `idx`. SQL `NULL` becomes `None`; out-of-bounds and type
250    /// mismatches still error. Mirrors [`get_opt`](Self::get_opt) for
251    /// positional access.
252    ///
253    /// # Errors
254    ///
255    /// - [`Error::ColumnIndexOutOfBounds`] if `idx` is past the row's
256    ///   column count.
257    /// - [`Error::Column`] with [`ColumnErrorKind::TypeMismatch`] if
258    ///   the cell is non-NULL but cannot be decoded as `T`. The
259    ///   synthesized name is `col[{idx}]`.
260    pub fn position_opt<T: RowValue>(&self, idx: usize) -> Result<Option<T>> {
261        if idx >= self.row.column_count() {
262            return Err(Error::column_index_out_of_bounds(
263                idx,
264                self.row.column_count(),
265            ));
266        }
267        if self.row.is_null(idx) {
268            return Ok(None);
269        }
270        if let Some(v) = self.row.get::<T>(idx) {
271            Ok(Some(v))
272        } else {
273            let actual = self
274                .row
275                .sql_type(idx)
276                .map_or_else(|| "<unknown>".to_string(), |t| format!("{t:?}"));
277            Err(Error::column(
278                format!("col[{idx}]"),
279                ColumnErrorKind::TypeMismatch {
280                    expected: std::any::type_name::<T>().to_string(),
281                    actual,
282                },
283            ))
284        }
285    }
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291    use crate::result::{ResultColumn, ResultSchema};
292    use arrow::array::{Int32Array, StringArray};
293    use arrow::datatypes::{DataType as ArrowType, Field, Schema};
294    use arrow::record_batch::RecordBatch;
295    use hyperdb_api_core::types::SqlType;
296    use std::sync::Arc;
297
298    /// Build a single-row `(id INT, name TEXT)` Arrow batch + matching
299    /// `ResultSchema` for use in `RowAccessor` unit tests.
300    fn user_row(id: Option<i32>, name: Option<&str>) -> (Row, Arc<ResultSchema>) {
301        let id_array = Int32Array::from(vec![id]);
302        let name_array = StringArray::from(vec![name]);
303        let arrow_schema = Arc::new(Schema::new(vec![
304            Field::new("id", ArrowType::Int32, true),
305            Field::new("name", ArrowType::Utf8, true),
306        ]));
307        let batch = Arc::new(
308            RecordBatch::try_new(arrow_schema, vec![Arc::new(id_array), Arc::new(name_array)])
309                .expect("batch"),
310        );
311        let schema = Arc::new(ResultSchema::from_columns(vec![
312            ResultColumn::new("id", SqlType::int(), 0),
313            ResultColumn::new("name", SqlType::text(), 1),
314        ]));
315        let row = Row::from_arrow(batch, 0, Some(Arc::clone(&schema)));
316        (row, schema)
317    }
318
319    #[test]
320    fn missing_column_errors_with_kind_missing() {
321        let (row, schema) = user_row(Some(1), Some("alice"));
322        let indices = RowAccessor::build_indices(&schema);
323        let accessor = RowAccessor::new(&row, &indices);
324
325        let err = accessor.get::<i32>("does_not_exist").unwrap_err();
326        match err {
327            Error::Column { name, kind } => {
328                assert_eq!(name, "does_not_exist");
329                assert!(matches!(kind, ColumnErrorKind::Missing));
330            }
331            other => panic!("expected Error::Column {{ kind: Missing }}, got {other:?}"),
332        }
333    }
334
335    #[test]
336    fn null_in_required_column_errors_with_kind_null() {
337        let (row, schema) = user_row(Some(1), None);
338        let indices = RowAccessor::build_indices(&schema);
339        let accessor = RowAccessor::new(&row, &indices);
340
341        let err = accessor.get::<String>("name").unwrap_err();
342        match err {
343            Error::Column { name, kind } => {
344                assert_eq!(name, "name");
345                assert!(matches!(kind, ColumnErrorKind::Null));
346            }
347            other => panic!("expected Error::Column {{ kind: Null }}, got {other:?}"),
348        }
349    }
350
351    #[test]
352    fn null_in_optional_column_returns_none() {
353        let (row, schema) = user_row(Some(1), None);
354        let indices = RowAccessor::build_indices(&schema);
355        let accessor = RowAccessor::new(&row, &indices);
356
357        let v: Option<String> = accessor.get_opt("name").expect("get_opt for NULL");
358        assert_eq!(v, None);
359    }
360
361    #[test]
362    fn happy_path_get_returns_value() {
363        let (row, schema) = user_row(Some(42), Some("alice"));
364        let indices = RowAccessor::build_indices(&schema);
365        let accessor = RowAccessor::new(&row, &indices);
366
367        let id: i32 = accessor.get("id").expect("get id");
368        let name: String = accessor.get("name").expect("get name");
369        assert_eq!(id, 42);
370        assert_eq!(name, "alice");
371    }
372
373    #[test]
374    fn happy_path_get_opt_returns_some() {
375        let (row, schema) = user_row(Some(42), Some("alice"));
376        let indices = RowAccessor::build_indices(&schema);
377        let accessor = RowAccessor::new(&row, &indices);
378
379        let id: Option<i32> = accessor.get_opt("id").expect("get_opt id");
380        let name: Option<String> = accessor.get_opt("name").expect("get_opt name");
381        assert_eq!(id, Some(42));
382        assert_eq!(name, Some("alice".to_string()));
383    }
384
385    #[test]
386    fn position_out_of_range_errors_with_index_oob() {
387        let (row, schema) = user_row(Some(1), Some("alice"));
388        let indices = RowAccessor::build_indices(&schema);
389        let accessor = RowAccessor::new(&row, &indices);
390
391        // Row has 2 columns; position 5 is out of range.
392        let err = accessor.position::<i32>(5).unwrap_err();
393        match err {
394            Error::ColumnIndexOutOfBounds { idx, column_count } => {
395                assert_eq!(idx, 5);
396                assert_eq!(column_count, 2);
397            }
398            other => panic!("expected Error::ColumnIndexOutOfBounds, got {other:?}"),
399        }
400    }
401
402    #[test]
403    fn position_in_range_returns_value() {
404        let (row, schema) = user_row(Some(42), Some("alice"));
405        let indices = RowAccessor::build_indices(&schema);
406        let accessor = RowAccessor::new(&row, &indices);
407
408        let id: i32 = accessor.position(0).expect("position 0");
409        assert_eq!(id, 42);
410    }
411
412    #[test]
413    fn position_opt_null_returns_none() {
414        let (row, schema) = user_row(Some(1), None);
415        let indices = RowAccessor::build_indices(&schema);
416        let accessor = RowAccessor::new(&row, &indices);
417
418        let v: Option<String> = accessor.position_opt(1).expect("position_opt for NULL");
419        assert_eq!(v, None);
420    }
421
422    #[test]
423    fn position_opt_value_returns_some() {
424        let (row, schema) = user_row(Some(42), Some("alice"));
425        let indices = RowAccessor::build_indices(&schema);
426        let accessor = RowAccessor::new(&row, &indices);
427
428        let id: Option<i32> = accessor.position_opt(0).expect("position_opt id");
429        let name: Option<String> = accessor.position_opt(1).expect("position_opt name");
430        assert_eq!(id, Some(42));
431        assert_eq!(name, Some("alice".to_string()));
432    }
433
434    #[test]
435    fn position_opt_out_of_range_errors_with_index_oob() {
436        let (row, schema) = user_row(Some(1), Some("alice"));
437        let indices = RowAccessor::build_indices(&schema);
438        let accessor = RowAccessor::new(&row, &indices);
439
440        let err = accessor.position_opt::<i32>(5).unwrap_err();
441        match err {
442            Error::ColumnIndexOutOfBounds { idx, column_count } => {
443                assert_eq!(idx, 5);
444                assert_eq!(column_count, 2);
445            }
446            other => panic!("expected Error::ColumnIndexOutOfBounds, got {other:?}"),
447        }
448    }
449
450    #[test]
451    fn position_null_errors_with_kind_null() {
452        // NULL at a positional access path should surface as
453        // Error::Column { kind: Null }, mirroring the named `get`
454        // error shape rather than the older Error::Conversion form.
455        let (row, schema) = user_row(Some(1), None);
456        let indices = RowAccessor::build_indices(&schema);
457        let accessor = RowAccessor::new(&row, &indices);
458
459        // Column 1 is `name`, which is NULL.
460        let err = accessor.position::<String>(1).unwrap_err();
461        match err {
462            Error::Column { name, kind } => {
463                assert_eq!(name, "col[1]");
464                assert!(matches!(kind, ColumnErrorKind::Null));
465            }
466            other => panic!("expected Error::Column {{ kind: Null }}, got {other:?}"),
467        }
468    }
469
470    // --- Owned-key variant tests ---
471
472    #[test]
473    fn owned_happy_path_get_returns_value() {
474        let (row, schema) = user_row(Some(42), Some("alice"));
475        let indices = RowAccessor::build_owned_indices(&schema);
476        let accessor = RowAccessor::new_owned(&row, &indices);
477
478        let id: i32 = accessor.get("id").expect("get id");
479        let name: String = accessor.get("name").expect("get name");
480        assert_eq!(id, 42);
481        assert_eq!(name, "alice");
482    }
483
484    #[test]
485    fn owned_missing_column_errors_with_kind_missing() {
486        let (row, schema) = user_row(Some(1), Some("alice"));
487        let indices = RowAccessor::build_owned_indices(&schema);
488        let accessor = RowAccessor::new_owned(&row, &indices);
489
490        let err = accessor.get::<i32>("does_not_exist").unwrap_err();
491        match err {
492            Error::Column { name, kind } => {
493                assert_eq!(name, "does_not_exist");
494                assert!(matches!(kind, ColumnErrorKind::Missing));
495            }
496            other => panic!("expected Error::Column {{ kind: Missing }}, got {other:?}"),
497        }
498    }
499
500    #[test]
501    fn owned_null_in_required_column_errors_with_kind_null() {
502        let (row, schema) = user_row(Some(1), None);
503        let indices = RowAccessor::build_owned_indices(&schema);
504        let accessor = RowAccessor::new_owned(&row, &indices);
505
506        let err = accessor.get::<String>("name").unwrap_err();
507        match err {
508            Error::Column { name, kind } => {
509                assert_eq!(name, "name");
510                assert!(matches!(kind, ColumnErrorKind::Null));
511            }
512            other => panic!("expected Error::Column {{ kind: Null }}, got {other:?}"),
513        }
514    }
515
516    #[test]
517    fn owned_null_in_optional_column_returns_none() {
518        let (row, schema) = user_row(Some(1), None);
519        let indices = RowAccessor::build_owned_indices(&schema);
520        let accessor = RowAccessor::new_owned(&row, &indices);
521
522        let v: Option<String> = accessor.get_opt("name").expect("get_opt for NULL");
523        assert_eq!(v, None);
524    }
525}