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/// A view over a [`Row`] that supports name-based access via a
29/// pre-resolved column-name → index lookup table.
30///
31/// `RowAccessor` is the parameter type of [`crate::FromRow::from_row`]; it
32/// borrows the row and a shared lookup map built once per query in
33/// [`fetch_one_as`](crate::Connection::fetch_one_as) /
34/// [`fetch_all_as`](crate::Connection::fetch_all_as).
35///
36/// # Example
37///
38/// ```no_run
39/// use hyperdb_api::{FromRow, RowAccessor, Result};
40///
41/// struct User { id: i32, name: String, email: Option<String> }
42///
43/// impl FromRow for User {
44///     fn from_row(row: RowAccessor<'_>) -> Result<Self> {
45///         Ok(User {
46///             id: row.get("id")?,
47///             name: row.get("name")?,
48///             email: row.get_opt("email")?,
49///         })
50///     }
51/// }
52/// ```
53#[derive(Debug)]
54pub struct RowAccessor<'a> {
55    row: &'a Row,
56    indices: &'a HashMap<&'a str, usize>,
57}
58
59impl<'a> RowAccessor<'a> {
60    /// Constructs a new `RowAccessor` over the given row and pre-built
61    /// lookup map. Crate-internal: callers go through `fetch_*_as` to
62    /// get a `RowAccessor`, never construct one directly.
63    pub(crate) fn new(row: &'a Row, indices: &'a HashMap<&'a str, usize>) -> Self {
64        Self { row, indices }
65    }
66
67    /// Builds a `name → index` lookup table from a [`ResultSchema`].
68    ///
69    /// Used by `fetch_*_as` to resolve names once per query before
70    /// iterating rows. Consumes O(N) time and allocates one entry per
71    /// column.
72    ///
73    /// [`ResultSchema`]: crate::ResultSchema
74    pub(crate) fn build_indices(schema: &'a crate::ResultSchema) -> HashMap<&'a str, usize> {
75        let mut map = HashMap::with_capacity(schema.column_count());
76        for i in 0..schema.column_count() {
77            map.insert(schema.column(i).name(), i);
78        }
79        map
80    }
81
82    /// Returns the named column's value, decoded as `T`.
83    ///
84    /// # Errors
85    ///
86    /// - [`Error::Column`] with [`ColumnErrorKind::Missing`] if `name`
87    ///   is not in the result schema.
88    /// - [`Error::Column`] with [`ColumnErrorKind::Null`] if the cell
89    ///   is SQL `NULL`.
90    /// - [`Error::Column`] with [`ColumnErrorKind::TypeMismatch`] if
91    ///   the cell value cannot be decoded as `T`.
92    pub fn get<T: RowValue>(&self, name: &str) -> Result<T> {
93        let idx = self
94            .indices
95            .get(name)
96            .copied()
97            .ok_or_else(|| Error::column(name, ColumnErrorKind::Missing))?;
98        match self.row.get::<T>(idx) {
99            Some(v) => Ok(v),
100            None => {
101                // Disambiguate NULL from type-mismatch by re-checking
102                // the underlying cell. `row.is_null(idx)` is the source
103                // of truth for SQL NULL.
104                if self.row.is_null(idx) {
105                    Err(Error::column(name, ColumnErrorKind::Null))
106                } else {
107                    let actual = self
108                        .row
109                        .sql_type(idx)
110                        .map_or_else(|| "<unknown>".to_string(), |t| format!("{t:?}"));
111                    Err(Error::column(
112                        name,
113                        ColumnErrorKind::TypeMismatch {
114                            expected: std::any::type_name::<T>().to_string(),
115                            actual,
116                        },
117                    ))
118                }
119            }
120        }
121    }
122
123    /// Returns the named column's value as `Option<T>`. SQL `NULL`
124    /// becomes `None`; missing columns and type mismatches still error.
125    ///
126    /// # Errors
127    ///
128    /// - [`Error::Column`] with [`ColumnErrorKind::Missing`] if `name`
129    ///   is not in the result schema.
130    /// - [`Error::Column`] with [`ColumnErrorKind::TypeMismatch`] if
131    ///   the cell is non-NULL but cannot be decoded as `T`.
132    pub fn get_opt<T: RowValue>(&self, name: &str) -> Result<Option<T>> {
133        let idx = self
134            .indices
135            .get(name)
136            .copied()
137            .ok_or_else(|| Error::column(name, ColumnErrorKind::Missing))?;
138        if self.row.is_null(idx) {
139            return Ok(None);
140        }
141        if let Some(v) = self.row.get::<T>(idx) {
142            Ok(Some(v))
143        } else {
144            let actual = self
145                .row
146                .sql_type(idx)
147                .map_or_else(|| "<unknown>".to_string(), |t| format!("{t:?}"));
148            Err(Error::column(
149                name,
150                ColumnErrorKind::TypeMismatch {
151                    expected: std::any::type_name::<T>().to_string(),
152                    actual,
153                },
154            ))
155        }
156    }
157
158    /// Positional escape hatch: returns the value at column `idx`,
159    /// decoded as `T`.
160    ///
161    /// # Errors
162    ///
163    /// - [`Error::ColumnIndexOutOfBounds`] if `idx` is past the row's
164    ///   column count.
165    /// - [`Error::Column`] with [`ColumnErrorKind::Null`] if the cell
166    ///   is SQL `NULL`. The synthesized name is `col[{idx}]`.
167    /// - [`Error::Column`] with [`ColumnErrorKind::TypeMismatch`] if
168    ///   the cell value cannot be decoded as `T`. Same synthesized name.
169    pub fn position<T: RowValue>(&self, idx: usize) -> Result<T> {
170        if idx >= self.row.column_count() {
171            return Err(Error::column_index_out_of_bounds(
172                idx,
173                self.row.column_count(),
174            ));
175        }
176        // Mirror the `get`/`get_opt` error shape so callers can match
177        // on `Error::Column { kind, .. }` uniformly across named and
178        // positional access. Synthesize a name for the error label.
179        if let Some(v) = self.row.get::<T>(idx) {
180            Ok(v)
181        } else if self.row.is_null(idx) {
182            Err(Error::column(format!("col[{idx}]"), ColumnErrorKind::Null))
183        } else {
184            let actual = self
185                .row
186                .sql_type(idx)
187                .map_or_else(|| "<unknown>".to_string(), |t| format!("{t:?}"));
188            Err(Error::column(
189                format!("col[{idx}]"),
190                ColumnErrorKind::TypeMismatch {
191                    expected: std::any::type_name::<T>().to_string(),
192                    actual,
193                },
194            ))
195        }
196    }
197
198    /// Positional optional access: returns `Option<T>` for the cell at
199    /// `idx`. SQL `NULL` becomes `None`; out-of-bounds and type
200    /// mismatches still error. Mirrors [`get_opt`](Self::get_opt) for
201    /// positional access.
202    ///
203    /// # Errors
204    ///
205    /// - [`Error::ColumnIndexOutOfBounds`] if `idx` is past the row's
206    ///   column count.
207    /// - [`Error::Column`] with [`ColumnErrorKind::TypeMismatch`] if
208    ///   the cell is non-NULL but cannot be decoded as `T`. The
209    ///   synthesized name is `col[{idx}]`.
210    pub fn position_opt<T: RowValue>(&self, idx: usize) -> Result<Option<T>> {
211        if idx >= self.row.column_count() {
212            return Err(Error::column_index_out_of_bounds(
213                idx,
214                self.row.column_count(),
215            ));
216        }
217        if self.row.is_null(idx) {
218            return Ok(None);
219        }
220        if let Some(v) = self.row.get::<T>(idx) {
221            Ok(Some(v))
222        } else {
223            let actual = self
224                .row
225                .sql_type(idx)
226                .map_or_else(|| "<unknown>".to_string(), |t| format!("{t:?}"));
227            Err(Error::column(
228                format!("col[{idx}]"),
229                ColumnErrorKind::TypeMismatch {
230                    expected: std::any::type_name::<T>().to_string(),
231                    actual,
232                },
233            ))
234        }
235    }
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use crate::result::{ResultColumn, ResultSchema};
242    use arrow::array::{Int32Array, StringArray};
243    use arrow::datatypes::{DataType as ArrowType, Field, Schema};
244    use arrow::record_batch::RecordBatch;
245    use hyperdb_api_core::types::SqlType;
246    use std::sync::Arc;
247
248    /// Build a single-row `(id INT, name TEXT)` Arrow batch + matching
249    /// `ResultSchema` for use in `RowAccessor` unit tests.
250    fn user_row(id: Option<i32>, name: Option<&str>) -> (Row, Arc<ResultSchema>) {
251        let id_array = Int32Array::from(vec![id]);
252        let name_array = StringArray::from(vec![name]);
253        let arrow_schema = Arc::new(Schema::new(vec![
254            Field::new("id", ArrowType::Int32, true),
255            Field::new("name", ArrowType::Utf8, true),
256        ]));
257        let batch = Arc::new(
258            RecordBatch::try_new(arrow_schema, vec![Arc::new(id_array), Arc::new(name_array)])
259                .expect("batch"),
260        );
261        let schema = Arc::new(ResultSchema::from_columns(vec![
262            ResultColumn::new("id", SqlType::int(), 0),
263            ResultColumn::new("name", SqlType::text(), 1),
264        ]));
265        let row = Row::from_arrow(batch, 0, Some(Arc::clone(&schema)));
266        (row, schema)
267    }
268
269    #[test]
270    fn missing_column_errors_with_kind_missing() {
271        let (row, schema) = user_row(Some(1), Some("alice"));
272        let indices = RowAccessor::build_indices(&schema);
273        let accessor = RowAccessor::new(&row, &indices);
274
275        let err = accessor.get::<i32>("does_not_exist").unwrap_err();
276        match err {
277            Error::Column { name, kind } => {
278                assert_eq!(name, "does_not_exist");
279                assert!(matches!(kind, ColumnErrorKind::Missing));
280            }
281            other => panic!("expected Error::Column {{ kind: Missing }}, got {other:?}"),
282        }
283    }
284
285    #[test]
286    fn null_in_required_column_errors_with_kind_null() {
287        let (row, schema) = user_row(Some(1), None);
288        let indices = RowAccessor::build_indices(&schema);
289        let accessor = RowAccessor::new(&row, &indices);
290
291        let err = accessor.get::<String>("name").unwrap_err();
292        match err {
293            Error::Column { name, kind } => {
294                assert_eq!(name, "name");
295                assert!(matches!(kind, ColumnErrorKind::Null));
296            }
297            other => panic!("expected Error::Column {{ kind: Null }}, got {other:?}"),
298        }
299    }
300
301    #[test]
302    fn null_in_optional_column_returns_none() {
303        let (row, schema) = user_row(Some(1), None);
304        let indices = RowAccessor::build_indices(&schema);
305        let accessor = RowAccessor::new(&row, &indices);
306
307        let v: Option<String> = accessor.get_opt("name").expect("get_opt for NULL");
308        assert_eq!(v, None);
309    }
310
311    #[test]
312    fn happy_path_get_returns_value() {
313        let (row, schema) = user_row(Some(42), Some("alice"));
314        let indices = RowAccessor::build_indices(&schema);
315        let accessor = RowAccessor::new(&row, &indices);
316
317        let id: i32 = accessor.get("id").expect("get id");
318        let name: String = accessor.get("name").expect("get name");
319        assert_eq!(id, 42);
320        assert_eq!(name, "alice");
321    }
322
323    #[test]
324    fn happy_path_get_opt_returns_some() {
325        let (row, schema) = user_row(Some(42), Some("alice"));
326        let indices = RowAccessor::build_indices(&schema);
327        let accessor = RowAccessor::new(&row, &indices);
328
329        let id: Option<i32> = accessor.get_opt("id").expect("get_opt id");
330        let name: Option<String> = accessor.get_opt("name").expect("get_opt name");
331        assert_eq!(id, Some(42));
332        assert_eq!(name, Some("alice".to_string()));
333    }
334
335    #[test]
336    fn position_out_of_range_errors_with_index_oob() {
337        let (row, schema) = user_row(Some(1), Some("alice"));
338        let indices = RowAccessor::build_indices(&schema);
339        let accessor = RowAccessor::new(&row, &indices);
340
341        // Row has 2 columns; position 5 is out of range.
342        let err = accessor.position::<i32>(5).unwrap_err();
343        match err {
344            Error::ColumnIndexOutOfBounds { idx, column_count } => {
345                assert_eq!(idx, 5);
346                assert_eq!(column_count, 2);
347            }
348            other => panic!("expected Error::ColumnIndexOutOfBounds, got {other:?}"),
349        }
350    }
351
352    #[test]
353    fn position_in_range_returns_value() {
354        let (row, schema) = user_row(Some(42), Some("alice"));
355        let indices = RowAccessor::build_indices(&schema);
356        let accessor = RowAccessor::new(&row, &indices);
357
358        let id: i32 = accessor.position(0).expect("position 0");
359        assert_eq!(id, 42);
360    }
361
362    #[test]
363    fn position_opt_null_returns_none() {
364        let (row, schema) = user_row(Some(1), None);
365        let indices = RowAccessor::build_indices(&schema);
366        let accessor = RowAccessor::new(&row, &indices);
367
368        let v: Option<String> = accessor.position_opt(1).expect("position_opt for NULL");
369        assert_eq!(v, None);
370    }
371
372    #[test]
373    fn position_opt_value_returns_some() {
374        let (row, schema) = user_row(Some(42), Some("alice"));
375        let indices = RowAccessor::build_indices(&schema);
376        let accessor = RowAccessor::new(&row, &indices);
377
378        let id: Option<i32> = accessor.position_opt(0).expect("position_opt id");
379        let name: Option<String> = accessor.position_opt(1).expect("position_opt name");
380        assert_eq!(id, Some(42));
381        assert_eq!(name, Some("alice".to_string()));
382    }
383
384    #[test]
385    fn position_opt_out_of_range_errors_with_index_oob() {
386        let (row, schema) = user_row(Some(1), Some("alice"));
387        let indices = RowAccessor::build_indices(&schema);
388        let accessor = RowAccessor::new(&row, &indices);
389
390        let err = accessor.position_opt::<i32>(5).unwrap_err();
391        match err {
392            Error::ColumnIndexOutOfBounds { idx, column_count } => {
393                assert_eq!(idx, 5);
394                assert_eq!(column_count, 2);
395            }
396            other => panic!("expected Error::ColumnIndexOutOfBounds, got {other:?}"),
397        }
398    }
399
400    #[test]
401    fn position_null_errors_with_kind_null() {
402        // NULL at a positional access path should surface as
403        // Error::Column { kind: Null }, mirroring the named `get`
404        // error shape rather than the older Error::Conversion form.
405        let (row, schema) = user_row(Some(1), None);
406        let indices = RowAccessor::build_indices(&schema);
407        let accessor = RowAccessor::new(&row, &indices);
408
409        // Column 1 is `name`, which is NULL.
410        let err = accessor.position::<String>(1).unwrap_err();
411        match err {
412            Error::Column { name, kind } => {
413                assert_eq!(name, "col[1]");
414                assert!(matches!(kind, ColumnErrorKind::Null));
415            }
416            other => panic!("expected Error::Column {{ kind: Null }}, got {other:?}"),
417        }
418    }
419}