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//! - 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}