From 10bb2b73a6239936eff73ba19ea7e7f238a33168 Mon Sep 17 00:00:00 2001 From: Stefan Steiner Date: Tue, 16 Jun 2026 09:21:27 -0700 Subject: [PATCH 1/4] feat(api): add sync parameterized FromRow methods (#137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add fetch_one_as_params, fetch_all_as_params, and stream_as_params on Connection — the intersection of FromRow struct-mapping and ToSqlParam parameter binding. Each delegates to query_params (binary Parse/Bind/ Execute) then maps rows exactly as the existing *_as methods do; the prepared-statement guard rides along on the returned Rowset, so Drop ordering is preserved. Round-trip tests cover single/all/stream, multi-param binding, and the empty-result error path. --- hyperdb-api/src/connection.rs | 168 ++++++++++++++++++ hyperdb-api/tests/remaining_features_tests.rs | 124 +++++++++++++ 2 files changed, 292 insertions(+) diff --git a/hyperdb-api/src/connection.rs b/hyperdb-api/src/connection.rs index a3b41b8..124d51b 100644 --- a/hyperdb-api/src/connection.rs +++ b/hyperdb-api/src/connection.rs @@ -845,6 +845,174 @@ impl Connection { Ok(crate::result::TypedRowIterator::::new(rowset)) } + /// Fetches a single row from a **parameterized** query and maps it to a + /// struct using [`FromRow`](crate::FromRow). + /// + /// This is the parameterized counterpart to + /// [`fetch_one_as`](Self::fetch_one_as): it binds `$1`, `$2`, … placeholders + /// from `params` (via [`ToSqlParam`](crate::params::ToSqlParam), exactly as + /// [`query_params`](Self::query_params) does) and maps the first result row + /// into `T`. Use it when a parameterized `SELECT` should yield a typed + /// struct rather than a raw [`Row`](crate::Row). + /// + /// # Example + /// + /// ```no_run + /// # use hyperdb_api::{Connection, FromRow, RowAccessor, Result}; + /// # struct User { id: i32, name: String } + /// # impl FromRow for User { + /// # fn from_row(row: RowAccessor<'_>) -> Result { + /// # Ok(User { id: row.get("id")?, name: row.get("name")? }) + /// # } + /// # } + /// # fn example(conn: &Connection) -> Result<()> { + /// let user: User = conn.fetch_one_as_params( + /// "SELECT id, name FROM users WHERE id = $1", + /// &[&1i32], + /// )?; + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// - Returns [`Error::FeatureNotSupported`] if the connection is using gRPC + /// transport (prepared statements are TCP-only). + /// - Returns the error from [`query_params`](Self::query_params) if the + /// server rejects the statement at `Parse`, `Bind`, or `Execute` time, or + /// on transport-level I/O failures. + /// - Returns [`Error::Conversion`] with message `"Query returned no rows"` + /// if the query produced zero rows. + /// - Returns whatever [`FromRow::from_row`](crate::FromRow::from_row) + /// produces when the row cannot be mapped into `T`. + pub fn fetch_one_as_params( + &self, + query: &str, + params: &[&dyn crate::params::ToSqlParam], + ) -> Result { + let row = self.query_params(query, params)?.require_first_row()?; + let indices = row + .schema() + .map(crate::row_accessor::RowAccessor::build_indices) + .unwrap_or_default(); + T::from_row(crate::RowAccessor::new(&row, &indices)) + } + + /// Fetches all rows from a **parameterized** query and maps them to structs + /// using [`FromRow`](crate::FromRow). + /// + /// This is the parameterized counterpart to + /// [`fetch_all_as`](Self::fetch_all_as): it binds `$1`, `$2`, … placeholders + /// from `params` (see [`query_params`](Self::query_params)) and maps every + /// result row into `T`. + /// + /// # Example + /// + /// ```no_run + /// # use hyperdb_api::{Connection, FromRow, RowAccessor, Result}; + /// # struct User { id: i32, name: String } + /// # impl FromRow for User { + /// # fn from_row(row: RowAccessor<'_>) -> Result { + /// # Ok(User { id: row.get("id")?, name: row.get("name")? }) + /// # } + /// # } + /// # fn example(conn: &Connection) -> Result<()> { + /// let users: Vec = conn.fetch_all_as_params( + /// "SELECT id, name FROM users WHERE org_id = $1", + /// &[&42i32], + /// )?; + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// - Returns [`Error::FeatureNotSupported`] if the connection is using gRPC + /// transport. + /// - Returns the error from [`query_params`](Self::query_params) if the + /// server rejects the statement, or on transport-level I/O failures. + /// - Returns the first error produced by + /// [`FromRow::from_row`](crate::FromRow::from_row) on any of the rows. + pub fn fetch_all_as_params( + &self, + query: &str, + params: &[&dyn crate::params::ToSqlParam], + ) -> Result> { + let rows = self.query_params(query, params)?.collect_rows()?; + // Build the column-name → index lookup once from the first row's + // schema; reuse for every row. See `fetch_all_as`. + let indices = rows + .first() + .and_then(crate::result::Row::schema) + .map(crate::row_accessor::RowAccessor::build_indices) + .unwrap_or_default(); + rows.iter() + .map(|r| T::from_row(crate::RowAccessor::new(r, &indices))) + .collect() + } + + /// Returns a lazy iterator over the rows of a **parameterized** query, + /// mapping each to `T` via [`FromRow`]. + /// + /// This is the parameterized counterpart to + /// [`stream_as`](Self::stream_as): it binds `$1`, `$2`, … placeholders from + /// `params` (see [`query_params`](Self::query_params)) and streams the + /// result, mapping each row into `T` while holding only one transport chunk + /// in memory at a time. The column-index map is built once on the first + /// chunk and reused, so per-row mapping is O(1) in the column count. + /// + /// # Example + /// + /// ```no_run + /// # use hyperdb_api::{Connection, FromRow, RowAccessor, Result}; + /// # struct User { id: i32, name: String } + /// # impl FromRow for User { + /// # fn from_row(row: RowAccessor<'_>) -> Result { + /// # Ok(User { id: row.get("id")?, name: row.get("name")? }) + /// # } + /// # } + /// # fn example(conn: &Connection) -> Result<()> { + /// for row_result in conn.stream_as_params::( + /// "SELECT id, name FROM users WHERE org_id = $1", + /// &[&42i32], + /// )? { + /// let user = row_result?; + /// println!("{}: {}", user.id, user.name); + /// } + /// # Ok(()) + /// # } + /// ``` + /// + /// # Errors + /// + /// - The returned outer `Result` wraps errors detected while *opening* the + /// stream: [`Error::FeatureNotSupported`] on gRPC transport, and any + /// `Parse`/`Bind` rejection or transport failure surfaced by + /// [`query_params`](Self::query_params). + /// - Each yielded item is itself a `Result`: `Ok(T)` when the row mapped + /// cleanly, or `Err(e)` for a per-row mapping failure (missing column, + /// type mismatch, NULL in a non-optional field) or a server/transport + /// error hit while streaming a later chunk. + /// + /// As with [`stream_as`](Self::stream_as), always handle errors *both* on + /// the outer `Result` and on each item. + /// + /// [`FromRow`]: crate::FromRow + pub fn stream_as_params<'a, T>( + &'a self, + query: &str, + params: &[&dyn crate::params::ToSqlParam], + ) -> Result> + 'a> + where + T: crate::FromRow + 'a, + { + // `query_params` returns a Rowset that already carries the prepared + // statement guard, so Drop ordering (close_statement after the rowset + // releases its connection lock) is preserved with no extra work here. + let rowset = self.query_params(query, params)?; + Ok(crate::result::TypedRowIterator::::new(rowset)) + } + /// Fetches a single scalar value from a query. /// /// Returns an error if the query returns no rows or NULL. diff --git a/hyperdb-api/tests/remaining_features_tests.rs b/hyperdb-api/tests/remaining_features_tests.rs index 1e549d8..4cc1eb2 100644 --- a/hyperdb-api/tests/remaining_features_tests.rs +++ b/hyperdb-api/tests/remaining_features_tests.rs @@ -676,3 +676,127 @@ fn test_ping_after_operations() { // Ping should still work after operations test.connection.ping().expect("ping after operations"); } + +// ============================================================================= +// #137: Parameterized FromRow mapping +// (fetch_one_as_params / fetch_all_as_params / stream_as_params) +// +// Round-trips a parameterized SELECT — `$1` bound via ToSqlParam — through +// FromRow into a #[derive(FromRow)] struct, the intersection #137 adds. +// ============================================================================= + +#[derive(Debug, PartialEq, FromRow)] +struct ParamUser { + id: i32, + name: Option, + score: Option, +} + +fn seed_param_users(test: &TestConnection) { + test.execute_command( + "CREATE TABLE param_users (id INT NOT NULL, org_id INT NOT NULL, \ + name TEXT, score DOUBLE PRECISION)", + ) + .expect("create"); + test.execute_command( + "INSERT INTO param_users VALUES \ + (1, 10, 'Alice', 95.5), \ + (2, 10, 'Bob', 87.0), \ + (3, 20, 'Carol', 92.3)", + ) + .expect("insert"); +} + +#[test] +fn test_fetch_one_as_params() { + let test = TestConnection::new().expect("Failed to create test connection"); + seed_param_users(&test); + + let user: ParamUser = test + .connection + .fetch_one_as_params( + "SELECT id, name, score FROM param_users WHERE id = $1", + &[&2i32], + ) + .expect("fetch_one_as_params"); + + assert_eq!(user.id, 2); + assert_eq!(user.name, Some("Bob".to_string())); + assert_eq!(user.score, Some(87.0)); +} + +#[test] +fn test_fetch_all_as_params() { + let test = TestConnection::new().expect("Failed to create test connection"); + seed_param_users(&test); + + // Bind org_id = 10 → exactly Alice and Bob. + let users: Vec = test + .connection + .fetch_all_as_params( + "SELECT id, name, score FROM param_users WHERE org_id = $1 ORDER BY id", + &[&10i32], + ) + .expect("fetch_all_as_params"); + + assert_eq!(users.len(), 2); + assert_eq!(users[0].id, 1); + assert_eq!(users[0].name, Some("Alice".to_string())); + assert_eq!(users[1].id, 2); + assert_eq!(users[1].name, Some("Bob".to_string())); +} + +#[test] +fn test_stream_as_params() { + let test = TestConnection::new().expect("Failed to create test connection"); + seed_param_users(&test); + + let users: Vec = test + .connection + .stream_as_params::( + "SELECT id, name, score FROM param_users WHERE org_id = $1 ORDER BY id", + &[&10i32], + ) + .expect("stream_as_params open") + .collect::>>() + .expect("stream_as_params collect"); + + assert_eq!(users.len(), 2); + assert_eq!(users[0].id, 1); + assert_eq!(users[1].id, 2); +} + +#[test] +fn test_fetch_one_as_params_no_rows_errors() { + // Mirrors fetch_one_as semantics: zero rows → "Query returned no rows". + let test = TestConnection::new().expect("Failed to create test connection"); + seed_param_users(&test); + + let result: hyperdb_api::Result = test.connection.fetch_one_as_params( + "SELECT id, name, score FROM param_users WHERE id = $1", + &[&999i32], + ); + + assert!(result.is_err(), "expected an error for an empty result set"); +} + +#[test] +fn test_fetch_all_as_params_multiple_params() { + // Two params of different types bound in one call. + let test = TestConnection::new().expect("Failed to create test connection"); + seed_param_users(&test); + + let users: Vec = test + .connection + .fetch_all_as_params( + "SELECT id, name, score FROM param_users \ + WHERE org_id = $1 AND score > $2 ORDER BY id", + &[&10i32, &90.0f64], + ) + .expect("fetch_all_as_params"); + + // org_id = 10 and score > 90 → only Alice (95.5). + assert_eq!(users.len(), 1); + assert_eq!(users[0].id, 1); + assert_eq!(users[0].name, Some("Alice".to_string())); +} From fb706e67792bdf9408ac140d7d2d8071edcbdea6 Mon Sep 17 00:00:00 2001 From: Stefan Steiner Date: Tue, 16 Jun 2026 09:27:30 -0700 Subject: [PATCH 2/4] feat(api): add async parameterized FromRow methods (#137) Mirror the sync fetch_one_as_params / fetch_all_as_params / stream_as_params on AsyncConnection. fetch_one/fetch_all delegate to query_params; stream_as_params encodes params before entering the try_stream! generator (it can't borrow &[&dyn ToSqlParam] across await points) and rebuilds query_params' prepare/execute sequence inline, carrying the statement guard. The Prepared path exposes its schema at prepare time, so the column-index map is built once up front. gRPC FeatureNotSupported surfaces as the stream's first yielded item (documented), not eagerly. Async round-trip tests cover all three. --- hyperdb-api/src/async_connection.rs | 143 ++++++++++++++++++++ hyperdb-api/tests/async_connection_tests.rs | 78 +++++++++++ 2 files changed, 221 insertions(+) diff --git a/hyperdb-api/src/async_connection.rs b/hyperdb-api/src/async_connection.rs index c6dd6ad..e012e9e 100644 --- a/hyperdb-api/src/async_connection.rs +++ b/hyperdb-api/src/async_connection.rs @@ -465,6 +465,149 @@ impl AsyncConnection { } } + /// Fetches a single row from a **parameterized** query and maps it to a + /// struct using [`FromRow`](crate::FromRow) (async). + /// + /// Parameterized counterpart to [`fetch_one_as`](Self::fetch_one_as): binds + /// `$1`, `$2`, … placeholders from `params` (via + /// [`ToSqlParam`](crate::params::ToSqlParam), exactly as + /// [`query_params`](Self::query_params)) and maps the first result row into + /// `T`. + /// + /// # Errors + /// + /// - Returns [`Error::FeatureNotSupported`] on gRPC transports (prepared + /// statements are TCP-only). + /// - Returns the error from [`query_params`](Self::query_params) if the + /// server rejects the statement, or on transport-level I/O failures. + /// - Returns [`Error::Conversion`] with message `"Query returned no rows"` + /// if the query produced zero rows. + /// - Returns whatever [`FromRow::from_row`](crate::FromRow::from_row) + /// produces when the row cannot be mapped. + pub async fn fetch_one_as_params( + &self, + query: &str, + params: &[&dyn crate::params::ToSqlParam], + ) -> Result { + let row = self + .query_params(query, params) + .await? + .require_first_row() + .await?; + let indices = row + .schema() + .map(crate::row_accessor::RowAccessor::build_indices) + .unwrap_or_default(); + T::from_row(crate::RowAccessor::new(&row, &indices)) + } + + /// Fetches all rows from a **parameterized** query and maps them to structs + /// using [`FromRow`](crate::FromRow) (async). + /// + /// Parameterized counterpart to [`fetch_all_as`](Self::fetch_all_as): binds + /// `$1`, `$2`, … placeholders from `params` (see + /// [`query_params`](Self::query_params)) and maps every result row into `T`. + /// + /// # Errors + /// + /// - Returns [`Error::FeatureNotSupported`] on gRPC transports. + /// - Returns the error from [`query_params`](Self::query_params) if the + /// server rejects the statement, or on transport-level I/O failures. + /// - Returns the first error produced by + /// [`FromRow::from_row`](crate::FromRow::from_row) on any row. + pub async fn fetch_all_as_params( + &self, + query: &str, + params: &[&dyn crate::params::ToSqlParam], + ) -> Result> { + let rows = self + .query_params(query, params) + .await? + .collect_rows() + .await?; + // Build the column-name → index lookup once from the first row's + // schema; reuse for every row. See `fetch_all_as`. + let indices = rows + .first() + .and_then(crate::result::Row::schema) + .map(crate::row_accessor::RowAccessor::build_indices) + .unwrap_or_default(); + rows.iter() + .map(|r| T::from_row(crate::RowAccessor::new(r, &indices))) + .collect() + } + + /// Returns a lazy `Stream` over the rows of a **parameterized** query, + /// mapping each to `T` via [`FromRow`] (async). + /// + /// Parameterized counterpart to [`stream_as`](Self::stream_as): binds `$1`, + /// `$2`, … placeholders from `params` and streams the result with O(chunk) + /// memory, mapping each row into `T`. The column-index map is built once on + /// the first chunk and reused. + /// + /// # Errors + /// + /// Like [`stream_as`](Self::stream_as), this returns the `Stream` directly + /// (no outer `Result`) — the query is lazy and does not execute until first + /// polled. Each yielded item is a `Result`: + /// - The **first** item is `Err(e)` if statement submission fails: + /// [`Error::FeatureNotSupported`] on gRPC transport, a `Parse`/`Bind` + /// rejection, or a transport failure. These surface as the first item, + /// *not* eagerly. + /// - Subsequent items are `Ok(T)` on a clean per-row mapping, or `Err(e)` + /// for a mapping failure (missing column, type mismatch, NULL in a + /// non-`Option` field) or a transport error hit on a later chunk. + /// + /// [`FromRow`]: crate::FromRow + pub fn stream_as_params<'a, T: crate::FromRow + 'a>( + &'a self, + query: &str, + params: &[&dyn crate::params::ToSqlParam], + ) -> impl futures_core::Stream> + 'a { + // `&[&dyn ToSqlParam]` can't cross the `try_stream!` await points, so + // own the query string and encode params up front (encoding needs no + // connection). The prepare+execute sequence below mirrors + // `query_params` (see that method) — keep the two in sync if its + // Parse/Bind/Execute handling ever changes. + let query = query.to_owned(); + let oids: Vec = params.iter().map(|p| p.sql_oid()).collect(); + let encoded: Vec>> = params.iter().map(|p| p.encode_param()).collect(); + async_stream::try_stream! { + let client = match &self.transport { + AsyncTransport::Tcp(tcp) => &tcp.client, + AsyncTransport::Grpc(_) => { + // `?` inside try_stream! yields Err(e) and terminates the + // generator, so `unreachable!()` is dead code — its `!` + // type just satisfies the arm's need for a `&AsyncClient` + // (same type the Tcp arm produces). + Err(Error::feature_not_supported( + "prepared statements are not supported over gRPC transport", + ))?; + unreachable!() + } + }; + let stmt = client.prepare_typed(&query, &oids).await?; + let stream = client + .execute_prepared_streaming(&stmt, encoded, crate::result::DEFAULT_BINARY_CHUNK_SIZE) + .await?; + let mut rs = AsyncRowset::from_prepared(stream).with_statement_guard(stmt); + // The Prepared path captures the schema at prepare time, so the + // column-name → index map is available immediately — build it once + // up front rather than deferring to the first chunk (the empty-map + // fallback matches `stream_as` / `fetch_all_as` if it is somehow + // unavailable, surfacing a per-row `Missing` error). + let idx = rs + .schema() + .map(|schema| crate::RowAccessor::build_owned_indices(&schema)) + .unwrap_or_default(); + while let Some(chunk) = rs.next_chunk().await? { + for row in &chunk { + yield T::from_row(crate::RowAccessor::new_owned(row, &idx))?; + } + } + } + } + /// Fetches a single non-NULL scalar value. Errors on empty / NULL. /// /// # Errors diff --git a/hyperdb-api/tests/async_connection_tests.rs b/hyperdb-api/tests/async_connection_tests.rs index dbc4007..95d34a2 100644 --- a/hyperdb-api/tests/async_connection_tests.rs +++ b/hyperdb-api/tests/async_connection_tests.rs @@ -427,3 +427,81 @@ async fn stream_as_lenient_extra_column() { conn.close().await.unwrap(); } + +// ============================================================================= +// #137: Parameterized FromRow mapping (async) +// fetch_one_as_params / fetch_all_as_params / stream_as_params +// ============================================================================= + +async fn seed_param_users(conn: &AsyncConnection) { + conn.execute_command( + "CREATE TABLE param_users (id INT NOT NULL, org_id INT NOT NULL, name TEXT)", + ) + .await + .unwrap(); + conn.execute_command( + "INSERT INTO param_users VALUES (1, 10, 'alice'), (2, 10, 'bob'), (3, 20, 'carol')", + ) + .await + .unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn fetch_one_as_params_async() { + let (_hyper, conn) = fresh_async_conn("async_fetch_one_as_params").await.unwrap(); + seed_param_users(&conn).await; + + let user: User = conn + .fetch_one_as_params("SELECT id, name FROM param_users WHERE id = $1", &[&2i32]) + .await + .unwrap(); + assert_eq!( + user, + User { + id: 2, + name: Some("bob".to_string()) + } + ); + + conn.close().await.unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn fetch_all_as_params_async() { + let (_hyper, conn) = fresh_async_conn("async_fetch_all_as_params").await.unwrap(); + seed_param_users(&conn).await; + + let users: Vec = conn + .fetch_all_as_params( + "SELECT id, name FROM param_users WHERE org_id = $1 ORDER BY id", + &[&10i32], + ) + .await + .unwrap(); + assert_eq!(users.len(), 2); + assert_eq!(users[0].id, 1); + assert_eq!(users[1].id, 2); + + conn.close().await.unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn stream_as_params_async() { + let (_hyper, conn) = fresh_async_conn("async_stream_as_params").await.unwrap(); + seed_param_users(&conn).await; + + let users = { + let stream = conn.stream_as_params::( + "SELECT id, name FROM param_users WHERE org_id = $1 ORDER BY id", + &[&10i32], + ); + tokio::pin!(stream); + stream.try_collect::>().await.unwrap() + }; + + assert_eq!(users.len(), 2); + assert_eq!(users[0].id, 1); + assert_eq!(users[1].id, 2); + + conn.close().await.unwrap(); +} From c22ce7c5db2f5d19ed0c9767989f2bbabadc6cd1 Mon Sep 17 00:00:00 2001 From: Stefan Steiner Date: Tue, 16 Jun 2026 09:30:52 -0700 Subject: [PATCH 3/4] docs(api): document parameterized FromRow methods (#137) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FromRow and ToSqlParam rustdoc cross-reference the new _as_params trio. - row_mapping_forms example gains Form 6 (fetch_all_as_params + stream_as_params), wired into main and verified end-to-end. - docs/ROW_MAPPING.md: new 'Form 6 — Parameterized struct mapping' section + Choosing-a-form row; retitled 'Six Forms'. - README: note + snippet under Parameterized Queries. - Fix stale async query_params rustdoc that described text-mode escaping; it has done binary Parse/Bind/Execute since the sync parity work. --- docs/ROW_MAPPING.md | 62 +++++++++++++++++-- hyperdb-api/README.md | 16 +++++ .../additional_examples/row_mapping_forms.rs | 44 ++++++++++++- hyperdb-api/src/async_connection.rs | 7 ++- hyperdb-api/src/params.rs | 16 ++++- hyperdb-api/src/result.rs | 9 +++ 6 files changed, 143 insertions(+), 11 deletions(-) diff --git a/docs/ROW_MAPPING.md b/docs/ROW_MAPPING.md index 76d3ed8..70cdeb0 100644 --- a/docs/ROW_MAPPING.md +++ b/docs/ROW_MAPPING.md @@ -1,10 +1,10 @@ -# Row Mapping: Five Forms +# Row Mapping: Six Forms -When querying Hyper, there are five ways to map result rows into Rust values — -from fully manual to fully automatic. Forms 1–4 trade manual control for +When querying Hyper, there are several ways to map result rows into Rust values +— from fully manual to fully automatic. Forms 1–4 trade manual control for convenience; Form 5 combines the automatic struct mapping of Form 4 with the -constant-memory streaming of Form 1. Start with the simplest that fits your -situation. +constant-memory streaming of Form 1; Form 6 adds `$1` parameter binding to the +struct mapping. Start with the simplest that fits your situation. All five forms are demonstrated end-to-end in one runnable example: @@ -307,6 +307,57 @@ async fn print_products(conn: &hyperdb_api::AsyncConnection) -> hyperdb_api::Res --- +## Form 6 — Parameterized struct mapping + +Forms 3–5 map rows into structs but take a plain `&str` query — no parameter +binding. [`query_params`](https://docs.rs/hyperdb-api/latest/hyperdb_api/struct.Connection.html#method.query_params) +binds `$1`, `$2`, … placeholders safely but returns raw `Row`s. The `_as_params` +methods are the intersection: they bind parameters via `ToSqlParam` *and* map +each result row into a `FromRow` struct, in a single call — no manual +`RowAccessor` loop and no SQL-injection risk. + +There are three, mirroring the non-param trio: + +- `fetch_one_as_params::(query, params)` → `Result` +- `fetch_all_as_params::(query, params)` → `Result>` +- `stream_as_params::(query, params)` → `Result>>` + (constant memory, like Form 5) + +```rust +use hyperdb_api::{Connection, CreateMode, FromRow, HyperProcess, Result}; + +fn main() -> Result<()> { + let hyper = HyperProcess::new(None, None)?; + let conn = Connection::new(&hyper, "products.hyper", CreateMode::DoNotCreate)?; + + // Product derives FromRow (see Form 4); $1 is bound from `params`. + let max_price = 15.0f64; + let affordable: Vec = conn.fetch_all_as_params( + "SELECT id, name, price, in_stock FROM products WHERE price < $1 ORDER BY id", + &[&max_price], + )?; + for p in &affordable { + println!("{:>2} {:<10} ${:.2} in_stock={}", p.id, p.name, p.price, p.in_stock); + } + Ok(()) +} +``` + +Error handling matches the underlying methods: `fetch_one_as_params` / +`fetch_all_as_params` return their errors on the `Result`; `stream_as_params` +(sync) reports stream-open errors — including `FeatureNotSupported` on the gRPC +transport, since prepared statements are TCP-only — on the outer `Result`, and +per-row mapping errors as each item's `Result`. The async `stream_as_params` +returns an `impl Stream` with no outer `Result`, so submission failures surface +as the first yielded `Err` item (as with the async Form 5 above). + +### Async + +Each `_as_params` method has an `AsyncConnection` equivalent — `await` the +`fetch_*` variants, and pin the `stream_as_params` stream just like Form 5. + +--- + ## Choosing a form | Need | Use | @@ -316,6 +367,7 @@ async fn print_products(conn: &hyperdb_api::AsyncConnection) -> hyperdb_api::Res | Named struct, custom mapping logic | Form 3 (`impl FromRow` manually) | | Named struct, fields match columns | Form 4 (`#[derive(FromRow)]`) | | Streaming + named struct (constant memory) | Form 5 (`stream_as`) | +| Named struct from a parameterized (`$1`) query | Form 6 (`fetch_*_as_params` / `stream_as_params`) | For scalar values (a single `COUNT(*)`, `MAX`, etc.), use [`fetch_scalar`](https://docs.rs/hyperdb-api/latest/hyperdb_api/struct.Connection.html#method.fetch_scalar) diff --git a/hyperdb-api/README.md b/hyperdb-api/README.md index 5a40a91..ad1e4f9 100644 --- a/hyperdb-api/README.md +++ b/hyperdb-api/README.md @@ -196,6 +196,22 @@ let rows_affected = conn.command_params( Supported parameter types: `i16`, `i32`, `i64`, `f32`, `f64`, `bool`, `&str`, `String`, `Option`, `Date`, `Time`, `Timestamp`, `OffsetTimestamp`, `Vec`, `&[u8]`. +To map a parameterized query's results straight into a `FromRow` struct (instead +of raw rows), use the `_as_params` variants — `fetch_one_as_params`, +`fetch_all_as_params`, and `stream_as_params` (sync and async). They combine the +`$1` binding above with the struct mapping of `fetch_*_as` / `stream_as` in one +call: + +```rust +#[derive(hyperdb_api_derive::FromRow)] +struct User { id: i32, name: String } + +let users: Vec = conn.fetch_all_as_params( + "SELECT id, name FROM users WHERE org_id = $1", + &[&42i32], +)?; +``` + #### Compile-time SQL validation (opt-in) With the `hyperdb-api-derive` crate's `compile-time` feature, the `query_as!` diff --git a/hyperdb-api/examples/additional_examples/row_mapping_forms.rs b/hyperdb-api/examples/additional_examples/row_mapping_forms.rs index c59b71a..33e1702 100644 --- a/hyperdb-api/examples/additional_examples/row_mapping_forms.rs +++ b/hyperdb-api/examples/additional_examples/row_mapping_forms.rs @@ -13,10 +13,13 @@ //! - **Form 3** — hand-written `FromRow` impl + `fetch_all_as`. //! - **Form 4** — `#[derive(FromRow)]` + `fetch_all_as`. //! - **Form 5** — streaming `FromRow`: `stream_as`, constant memory. +//! - **Form 6** — parameterized `FromRow`: `fetch_all_as_params` / +//! `stream_as_params`, combining `$1` parameter binding with struct mapping. //! -//! Every form prints the same four products, so you can see they are -//! equivalent. Form 5 is shown on both the sync `Connection` and the async +//! Forms 1–5 print the same four products, so you can see they are equivalent. +//! Form 5 is shown on both the sync `Connection` and the async //! `AsyncConnection` (which returns an `impl Stream` instead of an iterator). +//! Form 6 binds a `$1` price filter, so it prints only the matching subset. //! Run with: //! //! cargo run -p hyperdb-api --example row_mapping_forms @@ -51,6 +54,7 @@ fn main() -> Result<()> { form3_manual_from_row(&conn)?; form4_derive_from_row(&conn)?; form5_streaming_from_row(&conn)?; + form6_parameterized_from_row(&conn)?; // Form 5 also has an async flavor. Drop the sync connection first so the // async one reopens the same database file cleanly, then drive the stream @@ -216,6 +220,42 @@ fn form5_streaming_from_row(conn: &Connection) -> Result<()> { Ok(()) } +/// Form 6 — parameterized `FromRow`. The `_as_params` methods combine `$1` +/// parameter binding (via `ToSqlParam`, exactly as `query_params`) with the +/// automatic struct mapping of Forms 3–5. This closes the last gap: a +/// parameterized `SELECT` whose rows you want as structs, in one call, with no +/// manual `RowAccessor` loop and no SQL-injection risk. +/// +/// `fetch_all_as_params` collects the matches; `stream_as_params` is the +/// constant-memory streaming variant (shown here returning the same rows). Both +/// have async equivalents on `AsyncConnection`. +fn form6_parameterized_from_row(conn: &Connection) -> Result<()> { + println!("== Form 6 — parameterized FromRow (fetch_all_as_params / stream_as_params) =="); + + // Bind a price ceiling as $1 — only products cheaper than $15 come back. + let max_price = 15.0f64; + let affordable: Vec = conn.fetch_all_as_params( + "SELECT id, name, price, in_stock FROM products WHERE price < $1 ORDER BY id", + &[&max_price], + )?; + println!("fetch_all_as_params (price < ${max_price:.2}):"); + for p in &affordable { + print_row(p.id, &p.name, p.price, p.in_stock); + } + + // Same query, streamed one chunk at a time via stream_as_params. + println!("stream_as_params (same filter, constant memory):"); + for row_result in conn.stream_as_params::( + "SELECT id, name, price, in_stock FROM products WHERE price < $1 ORDER BY id", + &[&max_price], + )? { + let p = row_result?; + print_row(p.id, &p.name, p.price, p.in_stock); + } + println!(); + Ok(()) +} + /// Form 5, async flavor. `AsyncConnection::stream_as` returns an /// `impl Stream>` rather than an iterator — otherwise the /// shape is identical to the sync version: lazy, one chunk in memory at a diff --git a/hyperdb-api/src/async_connection.rs b/hyperdb-api/src/async_connection.rs index e012e9e..968c6ee 100644 --- a/hyperdb-api/src/async_connection.rs +++ b/hyperdb-api/src/async_connection.rs @@ -701,11 +701,12 @@ impl AsyncConnection { // Parameterized Queries // ========================================================================= - /// Executes a parameterized query with safely escaped parameters (async). + /// Executes a parameterized query with binary-encoded parameters (async). /// /// Mirrors the sync [`Connection::query_params`](crate::Connection::query_params); - /// see that method for the design rationale around text-mode escaping - /// vs. future native Bind/Execute support. + /// see that method for the design rationale. Parameters travel through the + /// extended query protocol (Parse/Bind/Execute) in HyperBinary format — no + /// SQL escaping, full SQL-injection safety regardless of parameter content. /// /// # Errors /// diff --git a/hyperdb-api/src/params.rs b/hyperdb-api/src/params.rs index 3f154d5..5cc7423 100644 --- a/hyperdb-api/src/params.rs +++ b/hyperdb-api/src/params.rs @@ -58,6 +58,16 @@ //! Ok(()) //! } //! ``` +//! +//! # Mapping parameterized results into structs +//! +//! [`query_params`](crate::Connection::query_params) returns raw +//! [`Row`](crate::Row)s. To map a parameterized query's results straight into +//! a [`FromRow`](crate::FromRow) struct in one call, use the `_as_params` +//! variants — [`fetch_one_as_params`](crate::Connection::fetch_one_as_params), +//! [`fetch_all_as_params`](crate::Connection::fetch_all_as_params), and +//! [`stream_as_params`](crate::Connection::stream_as_params) (and their +//! [`AsyncConnection`](crate::AsyncConnection) equivalents). use hyperdb_api_core::types::{ oids, Date, Interval, Numeric, OffsetTimestamp, Oid, Time, Timestamp, @@ -67,7 +77,11 @@ use hyperdb_api_core::types::{ /// /// This trait enables type-safe parameter encoding for use with /// [`Connection::query_params`](crate::Connection::query_params) and -/// [`Connection::command_params`](crate::Connection::command_params). +/// [`Connection::command_params`](crate::Connection::command_params), and with +/// the struct-mapping variants +/// [`fetch_one_as_params`](crate::Connection::fetch_one_as_params), +/// [`fetch_all_as_params`](crate::Connection::fetch_all_as_params), and +/// [`stream_as_params`](crate::Connection::stream_as_params). /// /// # Implementing for Custom Types /// diff --git a/hyperdb-api/src/result.rs b/hyperdb-api/src/result.rs index 5a7fa38..13e907b 100644 --- a/hyperdb-api/src/result.rs +++ b/hyperdb-api/src/result.rs @@ -751,6 +751,15 @@ impl RowValue for hyperdb_api_core::types::Numeric { /// a [`RowAccessor`](crate::RowAccessor), which provides name-based /// access via a column-name → index lookup built once per query. /// +/// To map the results of a **parameterized** query (`$1` placeholders +/// bound via [`ToSqlParam`](crate::params::ToSqlParam)) directly into a +/// struct, use the `_as_params` variants — +/// [`fetch_one_as_params`](crate::Connection::fetch_one_as_params), +/// [`fetch_all_as_params`](crate::Connection::fetch_all_as_params), and +/// [`stream_as_params`](crate::Connection::stream_as_params) — which combine +/// this trait with parameter binding in one call (also on +/// [`AsyncConnection`](crate::AsyncConnection)). +/// /// # Recommended: derive /// /// In most cases the `#[derive(FromRow)]` macro handles the mapping From 93e66e607e2752b82e5e0c27d39b12e8c2e86ecd Mon Sep 17 00:00:00 2001 From: Stefan Steiner Date: Tue, 16 Jun 2026 09:39:22 -0700 Subject: [PATCH 4/4] docs: fix stale 'five forms' line in ROW_MAPPING (#137) Final-sweep reviewer caught line 9 still saying 'All five forms' after the doc was retitled to Six Forms. --- docs/ROW_MAPPING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ROW_MAPPING.md b/docs/ROW_MAPPING.md index 70cdeb0..88289c5 100644 --- a/docs/ROW_MAPPING.md +++ b/docs/ROW_MAPPING.md @@ -6,7 +6,7 @@ convenience; Form 5 combines the automatic struct mapping of Form 4 with the constant-memory streaming of Form 1; Form 6 adds `$1` parameter binding to the struct mapping. Start with the simplest that fits your situation. -All five forms are demonstrated end-to-end in one runnable example: +All six forms are demonstrated end-to-end in one runnable example: ``` cargo run -p hyperdb-api --example row_mapping_forms