Skip to main content

hyperdb_api/
prepared.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! High-level prepared statements.
5//!
6//! [`PreparedStatement`] wraps [`hyperdb_api_core::client::OwnedPreparedStatement`]
7//! and integrates it with the rest of the hyperdb-api surface:
8//!
9//! - Returns [`Rowset`](crate::Rowset) from streaming executions, so
10//!   row decoding, schema capture, and `Row::get::<T>()` work exactly
11//!   the same way as with [`Connection::execute_query`](crate::Connection::execute_query).
12//! - `execute` / `fetch_one` / `fetch_optional` / `fetch_all` /
13//!   `fetch_scalar` mirror the same helpers on `Connection`.
14//! - `OwnedPreparedStatement` already auto-closes on Drop at the lower
15//!   layer, so this wrapper needs no additional cleanup logic.
16
17use std::sync::Arc;
18
19use hyperdb_api_core::client::OwnedPreparedStatement;
20use hyperdb_api_core::types::Oid;
21
22use crate::connection::Connection;
23use crate::error::{Error, Result};
24use crate::params::{ParamFormat, ToSqlParam};
25use crate::result::{ResultColumn, ResultSchema, Row, RowValue, Rowset};
26use crate::transport::Transport;
27
28/// A handle to a server-side prepared statement.
29///
30/// Construct via [`Connection::prepare`] or
31/// [`Connection::prepare_typed`]. Holding this type keeps the statement
32/// allocated on the server; it is released automatically when the handle
33/// is dropped.
34///
35/// # Reuse
36///
37/// A single `PreparedStatement` can be executed many times with different
38/// parameter values — the server caches the parsed plan. This is the
39/// primary reason to use prepared statements over
40/// [`Connection::query_params`] for loops over user input.
41#[derive(Debug)]
42pub struct PreparedStatement<'conn> {
43    connection: &'conn Connection,
44    inner: OwnedPreparedStatement,
45    schema: Arc<ResultSchema>,
46}
47
48impl<'conn> PreparedStatement<'conn> {
49    #[expect(
50        clippy::unnecessary_wraps,
51        reason = "signature retained for API symmetry / future fallibility; returning Result/Option keeps callers from breaking when the function later grows failure cases"
52    )]
53    pub(crate) fn new(
54        connection: &'conn Connection,
55        inner: OwnedPreparedStatement,
56    ) -> Result<Self> {
57        let schema = build_schema_from_columns(inner.columns());
58        Ok(Self {
59            connection,
60            inner,
61            schema: Arc::new(schema),
62        })
63    }
64
65    /// Returns the number of parameters the statement expects.
66    #[must_use]
67    pub fn param_count(&self) -> usize {
68        self.inner.param_count()
69    }
70
71    /// Returns the parameter type OIDs (as the server inferred or the
72    /// caller explicitly passed to [`Connection::prepare_typed`]).
73    #[must_use]
74    pub fn param_types(&self) -> &[Oid] {
75        self.inner.param_types()
76    }
77
78    /// Returns the result-column schema. Always available — it was
79    /// captured during the Parse/Describe at prepare time.
80    #[must_use]
81    pub fn schema(&self) -> &ResultSchema {
82        &self.schema
83    }
84
85    /// The original SQL text.
86    #[must_use]
87    pub fn sql(&self) -> &str {
88        self.inner.query()
89    }
90
91    /// Executes the statement and returns a streaming [`Rowset`].
92    ///
93    /// Memory stays bounded to one chunk regardless of result size —
94    /// the prepared-statement equivalent of
95    /// [`Connection::execute_query`].
96    ///
97    /// # Errors
98    ///
99    /// - Returns [`Error::FeatureNotSupported`] if the underlying [`Connection`] is on
100    ///   gRPC transport (prepared statements are TCP-only).
101    /// - Returns [`Error::Server`] if the server rejects `Bind` or
102    ///   `Execute` (type mismatch, runtime error while streaming).
103    /// - Returns [`Error::Io`] on transport-level I/O failures.
104    pub fn query(&self, params: &[&dyn ToSqlParam]) -> Result<Rowset<'conn>> {
105        let (encoded, formats) = encode_params(params);
106        let client = tcp_client(self.connection)?;
107        let stream = client.execute_streaming_with_formats(
108            &self.inner,
109            encoded,
110            &formats,
111            crate::result::DEFAULT_BINARY_CHUNK_SIZE,
112        )?;
113        Ok(Rowset::from_prepared(stream))
114    }
115
116    /// Executes the statement as a command (INSERT / UPDATE / DELETE /
117    /// DDL) and returns the affected-row count.
118    ///
119    /// # Errors
120    ///
121    /// - Returns [`Error::FeatureNotSupported`] on gRPC transport.
122    /// - Returns [`Error::Server`] if the server rejects `Bind` or
123    ///   `Execute`.
124    /// - Returns [`Error::Io`] on transport-level I/O failures.
125    pub fn execute(&self, params: &[&dyn ToSqlParam]) -> Result<u64> {
126        let (encoded, formats) = encode_params(params);
127        let client = tcp_client(self.connection)?;
128        Ok(client.execute_no_result_with_formats(&self.inner, encoded, &formats)?)
129    }
130
131    /// Fetches exactly one row; errors if the result is empty.
132    ///
133    /// # Errors
134    ///
135    /// - Returns the error from [`query`](Self::query).
136    /// - Returns [`Error::Conversion`] with message `"Query returned no rows"`
137    ///   if the result is empty.
138    pub fn fetch_one(&self, params: &[&dyn ToSqlParam]) -> Result<Row> {
139        self.query(params)?.require_first_row()
140    }
141
142    /// Fetches at most one row; returns `None` if the result is empty.
143    ///
144    /// # Errors
145    ///
146    /// Returns the error from [`query`](Self::query); an empty result
147    /// yields `Ok(None)`.
148    pub fn fetch_optional(&self, params: &[&dyn ToSqlParam]) -> Result<Option<Row>> {
149        self.query(params)?.first_row()
150    }
151
152    /// Fetches every row into a `Vec`.
153    ///
154    /// # Errors
155    ///
156    /// Returns the error from [`query`](Self::query), or a transport error
157    /// produced while draining every chunk.
158    pub fn fetch_all(&self, params: &[&dyn ToSqlParam]) -> Result<Vec<Row>> {
159        self.query(params)?.collect_rows()
160    }
161
162    /// Fetches a single non-NULL scalar; errors on empty / NULL.
163    ///
164    /// # Errors
165    ///
166    /// - Returns the error from [`query`](Self::query).
167    /// - Returns [`Error::Conversion`] with message `"Query returned no rows"`
168    ///   if the result is empty.
169    /// - Returns [`Error::Conversion`] with message `"Scalar query returned NULL"`
170    ///   if the first cell is SQL `NULL`.
171    pub fn fetch_scalar<T: RowValue>(&self, params: &[&dyn ToSqlParam]) -> Result<T> {
172        self.query(params)?.require_scalar()
173    }
174
175    /// Fetches a single scalar, allowing NULL as `None`.
176    ///
177    /// # Errors
178    ///
179    /// Returns the error from [`query`](Self::query). An empty result
180    /// still errors (see [`fetch_scalar`](Self::fetch_scalar)); SQL `NULL`
181    /// yields `Ok(None)`.
182    pub fn fetch_optional_scalar<T: RowValue>(
183        &self,
184        params: &[&dyn ToSqlParam],
185    ) -> Result<Option<T>> {
186        self.query(params)?.scalar()
187    }
188}
189
190/// Encode a slice of `&dyn ToSqlParam` into the wire bytes the
191/// prepared-statement Bind message expects, plus the matching per-parameter
192/// format code. `None` encodes SQL NULL.
193///
194/// The two vectors are always the same length; index `i` of the format vector
195/// describes index `i` of the byte vector.
196pub(crate) fn encode_params(
197    params: &[&dyn ToSqlParam],
198) -> (Vec<Option<Vec<u8>>>, Vec<ParamFormat>) {
199    params
200        .iter()
201        .map(|p| (p.encode_param(), p.param_format()))
202        .collect()
203}
204
205/// Extract the underlying sync TCP client or error with a clear message
206/// if the connection is on gRPC.
207pub(crate) fn tcp_client(connection: &Connection) -> Result<&hyperdb_api_core::client::Client> {
208    match connection.transport() {
209        Transport::Tcp(tcp) => Ok(&tcp.client),
210        Transport::Grpc(_) => Err(Error::feature_not_supported(
211            "prepared statements are not supported over gRPC transport",
212        )),
213    }
214}
215
216/// Build a `ResultSchema` from a slice of `hyperdb_api_core::client::Column`, using
217/// `SqlType::from_oid_and_modifier` so NUMERIC / VARCHAR modifiers are
218/// preserved.
219fn build_schema_from_columns(cols: &[hyperdb_api_core::client::Column]) -> ResultSchema {
220    let columns = cols
221        .iter()
222        .enumerate()
223        .map(|(idx, col)| {
224            let sql_type = hyperdb_api_core::types::SqlType::from_oid_and_modifier(
225                col.type_oid().0,
226                col.type_modifier(),
227            );
228            ResultColumn::new(col.name(), sql_type, idx)
229        })
230        .collect();
231    ResultSchema::from_columns(columns)
232}