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}