Skip to main content

cufflink_pgcodec/
bind.rs

1//! Prepare-then-coerce binding: Postgres reports each parameter's type when
2//! the statement is prepared, and every tagged guest parameter is converted
3//! to exactly that type, losslessly, or refused with a message naming it.
4
5use crate::numeric::PgNumeric;
6use crate::row_json::base_type;
7use bytes::BytesMut;
8use chrono::{DateTime, NaiveDate, NaiveDateTime, NaiveTime, Utc};
9use cufflink_types::db::{DbError, DbErrorFields, DbErrorKind, Param};
10use serde_json::Value;
11use std::error::Error;
12use std::str::FromStr;
13use tokio_postgres::types::{to_sql_checked, IsNull, Kind, ToSql, Type};
14use uuid::Uuid;
15
16#[derive(Debug, Clone, PartialEq)]
17pub enum Bound {
18    Null,
19    Bool(bool),
20    I16(i16),
21    I32(i32),
22    I64(i64),
23    F32(f32),
24    F64(f64),
25    /// Sent as its text bytes: text-like types, enum labels, `unknown`.
26    Text(String),
27    Uuid(Uuid),
28    Timestamptz(DateTime<Utc>),
29    Timestamp(NaiveDateTime),
30    Date(NaiveDate),
31    Time(NaiveTime),
32    Json(Value),
33    Numeric(PgNumeric),
34    Bytes(Vec<u8>),
35    TextArray(Vec<Option<String>>),
36    UuidArray(Vec<Option<Uuid>>),
37    I16Array(Vec<Option<i16>>),
38    I32Array(Vec<Option<i32>>),
39    I64Array(Vec<Option<i64>>),
40    BoolArray(Vec<Option<bool>>),
41    F64Array(Vec<Option<f64>>),
42    NumericArray(Vec<Option<PgNumeric>>),
43    JsonArray(Vec<Option<Value>>),
44}
45
46/// Every value is encoded as its type's base: a domain is received by its
47/// base type's input function, and an array of domains carries the domain in
48/// its header but base-encoded elements.
49impl ToSql for Bound {
50    fn to_sql(
51        &self,
52        ty: &Type,
53        out: &mut BytesMut,
54    ) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
55        let ty = base_type(ty);
56        match self {
57            Bound::Null => Ok(IsNull::Yes),
58            Bound::Bool(v) => v.to_sql(ty, out),
59            Bound::I16(v) => v.to_sql(ty, out),
60            Bound::I32(v) => v.to_sql(ty, out),
61            Bound::I64(v) => v.to_sql(ty, out),
62            Bound::F32(v) => v.to_sql(ty, out),
63            Bound::F64(v) => v.to_sql(ty, out),
64            Bound::Text(v) => {
65                out.extend_from_slice(v.as_bytes());
66                Ok(IsNull::No)
67            }
68            Bound::Uuid(v) => v.to_sql(ty, out),
69            Bound::Timestamptz(v) => v.to_sql(ty, out),
70            Bound::Timestamp(v) => v.to_sql(ty, out),
71            Bound::Date(v) => v.to_sql(ty, out),
72            Bound::Time(v) => v.to_sql(ty, out),
73            Bound::Json(v) => v.to_sql(ty, out),
74            Bound::Numeric(v) => v.to_sql(ty, out),
75            Bound::Bytes(v) => v.to_sql(ty, out),
76            Bound::TextArray(v) => array(v, ty, out),
77            Bound::UuidArray(v) => array(v, ty, out),
78            Bound::I16Array(v) => array(v, ty, out),
79            Bound::I32Array(v) => array(v, ty, out),
80            Bound::I64Array(v) => array(v, ty, out),
81            Bound::BoolArray(v) => array(v, ty, out),
82            Bound::F64Array(v) => array(v, ty, out),
83            Bound::NumericArray(v) => array(v, ty, out),
84            Bound::JsonArray(v) => array(v, ty, out),
85        }
86    }
87
88    fn accepts(_: &Type) -> bool {
89        true
90    }
91
92    to_sql_checked!();
93}
94
95fn array<T: ToSql>(
96    values: &[Option<T>],
97    ty: &Type,
98    out: &mut BytesMut,
99) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
100    if !matches!(ty.kind(), Kind::Array(_)) {
101        return Err(format!("an array cannot be sent as {}", ty.name()).into());
102    }
103    values
104        .iter()
105        .map(|value| value.as_ref().map(OnBase))
106        .collect::<Vec<_>>()
107        .to_sql(ty, out)
108}
109
110/// An array element, encoded as its type's base.
111#[derive(Debug)]
112struct OnBase<'a, T>(&'a T);
113
114impl<T: ToSql> ToSql for OnBase<'_, T> {
115    fn to_sql(
116        &self,
117        ty: &Type,
118        out: &mut BytesMut,
119    ) -> Result<IsNull, Box<dyn Error + Sync + Send>> {
120        self.0.to_sql(base_type(ty), out)
121    }
122
123    fn accepts(_: &Type) -> bool {
124        true
125    }
126
127    to_sql_checked!();
128}
129
130/// Coerce every parameter to the type the prepared statement reported.
131pub fn bind(types: &[Type], params: &[Param]) -> Result<Vec<Bound>, DbError> {
132    if types.len() != params.len() {
133        return Err(DbError::new(
134            DbErrorKind::InvalidParameter,
135            format!(
136                "the statement takes {} parameter(s), {} were given",
137                types.len(),
138                params.len()
139            ),
140        ));
141    }
142    params
143        .iter()
144        .zip(types)
145        .enumerate()
146        .map(|(i, (param, ty))| coerce(i + 1, param, ty))
147        .collect()
148}
149
150/// Why a parameter does not fit its type, before the parameter is named.
151enum Refusal {
152    Mismatch(Option<String>),
153    Unsupported,
154}
155
156type Coerced = Result<Bound, Refusal>;
157
158fn mismatch() -> Refusal {
159    Refusal::Mismatch(None)
160}
161
162fn invalid(reason: impl ToString) -> Refusal {
163    Refusal::Mismatch(Some(reason.to_string()))
164}
165
166/// Convert parameter `$index` to `ty`, or say why not.
167pub fn coerce(index: usize, param: &Param, ty: &Type) -> Result<Bound, DbError> {
168    coerce_to(param, ty).map_err(|refusal| match refusal {
169        Refusal::Mismatch(reason) => DbError::new(
170            DbErrorKind::InvalidParameter,
171            format!(
172                "parameter ${index}: expected {}, got {}{}",
173                ty.name(),
174                describe(param),
175                reason.map(|r| format!(" ({r})")).unwrap_or_default()
176            ),
177        ),
178        Refusal::Unsupported => DbErrorFields {
179            hint: Some(format!(
180                "cast it in SQL, e.g. ${index}::text::{}",
181                ty.name()
182            )),
183            ..DbErrorFields::new(
184                DbErrorKind::UnsupportedType,
185                format!("parameter ${index}: type {} is not supported", ty.name()),
186            )
187        }
188        .into(),
189    })
190}
191
192/// The parameter's tag, with a short preview of a scalar text value.
193fn describe(param: &Param) -> String {
194    let text = match param {
195        Param::Text(s)
196        | Param::Uuid(s)
197        | Param::Timestamp(s)
198        | Param::NaiveTimestamp(s)
199        | Param::Date(s)
200        | Param::Time(s)
201        | Param::Numeric(s) => Some(s),
202        _ => None,
203    };
204    match text {
205        Some(s) if s.chars().count() > 40 => {
206            format!(
207                "{} '{}…'",
208                param.tag(),
209                s.chars().take(40).collect::<String>()
210            )
211        }
212        Some(s) => format!("{} '{s}'", param.tag()),
213        None => param.tag().to_string(),
214    }
215}
216
217/// An enum label is sent as given: the server checks it against the labels
218/// it has now, which a connection's type cache may predate.
219fn coerce_to(param: &Param, ty: &Type) -> Coerced {
220    let ty = base_type(ty);
221    match ty.kind() {
222        Kind::Array(element) => return coerce_array(param, element),
223        Kind::Enum(_) => {
224            return match param {
225                Param::Null => Ok(Bound::Null),
226                Param::Text(label) => Ok(Bound::Text(label.clone())),
227                _ => Err(mismatch()),
228            }
229        }
230        _ => {}
231    }
232    if *param == Param::Null {
233        return Ok(Bound::Null);
234    }
235    match *ty {
236        Type::BOOL => match param {
237            Param::Bool(v) => Ok(Bound::Bool(*v)),
238            _ => Err(mismatch()),
239        },
240        Type::INT2 => integer(param)
241            .and_then(|v| i16::try_from(v).map_err(|_| invalid("out of range for int2")))
242            .map(Bound::I16),
243        Type::INT4 => integer(param)
244            .and_then(|v| i32::try_from(v).map_err(|_| invalid("out of range for int4")))
245            .map(Bound::I32),
246        Type::INT8 => integer(param).map(Bound::I64),
247        Type::FLOAT4 => float4(param).map(Bound::F32),
248        Type::FLOAT8 => float8(param).map(Bound::F64),
249        Type::NUMERIC => numeric(param).map(Bound::Numeric),
250        Type::TEXT | Type::VARCHAR | Type::BPCHAR | Type::NAME => text(param).map(Bound::Text),
251        Type::UUID => uuid(param).map(Bound::Uuid),
252        Type::TIMESTAMPTZ => match param {
253            Param::Timestamp(s) | Param::Text(s) => rfc3339(s).map(Bound::Timestamptz),
254            _ => Err(mismatch()),
255        },
256        Type::TIMESTAMP => match param {
257            Param::NaiveTimestamp(s) | Param::Text(s) => naive_timestamp(s).map(Bound::Timestamp),
258            Param::Timestamp(s) => rfc3339(s).map(|t| Bound::Timestamp(t.naive_utc())),
259            _ => Err(mismatch()),
260        },
261        Type::DATE => match param {
262            Param::Date(s) | Param::Text(s) => NaiveDate::parse_from_str(s, "%Y-%m-%d")
263                .map(Bound::Date)
264                .map_err(invalid),
265            _ => Err(mismatch()),
266        },
267        Type::TIME => match param {
268            Param::Time(s) | Param::Text(s) => NaiveTime::parse_from_str(s, "%H:%M:%S%.f")
269                .map(Bound::Time)
270                .map_err(invalid),
271            _ => Err(mismatch()),
272        },
273        Type::JSON | Type::JSONB => json(param).map(Bound::Json),
274        Type::BYTEA => match param {
275            Param::Bytes(v) => Ok(Bound::Bytes(v.clone())),
276            _ => Err(mismatch()),
277        },
278        Type::UNKNOWN => match param {
279            Param::Text(s) => Ok(Bound::Text(s.clone())),
280            _ => Err(mismatch()),
281        },
282        _ if ty.name() == "citext" => text(param).map(Bound::Text),
283        _ => Err(Refusal::Unsupported),
284    }
285}
286
287fn coerce_array(param: &Param, element: &Type) -> Coerced {
288    if *param == Param::Null {
289        return Ok(Bound::Null);
290    }
291    let element = base_type(element);
292    if let Kind::Enum(_) = element.kind() {
293        return match param {
294            Param::TextArray(values) => Ok(Bound::TextArray(values.clone())),
295            _ => Err(mismatch()),
296        };
297    }
298    match (element, param) {
299        (&Type::TEXT | &Type::VARCHAR | &Type::BPCHAR | &Type::NAME, Param::TextArray(v))
300        | (&Type::TEXT | &Type::VARCHAR | &Type::BPCHAR | &Type::NAME, Param::UuidArray(v)) => {
301            Ok(Bound::TextArray(v.clone()))
302        }
303        (&Type::UUID, Param::UuidArray(v) | Param::TextArray(v)) => {
304            each(v, |s| Uuid::parse_str(s).map_err(invalid)).map(Bound::UuidArray)
305        }
306        (&Type::INT2 | &Type::INT4 | &Type::INT8, Param::TextArray(v)) => {
307            each(v, |s| integer(&Param::Text(s.clone())))
308                .and_then(|integers| coerce_array(&Param::I64Array(integers), element))
309        }
310        (&Type::INT2, Param::I32Array(v)) => each(v, narrow::<i32, i16>).map(Bound::I16Array),
311        (&Type::INT2, Param::I64Array(v)) => each(v, narrow::<i64, i16>).map(Bound::I16Array),
312        (&Type::INT4, Param::I32Array(v)) => Ok(Bound::I32Array(v.clone())),
313        (&Type::INT4, Param::I64Array(v)) => each(v, narrow::<i64, i32>).map(Bound::I32Array),
314        (&Type::INT8, Param::I32Array(v)) => Ok(Bound::I64Array(
315            v.iter().map(|x| x.map(i64::from)).collect(),
316        )),
317        (&Type::INT8, Param::I64Array(v)) => Ok(Bound::I64Array(v.clone())),
318        (&Type::BOOL, Param::BoolArray(v)) => Ok(Bound::BoolArray(v.clone())),
319        (&Type::FLOAT8, Param::F64Array(v)) => Ok(Bound::F64Array(v.clone())),
320        (&Type::FLOAT8, Param::TextArray(v)) => {
321            each(v, |s| float8(&Param::Text(s.clone()))).map(Bound::F64Array)
322        }
323        (&Type::NUMERIC, Param::NumericArray(v) | Param::TextArray(v)) => {
324            each(v, |s| s.parse::<PgNumeric>().map_err(invalid)).map(Bound::NumericArray)
325        }
326        (&Type::JSON | &Type::JSONB, Param::Json(Value::Array(v))) => {
327            Ok(Bound::JsonArray(v.iter().cloned().map(Some).collect()))
328        }
329        (
330            &Type::TEXT
331            | &Type::VARCHAR
332            | &Type::BPCHAR
333            | &Type::NAME
334            | &Type::UUID
335            | &Type::INT2
336            | &Type::INT4
337            | &Type::INT8
338            | &Type::BOOL
339            | &Type::FLOAT8
340            | &Type::NUMERIC
341            | &Type::JSON
342            | &Type::JSONB,
343            _,
344        ) => Err(mismatch()),
345        _ => Err(Refusal::Unsupported),
346    }
347}
348
349fn each<T, U>(
350    values: &[Option<T>],
351    convert: impl Fn(&T) -> Result<U, Refusal>,
352) -> Result<Vec<Option<U>>, Refusal> {
353    values
354        .iter()
355        .map(|v| v.as_ref().map(&convert).transpose())
356        .collect()
357}
358
359fn narrow<T: Copy, U: TryFrom<T>>(value: &T) -> Result<U, Refusal> {
360    U::try_from(*value).map_err(|_| invalid("element out of range"))
361}
362
363fn integer(param: &Param) -> Result<i64, Refusal> {
364    match param {
365        Param::I16(v) => Ok(i64::from(*v)),
366        Param::I32(v) => Ok(i64::from(*v)),
367        Param::I64(v) => Ok(*v),
368        Param::Text(s) => s.trim().parse().map_err(|_| invalid("not an integer")),
369        Param::Numeric(s) => s
370            .parse::<PgNumeric>()
371            .ok()
372            .and_then(|n| n.to_i64())
373            .ok_or_else(|| invalid("not an integer in range")),
374        _ => Err(mismatch()),
375    }
376}
377
378/// A binary float type a parameter can be coerced to.
379trait Float: FromStr + Copy + PartialEq {
380    const ZERO: Self;
381    fn is_finite(self) -> bool;
382}
383
384impl Float for f32 {
385    const ZERO: Self = 0.0;
386    fn is_finite(self) -> bool {
387        f32::is_finite(self)
388    }
389}
390
391impl Float for f64 {
392    const ZERO: Self = 0.0;
393    fn is_finite(self) -> bool {
394        f64::is_finite(self)
395    }
396}
397
398/// Decimal text at the target width, rounded to nearest. `NaN` and the
399/// infinities only when spelled out; a value that overflows to an infinity
400/// or underflows to zero is refused, as Postgres's own input refuses it.
401fn parse_float<F: Float>(text: &str) -> Result<F, Refusal> {
402    let text = text.trim();
403    let value = text.parse::<F>().map_err(|_| invalid("not a number"))?;
404    let spelled_out = matches!(
405        text.trim_start_matches(['+', '-'])
406            .to_ascii_lowercase()
407            .as_str(),
408        "nan" | "inf" | "infinity"
409    );
410    let mantissa_is_zero = text
411        .split(['e', 'E'])
412        .next()
413        .is_none_or(|mantissa| !mantissa.bytes().any(|b| (b'1'..=b'9').contains(&b)));
414    match value {
415        v if !v.is_finite() && !spelled_out => Err(invalid("out of range")),
416        v if v == F::ZERO && !mantissa_is_zero => Err(invalid("out of range")),
417        v => Ok(v),
418    }
419}
420
421/// An integer, when the float holds it exactly.
422fn exact_integer<F: Float>(
423    value: i64,
424    to_float: fn(i64) -> F,
425    to_int: fn(F) -> i128,
426) -> Result<F, Refusal> {
427    let float = to_float(value);
428    (to_int(float) == i128::from(value))
429        .then_some(float)
430        .ok_or_else(|| invalid("not exactly representable as a float"))
431}
432
433fn float8(param: &Param) -> Result<f64, Refusal> {
434    let exact = |v: i64| exact_integer(v, |v| v as f64, |f| f as i128);
435    match param {
436        Param::F32(v) => Ok(f64::from(*v)),
437        Param::F64(v) => Ok(*v),
438        Param::I16(v) => Ok(f64::from(*v)),
439        Param::I32(v) => Ok(f64::from(*v)),
440        Param::I64(v) => exact(*v),
441        Param::Numeric(s) | Param::Text(s) => parse_float(s),
442        _ => Err(mismatch()),
443    }
444}
445
446/// A `float8` value is rounded to `float4` as Postgres's cast rounds it,
447/// but one too large or too small for `float4` is refused.
448fn float4(param: &Param) -> Result<f32, Refusal> {
449    let exact = |v: i64| exact_integer(v, |v| v as f32, |f| f as i128);
450    match param {
451        Param::F32(v) => Ok(*v),
452        Param::F64(v) => {
453            let narrowed = *v as f32;
454            let overflow = v.is_finite() && narrowed.is_infinite();
455            let underflow = *v != 0.0 && narrowed == 0.0;
456            (!overflow && !underflow)
457                .then_some(narrowed)
458                .ok_or_else(|| invalid("out of range for float4"))
459        }
460        Param::I16(v) => Ok(f32::from(*v)),
461        Param::I32(v) => exact(i64::from(*v)),
462        Param::I64(v) => exact(*v),
463        Param::Numeric(s) | Param::Text(s) => parse_float(s),
464        _ => Err(mismatch()),
465    }
466}
467
468fn numeric(param: &Param) -> Result<PgNumeric, Refusal> {
469    let finite = |v: f64, shortest: String| {
470        v.is_finite()
471            .then_some(shortest)
472            .ok_or_else(|| invalid("not finite"))
473    };
474    let text = match param {
475        Param::Numeric(s) | Param::Text(s) => s.clone(),
476        Param::I16(v) => v.to_string(),
477        Param::I32(v) => v.to_string(),
478        Param::I64(v) => v.to_string(),
479        Param::F64(v) => finite(*v, v.to_string())?,
480        Param::F32(v) => finite(f64::from(*v), v.to_string())?,
481        _ => return Err(mismatch()),
482    };
483    text.parse().map_err(invalid)
484}
485
486fn text(param: &Param) -> Result<String, Refusal> {
487    match param {
488        Param::Text(s) | Param::Uuid(s) => Ok(s.clone()),
489        _ => Err(mismatch()),
490    }
491}
492
493fn uuid(param: &Param) -> Result<Uuid, Refusal> {
494    match param {
495        Param::Uuid(s) | Param::Text(s) => Uuid::parse_str(s).map_err(|_| invalid("invalid uuid")),
496        _ => Err(mismatch()),
497    }
498}
499
500fn rfc3339(s: &str) -> Result<DateTime<Utc>, Refusal> {
501    DateTime::parse_from_rfc3339(s)
502        .map(|t| t.with_timezone(&Utc))
503        .map_err(|_| invalid("not an RFC 3339 timestamp with an offset"))
504}
505
506fn naive_timestamp(s: &str) -> Result<NaiveDateTime, Refusal> {
507    ["%Y-%m-%dT%H:%M:%S%.f", "%Y-%m-%d %H:%M:%S%.f"]
508        .iter()
509        .find_map(|format| NaiveDateTime::parse_from_str(s, format).ok())
510        .ok_or_else(|| invalid("not a timestamp without an offset"))
511}
512
513fn json(param: &Param) -> Result<Value, Refusal> {
514    Ok(match param {
515        Param::Json(v) => v.clone(),
516        Param::Bool(v) => Value::Bool(*v),
517        Param::I16(v) => Value::from(*v),
518        Param::I32(v) => Value::from(*v),
519        Param::I64(v) => Value::from(*v),
520        Param::F32(v) => serde_json::Number::from_f64(f64::from(*v))
521            .map(Value::Number)
522            .ok_or_else(|| invalid("not finite"))?,
523        Param::F64(v) => serde_json::Number::from_f64(*v)
524            .map(Value::Number)
525            .ok_or_else(|| invalid("not finite"))?,
526        Param::Text(s)
527        | Param::Uuid(s)
528        | Param::Timestamp(s)
529        | Param::NaiveTimestamp(s)
530        | Param::Date(s)
531        | Param::Time(s)
532        | Param::Numeric(s) => Value::String(s.clone()),
533        _ => return Err(mismatch()),
534    })
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540    use serde_json::json;
541
542    fn ok(param: Param, ty: &Type) -> Bound {
543        coerce(1, &param, ty).unwrap_or_else(|e| panic!("{ty} from {param:?}: {e}"))
544    }
545
546    fn err(param: Param, ty: &Type) -> DbError {
547        match coerce(3, &param, ty) {
548            Ok(bound) => panic!("{ty} accepted {param:?} as {bound:?}"),
549            Err(e) => e,
550        }
551    }
552
553    fn enum_type() -> Type {
554        Type::new(
555            "mood".into(),
556            90_001,
557            Kind::Enum(vec!["sad".into(), "happy".into()]),
558            "public".into(),
559        )
560    }
561
562    fn domain_over(base: Type) -> Type {
563        Type::new(
564            format!("{}_domain", base.name()),
565            90_002,
566            Kind::Domain(base),
567            "public".into(),
568        )
569    }
570
571    #[test]
572    fn integers_are_range_checked_and_parsed_exactly() {
573        assert_eq!(ok(Param::I64(7), &Type::INT2), Bound::I16(7));
574        assert_eq!(ok(Param::I16(7), &Type::INT8), Bound::I64(7));
575        assert_eq!(ok(Param::Text(" 42 ".into()), &Type::INT4), Bound::I32(42));
576        assert_eq!(
577            ok(Param::Numeric("42.000".into()), &Type::INT8),
578            Bound::I64(42)
579        );
580        let range = err(Param::I64(70_000), &Type::INT2);
581        assert_eq!(range.kind, DbErrorKind::InvalidParameter);
582        assert_eq!(
583            range.message,
584            "parameter $3: expected int2, got i64 (out of range for int2)"
585        );
586        err(Param::I64(i64::from(i32::MAX) + 1), &Type::INT4);
587        err(Param::Numeric("1.5".into()), &Type::INT4);
588        err(Param::Text("1.5".into()), &Type::INT4);
589        err(Param::F64(1.0), &Type::INT4);
590        err(Param::Bool(true), &Type::INT4);
591    }
592
593    #[test]
594    fn floats_take_numbers_that_fit_exactly() {
595        assert_eq!(ok(Param::F64(1.5), &Type::FLOAT8), Bound::F64(1.5));
596        assert_eq!(ok(Param::F64(0.5), &Type::FLOAT4), Bound::F32(0.5));
597        assert_eq!(
598            ok(Param::I64(1 << 53), &Type::FLOAT8),
599            Bound::F64(9_007_199_254_740_992.0)
600        );
601        assert_eq!(
602            ok(Param::I64(1 << 60), &Type::FLOAT8),
603            Bound::F64(1_152_921_504_606_846_976.0),
604            "a power of two is exact"
605        );
606        err(Param::I64((1 << 53) + 1), &Type::FLOAT8);
607        err(Param::I64(i64::MAX), &Type::FLOAT8);
608        assert_eq!(
609            ok(Param::Text("2.25".into()), &Type::FLOAT8),
610            Bound::F64(2.25)
611        );
612        assert!(
613            matches!(ok(Param::Numeric("NaN".into()), &Type::FLOAT8), Bound::F64(v) if v.is_nan())
614        );
615        err(Param::Text("two".into()), &Type::FLOAT8);
616    }
617
618    #[test]
619    fn float4_refuses_overflow_underflow_and_inexact_integers() {
620        assert_eq!(ok(Param::F64(0.1), &Type::FLOAT4), Bound::F32(0.1));
621        assert_eq!(ok(Param::F64(-0.0), &Type::FLOAT4), Bound::F32(-0.0));
622        assert_eq!(
623            ok(Param::Text("0.1".into()), &Type::FLOAT4),
624            ok(Param::F64(0.1), &Type::FLOAT4),
625            "text and float8 round alike"
626        );
627        for too_far in [1e300, -1e300, 1e-300, f64::from(f32::MAX) * 2.0] {
628            let error = err(Param::F64(too_far), &Type::FLOAT4);
629            assert!(error.message.contains("out of range"), "{too_far}: {error}");
630        }
631        for explicit in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY] {
632            assert!(
633                matches!(ok(Param::F64(explicit), &Type::FLOAT4), Bound::F32(v) if v.is_nan() || v.is_infinite())
634            );
635        }
636        assert_eq!(
637            ok(Param::I32(1 << 24), &Type::FLOAT4),
638            Bound::F32(16_777_216.0)
639        );
640        err(Param::I32((1 << 24) + 1), &Type::FLOAT4);
641        assert!(err(Param::I64((1 << 40) + 1), &Type::FLOAT4)
642            .message
643            .contains("not exactly representable"));
644        assert_eq!(
645            ok(Param::I64(1 << 40), &Type::FLOAT4),
646            Bound::F32(1_099_511_627_776.0)
647        );
648        assert_eq!(ok(Param::I16(-7), &Type::FLOAT4), Bound::F32(-7.0));
649    }
650
651    #[test]
652    fn float_text_is_finite_unless_spelled_out() {
653        for (text, ty) in [
654            ("1e400", Type::FLOAT8),
655            ("-1e400", Type::FLOAT8),
656            ("1e-400", Type::FLOAT8),
657            ("1e40", Type::FLOAT4),
658            ("1e-50", Type::FLOAT4),
659        ] {
660            let error = err(Param::Text(text.into()), &ty);
661            assert!(error.message.contains("out of range"), "{text}: {error}");
662        }
663        for (text, ty) in [
664            ("0", Type::FLOAT4),
665            ("0.000e-999", Type::FLOAT8),
666            ("-0", Type::FLOAT8),
667        ] {
668            assert!(
669                matches!(ok(Param::Text(text.into()), &ty), Bound::F32(v) if v == 0.0)
670                    || matches!(ok(Param::Text(text.into()), &ty), Bound::F64(v) if v == 0.0),
671                "{text}"
672            );
673        }
674        for spelled in [
675            "Infinity",
676            "-Infinity",
677            "inf",
678            "+INF",
679            "NaN",
680            "nan",
681            " infinity ",
682        ] {
683            assert!(
684                matches!(ok(Param::Text(spelled.into()), &Type::FLOAT8), Bound::F64(v) if !v.is_finite()),
685                "{spelled}"
686            );
687            assert!(
688                matches!(ok(Param::Numeric(spelled.into()), &Type::FLOAT4), Bound::F32(v) if !v.is_finite()),
689                "{spelled}"
690            );
691        }
692    }
693
694    #[test]
695    fn numerics_stay_exact() {
696        let n = |s: &str| Bound::Numeric(s.parse().expect("numeric"));
697        assert_eq!(ok(Param::Numeric("0.1".into()), &Type::NUMERIC), n("0.1"));
698        assert_eq!(ok(Param::Text("12.50".into()), &Type::NUMERIC), n("12.50"));
699        assert_eq!(ok(Param::I64(-3), &Type::NUMERIC), n("-3"));
700        assert_eq!(ok(Param::F64(0.1), &Type::NUMERIC), n("0.1"));
701        err(Param::F64(f64::NAN), &Type::NUMERIC);
702        err(Param::Text("1,5".into()), &Type::NUMERIC);
703        err(Param::Bool(true), &Type::NUMERIC);
704    }
705
706    #[test]
707    fn text_uuid_and_time_types_parse_their_grammar() {
708        let id = "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11";
709        assert_eq!(
710            ok(Param::Text("x".into()), &Type::VARCHAR),
711            Bound::Text("x".into())
712        );
713        assert_eq!(
714            ok(Param::Uuid(id.into()), &Type::TEXT),
715            Bound::Text(id.into())
716        );
717        err(Param::I32(1), &Type::TEXT);
718        let parsed = Uuid::parse_str(id).expect("uuid");
719        assert_eq!(ok(Param::Uuid(id.into()), &Type::UUID), Bound::Uuid(parsed));
720        assert_eq!(ok(Param::Text(id.into()), &Type::UUID), Bound::Uuid(parsed));
721        assert_eq!(
722            err(Param::Text("abc".into()), &Type::UUID).message,
723            "parameter $3: expected uuid, got text 'abc' (invalid uuid)"
724        );
725        let utc = DateTime::parse_from_rfc3339("2026-01-01T10:00:00Z")
726            .expect("ts")
727            .with_timezone(&Utc);
728        assert_eq!(
729            ok(
730                Param::Timestamp("2026-01-01T12:00:00+02:00".into()),
731                &Type::TIMESTAMPTZ
732            ),
733            Bound::Timestamptz(utc)
734        );
735        err(
736            Param::Timestamp("2026-01-01T12:00:00".into()),
737            &Type::TIMESTAMPTZ,
738        );
739        assert_eq!(
740            ok(
741                Param::Timestamp("2026-01-01T12:00:00+02:00".into()),
742                &Type::TIMESTAMP
743            ),
744            Bound::Timestamp(utc.naive_utc())
745        );
746        for naive in [
747            "2026-01-01T10:00:00",
748            "2026-01-01 10:00:00",
749            "2026-01-01 10:00:00.000",
750        ] {
751            assert_eq!(
752                ok(Param::NaiveTimestamp(naive.into()), &Type::TIMESTAMP),
753                Bound::Timestamp(utc.naive_utc()),
754                "{naive}"
755            );
756        }
757        assert_eq!(
758            ok(Param::Date("2026-02-03".into()), &Type::DATE),
759            Bound::Date(NaiveDate::from_ymd_opt(2026, 2, 3).expect("date"))
760        );
761        err(Param::Date("2026-02-30".into()), &Type::DATE);
762        assert_eq!(
763            ok(Param::Time("04:05:06.5".into()), &Type::TIME),
764            Bound::Time(NaiveTime::from_hms_milli_opt(4, 5, 6, 500).expect("time"))
765        );
766    }
767
768    #[test]
769    fn json_wraps_scalars_and_bytes_need_bytes() {
770        assert_eq!(
771            ok(Param::Json(json!({"a": 1})), &Type::JSONB),
772            Bound::Json(json!({"a": 1}))
773        );
774        assert_eq!(ok(Param::I32(1), &Type::JSON), Bound::Json(json!(1)));
775        assert_eq!(
776            ok(Param::Text("s".into()), &Type::JSONB),
777            Bound::Json(json!("s"))
778        );
779        err(Param::F64(f64::INFINITY), &Type::JSONB);
780        assert_eq!(
781            ok(Param::Bytes(vec![1]), &Type::BYTEA),
782            Bound::Bytes(vec![1])
783        );
784        err(Param::Text("AQ==".into()), &Type::BYTEA);
785    }
786
787    #[test]
788    fn nulls_bind_to_anything_supported() {
789        for ty in [
790            Type::INT4,
791            Type::TEXT,
792            Type::JSONB,
793            Type::UUID_ARRAY,
794            enum_type(),
795        ] {
796            assert_eq!(ok(Param::Null, &ty), Bound::Null, "{ty}");
797        }
798    }
799
800    #[test]
801    fn enums_domains_and_unknown_follow_their_rules() {
802        assert_eq!(
803            ok(Param::Text("happy".into()), &enum_type()),
804            Bound::Text("happy".into())
805        );
806        assert_eq!(
807            ok(Param::Text("added_later".into()), &enum_type()),
808            Bound::Text("added_later".into()),
809            "labels are the server's to check"
810        );
811        err(Param::I32(1), &enum_type());
812        let domain = domain_over(Type::INT4);
813        assert_eq!(ok(Param::I64(5), &domain), Bound::I32(5));
814        let nested = Type::new(
815            "small_positive".into(),
816            90_005,
817            Kind::Domain(domain),
818            "public".into(),
819        );
820        assert_eq!(ok(Param::I64(5), &nested), Bound::I32(5));
821        assert_eq!(
822            ok(Param::Text("sad".into()), &domain_over(enum_type())),
823            Bound::Text("sad".into())
824        );
825        assert_eq!(
826            ok(Param::Text("x".into()), &Type::UNKNOWN),
827            Bound::Text("x".into())
828        );
829        err(Param::I32(1), &Type::UNKNOWN);
830    }
831
832    #[test]
833    fn arrays_bind_element_by_element() {
834        let id = "a0eebc99-9c0b-4ef8-bb6d-6bb9bd380a11";
835        assert_eq!(
836            ok(
837                Param::TextArray(vec![Some("a".into()), None]),
838                &Type::TEXT_ARRAY
839            ),
840            Bound::TextArray(vec![Some("a".into()), None])
841        );
842        assert_eq!(
843            ok(
844                Param::TextArray(vec![Some(id.into()), None]),
845                &Type::UUID_ARRAY
846            ),
847            Bound::UuidArray(vec![Some(Uuid::parse_str(id).expect("uuid")), None])
848        );
849        err(
850            Param::TextArray(vec![Some("nope".into())]),
851            &Type::UUID_ARRAY,
852        );
853        assert_eq!(
854            ok(Param::I32Array(vec![Some(1), None]), &Type::INT8_ARRAY),
855            Bound::I64Array(vec![Some(1), None])
856        );
857        assert_eq!(
858            ok(Param::I64Array(vec![Some(2)]), &Type::INT2_ARRAY),
859            Bound::I16Array(vec![Some(2)])
860        );
861        err(Param::I64Array(vec![Some(i64::MAX)]), &Type::INT4_ARRAY);
862        assert_eq!(
863            ok(
864                Param::TextArray(vec![Some(" 7".into()), None]),
865                &Type::INT4_ARRAY
866            ),
867            Bound::I32Array(vec![Some(7), None])
868        );
869        err(
870            Param::TextArray(vec![Some("seven".into())]),
871            &Type::INT8_ARRAY,
872        );
873        err(
874            Param::TextArray(vec![Some("70000".into())]),
875            &Type::INT2_ARRAY,
876        );
877        assert_eq!(
878            ok(
879                Param::TextArray(vec![Some("2.5".into())]),
880                &Type::FLOAT8_ARRAY
881            ),
882            Bound::F64Array(vec![Some(2.5)])
883        );
884        assert_eq!(
885            ok(
886                Param::TextArray(vec![Some("1.10".into())]),
887                &Type::NUMERIC_ARRAY
888            ),
889            Bound::NumericArray(vec![Some("1.10".parse().expect("numeric"))])
890        );
891        assert_eq!(
892            ok(Param::BoolArray(vec![None]), &Type::BOOL_ARRAY),
893            Bound::BoolArray(vec![None])
894        );
895        assert_eq!(
896            ok(Param::F64Array(vec![Some(1.5)]), &Type::FLOAT8_ARRAY),
897            Bound::F64Array(vec![Some(1.5)])
898        );
899        assert_eq!(
900            ok(
901                Param::NumericArray(vec![Some("1.10".into())]),
902                &Type::NUMERIC_ARRAY
903            ),
904            Bound::NumericArray(vec![Some("1.10".parse().expect("numeric"))])
905        );
906        assert_eq!(
907            ok(Param::Json(json!([1, "a"])), &Type::JSONB_ARRAY),
908            Bound::JsonArray(vec![Some(json!(1)), Some(json!("a"))])
909        );
910        let moods = Type::new(
911            "_mood".into(),
912            90_003,
913            Kind::Array(enum_type()),
914            "public".into(),
915        );
916        assert_eq!(
917            ok(Param::TextArray(vec![Some("sad".into())]), &moods),
918            Bound::TextArray(vec![Some("sad".into())])
919        );
920        assert_eq!(
921            ok(Param::TextArray(vec![Some("added".into())]), &moods),
922            Bound::TextArray(vec![Some("added".into())]),
923            "labels are the server's to check"
924        );
925        err(Param::I32Array(vec![Some(1)]), &Type::TEXT_ARRAY);
926        assert_eq!(
927            err(Param::TextArray(vec![]), &Type::POINT_ARRAY).kind,
928            DbErrorKind::UnsupportedType
929        );
930    }
931
932    #[test]
933    fn unsupported_types_suggest_a_cast() {
934        let error = err(Param::Text("(1,2)".into()), &Type::POINT);
935        assert_eq!(error.kind, DbErrorKind::UnsupportedType);
936        assert_eq!(error.message, "parameter $3: type point is not supported");
937        assert_eq!(
938            error.hint.as_deref(),
939            Some("cast it in SQL, e.g. $3::text::point")
940        );
941    }
942
943    #[test]
944    fn the_count_must_match_the_statement() {
945        let error = bind(&[Type::INT4], &[]).expect_err("count");
946        assert_eq!(error.kind, DbErrorKind::InvalidParameter);
947        assert_eq!(
948            error.message,
949            "the statement takes 1 parameter(s), 0 were given"
950        );
951        assert_eq!(
952            bind(
953                &[Type::INT4, Type::TEXT],
954                &[Param::I32(1), Param::Text("a".into())]
955            )
956            .expect("bound"),
957            vec![Bound::I32(1), Bound::Text("a".into())]
958        );
959    }
960
961    #[test]
962    fn long_values_are_previewed() {
963        let error = err(Param::Text("x".repeat(100)), &Type::UUID);
964        assert!(
965            error.message.contains(&format!("'{}…'", "x".repeat(40))),
966            "{}",
967            error.message
968        );
969    }
970
971    #[test]
972    fn bound_values_encode_as_their_type() {
973        let encode = |bound: Bound, ty: Type| {
974            let mut out = BytesMut::new();
975            let null = bound.to_sql_checked(&ty, &mut out).expect("encode");
976            (matches!(null, IsNull::Yes), out.to_vec())
977        };
978        assert_eq!(encode(Bound::Null, Type::INT4), (true, vec![]));
979        assert_eq!(encode(Bound::I32(1), Type::INT4), (false, vec![0, 0, 0, 1]));
980        assert_eq!(
981            encode(Bound::Text("happy".into()), enum_type()),
982            (false, b"happy".to_vec())
983        );
984        assert_eq!(
985            encode(Bound::Json(json!(1)), Type::JSONB),
986            (false, b"\x011".to_vec())
987        );
988    }
989
990    #[test]
991    fn domains_encode_as_their_base_type() {
992        let encode = |bound: Bound, ty: &Type| {
993            let mut out = BytesMut::new();
994            bound.to_sql_checked(ty, &mut out).expect("encode");
995            out.to_vec()
996        };
997        let jsonb = domain_over(Type::JSONB);
998        assert_eq!(
999            encode(Bound::Json(json!(1)), &jsonb),
1000            encode(Bound::Json(json!(1)), &Type::JSONB),
1001            "jsonb's version byte"
1002        );
1003        let tags = domain_over(Type::TEXT_ARRAY);
1004        assert_eq!(
1005            encode(Bound::TextArray(vec![Some("a".into())]), &tags),
1006            encode(Bound::TextArray(vec![Some("a".into())]), &Type::TEXT_ARRAY)
1007        );
1008        let int = domain_over(Type::INT4);
1009        assert_eq!(encode(Bound::I32(5), &int), vec![0, 0, 0, 5]);
1010
1011        let docs = Type::new(
1012            "_jsonb_domain".into(),
1013            90_004,
1014            Kind::Array(jsonb.clone()),
1015            "public".into(),
1016        );
1017        let encoded = encode(Bound::JsonArray(vec![Some(json!(1)), None]), &docs);
1018        let element_oid = u32::from_be_bytes(encoded[8..12].try_into().expect("oid"));
1019        assert_eq!(element_oid, jsonb.oid(), "the header names the domain");
1020        assert_eq!(&encoded[20..24], &2i32.to_be_bytes(), "element length");
1021        assert_eq!(&encoded[24..26], b"\x011", "a base-encoded jsonb element");
1022        assert_eq!(&encoded[26..30], &(-1i32).to_be_bytes(), "a null element");
1023
1024        let mut out = BytesMut::new();
1025        assert!(
1026            Bound::TextArray(vec![])
1027                .to_sql_checked(&Type::TEXT, &mut out)
1028                .is_err(),
1029            "an array sent as a scalar is an error, not a panic"
1030        );
1031    }
1032}