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