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}