Skip to main content

hyperdb_api/
params.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Parameter encoding for parameterized queries.
5//!
6//! This module provides the [`ToSqlParam`] trait for type-safe parameter encoding
7//! in parameterized SQL queries, preventing SQL injection attacks.
8//!
9//! # SQL Injection Prevention
10//!
11//! Using parameterized queries is the safest way to include user input in SQL:
12//!
13//! ```no_run
14//! # use hyperdb_api::{Connection, Result};
15//! # fn example(conn: &Connection, user_input: &str) -> Result<()> {
16//! // DANGEROUS - vulnerable to SQL injection:
17//! let query = format!("SELECT * FROM users WHERE name = '{}'", user_input);
18//!
19//! // SAFE - parameterized query:
20//! let result = conn.query_params("SELECT * FROM users WHERE name = $1", &[&user_input])?;
21//! # Ok(())
22//! # }
23//! ```
24//!
25//! # Supported Types
26//!
27//! The following types implement [`ToSqlParam`]:
28//!
29//! - Integers: `i16`, `i32`, `i64`
30//! - Floats: `f32`, `f64`
31//! - `bool`
32//! - `&str`, `String`
33//! - `Option<T>` where `T: ToSqlParam` (for nullable parameters)
34//! - Date/time types: `Date`, `Time`, `Timestamp`, `OffsetTimestamp`
35//!
36//! # Example
37//!
38//! ```no_run
39//! use hyperdb_api::{Connection, CreateMode, ToSqlParam, Result};
40//!
41//! fn find_user(conn: &Connection, user_id: i32, name: &str) -> Result<()> {
42//!     // Multiple parameters with different types
43//!     let result = conn.query_params(
44//!         "SELECT * FROM users WHERE id = $1 AND name = $2",
45//!         &[&user_id, &name],
46//!     )?;
47//!     Ok(())
48//! }
49//! ```
50
51use hyperdb_api_core::types::{oids, Date, OffsetTimestamp, Oid, Time, Timestamp};
52
53/// Trait for types that can be used as parameters in parameterized SQL queries.
54///
55/// This trait enables type-safe parameter encoding for use with
56/// [`Connection::query_params`](crate::Connection::query_params) and
57/// [`Connection::command_params`](crate::Connection::command_params).
58///
59/// # Implementing for Custom Types
60///
61/// You can implement this trait for custom types:
62///
63/// ```no_run
64/// # use hyperdb_api::ToSqlParam;
65/// # struct MyType;
66/// # impl MyType { fn to_bytes(&self) -> Vec<u8> { vec![] } }
67/// # impl ToString for MyType { fn to_string(&self) -> String { String::new() } }
68/// impl ToSqlParam for MyType {
69///     fn encode_param(&self) -> Option<Vec<u8>> {
70///         Some(self.to_bytes())
71///     }
72///
73///     fn to_sql_literal(&self) -> String {
74///         format!("'{}'", self.to_string().replace('\'', "''"))
75///     }
76/// }
77/// ```
78pub trait ToSqlParam: Send + Sync {
79    /// Encodes this value as binary bytes for use in parameterized queries.
80    ///
81    /// Returns `None` to represent a SQL NULL value.
82    /// Returns `Some(bytes)` with the binary-encoded value otherwise.
83    fn encode_param(&self) -> Option<Vec<u8>>;
84
85    /// Returns the SQL type OID this parameter should bind as.
86    ///
87    /// The default returns `Oid(0)` (unspecified) which asks the server
88    /// to infer the type from surrounding SQL context. That works for
89    /// clauses like `WHERE column = $1` where the column type is known,
90    /// but not for `INSERT INTO t VALUES ($1, $2)` — those require the
91    /// caller (or the trait impl) to return a concrete OID.
92    ///
93    /// All built-in `ToSqlParam` impls override this with a concrete
94    /// value from [`hyperdb_api_core::types::oids`].
95    fn sql_oid(&self) -> Oid {
96        Oid::new(0)
97    }
98
99    /// Returns the SQL literal representation of this value.
100    ///
101    /// Retained for building DDL statement strings that cannot use
102    /// parameterized queries (e.g. `escape_sql_path` in catalog code).
103    /// The parameterized-query path in
104    /// [`Connection::query_params`](crate::Connection::query_params)
105    /// no longer uses this method — parameters travel as binary bytes
106    /// via `encode_param`.
107    fn to_sql_literal(&self) -> String;
108}
109
110// =============================================================================
111// Integer implementations
112// =============================================================================
113
114impl ToSqlParam for i16 {
115    fn encode_param(&self) -> Option<Vec<u8>> {
116        // PostgreSQL wire-protocol Bind uses big-endian for numeric
117        // binary parameters. (Results come back as little-endian
118        // HyperBinary because we request format code 2 for results;
119        // params use format code 1 = standard PG binary = BE.)
120        Some(self.to_be_bytes().to_vec())
121    }
122
123    fn sql_oid(&self) -> Oid {
124        oids::SMALL_INT
125    }
126
127    fn to_sql_literal(&self) -> String {
128        self.to_string()
129    }
130}
131
132impl ToSqlParam for i32 {
133    fn encode_param(&self) -> Option<Vec<u8>> {
134        Some(self.to_be_bytes().to_vec())
135    }
136
137    fn sql_oid(&self) -> Oid {
138        oids::INT
139    }
140
141    fn to_sql_literal(&self) -> String {
142        self.to_string()
143    }
144}
145
146impl ToSqlParam for i64 {
147    fn encode_param(&self) -> Option<Vec<u8>> {
148        Some(self.to_be_bytes().to_vec())
149    }
150
151    fn sql_oid(&self) -> Oid {
152        oids::BIG_INT
153    }
154
155    fn to_sql_literal(&self) -> String {
156        self.to_string()
157    }
158}
159
160// =============================================================================
161// Float implementations
162// =============================================================================
163
164impl ToSqlParam for f32 {
165    fn encode_param(&self) -> Option<Vec<u8>> {
166        Some(self.to_be_bytes().to_vec())
167    }
168
169    fn sql_oid(&self) -> Oid {
170        oids::FLOAT
171    }
172
173    fn to_sql_literal(&self) -> String {
174        // Handle special float values
175        if self.is_nan() {
176            "'NaN'".to_string()
177        } else if self.is_infinite() {
178            if *self > 0.0 {
179                "'Infinity'".to_string()
180            } else {
181                "'-Infinity'".to_string()
182            }
183        } else {
184            self.to_string()
185        }
186    }
187}
188
189impl ToSqlParam for f64 {
190    fn encode_param(&self) -> Option<Vec<u8>> {
191        Some(self.to_be_bytes().to_vec())
192    }
193
194    fn sql_oid(&self) -> Oid {
195        oids::DOUBLE
196    }
197
198    fn to_sql_literal(&self) -> String {
199        // Handle special float values
200        if self.is_nan() {
201            "'NaN'".to_string()
202        } else if self.is_infinite() {
203            if *self > 0.0 {
204                "'Infinity'".to_string()
205            } else {
206                "'-Infinity'".to_string()
207            }
208        } else {
209            self.to_string()
210        }
211    }
212}
213
214// =============================================================================
215// Boolean implementation
216// =============================================================================
217
218impl ToSqlParam for bool {
219    fn encode_param(&self) -> Option<Vec<u8>> {
220        Some(vec![u8::from(*self)])
221    }
222
223    fn sql_oid(&self) -> Oid {
224        oids::BOOL
225    }
226
227    fn to_sql_literal(&self) -> String {
228        if *self { "TRUE" } else { "FALSE" }.to_string()
229    }
230}
231
232// =============================================================================
233// String implementations
234// =============================================================================
235
236impl ToSqlParam for str {
237    fn encode_param(&self) -> Option<Vec<u8>> {
238        Some(self.as_bytes().to_vec())
239    }
240
241    fn sql_oid(&self) -> Oid {
242        oids::TEXT
243    }
244
245    fn to_sql_literal(&self) -> String {
246        // Escape single quotes by doubling them
247        format!("'{}'", self.replace('\'', "''"))
248    }
249}
250
251impl ToSqlParam for String {
252    fn encode_param(&self) -> Option<Vec<u8>> {
253        Some(self.as_bytes().to_vec())
254    }
255
256    fn sql_oid(&self) -> Oid {
257        oids::TEXT
258    }
259
260    fn to_sql_literal(&self) -> String {
261        format!("'{}'", self.replace('\'', "''"))
262    }
263}
264
265impl ToSqlParam for &str {
266    fn encode_param(&self) -> Option<Vec<u8>> {
267        Some(self.as_bytes().to_vec())
268    }
269
270    fn sql_oid(&self) -> Oid {
271        oids::TEXT
272    }
273
274    fn to_sql_literal(&self) -> String {
275        format!("'{}'", self.replace('\'', "''"))
276    }
277}
278
279// =============================================================================
280// Reference implementations
281// =============================================================================
282
283impl<T: ToSqlParam> ToSqlParam for &T {
284    fn encode_param(&self) -> Option<Vec<u8>> {
285        (*self).encode_param()
286    }
287
288    fn sql_oid(&self) -> Oid {
289        (*self).sql_oid()
290    }
291
292    fn to_sql_literal(&self) -> String {
293        (*self).to_sql_literal()
294    }
295}
296
297// =============================================================================
298// Option implementation (for nullable parameters)
299// =============================================================================
300
301impl<T: ToSqlParam> ToSqlParam for Option<T> {
302    fn encode_param(&self) -> Option<Vec<u8>> {
303        match self {
304            Some(value) => value.encode_param(),
305            None => None, // SQL NULL
306        }
307    }
308
309    fn sql_oid(&self) -> Oid {
310        match self {
311            Some(value) => value.sql_oid(),
312            // For NULL we leave the OID unspecified — server infers
313            // from context, which is the correct behavior for `WHERE
314            // col = $1` with a NULL binding.
315            None => Oid::new(0),
316        }
317    }
318
319    fn to_sql_literal(&self) -> String {
320        match self {
321            Some(value) => value.to_sql_literal(),
322            None => "NULL".to_string(),
323        }
324    }
325}
326
327// =============================================================================
328// Date/Time implementations
329// =============================================================================
330
331impl ToSqlParam for Date {
332    fn encode_param(&self) -> Option<Vec<u8>> {
333        // Date is stored as i32 Julian day offset from 2000-01-01.
334        // Big-endian per the PG Bind protocol (format code 1).
335        Some(self.to_julian_day().to_be_bytes().to_vec())
336    }
337
338    fn sql_oid(&self) -> Oid {
339        oids::DATE
340    }
341
342    fn to_sql_literal(&self) -> String {
343        format!("DATE '{self}'")
344    }
345}
346
347impl ToSqlParam for Time {
348    fn encode_param(&self) -> Option<Vec<u8>> {
349        // Time is stored as i64 microseconds since midnight.
350        Some(self.to_microseconds().to_be_bytes().to_vec())
351    }
352
353    fn sql_oid(&self) -> Oid {
354        oids::TIME
355    }
356
357    fn to_sql_literal(&self) -> String {
358        format!("TIME '{self}'")
359    }
360}
361
362impl ToSqlParam for Timestamp {
363    fn encode_param(&self) -> Option<Vec<u8>> {
364        // Timestamp is stored as i64 microseconds since 2000-01-01.
365        Some(self.to_microseconds().to_be_bytes().to_vec())
366    }
367
368    fn sql_oid(&self) -> Oid {
369        oids::TIMESTAMP
370    }
371
372    fn to_sql_literal(&self) -> String {
373        format!("TIMESTAMP '{self}'")
374    }
375}
376
377impl ToSqlParam for OffsetTimestamp {
378    fn encode_param(&self) -> Option<Vec<u8>> {
379        // OffsetTimestamp is stored as i64 microseconds UTC since 2000-01-01.
380        Some(self.to_microseconds_utc().to_be_bytes().to_vec())
381    }
382
383    fn sql_oid(&self) -> Oid {
384        oids::TIMESTAMP_TZ
385    }
386
387    fn to_sql_literal(&self) -> String {
388        format!("TIMESTAMPTZ '{self}'")
389    }
390}
391
392// =============================================================================
393// Bytes implementation
394// =============================================================================
395
396impl ToSqlParam for [u8] {
397    fn encode_param(&self) -> Option<Vec<u8>> {
398        Some(self.to_vec())
399    }
400
401    fn sql_oid(&self) -> Oid {
402        oids::BYTE_A
403    }
404
405    #[expect(
406        clippy::format_collect,
407        reason = "readable hex/string formatting loop; refactoring to fold! obscures intent"
408    )]
409    fn to_sql_literal(&self) -> String {
410        // Encode as hex bytea literal
411        let hex_str: String = self.iter().map(|b| format!("{b:02x}")).collect();
412        format!("E'\\\\x{hex_str}'")
413    }
414}
415
416impl ToSqlParam for Vec<u8> {
417    fn encode_param(&self) -> Option<Vec<u8>> {
418        Some(self.clone())
419    }
420
421    fn sql_oid(&self) -> Oid {
422        oids::BYTE_A
423    }
424
425    #[expect(
426        clippy::format_collect,
427        reason = "readable hex/string formatting loop; refactoring to fold! obscures intent"
428    )]
429    fn to_sql_literal(&self) -> String {
430        let hex_str: String = self.iter().map(|b| format!("{b:02x}")).collect();
431        format!("E'\\\\x{hex_str}'")
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438
439    #[test]
440    fn test_i32_encoding() {
441        // Big-endian per PG Bind format code 1.
442        assert_eq!(42i32.encode_param(), Some(vec![0, 0, 0, 42]));
443        assert_eq!((-1i32).encode_param(), Some(vec![255, 255, 255, 255]));
444    }
445
446    #[test]
447    fn test_i64_encoding() {
448        assert_eq!(42i64.encode_param(), Some(vec![0, 0, 0, 0, 0, 0, 0, 42]));
449    }
450
451    #[test]
452    fn test_string_encoding() {
453        assert_eq!("hello".encode_param(), Some(b"hello".to_vec()));
454        assert_eq!(
455            String::from("world").encode_param(),
456            Some(b"world".to_vec())
457        );
458    }
459
460    #[test]
461    fn test_bool_encoding() {
462        assert_eq!(true.encode_param(), Some(vec![1]));
463        assert_eq!(false.encode_param(), Some(vec![0]));
464    }
465
466    #[test]
467    fn test_option_encoding() {
468        // Big-endian per PG Bind format code 1.
469        assert_eq!(Some(42i32).encode_param(), Some(vec![0, 0, 0, 42]));
470        assert_eq!(None::<i32>.encode_param(), None);
471    }
472
473    #[test]
474    fn test_reference_encoding() {
475        let value = 42i32;
476        assert_eq!(value.encode_param(), Some(vec![0, 0, 0, 42]));
477        assert_eq!((&&value).encode_param(), Some(vec![0, 0, 0, 42]));
478    }
479}