hyperdb_api_core/client/statement.rs
1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Prepared statement handling.
5
6use super::error::{Error, Result};
7use crate::types::Oid;
8use std::borrow::Cow;
9
10/// Wire format for a *bound parameter* in the `Bind` message.
11///
12/// Distinct from [`ColumnFormat`], which describes *result* columns and
13/// carries a third `HyperBinary` variant. Parameters only ever travel as
14/// PostgreSQL text or PostgreSQL binary — Hyper has no `HyperBinary` input
15/// decoder for bound parameters.
16///
17/// Binary is the default and the fast path. Text exists because Hyper has
18/// no PG-binary *input* function for a couple of types:
19///
20/// - **scaled `NUMERIC`** — a binary NUMERIC whose `dscale` exceeds the
21/// parameter's resolved scale is rejected with SQLSTATE `0A000`
22/// ("cannot handle truncation when reading numerics").
23/// - **`geography`** — rejected with `42883`, "no pg binary input function
24/// available for type geography".
25///
26/// The `Bind` message carries a *per-parameter* format-code array, so a
27/// single statement can mix both.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum ParamFormat {
30 /// PostgreSQL text format (format code `0`) — the value's SQL literal
31 /// representation, sent as UTF-8 without surrounding quotes.
32 Text,
33 /// Standard PostgreSQL binary format (format code `1`, big-endian).
34 #[default]
35 Binary,
36}
37
38impl ParamFormat {
39 /// Wire format code for [`ParamFormat::Binary`].
40 pub const BINARY_CODE: i16 = 1;
41 /// Wire format code for [`ParamFormat::Text`].
42 pub const TEXT_CODE: i16 = 0;
43
44 /// Returns the wire protocol format code (`0` = text, `1` = binary).
45 #[must_use]
46 pub fn to_code(self) -> i16 {
47 match self {
48 ParamFormat::Text => Self::TEXT_CODE,
49 ParamFormat::Binary => Self::BINARY_CODE,
50 }
51 }
52
53 /// Returns true if this is the binary format.
54 #[must_use]
55 pub fn is_binary(self) -> bool {
56 matches!(self, ParamFormat::Binary)
57 }
58}
59
60/// A single binary format code, which `Bind` broadcasts to every parameter.
61const BROADCAST_BINARY: &[i16] = &[ParamFormat::BINARY_CODE];
62
63/// Builds the `Bind` parameter-format-code array for `param_count`
64/// parameters described by `formats`.
65///
66/// **An empty `formats` means "every parameter is binary"** — the crate-wide
67/// shorthand, and deliberately *not* the wire meaning. The PostgreSQL
68/// protocol reads a zero-length format array as "every parameter is text",
69/// so emitting `formats` straight through would reinterpret every binary
70/// parameter as text: silent corruption rather than an error. This is the
71/// single place that translation happens, which is why the function needs
72/// `param_count` — it is the only way to tell "no parameters" (where a
73/// zero-length array is correct) from "no format overrides".
74///
75/// The protocol also lets the array hold `1` code that applies to *all*
76/// parameters. The all-binary case — every parameterized query that doesn't
77/// bind a scaled `NUMERIC` or a `geography` — therefore ships one code
78/// instead of `n` and borrows a `const`, so the hot path performs no
79/// allocation at all.
80///
81/// # Errors
82///
83/// Returns a protocol error if `formats` is non-empty and its length differs
84/// from `param_count`. Broadcasting a mismatched array would bind parameters
85/// under a format the caller never chose.
86pub(crate) fn bind_format_codes(
87 formats: &[ParamFormat],
88 param_count: usize,
89) -> Result<Cow<'static, [i16]>> {
90 if !formats.is_empty() && formats.len() != param_count {
91 return Err(Error::protocol(format!(
92 "parameter format count ({}) does not match parameter count ({param_count})",
93 formats.len()
94 )));
95 }
96 if param_count == 0 {
97 return Ok(Cow::Borrowed(&[]));
98 }
99 if formats.is_empty() || formats.iter().copied().all(ParamFormat::is_binary) {
100 return Ok(Cow::Borrowed(BROADCAST_BINARY));
101 }
102 Ok(Cow::Owned(
103 formats.iter().copied().map(ParamFormat::to_code).collect(),
104 ))
105}
106
107/// Format code for column data.
108///
109/// This indicates how data values are encoded in the wire protocol.
110/// The format affects how values are serialized/deserialized and can
111/// significantly impact performance.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
113pub enum ColumnFormat {
114 /// Text format (human-readable ASCII).
115 ///
116 /// Values are sent as UTF-8 strings. Slower but human-readable.
117 /// Use for debugging or when compatibility with text-based tools is needed.
118 #[default]
119 Text,
120 /// Standard `PostgreSQL` binary format.
121 ///
122 /// Uses `PostgreSQL`'s standard binary encoding (`BigEndian` for most types).
123 /// Compatible with standard `PostgreSQL` clients.
124 Binary,
125 /// Hyper-specific binary format (little-endian, optimized).
126 ///
127 /// Uses Hyper's optimized binary format where all multi-byte values are
128 /// **little-endian** (x86/ARM-LE native byte order), avoiding byte-swapping
129 /// on modern hardware. This contrasts with standard `PostgreSQL` binary format
130 /// which uses **big-endian** (network byte order).
131 ///
132 /// Additional differences from `PostgreSQL` binary:
133 /// - No per-row field count prefix (rows are implicitly framed)
134 /// - NULL is a 1-byte indicator on nullable columns only (vs 4-byte `-1` length)
135 /// - Fixed-size types have no length prefix (vs 4-byte length in PG binary)
136 ///
137 /// This is the fastest format and is used by default for `query_fast()`,
138 /// `query_streaming()`, and the COPY bulk insertion path.
139 HyperBinary,
140}
141
142impl ColumnFormat {
143 /// Creates a `ColumnFormat` from the wire protocol format code.
144 ///
145 /// Format codes: 0 = Text, 1 = Binary, 2 = `HyperBinary`
146 #[must_use]
147 pub fn from_code(code: i16) -> Self {
148 match code {
149 0 => ColumnFormat::Text,
150 1 => ColumnFormat::Binary,
151 2 => ColumnFormat::HyperBinary,
152 _ => ColumnFormat::Text, // Default to text for unknown codes
153 }
154 }
155
156 /// Returns the wire protocol format code.
157 #[must_use]
158 pub fn to_code(self) -> i16 {
159 match self {
160 ColumnFormat::Text => 0,
161 ColumnFormat::Binary => 1,
162 ColumnFormat::HyperBinary => 2,
163 }
164 }
165
166 /// Returns true if this is a binary format (Binary or `HyperBinary`).
167 #[must_use]
168 pub fn is_binary(self) -> bool {
169 matches!(self, ColumnFormat::Binary | ColumnFormat::HyperBinary)
170 }
171}
172
173/// Metadata about a column in a result set.
174///
175/// `Column` carries the three pieces of information the wire protocol's
176/// `RowDescription` message provides for each result field: the column
177/// name, its type OID, and the type modifier that encodes width-specific
178/// parameters like `NUMERIC(precision, scale)` or `VARCHAR(n)`.
179///
180/// The type modifier is essential for decoding types whose wire format
181/// depends on declared precision/scale (e.g. `NUMERIC`, where precision
182/// ≤ 18 uses an 8-byte `i64` wire form and precision > 18 uses a
183/// 16-byte `i128` wire form — and in both cases the scale needed to
184/// interpret the unscaled integer value lives only in the type
185/// modifier). Upper layers construct a `SqlType` from OID + modifier
186/// via [`crate::types::SqlType::from_oid_and_modifier`] — using
187/// [`crate::types::SqlType::from_oid`] alone silently drops precision
188/// and scale, causing decoders to default to scale = 0 and corrupt
189/// fractional values.
190#[derive(Debug, Clone)]
191pub struct Column {
192 pub(crate) name: String,
193 pub(crate) type_oid: Oid,
194 /// PostgreSQL-style type modifier. For NUMERIC columns the encoding
195 /// is `((precision << 16) | scale) + 4`; for VARCHAR it's
196 /// `length + 4`; for most other types the server sends `-1`
197 /// (no modifier). Parse with
198 /// [`SqlType::from_oid_and_modifier`](crate::types::SqlType::from_oid_and_modifier).
199 pub(crate) type_modifier: i32,
200 pub(crate) format: ColumnFormat,
201}
202
203impl Column {
204 /// Creates a new Column.
205 #[inline]
206 pub(crate) fn new(
207 name: String,
208 type_oid: Oid,
209 type_modifier: i32,
210 format: ColumnFormat,
211 ) -> Self {
212 Column {
213 name,
214 type_oid,
215 type_modifier,
216 format,
217 }
218 }
219
220 /// Returns the column name.
221 #[inline]
222 #[must_use]
223 pub fn name(&self) -> &str {
224 &self.name
225 }
226
227 /// Returns the column type OID.
228 #[inline]
229 #[must_use]
230 pub fn type_oid(&self) -> Oid {
231 self.type_oid
232 }
233
234 /// Returns the column's type modifier (PostgreSQL-style `atttypmod`).
235 ///
236 /// For `NUMERIC` columns this encodes precision and scale and is
237 /// required for correct decode (see
238 /// [`crate::types::SqlType::from_oid_and_modifier`]). For most other
239 /// types the server sends `-1` to indicate "no modifier".
240 #[inline]
241 #[must_use]
242 pub fn type_modifier(&self) -> i32 {
243 self.type_modifier
244 }
245
246 /// Returns the data format for this column.
247 #[inline]
248 #[must_use]
249 pub fn format(&self) -> ColumnFormat {
250 self.format
251 }
252}
253
254#[cfg(test)]
255mod tests {
256 use super::{ParamFormat, bind_format_codes};
257
258 #[test]
259 fn all_binary_collapses_to_one_broadcast_code() {
260 // PG applies a lone format code to every parameter, so N binary
261 // parameters need exactly one code on the wire.
262 for n in 1..=8 {
263 let formats = vec![ParamFormat::Binary; n];
264 assert_eq!(
265 &*bind_format_codes(&formats, n).unwrap(),
266 &[1_i16],
267 "{n} binary params should ship one broadcast code"
268 );
269 }
270 }
271
272 #[test]
273 fn empty_formats_mean_all_binary_not_all_text() {
274 // Regression: the helper used to hand an empty slice straight back,
275 // and PG reads a zero-length format array as "every parameter is
276 // text". Every caller in the crate means "all binary" by `&[]`, so
277 // passing it through silently reinterpreted binary bytes as text.
278 for n in 1..=8 {
279 assert_eq!(
280 &*bind_format_codes(&[], n).unwrap(),
281 &[1_i16],
282 "empty formats with {n} params must broadcast binary"
283 );
284 }
285 }
286
287 #[test]
288 fn zero_parameters_send_no_format_codes() {
289 // A broadcast code with no parameters would be a malformed Bind.
290 // This is the *only* case where a zero-length array is correct.
291 assert!(bind_format_codes(&[], 0).unwrap().is_empty());
292 }
293
294 #[test]
295 fn format_count_must_match_parameter_count() {
296 // Silently broadcasting a one-element array over three parameters, or
297 // truncating a three-element array to two parameters, binds values
298 // under a format the caller never chose.
299 for (formats, param_count) in [
300 (vec![ParamFormat::Text], 3),
301 (vec![ParamFormat::Text, ParamFormat::Binary], 3),
302 (vec![ParamFormat::Binary; 3], 2),
303 (vec![ParamFormat::Binary], 0),
304 ] {
305 let err = bind_format_codes(&formats, param_count)
306 .expect_err("length mismatch must be rejected");
307 let msg = err.to_string();
308 assert!(
309 msg.contains("does not match parameter count"),
310 "unexpected error for {}/{param_count}: {msg}",
311 formats.len()
312 );
313 }
314 }
315
316 #[test]
317 fn mixed_formats_expand_to_one_code_per_parameter() {
318 assert_eq!(
319 &*bind_format_codes(&[ParamFormat::Binary, ParamFormat::Text], 2).unwrap(),
320 &[1_i16, 0]
321 );
322 assert_eq!(
323 &*bind_format_codes(&[ParamFormat::Text, ParamFormat::Binary], 2).unwrap(),
324 &[0_i16, 1]
325 );
326 // All-text is still a per-parameter array, never an empty one — an
327 // empty array means "all binary" to this helper, and all-text to PG.
328 assert_eq!(
329 &*bind_format_codes(&[ParamFormat::Text, ParamFormat::Text], 2).unwrap(),
330 &[0_i16, 0]
331 );
332 }
333
334 #[test]
335 fn param_format_codes_match_the_protocol() {
336 assert_eq!(ParamFormat::Text.to_code(), 0);
337 assert_eq!(ParamFormat::Binary.to_code(), 1);
338 assert!(ParamFormat::Binary.is_binary());
339 assert!(!ParamFormat::Text.is_binary());
340 assert_eq!(ParamFormat::default(), ParamFormat::Binary);
341 }
342}