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` (any scale)
37//! - `Geography` (binds as WKT)
38//! - `serde_json::Value` (binds as PostgreSQL `json`)
39//! - `Option<T>` where `T: ToSqlParam` (for nullable parameters)
40//! - `&T` where `T: ToSqlParam`
41//!
42//! # Wire format
43//!
44//! Parameters travel as PostgreSQL **binary** (format code `1`) by default.
45//! Two types have no binary input function in Hyper and bind as **text**
46//! (format code `0`) instead — see [`ParamFormat`]:
47//!
48//! - a `Numeric` with `scale() > 0`, and
49//! - `Geography`.
50//!
51//! The `Bind` message carries a per-parameter format-code array, so a single
52//! statement can mix both and every other parameter keeps the binary fast
53//! path.
54//!
55//! # Example
56//!
57//! ```no_run
58//! use hyperdb_api::{Connection, CreateMode, ToSqlParam, Result};
59//!
60//! fn find_user(conn: &Connection, user_id: i32, name: &str) -> Result<()> {
61//!     // Multiple parameters with different types
62//!     let result = conn.query_params(
63//!         "SELECT * FROM users WHERE id = $1 AND name = $2",
64//!         &[&user_id, &name],
65//!     )?;
66//!     Ok(())
67//! }
68//! ```
69//!
70//! # Mapping parameterized results into structs
71//!
72//! [`query_params`](crate::Connection::query_params) returns raw
73//! [`Row`](crate::Row)s. To map a parameterized query's results straight into
74//! a [`FromRow`](crate::FromRow) struct in one call, use the `_as_params`
75//! variants — [`fetch_one_as_params`](crate::Connection::fetch_one_as_params),
76//! [`fetch_all_as_params`](crate::Connection::fetch_all_as_params), and
77//! [`stream_as_params`](crate::Connection::stream_as_params) (and their
78//! [`AsyncConnection`](crate::AsyncConnection) equivalents).
79
80pub use hyperdb_api_core::client::ParamFormat;
81use hyperdb_api_core::types::{
82    Date, Geography, Interval, Numeric, OffsetTimestamp, Oid, Time, Timestamp, oids,
83};
84
85/// Trait for types that can be used as parameters in parameterized SQL queries.
86///
87/// This trait enables type-safe parameter encoding for use with
88/// [`Connection::query_params`](crate::Connection::query_params) and
89/// [`Connection::command_params`](crate::Connection::command_params), and with
90/// the struct-mapping variants
91/// [`fetch_one_as_params`](crate::Connection::fetch_one_as_params),
92/// [`fetch_all_as_params`](crate::Connection::fetch_all_as_params), and
93/// [`stream_as_params`](crate::Connection::stream_as_params).
94///
95/// # Implementing for Custom Types
96///
97/// You can implement this trait for custom types:
98///
99/// ```no_run
100/// # use hyperdb_api::ToSqlParam;
101/// # struct MyType;
102/// # impl MyType { fn to_bytes(&self) -> Vec<u8> { vec![] } }
103/// # impl ToString for MyType { fn to_string(&self) -> String { String::new() } }
104/// impl ToSqlParam for MyType {
105///     fn encode_param(&self) -> Option<Vec<u8>> {
106///         Some(self.to_bytes())
107///     }
108///
109///     fn to_sql_literal(&self) -> String {
110///         format!("'{}'", self.to_string().replace('\'', "''"))
111///     }
112/// }
113/// ```
114pub trait ToSqlParam: Send + Sync {
115    /// Encodes this value as the wire bytes for a bound parameter.
116    ///
117    /// Returns `None` to represent a SQL NULL value.
118    /// Returns `Some(bytes)` with the encoded value otherwise.
119    ///
120    /// The bytes must match whatever [`Self::param_format`] returns —
121    /// big-endian PostgreSQL binary for [`ParamFormat::Binary`] (the
122    /// default), UTF-8 SQL text without surrounding quotes for
123    /// [`ParamFormat::Text`].
124    fn encode_param(&self) -> Option<Vec<u8>>;
125
126    /// Returns the wire format [`Self::encode_param`] produced.
127    ///
128    /// Defaults to [`ParamFormat::Binary`]. Override it only for types
129    /// Hyper cannot read in binary — a scaled `Numeric` and `Geography`
130    /// are the two built-in cases.
131    fn param_format(&self) -> ParamFormat {
132        ParamFormat::Binary
133    }
134
135    /// Returns the SQL type OID this parameter should bind as.
136    ///
137    /// The default returns `Oid(0)` (unspecified) which asks the server
138    /// to infer the type from surrounding SQL context. That works for
139    /// clauses like `WHERE column = $1` where the column type is known,
140    /// but not for `INSERT INTO t VALUES ($1, $2)` — those require the
141    /// caller (or the trait impl) to return a concrete OID.
142    ///
143    /// All built-in `ToSqlParam` impls override this with a concrete
144    /// value from [`hyperdb_api_core::types::oids`].
145    fn sql_oid(&self) -> Oid {
146        Oid::new(0)
147    }
148
149    /// Returns the SQL literal representation of this value.
150    ///
151    /// Retained for building DDL statement strings that cannot use
152    /// parameterized queries (e.g. `escape_sql_path` in catalog code).
153    /// The parameterized-query path in
154    /// [`Connection::query_params`](crate::Connection::query_params)
155    /// no longer uses this method — parameters travel as binary bytes
156    /// via `encode_param`.
157    fn to_sql_literal(&self) -> String;
158}
159
160// =============================================================================
161// Integer implementations
162// =============================================================================
163
164impl ToSqlParam for i16 {
165    fn encode_param(&self) -> Option<Vec<u8>> {
166        // PostgreSQL wire-protocol Bind uses big-endian for numeric
167        // binary parameters. (Results come back as little-endian
168        // HyperBinary because we request format code 2 for results;
169        // params use format code 1 = standard PG binary = BE.)
170        Some(self.to_be_bytes().to_vec())
171    }
172
173    fn sql_oid(&self) -> Oid {
174        oids::SMALL_INT
175    }
176
177    fn to_sql_literal(&self) -> String {
178        self.to_string()
179    }
180}
181
182impl ToSqlParam for i32 {
183    fn encode_param(&self) -> Option<Vec<u8>> {
184        Some(self.to_be_bytes().to_vec())
185    }
186
187    fn sql_oid(&self) -> Oid {
188        oids::INT
189    }
190
191    fn to_sql_literal(&self) -> String {
192        self.to_string()
193    }
194}
195
196impl ToSqlParam for i64 {
197    fn encode_param(&self) -> Option<Vec<u8>> {
198        Some(self.to_be_bytes().to_vec())
199    }
200
201    fn sql_oid(&self) -> Oid {
202        oids::BIG_INT
203    }
204
205    fn to_sql_literal(&self) -> String {
206        self.to_string()
207    }
208}
209
210// =============================================================================
211// Float implementations
212// =============================================================================
213
214impl ToSqlParam for f32 {
215    fn encode_param(&self) -> Option<Vec<u8>> {
216        Some(self.to_be_bytes().to_vec())
217    }
218
219    fn sql_oid(&self) -> Oid {
220        oids::FLOAT
221    }
222
223    fn to_sql_literal(&self) -> String {
224        // Handle special float values
225        if self.is_nan() {
226            "'NaN'".to_string()
227        } else if self.is_infinite() {
228            if *self > 0.0 {
229                "'Infinity'".to_string()
230            } else {
231                "'-Infinity'".to_string()
232            }
233        } else {
234            self.to_string()
235        }
236    }
237}
238
239impl ToSqlParam for f64 {
240    fn encode_param(&self) -> Option<Vec<u8>> {
241        Some(self.to_be_bytes().to_vec())
242    }
243
244    fn sql_oid(&self) -> Oid {
245        oids::DOUBLE
246    }
247
248    fn to_sql_literal(&self) -> String {
249        // Handle special float values
250        if self.is_nan() {
251            "'NaN'".to_string()
252        } else if self.is_infinite() {
253            if *self > 0.0 {
254                "'Infinity'".to_string()
255            } else {
256                "'-Infinity'".to_string()
257            }
258        } else {
259            self.to_string()
260        }
261    }
262}
263
264// =============================================================================
265// Boolean implementation
266// =============================================================================
267
268impl ToSqlParam for bool {
269    fn encode_param(&self) -> Option<Vec<u8>> {
270        Some(vec![u8::from(*self)])
271    }
272
273    fn sql_oid(&self) -> Oid {
274        oids::BOOL
275    }
276
277    fn to_sql_literal(&self) -> String {
278        if *self { "TRUE" } else { "FALSE" }.to_string()
279    }
280}
281
282// =============================================================================
283// String implementations
284// =============================================================================
285
286impl ToSqlParam for str {
287    fn encode_param(&self) -> Option<Vec<u8>> {
288        Some(self.as_bytes().to_vec())
289    }
290
291    fn sql_oid(&self) -> Oid {
292        oids::TEXT
293    }
294
295    fn to_sql_literal(&self) -> String {
296        // Escape single quotes by doubling them
297        format!("'{}'", self.replace('\'', "''"))
298    }
299}
300
301impl ToSqlParam for String {
302    fn encode_param(&self) -> Option<Vec<u8>> {
303        Some(self.as_bytes().to_vec())
304    }
305
306    fn sql_oid(&self) -> Oid {
307        oids::TEXT
308    }
309
310    fn to_sql_literal(&self) -> String {
311        format!("'{}'", self.replace('\'', "''"))
312    }
313}
314
315impl ToSqlParam for &str {
316    fn encode_param(&self) -> Option<Vec<u8>> {
317        Some(self.as_bytes().to_vec())
318    }
319
320    fn sql_oid(&self) -> Oid {
321        oids::TEXT
322    }
323
324    fn to_sql_literal(&self) -> String {
325        format!("'{}'", self.replace('\'', "''"))
326    }
327}
328
329// =============================================================================
330// Reference implementations
331// =============================================================================
332
333impl<T: ToSqlParam> ToSqlParam for &T {
334    fn encode_param(&self) -> Option<Vec<u8>> {
335        (*self).encode_param()
336    }
337
338    fn param_format(&self) -> ParamFormat {
339        (*self).param_format()
340    }
341
342    fn sql_oid(&self) -> Oid {
343        (*self).sql_oid()
344    }
345
346    fn to_sql_literal(&self) -> String {
347        (*self).to_sql_literal()
348    }
349}
350
351// =============================================================================
352// Option implementation (for nullable parameters)
353// =============================================================================
354
355impl<T: ToSqlParam> ToSqlParam for Option<T> {
356    fn encode_param(&self) -> Option<Vec<u8>> {
357        match self {
358            Some(value) => value.encode_param(),
359            None => None, // SQL NULL
360        }
361    }
362
363    fn param_format(&self) -> ParamFormat {
364        match self {
365            Some(value) => value.param_format(),
366            // A NULL carries a -1 length and no bytes, so its format code is
367            // never consulted. Binary keeps the all-binary broadcast intact.
368            None => ParamFormat::Binary,
369        }
370    }
371
372    fn sql_oid(&self) -> Oid {
373        match self {
374            Some(value) => value.sql_oid(),
375            // For NULL we leave the OID unspecified — server infers
376            // from context, which is the correct behavior for `WHERE
377            // col = $1` with a NULL binding.
378            None => Oid::new(0),
379        }
380    }
381
382    fn to_sql_literal(&self) -> String {
383        match self {
384            Some(value) => value.to_sql_literal(),
385            None => "NULL".to_string(),
386        }
387    }
388}
389
390// =============================================================================
391// Date/Time implementations
392// =============================================================================
393
394impl ToSqlParam for Date {
395    fn encode_param(&self) -> Option<Vec<u8>> {
396        // Date is stored as i32 Julian day offset from 2000-01-01.
397        // Big-endian per the PG Bind protocol (format code 1).
398        Some(self.to_julian_day().to_be_bytes().to_vec())
399    }
400
401    fn sql_oid(&self) -> Oid {
402        oids::DATE
403    }
404
405    fn to_sql_literal(&self) -> String {
406        format!("DATE '{self}'")
407    }
408}
409
410impl ToSqlParam for Time {
411    fn encode_param(&self) -> Option<Vec<u8>> {
412        // Time is stored as i64 microseconds since midnight.
413        Some(self.to_microseconds().to_be_bytes().to_vec())
414    }
415
416    fn sql_oid(&self) -> Oid {
417        oids::TIME
418    }
419
420    fn to_sql_literal(&self) -> String {
421        format!("TIME '{self}'")
422    }
423}
424
425impl ToSqlParam for Timestamp {
426    fn encode_param(&self) -> Option<Vec<u8>> {
427        // Timestamp is stored as i64 microseconds since 2000-01-01.
428        Some(self.to_microseconds().to_be_bytes().to_vec())
429    }
430
431    fn sql_oid(&self) -> Oid {
432        oids::TIMESTAMP
433    }
434
435    fn to_sql_literal(&self) -> String {
436        format!("TIMESTAMP '{self}'")
437    }
438}
439
440impl ToSqlParam for OffsetTimestamp {
441    fn encode_param(&self) -> Option<Vec<u8>> {
442        // OffsetTimestamp is stored as i64 microseconds UTC since 2000-01-01.
443        Some(self.to_microseconds_utc().to_be_bytes().to_vec())
444    }
445
446    fn sql_oid(&self) -> Oid {
447        oids::TIMESTAMP_TZ
448    }
449
450    fn to_sql_literal(&self) -> String {
451        format!("TIMESTAMPTZ '{self}'")
452    }
453}
454
455// =============================================================================
456// Bytes implementation
457// =============================================================================
458
459impl ToSqlParam for [u8] {
460    fn encode_param(&self) -> Option<Vec<u8>> {
461        Some(self.to_vec())
462    }
463
464    fn sql_oid(&self) -> Oid {
465        oids::BYTE_A
466    }
467
468    #[expect(
469        clippy::format_collect,
470        reason = "readable hex/string formatting loop; refactoring to fold! obscures intent"
471    )]
472    fn to_sql_literal(&self) -> String {
473        // Encode as hex bytea literal
474        let hex_str: String = self.iter().map(|b| format!("{b:02x}")).collect();
475        format!("E'\\\\x{hex_str}'")
476    }
477}
478
479impl ToSqlParam for Vec<u8> {
480    fn encode_param(&self) -> Option<Vec<u8>> {
481        Some(self.clone())
482    }
483
484    fn sql_oid(&self) -> Oid {
485        oids::BYTE_A
486    }
487
488    #[expect(
489        clippy::format_collect,
490        reason = "readable hex/string formatting loop; refactoring to fold! obscures intent"
491    )]
492    fn to_sql_literal(&self) -> String {
493        let hex_str: String = self.iter().map(|b| format!("{b:02x}")).collect();
494        format!("E'\\\\x{hex_str}'")
495    }
496}
497
498// =============================================================================
499// Numeric implementation
500// =============================================================================
501
502/// The integer type behind [`Numeric::unscaled_value`], and the only input to
503/// [`pg_numeric_encode_unscaled`].
504///
505/// Naming it lets [`MAX_NUMERIC_GROUPS`] be *derived* from its width. If
506/// `Numeric` ever widens past `i128`, the call site stops compiling (a
507/// mismatched argument type) instead of silently overflowing a stack buffer,
508/// and the fix — widening this alias — resizes the buffer automatically.
509type UnscaledValue = i128;
510
511/// Base-10000 groups needed for the widest [`UnscaledValue`] magnitude.
512///
513/// The bound is exact, not generous: an `i128` magnitude spans at most 39
514/// decimal digits, so both `i128::MAX` and `i128::MIN` decompose to precisely
515/// 10 groups with no slack.
516const MAX_NUMERIC_GROUPS: usize = {
517    let decimal_digits = UnscaledValue::MAX.ilog10() as usize + 1;
518    // Each base-10000 group holds 4 decimal digits.
519    decimal_digits.div_ceil(4)
520};
521
522/// Encode a whole-number (`scale == 0`) `Numeric` as PostgreSQL binary NUMERIC.
523///
524/// Header (i16 BE): `ndigits`, `weight`, `sign` (0x0000 pos / 0x4000 neg),
525/// `dscale = 0`; then `ndigits` base-10000 groups (i16 BE, most-significant
526/// first). The `weight` of the most-significant group is `ndigits - 1` (it
527/// sits at base-10000 position `ndigits-1`), and `dscale` is 0 because there
528/// are no fractional digits.
529///
530/// This handles ONLY `scale == 0` — a scaled `Numeric` binds as text instead
531/// (see [`ToSqlParam for Numeric`]), so there is no scaled binary encoder.
532/// The caller is responsible for only invoking this with `scale == 0`.
533fn pg_numeric_encode_unscaled(unscaled: UnscaledValue) -> Vec<u8> {
534    let sign_neg = unscaled < 0;
535    let mut mag = unscaled.unsigned_abs();
536
537    // Decompose the integer magnitude into base-10000 groups, least-significant
538    // first. A stack array keeps the whole encode to a single allocation (the
539    // returned buffer) — measurably cheaper than growing a `Vec` and reversing
540    // it, and this is the hot path for every whole-number NUMERIC parameter.
541    let mut groups = [0_i16; MAX_NUMERIC_GROUPS];
542    let mut ngroups = 0_usize;
543    while mag > 0 {
544        groups[ngroups] = i16::try_from(mag % 10000)
545            .expect("a base-10000 remainder is 0..=9999, which fits in i16");
546        mag /= 10000;
547        ngroups += 1;
548    }
549
550    let ndigits =
551        i16::try_from(ngroups).expect("an i128 magnitude yields at most 10 base-10000 groups");
552    let weight = if ngroups == 0 { 0 } else { ndigits - 1 };
553
554    let mut buf = Vec::with_capacity(8 + ngroups * 2);
555    buf.extend_from_slice(&ndigits.to_be_bytes());
556    buf.extend_from_slice(&weight.to_be_bytes());
557    buf.extend_from_slice(&(if sign_neg { 0x4000_i16 } else { 0 }).to_be_bytes());
558    buf.extend_from_slice(&0_i16.to_be_bytes()); // dscale = 0 (whole number)
559    // Most-significant group first.
560    for g in groups[..ngroups].iter().rev() {
561        buf.extend_from_slice(&g.to_be_bytes());
562    }
563    buf
564}
565
566impl ToSqlParam for Numeric {
567    /// Binds whole numbers (`scale() == 0`) as PostgreSQL binary NUMERIC and
568    /// scaled decimals (`scale() > 0`) as text.
569    ///
570    /// Hyper has no binary input path for a scaled NUMERIC: a binary NUMERIC
571    /// whose `dscale` exceeds the parameter's resolved scale is rejected with
572    /// SQLSTATE `0A000` ("cannot handle truncation when reading numerics"),
573    /// and a bare `numeric` parameter OID resolves server-side to
574    /// `NUMERIC(1,0)` — scale 0 — so *every* scaled value hits that check,
575    /// whatever the SQL says. Correct nbase-10000 encoding does not help; the
576    /// blocker is type resolution, not the bytes.
577    ///
578    /// Text sidesteps it, which is why [`Self::param_format`] returns
579    /// [`ParamFormat::Text`] and [`Self::sql_oid`] leaves the OID unspecified
580    /// for scaled values. See [`Self::sql_oid`] for what that costs.
581    fn encode_param(&self) -> Option<Vec<u8>> {
582        if self.scale() == 0 {
583            return Some(pg_numeric_encode_unscaled(self.unscaled_value()));
584        }
585        // Display renders the decimal string with exactly `scale` fractional
586        // digits, which is precisely Hyper's text input form for NUMERIC.
587        Some(self.to_string().into_bytes())
588    }
589
590    fn param_format(&self) -> ParamFormat {
591        if self.scale() == 0 {
592            ParamFormat::Binary
593        } else {
594            ParamFormat::Text
595        }
596    }
597
598    /// `NUMERIC` for whole numbers, unspecified (`0`) for scaled values.
599    ///
600    /// A declared `numeric` OID carries no type modifier, and Hyper resolves
601    /// that to `NUMERIC(1,0)` — one digit, no fraction — so declaring it for
602    /// a scaled value fails with `22003` (numeric overflow) before the text
603    /// is even considered. Leaving the OID unspecified lets the server infer
604    /// the type from context, which is what makes `INSERT INTO t VALUES ($1)`
605    /// and `WHERE col = $1` work against a real `NUMERIC(p,s)` column.
606    ///
607    /// The trade-off: with no context to infer from, `SELECT $1` returns the
608    /// parameter as `TEXT` rather than `NUMERIC`. That means
609    /// [`Row::get::<Numeric>`](crate::Row::get) returns `None` — the column
610    /// really is text — while `get::<String>` yields `"1234.56"`. Wrap it —
611    /// `SELECT CAST($1 AS NUMERIC(10,2))` — when the result type matters.
612    /// Two other bare-`$1` contexts reject a scaled value outright with
613    /// `42601` ("unable to deduce parameter type"): `WHERE n IN ($1)` and
614    /// `COALESCE($1, 0)`. And in `WHERE textcol = $1` the inference resolves
615    /// `$1` to text, so the comparison is a *string* comparison: `1234.56`
616    /// matches the literal `'1234.56'` but not `'1234.560'`. Whole numbers
617    /// keep the concrete OID and are unaffected by all of this.
618    ///
619    /// # Prepared statements
620    ///
621    /// `sql_oid` is consulted only on the one-shot
622    /// [`query_params`](crate::Connection::query_params) /
623    /// [`command_params`](crate::Connection::command_params) path, which
624    /// re-parses per call. A [`PreparedStatement`](crate::PreparedStatement)
625    /// fixes its parameter OIDs at
626    /// [`prepare_typed`](crate::Connection::prepare_typed) time, before any
627    /// value exists, so **one prepared statement cannot accept both whole and
628    /// scaled `NUMERIC` values**:
629    ///
630    /// - `prepare_typed(sql, &[oids::NUMERIC])` — whole numbers work; a
631    ///   scaled value fails `22003` (numeric overflow).
632    /// - `prepare_typed(sql, &[Oid::new(0)])` — scaled values work; a whole
633    ///   number fails `0A000` (cannot handle truncation when reading
634    ///   numerics).
635    ///
636    /// Use `query_params` when the scale varies across calls, or pick the OID
637    /// that matches the one class you bind. `Geography` has no such split —
638    /// it declares a concrete OID and always binds as text, so it works on
639    /// both paths.
640    fn sql_oid(&self) -> Oid {
641        if self.scale() == 0 {
642            oids::NUMERIC
643        } else {
644            Oid::new(0)
645        }
646    }
647
648    fn to_sql_literal(&self) -> String {
649        self.to_string()
650    } // Display = decimal string
651}
652
653// =============================================================================
654// Geography implementation
655// =============================================================================
656
657impl ToSqlParam for Geography {
658    /// Binds as WKT text.
659    ///
660    /// Hyper has no PostgreSQL-binary *input* function for `geography`
661    /// (binding one in binary fails with `42883`, "no pg binary input
662    /// function available for type geography"), but it does accept WKT
663    /// through the text path, so [`Self::param_format`] returns
664    /// [`ParamFormat::Text`].
665    ///
666    /// # Hyper-legacy values
667    ///
668    /// A `Geography` read back out of Hyper is in Hyper's proprietary legacy
669    /// format, which this client cannot convert to WKT
670    /// ([`Geography::to_wkt`] fails, independently of parameter binding).
671    /// Binding such a value sends the raw bytes, and the server rejects them
672    /// with `22P02` — "invalid geography format (valid well known text is
673    /// required)" — rather than storing something wrong. Check
674    /// [`Geography::binary_format`] first, or construct the value with
675    /// [`Geography::from_wkt`] / [`Geography::from_wkb`], whose results
676    /// always bind correctly.
677    ///
678    /// # Empty points
679    ///
680    /// `Geography::from_wkt("POINT EMPTY")` re-renders as `MULTIPOINT EMPTY`
681    /// — the underlying WKT writer has no representation for an empty point
682    /// and widens it. That has always been true of [`Geography::to_wkt`];
683    /// binding is the first path where it reaches *stored* data, so a
684    /// round-trip through a parameter returns the widened type. No other
685    /// geometry is affected.
686    fn encode_param(&self) -> Option<Vec<u8>> {
687        match self.to_wkt() {
688            Ok(wkt) => Some(wkt.into_bytes()),
689            // Hyper-legacy bytes: no client-side WKT is possible, so hand the
690            // server bytes it will reject loudly (22P02) instead of guessing.
691            Err(_) => Some(self.as_bytes().to_vec()),
692        }
693    }
694
695    fn param_format(&self) -> ParamFormat {
696        ParamFormat::Text
697    }
698
699    fn sql_oid(&self) -> Oid {
700        oids::GEOGRAPHY
701    }
702
703    /// Renders `CAST('<wkt>' AS TABLEAU.TABGEOGRAPHY)`.
704    ///
705    /// Hyper-legacy bytes have no WKT rendering, and this signature has no
706    /// error channel. Rather than substitute `NULL` — which would silently
707    /// erase the value — the literal carries the lossily-decoded bytes, so
708    /// the server rejects it with `22P02` exactly as
709    /// [`Self::encode_param`] does. Single quotes are doubled in both
710    /// branches, so neither can break out of the literal.
711    fn to_sql_literal(&self) -> String {
712        let text = match self.to_wkt() {
713            Ok(wkt) => wkt,
714            Err(_) => String::from_utf8_lossy(self.as_bytes()).into_owned(),
715        };
716        format!(
717            "CAST('{}' AS TABLEAU.TABGEOGRAPHY)",
718            text.replace('\'', "''")
719        )
720    }
721}
722
723// =============================================================================
724// Interval implementation
725// =============================================================================
726
727impl ToSqlParam for Interval {
728    fn encode_param(&self) -> Option<Vec<u8>> {
729        // PG interval binary (Bind format code 1): i64 microseconds, i32 days,
730        // i32 months — all BIG-endian. NB this differs from Hyper's HyperBinary
731        // `Interval::encode()` which is the same field order but LITTLE-endian.
732        let mut buf = Vec::with_capacity(16);
733        buf.extend_from_slice(&self.microseconds().to_be_bytes());
734        buf.extend_from_slice(&self.days().to_be_bytes());
735        buf.extend_from_slice(&self.months().to_be_bytes());
736        Some(buf)
737    }
738    fn sql_oid(&self) -> Oid {
739        oids::INTERVAL
740    }
741    fn to_sql_literal(&self) -> String {
742        format!("INTERVAL '{self}'")
743    }
744}
745
746// =============================================================================
747// JSON implementation
748// =============================================================================
749
750impl ToSqlParam for serde_json::Value {
751    fn encode_param(&self) -> Option<Vec<u8>> {
752        // PG `json` binary form == the UTF-8 text. (jsonb has a leading
753        // version byte; `json` does not, and oids::JSON is `json`.)
754        // Value::to_string() is compact (no whitespace, no trailing newline)
755        // and correctly escapes embedded quotes — exactly the wire form needed.
756        Some(self.to_string().into_bytes())
757    }
758    fn sql_oid(&self) -> Oid {
759        oids::JSON
760    }
761    fn to_sql_literal(&self) -> String {
762        format!("'{}'", self.to_string().replace('\'', "''"))
763    }
764}
765
766#[cfg(test)]
767mod tests {
768    use super::*;
769
770    #[test]
771    fn test_i32_encoding() {
772        // Big-endian per PG Bind format code 1.
773        assert_eq!(42i32.encode_param(), Some(vec![0, 0, 0, 42]));
774        assert_eq!((-1i32).encode_param(), Some(vec![255, 255, 255, 255]));
775    }
776
777    #[test]
778    fn test_i64_encoding() {
779        assert_eq!(42i64.encode_param(), Some(vec![0, 0, 0, 0, 0, 0, 0, 42]));
780    }
781
782    #[test]
783    fn test_string_encoding() {
784        assert_eq!("hello".encode_param(), Some(b"hello".to_vec()));
785        assert_eq!(
786            String::from("world").encode_param(),
787            Some(b"world".to_vec())
788        );
789    }
790
791    #[test]
792    fn test_bool_encoding() {
793        assert_eq!(true.encode_param(), Some(vec![1]));
794        assert_eq!(false.encode_param(), Some(vec![0]));
795    }
796
797    #[test]
798    fn test_option_encoding() {
799        // Big-endian per PG Bind format code 1.
800        assert_eq!(Some(42i32).encode_param(), Some(vec![0, 0, 0, 42]));
801        assert_eq!(None::<i32>.encode_param(), None);
802    }
803
804    #[test]
805    fn test_reference_encoding() {
806        let value = 42i32;
807        assert_eq!(value.encode_param(), Some(vec![0, 0, 0, 42]));
808        assert_eq!((&&value).encode_param(), Some(vec![0, 0, 0, 42]));
809    }
810
811    #[test]
812    fn test_pg_numeric_encode_unscaled() {
813        // 42 → ndigits=1, weight=0, sign=0, dscale=0, group=42
814        assert_eq!(
815            pg_numeric_encode_unscaled(42),
816            vec![0, 1, 0, 0, 0, 0, 0, 0, 0, 42]
817        );
818
819        // 0 → ndigits=0, weight=0, sign=0, dscale=0 (empty digit list)
820        assert_eq!(pg_numeric_encode_unscaled(0), vec![0, 0, 0, 0, 0, 0, 0, 0]);
821
822        // -1 → ndigits=1, weight=0, sign=0x4000, dscale=0, group=1
823        assert_eq!(
824            pg_numeric_encode_unscaled(-1),
825            vec![0, 1, 0, 0, 0x40, 0, 0, 0, 0, 1]
826        );
827
828        // 123456789 = 1*10000^2 + 2345*10000 + 6789
829        // → ndigits=3, weight=2, sign=0, dscale=0, groups=[1, 2345, 6789]
830        assert_eq!(
831            pg_numeric_encode_unscaled(123_456_789),
832            vec![
833                0, 3, // ndigits=3
834                0, 2, // weight=2
835                0, 0, // sign=0
836                0, 0, // dscale=0
837                0, 1, // group 1
838                9, 41, // group 2345 (0x0929)
839                26, 133 // group 6789 (0x1A85)
840            ]
841        );
842    }
843
844    #[test]
845    fn test_numeric_scale0_encode_param() {
846        // The scale=0 ToSqlParam path produces the canonical whole-number form.
847        assert_eq!(
848            Numeric::new(42, 0).encode_param(),
849            Some(vec![0, 1, 0, 0, 0, 0, 0, 0, 0, 42])
850        );
851    }
852
853    #[test]
854    fn test_numeric_scale0_binds_binary_with_concrete_oid() {
855        let n = Numeric::new(42, 0);
856        assert_eq!(n.param_format(), ParamFormat::Binary);
857        assert_eq!(n.sql_oid(), oids::NUMERIC);
858    }
859
860    #[test]
861    fn test_numeric_scaled_binds_as_text() {
862        // scale>0 travels as the decimal string, with the OID left
863        // unspecified so the server infers a properly-scaled NUMERIC from
864        // context (a declared `numeric` OID resolves to NUMERIC(1,0)).
865        for (unscaled, scale, expected) in [
866            (123_i128, 2_u8, "1.23"),
867            (123_456, 2, "1234.56"),
868            (-987_654_321, 4, "-98765.4321"),
869            (5, 1, "0.5"),
870            (-5, 1, "-0.5"),
871            (1, 10, "0.0000000001"),
872        ] {
873            let n = Numeric::new(unscaled, scale);
874            assert_eq!(
875                n.encode_param(),
876                Some(expected.as_bytes().to_vec()),
877                "Numeric({unscaled}, {scale}) should encode as {expected:?}"
878            );
879            assert_eq!(n.param_format(), ParamFormat::Text);
880            assert_eq!(n.sql_oid(), Oid::new(0));
881        }
882    }
883
884    #[test]
885    fn test_default_param_format_is_binary() {
886        assert_eq!(42i32.param_format(), ParamFormat::Binary);
887        assert_eq!("hi".param_format(), ParamFormat::Binary);
888        // References and Options must forward the inner format rather than
889        // fall back to the trait default. (`&&` so the blanket `&T` impl is
890        // what resolves, not auto-deref to `Numeric`.)
891        let scaled = Numeric::new(123, 2);
892        assert_eq!((&&scaled).param_format(), ParamFormat::Text);
893        assert_eq!(Some(scaled).param_format(), ParamFormat::Text);
894        assert_eq!(None::<Numeric>.param_format(), ParamFormat::Binary);
895    }
896
897    #[test]
898    fn test_geography_encodes_as_wkt_text() {
899        let geo = Geography::from_wkt("POINT(-122.4194 37.7749)").expect("valid WKT");
900        let encoded = geo.encode_param().expect("some");
901        assert_eq!(
902            String::from_utf8(encoded).expect("utf-8"),
903            "POINT(-122.4194 37.7749)"
904        );
905        assert_eq!(geo.param_format(), ParamFormat::Text);
906        assert_eq!(geo.sql_oid(), oids::GEOGRAPHY);
907    }
908
909    #[test]
910    fn test_geography_hyper_legacy_falls_back_to_raw_bytes() {
911        // Legacy bytes cannot be rendered as WKT client-side; we pass them
912        // through so the server rejects them with 22P02 rather than binding
913        // something wrong.
914        let legacy = Geography::from_bytes(vec![0x01, 0x02, 0x03]);
915        assert_eq!(legacy.encode_param(), Some(vec![0x01, 0x02, 0x03]));
916        assert_eq!(legacy.param_format(), ParamFormat::Text);
917    }
918
919    #[test]
920    fn test_interval_encoding() {
921        // Interval::new(months, days, microseconds)
922        let interval = Interval::new(2, 5, 0);
923        // PG binary: [us:i64 BE][days:i32 BE][months:i32 BE]
924        assert_eq!(
925            interval.encode_param(),
926            Some(vec![
927                0, 0, 0, 0, 0, 0, 0, 0, // us = 0
928                0, 0, 0, 5, // days = 5
929                0, 0, 0, 2 // months = 2
930            ])
931        );
932    }
933
934    #[test]
935    fn test_json_encoding() {
936        let json = serde_json::json!({"a": 1});
937        // UTF-8 bytes of compact JSON string
938        assert_eq!(json.encode_param(), Some(br#"{"a":1}"#.to_vec()));
939    }
940}