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//! - Bytes: `&[u8]`, `Vec<u8>`
34//! - Date/time types: `Date`, `Time`, `Timestamp`, `OffsetTimestamp`
35//! - `Interval`
36//! - `Numeric` — **whole numbers only (`scale == 0`)**; Hyper rejects
37//! scaled binary NUMERIC params (see the `Numeric` impl and issue #132)
38//! - `serde_json::Value` (binds as PostgreSQL `json`)
39//! - `Option<T>` where `T: ToSqlParam` (for nullable parameters)
40//! - `&T` where `T: ToSqlParam`
41//!
42//! Note: `Geography` does **not** implement `ToSqlParam` — Hyper has no
43//! PostgreSQL-binary input function for the geography type (issue #133).
44//! Use the [`Inserter`](crate::Inserter) (`IntoValue`) path to write
45//! geography values instead.
46//!
47//! # Example
48//!
49//! ```no_run
50//! use hyperdb_api::{Connection, CreateMode, ToSqlParam, Result};
51//!
52//! fn find_user(conn: &Connection, user_id: i32, name: &str) -> Result<()> {
53//! // Multiple parameters with different types
54//! let result = conn.query_params(
55//! "SELECT * FROM users WHERE id = $1 AND name = $2",
56//! &[&user_id, &name],
57//! )?;
58//! Ok(())
59//! }
60//! ```
61
62use hyperdb_api_core::types::{
63 oids, Date, Interval, Numeric, OffsetTimestamp, Oid, Time, Timestamp,
64};
65
66/// Trait for types that can be used as parameters in parameterized SQL queries.
67///
68/// This trait enables type-safe parameter encoding for use with
69/// [`Connection::query_params`](crate::Connection::query_params) and
70/// [`Connection::command_params`](crate::Connection::command_params).
71///
72/// # Implementing for Custom Types
73///
74/// You can implement this trait for custom types:
75///
76/// ```no_run
77/// # use hyperdb_api::ToSqlParam;
78/// # struct MyType;
79/// # impl MyType { fn to_bytes(&self) -> Vec<u8> { vec![] } }
80/// # impl ToString for MyType { fn to_string(&self) -> String { String::new() } }
81/// impl ToSqlParam for MyType {
82/// fn encode_param(&self) -> Option<Vec<u8>> {
83/// Some(self.to_bytes())
84/// }
85///
86/// fn to_sql_literal(&self) -> String {
87/// format!("'{}'", self.to_string().replace('\'', "''"))
88/// }
89/// }
90/// ```
91pub trait ToSqlParam: Send + Sync {
92 /// Encodes this value as binary bytes for use in parameterized queries.
93 ///
94 /// Returns `None` to represent a SQL NULL value.
95 /// Returns `Some(bytes)` with the binary-encoded value otherwise.
96 fn encode_param(&self) -> Option<Vec<u8>>;
97
98 /// Returns the SQL type OID this parameter should bind as.
99 ///
100 /// The default returns `Oid(0)` (unspecified) which asks the server
101 /// to infer the type from surrounding SQL context. That works for
102 /// clauses like `WHERE column = $1` where the column type is known,
103 /// but not for `INSERT INTO t VALUES ($1, $2)` — those require the
104 /// caller (or the trait impl) to return a concrete OID.
105 ///
106 /// All built-in `ToSqlParam` impls override this with a concrete
107 /// value from [`hyperdb_api_core::types::oids`].
108 fn sql_oid(&self) -> Oid {
109 Oid::new(0)
110 }
111
112 /// Returns the SQL literal representation of this value.
113 ///
114 /// Retained for building DDL statement strings that cannot use
115 /// parameterized queries (e.g. `escape_sql_path` in catalog code).
116 /// The parameterized-query path in
117 /// [`Connection::query_params`](crate::Connection::query_params)
118 /// no longer uses this method — parameters travel as binary bytes
119 /// via `encode_param`.
120 fn to_sql_literal(&self) -> String;
121}
122
123// =============================================================================
124// Integer implementations
125// =============================================================================
126
127impl ToSqlParam for i16 {
128 fn encode_param(&self) -> Option<Vec<u8>> {
129 // PostgreSQL wire-protocol Bind uses big-endian for numeric
130 // binary parameters. (Results come back as little-endian
131 // HyperBinary because we request format code 2 for results;
132 // params use format code 1 = standard PG binary = BE.)
133 Some(self.to_be_bytes().to_vec())
134 }
135
136 fn sql_oid(&self) -> Oid {
137 oids::SMALL_INT
138 }
139
140 fn to_sql_literal(&self) -> String {
141 self.to_string()
142 }
143}
144
145impl ToSqlParam for i32 {
146 fn encode_param(&self) -> Option<Vec<u8>> {
147 Some(self.to_be_bytes().to_vec())
148 }
149
150 fn sql_oid(&self) -> Oid {
151 oids::INT
152 }
153
154 fn to_sql_literal(&self) -> String {
155 self.to_string()
156 }
157}
158
159impl ToSqlParam for i64 {
160 fn encode_param(&self) -> Option<Vec<u8>> {
161 Some(self.to_be_bytes().to_vec())
162 }
163
164 fn sql_oid(&self) -> Oid {
165 oids::BIG_INT
166 }
167
168 fn to_sql_literal(&self) -> String {
169 self.to_string()
170 }
171}
172
173// =============================================================================
174// Float implementations
175// =============================================================================
176
177impl ToSqlParam for f32 {
178 fn encode_param(&self) -> Option<Vec<u8>> {
179 Some(self.to_be_bytes().to_vec())
180 }
181
182 fn sql_oid(&self) -> Oid {
183 oids::FLOAT
184 }
185
186 fn to_sql_literal(&self) -> String {
187 // Handle special float values
188 if self.is_nan() {
189 "'NaN'".to_string()
190 } else if self.is_infinite() {
191 if *self > 0.0 {
192 "'Infinity'".to_string()
193 } else {
194 "'-Infinity'".to_string()
195 }
196 } else {
197 self.to_string()
198 }
199 }
200}
201
202impl ToSqlParam for f64 {
203 fn encode_param(&self) -> Option<Vec<u8>> {
204 Some(self.to_be_bytes().to_vec())
205 }
206
207 fn sql_oid(&self) -> Oid {
208 oids::DOUBLE
209 }
210
211 fn to_sql_literal(&self) -> String {
212 // Handle special float values
213 if self.is_nan() {
214 "'NaN'".to_string()
215 } else if self.is_infinite() {
216 if *self > 0.0 {
217 "'Infinity'".to_string()
218 } else {
219 "'-Infinity'".to_string()
220 }
221 } else {
222 self.to_string()
223 }
224 }
225}
226
227// =============================================================================
228// Boolean implementation
229// =============================================================================
230
231impl ToSqlParam for bool {
232 fn encode_param(&self) -> Option<Vec<u8>> {
233 Some(vec![u8::from(*self)])
234 }
235
236 fn sql_oid(&self) -> Oid {
237 oids::BOOL
238 }
239
240 fn to_sql_literal(&self) -> String {
241 if *self { "TRUE" } else { "FALSE" }.to_string()
242 }
243}
244
245// =============================================================================
246// String implementations
247// =============================================================================
248
249impl ToSqlParam for str {
250 fn encode_param(&self) -> Option<Vec<u8>> {
251 Some(self.as_bytes().to_vec())
252 }
253
254 fn sql_oid(&self) -> Oid {
255 oids::TEXT
256 }
257
258 fn to_sql_literal(&self) -> String {
259 // Escape single quotes by doubling them
260 format!("'{}'", self.replace('\'', "''"))
261 }
262}
263
264impl ToSqlParam for String {
265 fn encode_param(&self) -> Option<Vec<u8>> {
266 Some(self.as_bytes().to_vec())
267 }
268
269 fn sql_oid(&self) -> Oid {
270 oids::TEXT
271 }
272
273 fn to_sql_literal(&self) -> String {
274 format!("'{}'", self.replace('\'', "''"))
275 }
276}
277
278impl ToSqlParam for &str {
279 fn encode_param(&self) -> Option<Vec<u8>> {
280 Some(self.as_bytes().to_vec())
281 }
282
283 fn sql_oid(&self) -> Oid {
284 oids::TEXT
285 }
286
287 fn to_sql_literal(&self) -> String {
288 format!("'{}'", self.replace('\'', "''"))
289 }
290}
291
292// =============================================================================
293// Reference implementations
294// =============================================================================
295
296impl<T: ToSqlParam> ToSqlParam for &T {
297 fn encode_param(&self) -> Option<Vec<u8>> {
298 (*self).encode_param()
299 }
300
301 fn sql_oid(&self) -> Oid {
302 (*self).sql_oid()
303 }
304
305 fn to_sql_literal(&self) -> String {
306 (*self).to_sql_literal()
307 }
308}
309
310// =============================================================================
311// Option implementation (for nullable parameters)
312// =============================================================================
313
314impl<T: ToSqlParam> ToSqlParam for Option<T> {
315 fn encode_param(&self) -> Option<Vec<u8>> {
316 match self {
317 Some(value) => value.encode_param(),
318 None => None, // SQL NULL
319 }
320 }
321
322 fn sql_oid(&self) -> Oid {
323 match self {
324 Some(value) => value.sql_oid(),
325 // For NULL we leave the OID unspecified — server infers
326 // from context, which is the correct behavior for `WHERE
327 // col = $1` with a NULL binding.
328 None => Oid::new(0),
329 }
330 }
331
332 fn to_sql_literal(&self) -> String {
333 match self {
334 Some(value) => value.to_sql_literal(),
335 None => "NULL".to_string(),
336 }
337 }
338}
339
340// =============================================================================
341// Date/Time implementations
342// =============================================================================
343
344impl ToSqlParam for Date {
345 fn encode_param(&self) -> Option<Vec<u8>> {
346 // Date is stored as i32 Julian day offset from 2000-01-01.
347 // Big-endian per the PG Bind protocol (format code 1).
348 Some(self.to_julian_day().to_be_bytes().to_vec())
349 }
350
351 fn sql_oid(&self) -> Oid {
352 oids::DATE
353 }
354
355 fn to_sql_literal(&self) -> String {
356 format!("DATE '{self}'")
357 }
358}
359
360impl ToSqlParam for Time {
361 fn encode_param(&self) -> Option<Vec<u8>> {
362 // Time is stored as i64 microseconds since midnight.
363 Some(self.to_microseconds().to_be_bytes().to_vec())
364 }
365
366 fn sql_oid(&self) -> Oid {
367 oids::TIME
368 }
369
370 fn to_sql_literal(&self) -> String {
371 format!("TIME '{self}'")
372 }
373}
374
375impl ToSqlParam for Timestamp {
376 fn encode_param(&self) -> Option<Vec<u8>> {
377 // Timestamp is stored as i64 microseconds since 2000-01-01.
378 Some(self.to_microseconds().to_be_bytes().to_vec())
379 }
380
381 fn sql_oid(&self) -> Oid {
382 oids::TIMESTAMP
383 }
384
385 fn to_sql_literal(&self) -> String {
386 format!("TIMESTAMP '{self}'")
387 }
388}
389
390impl ToSqlParam for OffsetTimestamp {
391 fn encode_param(&self) -> Option<Vec<u8>> {
392 // OffsetTimestamp is stored as i64 microseconds UTC since 2000-01-01.
393 Some(self.to_microseconds_utc().to_be_bytes().to_vec())
394 }
395
396 fn sql_oid(&self) -> Oid {
397 oids::TIMESTAMP_TZ
398 }
399
400 fn to_sql_literal(&self) -> String {
401 format!("TIMESTAMPTZ '{self}'")
402 }
403}
404
405// =============================================================================
406// Bytes implementation
407// =============================================================================
408
409impl ToSqlParam for [u8] {
410 fn encode_param(&self) -> Option<Vec<u8>> {
411 Some(self.to_vec())
412 }
413
414 fn sql_oid(&self) -> Oid {
415 oids::BYTE_A
416 }
417
418 #[expect(
419 clippy::format_collect,
420 reason = "readable hex/string formatting loop; refactoring to fold! obscures intent"
421 )]
422 fn to_sql_literal(&self) -> String {
423 // Encode as hex bytea literal
424 let hex_str: String = self.iter().map(|b| format!("{b:02x}")).collect();
425 format!("E'\\\\x{hex_str}'")
426 }
427}
428
429impl ToSqlParam for Vec<u8> {
430 fn encode_param(&self) -> Option<Vec<u8>> {
431 Some(self.clone())
432 }
433
434 fn sql_oid(&self) -> Oid {
435 oids::BYTE_A
436 }
437
438 #[expect(
439 clippy::format_collect,
440 reason = "readable hex/string formatting loop; refactoring to fold! obscures intent"
441 )]
442 fn to_sql_literal(&self) -> String {
443 let hex_str: String = self.iter().map(|b| format!("{b:02x}")).collect();
444 format!("E'\\\\x{hex_str}'")
445 }
446}
447
448// =============================================================================
449// Numeric implementation
450// =============================================================================
451
452/// Encode a whole-number (`scale == 0`) `Numeric` as PostgreSQL binary NUMERIC.
453///
454/// Header (i16 BE): `ndigits`, `weight`, `sign` (0x0000 pos / 0x4000 neg),
455/// `dscale = 0`; then `ndigits` base-10000 groups (i16 BE, most-significant
456/// first). The `weight` of the most-significant group is `ndigits - 1` (it
457/// sits at base-10000 position `ndigits-1`), and `dscale` is 0 because there
458/// are no fractional digits.
459///
460/// This handles ONLY `scale == 0`. Correctly encoding a scaled NUMERIC
461/// requires decomposing the *decimal* representation into base-10000 groups
462/// aligned on the decimal point (not decomposing the unscaled integer) — that
463/// is out of scope here because Hyper rejects scaled binary NUMERIC params
464/// regardless (see [`ToSqlParam for Numeric`] and #132). The caller is
465/// responsible for only invoking this with `scale == 0`.
466#[expect(
467 clippy::cast_possible_truncation,
468 clippy::cast_possible_wrap,
469 reason = "an i128 spans at most ~39 decimal digits → ≤10 base-10000 groups; \
470 ndigits and weight always fit in i16"
471)]
472fn pg_numeric_encode_unscaled(unscaled: i128) -> Vec<u8> {
473 let sign_neg = unscaled < 0;
474 let mut mag = unscaled.unsigned_abs();
475
476 // Decompose the integer magnitude into base-10000 groups, least-significant
477 // first, then reverse to most-significant first.
478 let mut groups: Vec<i16> = Vec::new();
479 while mag > 0 {
480 groups.push((mag % 10000) as i16);
481 mag /= 10000;
482 }
483 groups.reverse(); // empty when unscaled == 0
484
485 let ndigits = groups.len() as i16;
486 let weight = if groups.is_empty() { 0 } else { ndigits - 1 };
487
488 let mut buf = Vec::with_capacity(8 + groups.len() * 2);
489 buf.extend_from_slice(&ndigits.to_be_bytes());
490 buf.extend_from_slice(&weight.to_be_bytes());
491 buf.extend_from_slice(&(if sign_neg { 0x4000_i16 } else { 0 }).to_be_bytes());
492 buf.extend_from_slice(&0_i16.to_be_bytes()); // dscale = 0 (whole number)
493 for g in groups {
494 buf.extend_from_slice(&g.to_be_bytes());
495 }
496 buf
497}
498
499impl ToSqlParam for Numeric {
500 /// Binds as PostgreSQL binary NUMERIC. **Only `scale() == 0` (whole
501 /// numbers) is supported.**
502 ///
503 /// Hyper rejects scaled binary NUMERIC params at query time with SQLSTATE
504 /// `0A000` ("cannot handle truncation when reading numerics") — verified
505 /// empirically, and regardless of an explicit `CAST`. So a faithful scaled
506 /// encoder would never succeed anyway; full scaled support is tracked in
507 /// #132.
508 ///
509 /// For `scale() > 0` this returns a header whose `dscale` is set to the
510 /// true scale. The byte payload is therefore NOT a correct PostgreSQL
511 /// NUMERIC for the value (correct scaled encoding requires decimal-aligned
512 /// base-10000 grouping, deferred to #132) — but because `dscale > 0`, Hyper
513 /// rejects it server-side before it can be misinterpreted. The net effect
514 /// is fail-fast: a scaled param errors clearly instead of silently binding
515 /// a wrong whole number.
516 fn encode_param(&self) -> Option<Vec<u8>> {
517 if self.scale() == 0 {
518 return Some(pg_numeric_encode_unscaled(self.unscaled_value()));
519 }
520 // scale > 0: emit the unscaled digits but with dscale = scale so the
521 // server rejects it (0A000) rather than reading a mis-scaled integer.
522 // These bytes are intentionally server-rejected, not a valid value;
523 // see the doc comment and #132.
524 let mut buf = pg_numeric_encode_unscaled(self.unscaled_value());
525 // Overwrite the dscale field (bytes 6..8) with the true scale.
526 let dscale = i16::from(self.scale()).to_be_bytes();
527 buf[6] = dscale[0];
528 buf[7] = dscale[1];
529 Some(buf)
530 }
531 fn sql_oid(&self) -> Oid {
532 oids::NUMERIC
533 }
534 fn to_sql_literal(&self) -> String {
535 self.to_string()
536 } // Display = decimal string
537}
538
539// =============================================================================
540// Interval implementation
541// =============================================================================
542
543impl ToSqlParam for Interval {
544 fn encode_param(&self) -> Option<Vec<u8>> {
545 // PG interval binary (Bind format code 1): i64 microseconds, i32 days,
546 // i32 months — all BIG-endian. NB this differs from Hyper's HyperBinary
547 // `Interval::encode()` which is the same field order but LITTLE-endian.
548 let mut buf = Vec::with_capacity(16);
549 buf.extend_from_slice(&self.microseconds().to_be_bytes());
550 buf.extend_from_slice(&self.days().to_be_bytes());
551 buf.extend_from_slice(&self.months().to_be_bytes());
552 Some(buf)
553 }
554 fn sql_oid(&self) -> Oid {
555 oids::INTERVAL
556 }
557 fn to_sql_literal(&self) -> String {
558 format!("INTERVAL '{self}'")
559 }
560}
561
562// =============================================================================
563// JSON implementation
564// =============================================================================
565
566impl ToSqlParam for serde_json::Value {
567 fn encode_param(&self) -> Option<Vec<u8>> {
568 // PG `json` binary form == the UTF-8 text. (jsonb has a leading
569 // version byte; `json` does not, and oids::JSON is `json`.)
570 // Value::to_string() is compact (no whitespace, no trailing newline)
571 // and correctly escapes embedded quotes — exactly the wire form needed.
572 Some(self.to_string().into_bytes())
573 }
574 fn sql_oid(&self) -> Oid {
575 oids::JSON
576 }
577 fn to_sql_literal(&self) -> String {
578 format!("'{}'", self.to_string().replace('\'', "''"))
579 }
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585
586 #[test]
587 fn test_i32_encoding() {
588 // Big-endian per PG Bind format code 1.
589 assert_eq!(42i32.encode_param(), Some(vec![0, 0, 0, 42]));
590 assert_eq!((-1i32).encode_param(), Some(vec![255, 255, 255, 255]));
591 }
592
593 #[test]
594 fn test_i64_encoding() {
595 assert_eq!(42i64.encode_param(), Some(vec![0, 0, 0, 0, 0, 0, 0, 42]));
596 }
597
598 #[test]
599 fn test_string_encoding() {
600 assert_eq!("hello".encode_param(), Some(b"hello".to_vec()));
601 assert_eq!(
602 String::from("world").encode_param(),
603 Some(b"world".to_vec())
604 );
605 }
606
607 #[test]
608 fn test_bool_encoding() {
609 assert_eq!(true.encode_param(), Some(vec![1]));
610 assert_eq!(false.encode_param(), Some(vec![0]));
611 }
612
613 #[test]
614 fn test_option_encoding() {
615 // Big-endian per PG Bind format code 1.
616 assert_eq!(Some(42i32).encode_param(), Some(vec![0, 0, 0, 42]));
617 assert_eq!(None::<i32>.encode_param(), None);
618 }
619
620 #[test]
621 fn test_reference_encoding() {
622 let value = 42i32;
623 assert_eq!(value.encode_param(), Some(vec![0, 0, 0, 42]));
624 assert_eq!((&&value).encode_param(), Some(vec![0, 0, 0, 42]));
625 }
626
627 #[test]
628 fn test_pg_numeric_encode_unscaled() {
629 // 42 → ndigits=1, weight=0, sign=0, dscale=0, group=42
630 assert_eq!(
631 pg_numeric_encode_unscaled(42),
632 vec![0, 1, 0, 0, 0, 0, 0, 0, 0, 42]
633 );
634
635 // 0 → ndigits=0, weight=0, sign=0, dscale=0 (empty digit list)
636 assert_eq!(pg_numeric_encode_unscaled(0), vec![0, 0, 0, 0, 0, 0, 0, 0]);
637
638 // -1 → ndigits=1, weight=0, sign=0x4000, dscale=0, group=1
639 assert_eq!(
640 pg_numeric_encode_unscaled(-1),
641 vec![0, 1, 0, 0, 0x40, 0, 0, 0, 0, 1]
642 );
643
644 // 123456789 = 1*10000^2 + 2345*10000 + 6789
645 // → ndigits=3, weight=2, sign=0, dscale=0, groups=[1, 2345, 6789]
646 assert_eq!(
647 pg_numeric_encode_unscaled(123_456_789),
648 vec![
649 0, 3, // ndigits=3
650 0, 2, // weight=2
651 0, 0, // sign=0
652 0, 0, // dscale=0
653 0, 1, // group 1
654 9, 41, // group 2345 (0x0929)
655 26, 133 // group 6789 (0x1A85)
656 ]
657 );
658 }
659
660 #[test]
661 fn test_numeric_scale0_encode_param() {
662 // The scale=0 ToSqlParam path produces the canonical whole-number form.
663 assert_eq!(
664 Numeric::new(42, 0).encode_param(),
665 Some(vec![0, 1, 0, 0, 0, 0, 0, 0, 0, 42])
666 );
667 }
668
669 #[test]
670 fn test_numeric_scaled_sets_dscale_for_rejection() {
671 // For scale>0, encode_param sets dscale = true scale so the server
672 // REJECTS the param (0A000). These bytes are intentionally NOT a valid
673 // representation of 1.23 — correct scaled encoding is #132. We only
674 // assert the dscale field (bytes 6..8) carries the scale, which is what
675 // triggers Hyper's fail-fast rejection.
676 let bytes = Numeric::new(123, 2).encode_param().expect("some");
677 assert_eq!(&bytes[6..8], &[0, 2], "dscale must equal the true scale");
678 assert_ne!(&bytes[6..8], &[0, 0], "must not look like a whole number");
679 }
680
681 #[test]
682 fn test_interval_encoding() {
683 // Interval::new(months, days, microseconds)
684 let interval = Interval::new(2, 5, 0);
685 // PG binary: [us:i64 BE][days:i32 BE][months:i32 BE]
686 assert_eq!(
687 interval.encode_param(),
688 Some(vec![
689 0, 0, 0, 0, 0, 0, 0, 0, // us = 0
690 0, 0, 0, 5, // days = 5
691 0, 0, 0, 2 // months = 2
692 ])
693 );
694 }
695
696 #[test]
697 fn test_json_encoding() {
698 let json = serde_json::json!({"a": 1});
699 // UTF-8 bytes of compact JSON string
700 assert_eq!(json.encode_param(), Some(br#"{"a":1}"#.to_vec()));
701 }
702}