use serde_json::Value;
use sqlx::postgres::{PgArguments, PgTypeInfo, PgTypeKind};
use sqlx::{Arguments, TypeInfo};
#[derive(Debug)]
pub enum Bound<A> {
Typed(A),
Fallback { cache: bool },
}
#[derive(Debug, Clone, PartialEq)]
pub enum Scalar {
Null,
Bool(bool),
Int(i64),
Float(f64),
Str(String),
Json(Value),
}
impl Scalar {
fn kind_name(&self) -> &'static str {
match self {
Scalar::Null => "null",
Scalar::Bool(_) => "a boolean",
Scalar::Int(_) | Scalar::Float(_) => "a number",
Scalar::Str(_) => "a string",
Scalar::Json(Value::Array(_)) => "an array",
Scalar::Json(_) => "an object",
}
}
}
impl From<&Value> for Scalar {
fn from(v: &Value) -> Self {
match v {
Value::Null => Scalar::Null,
Value::Bool(b) => Scalar::Bool(*b),
Value::Number(n) => n
.as_i64()
.map(Scalar::Int)
.or_else(|| n.as_u64().map(|u| Scalar::Str(u.to_string())))
.or_else(|| n.as_f64().map(Scalar::Float))
.unwrap_or_else(|| Scalar::Str(n.to_string())),
Value::String(s) => Scalar::Str(s.clone()),
other => Scalar::Json(other.clone()),
}
}
}
pub fn scalars_from_sea(values: &sea_query::Values) -> Option<Vec<Scalar>> {
use sea_query::Value as V;
values
.0
.iter()
.map(|v| {
Some(match v {
V::Bool(o) => o.map_or(Scalar::Null, Scalar::Bool),
V::TinyInt(o) => o.map_or(Scalar::Null, |i| Scalar::Int(i.into())),
V::SmallInt(o) => o.map_or(Scalar::Null, |i| Scalar::Int(i.into())),
V::Int(o) => o.map_or(Scalar::Null, |i| Scalar::Int(i.into())),
V::BigInt(o) => o.map_or(Scalar::Null, Scalar::Int),
V::TinyUnsigned(o) => o.map_or(Scalar::Null, |i| Scalar::Int(i.into())),
V::SmallUnsigned(o) => o.map_or(Scalar::Null, |i| Scalar::Int(i.into())),
V::Unsigned(o) => o.map_or(Scalar::Null, |i| Scalar::Int(i.into())),
V::BigUnsigned(o) => o.map_or(Scalar::Null, |u| {
i64::try_from(u).map_or_else(|_| Scalar::Str(u.to_string()), Scalar::Int)
}),
V::Float(o) => o.map_or(Scalar::Null, |f| Scalar::Float(f.into())),
V::Double(o) => o.map_or(Scalar::Null, Scalar::Float),
V::String(o) => o.as_ref().map_or(Scalar::Null, |s| Scalar::Str(s.clone())),
V::Char(o) => o.map_or(Scalar::Null, |c| Scalar::Str(c.to_string())),
_ => return None,
})
})
.collect()
}
#[derive(Debug)]
pub struct EncodeError {
pub param: usize,
pub sql_type: String,
pub detail: String,
}
impl EncodeError {
pub fn message(&self, function: &str) -> String {
format!(
"{function}: parameter ${n} is declared {} by the query, and the \
value given cannot be converted to it ({}). Pass a value of the \
right shape, or cast the placeholder in the query — \
`(${n})::text` — to bind it as text instead.",
self.sql_type,
self.detail,
n = self.param + 1
)
}
}
type Encoded<T> = Result<T, EncodeError>;
#[derive(Debug)]
pub enum Binding {
Typed(PgArguments),
Unsupported(String),
}
enum Declared<'a> {
Scalar(&'a str),
Array(&'a str),
Enum,
}
const MAX_DOMAIN_DEPTH: usize = 8;
fn resolve(info: &PgTypeInfo) -> Option<Declared<'_>> {
if info.oid().is_none() || info.name() == "?" {
return None;
}
let mut cur = info;
for _ in 0..MAX_DOMAIN_DEPTH {
match cur.kind() {
PgTypeKind::Domain(inner) => cur = inner,
PgTypeKind::Enum(_) => return Some(Declared::Enum),
PgTypeKind::Array(elem) => return Some(Declared::Array(elem.name())),
_ => return Some(Declared::Scalar(cur.name())),
}
}
Some(Declared::Scalar(cur.name()))
}
const BINDABLE: &[&str] = &[
"BOOL",
"INT2",
"INT4",
"INT8",
"OID",
"FLOAT4",
"FLOAT8",
"NUMERIC",
"TEXT",
"VARCHAR",
"CHAR",
"NAME",
"citext",
"UNKNOWN",
"UUID",
"JSON",
"JSONB",
"TIMESTAMPTZ",
"TIMESTAMP",
"DATE",
"TIME",
];
pub fn pg_arguments(declared: &[PgTypeInfo], values: &[Scalar]) -> Encoded<Binding> {
for info in declared {
let unsupported = match resolve(info) {
None => Some(info.name().to_string()),
Some(Declared::Enum) => None,
Some(Declared::Scalar(n) | Declared::Array(n)) => {
(!BINDABLE.contains(&n)).then(|| n.to_string())
}
};
if let Some(name) = unsupported {
return Ok(Binding::Unsupported(name));
}
}
if declared.len() != values.len() {
return Err(EncodeError {
param: values.len().min(declared.len()),
sql_type: "?".to_string(),
detail: format!(
"the query has {} placeholder(s) but {} parameter(s) were given",
declared.len(),
values.len()
),
});
}
let mut args = PgArguments::default();
args.reserve(values.len(), 0);
for (i, (info, value)) in declared.iter().zip(values).enumerate() {
let sql_type = info.name().to_string();
let fail = |detail: String| EncodeError {
param: i,
sql_type: sql_type.clone(),
detail,
};
match resolve(info) {
Some(Declared::Enum) => add(&mut args, opt(value, |v| as_str(v, &fail))?, &fail)?,
Some(Declared::Scalar(name)) => add_scalar(&mut args, name, value, &fail)?,
Some(Declared::Array(elem)) => add_array(&mut args, elem, value, &fail)?,
None => return Err(fail("the declared type could not be read".to_string())),
}
}
Ok(Binding::Typed(args))
}
fn opt<T>(v: &Scalar, f: impl FnOnce(&Scalar) -> Encoded<T>) -> Encoded<Option<T>> {
match v {
Scalar::Null => Ok(None),
other => f(other).map(Some),
}
}
fn add<'q, T>(args: &mut PgArguments, value: T, fail: &dyn Fn(String) -> EncodeError) -> Encoded<()>
where
T: 'q + sqlx::Encode<'q, sqlx::Postgres> + sqlx::Type<sqlx::Postgres>,
{
args.add(value).map_err(|e| fail(e.to_string()))
}
fn add_scalar(
args: &mut PgArguments,
name: &str,
v: &Scalar,
fail: &dyn Fn(String) -> EncodeError,
) -> Encoded<()> {
match name {
"BOOL" => add(args, opt(v, |v| as_bool(v, fail))?, fail),
"INT2" => add(args, opt(v, |v| as_int::<i16>(v, fail))?, fail),
"INT4" => add(args, opt(v, |v| as_int::<i32>(v, fail))?, fail),
"INT8" => add(args, opt(v, |v| as_i64(v, fail))?, fail),
"OID" => add(
args,
opt(v, |v| {
as_int::<u32>(v, fail).map(sqlx::postgres::types::Oid)
})?,
fail,
),
"FLOAT4" => add(args, opt(v, |v| as_f64(v, fail).map(|f| f as f32))?, fail),
"FLOAT8" => add(args, opt(v, |v| as_f64(v, fail))?, fail),
"NUMERIC" => add(args, opt(v, |v| as_decimal(v, fail))?, fail),
"TEXT" | "VARCHAR" | "CHAR" | "NAME" | "citext" | "UNKNOWN" => {
add(args, opt(v, |v| as_str(v, fail))?, fail)
}
"UUID" => add(args, opt(v, |v| as_uuid(v, fail))?, fail),
"JSON" | "JSONB" => add(args, opt(v, |v| as_json(v, fail))?, fail),
"TIMESTAMPTZ" => add(args, opt(v, |v| as_timestamptz(v, fail))?, fail),
"TIMESTAMP" => add(args, opt(v, |v| as_timestamp(v, fail))?, fail),
"DATE" => add(args, opt(v, |v| as_date(v, fail))?, fail),
"TIME" => add(args, opt(v, |v| as_time(v, fail))?, fail),
other => Err(fail(format!("no binding for {other} is defined"))),
}
}
fn add_array(
args: &mut PgArguments,
elem: &str,
v: &Scalar,
fail: &dyn Fn(String) -> EncodeError,
) -> Encoded<()> {
let items: Option<Vec<Scalar>> = match v {
Scalar::Null => None,
Scalar::Json(Value::Array(a)) => Some(a.iter().map(Scalar::from).collect()),
other => {
return Err(fail(format!(
"expected an array, got {}",
other.kind_name()
)));
}
};
macro_rules! each {
($f:expr) => {
match &items {
None => None,
Some(list) => Some(
list.iter()
.map(|s| opt(s, $f))
.collect::<Encoded<Vec<_>>>()?,
),
}
};
}
match elem {
"BOOL" => add(args, each!(|v| as_bool(v, fail)), fail),
"INT2" => add(args, each!(|v| as_int::<i16>(v, fail)), fail),
"INT4" => add(args, each!(|v| as_int::<i32>(v, fail)), fail),
"INT8" => add(args, each!(|v| as_i64(v, fail)), fail),
"OID" => add(
args,
each!(|v| as_int::<u32>(v, fail).map(sqlx::postgres::types::Oid)),
fail,
),
"FLOAT4" => add(args, each!(|v| as_f64(v, fail).map(|f| f as f32)), fail),
"FLOAT8" => add(args, each!(|v| as_f64(v, fail)), fail),
"NUMERIC" => add(args, each!(|v| as_decimal(v, fail)), fail),
"TEXT" | "VARCHAR" | "CHAR" | "NAME" | "citext" | "UNKNOWN" => {
add(args, each!(|v| as_str(v, fail)), fail)
}
"UUID" => add(args, each!(|v| as_uuid(v, fail)), fail),
"JSON" | "JSONB" => add(args, each!(|v| as_json(v, fail)), fail),
"TIMESTAMPTZ" => add(args, each!(|v| as_timestamptz(v, fail)), fail),
"TIMESTAMP" => add(args, each!(|v| as_timestamp(v, fail)), fail),
"DATE" => add(args, each!(|v| as_date(v, fail)), fail),
"TIME" => add(args, each!(|v| as_time(v, fail)), fail),
other => Err(fail(format!(
"no binding for an array of {other} is defined"
))),
}
}
fn as_i64(v: &Scalar, fail: &dyn Fn(String) -> EncodeError) -> Encoded<i64> {
match v {
Scalar::Int(i) => Ok(*i),
Scalar::Float(f) if f.fract() == 0.0 && *f >= -(2f64.powi(63)) && *f < 2f64.powi(63) => {
Ok(*f as i64)
}
Scalar::Str(s) => s
.trim()
.parse::<i64>()
.map_err(|e| fail(format!("expected an integer, got a string ({e})"))),
other => Err(fail(format!(
"expected an integer, got {}",
other.kind_name()
))),
}
}
fn as_int<T>(v: &Scalar, fail: &dyn Fn(String) -> EncodeError) -> Encoded<T>
where
T: TryFrom<i64>,
{
let n = as_i64(v, fail)?;
T::try_from(n).map_err(|_| {
fail(format!(
"{n} is outside the range of this column's integer type"
))
})
}
fn as_f64(v: &Scalar, fail: &dyn Fn(String) -> EncodeError) -> Encoded<f64> {
let f = match v {
Scalar::Int(i) => *i as f64,
Scalar::Float(f) => *f,
Scalar::Str(s) => s
.trim()
.parse::<f64>()
.map_err(|e| fail(format!("expected a number, got a string ({e})")))?,
other => {
return Err(fail(format!(
"expected a number, got {}",
other.kind_name()
)));
}
};
if f.is_finite() {
Ok(f)
} else {
Err(fail("the value is not a finite number".to_string()))
}
}
fn as_decimal(v: &Scalar, fail: &dyn Fn(String) -> EncodeError) -> Encoded<bigdecimal::BigDecimal> {
use std::str::FromStr;
let text = match v {
Scalar::Str(s) => s.trim().to_string(),
Scalar::Int(i) => i.to_string(),
Scalar::Float(f) => f.to_string(),
other => {
return Err(fail(format!(
"expected a decimal, got {}",
other.kind_name()
)));
}
};
bigdecimal::BigDecimal::from_str(&text)
.map_err(|e| fail(format!("expected a decimal number ({e})")))
}
fn as_str(v: &Scalar, _fail: &dyn Fn(String) -> EncodeError) -> Encoded<String> {
Ok(match v {
Scalar::Str(s) => s.clone(),
Scalar::Int(i) => i.to_string(),
Scalar::Float(f) => f.to_string(),
Scalar::Bool(b) => b.to_string(),
Scalar::Json(j) => j.to_string(),
Scalar::Null => String::new(),
})
}
fn as_bool(v: &Scalar, fail: &dyn Fn(String) -> EncodeError) -> Encoded<bool> {
match v {
Scalar::Bool(b) => Ok(*b),
Scalar::Str(s) => match s.trim().to_ascii_lowercase().as_str() {
"true" => Ok(true),
"false" => Ok(false),
_ => Err(fail(
"expected a boolean, or the string \"true\" or \"false\"".to_string(),
)),
},
other => Err(fail(format!(
"expected a boolean, got {}",
other.kind_name()
))),
}
}
fn as_uuid(v: &Scalar, fail: &dyn Fn(String) -> EncodeError) -> Encoded<uuid::Uuid> {
match v {
Scalar::Str(s) => uuid::Uuid::parse_str(s.trim())
.map_err(|e| fail(format!("expected a UUID string ({e})"))),
other => Err(fail(format!(
"expected a UUID string, got {}",
other.kind_name()
))),
}
}
fn as_json(v: &Scalar, fail: &dyn Fn(String) -> EncodeError) -> Encoded<Value> {
Ok(match v {
Scalar::Json(j) => j.clone(),
Scalar::Bool(b) => Value::Bool(*b),
Scalar::Int(i) => Value::Number((*i).into()),
Scalar::Float(f) => serde_json::Number::from_f64(*f)
.map(Value::Number)
.ok_or_else(|| fail("the value is not a finite number".to_string()))?,
Scalar::Str(s) => {
serde_json::from_str(s).map_err(|e| fail(format!("expected JSON text ({e})")))?
}
Scalar::Null => Value::Null,
})
}
fn as_timestamptz(
v: &Scalar,
fail: &dyn Fn(String) -> EncodeError,
) -> Encoded<chrono::DateTime<chrono::Utc>> {
let s = match v {
Scalar::Str(s) => s.trim().to_string(),
other => {
return Err(fail(format!(
"expected a timestamp string, got {}",
other.kind_name()
)));
}
};
chrono::DateTime::parse_from_rfc3339(&s)
.or_else(|_| chrono::DateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S%.f%#z"))
.or_else(|_| chrono::DateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S%.f%:z"))
.map(|dt| dt.with_timezone(&chrono::Utc))
.map_err(|e| {
fail(format!(
"expected a timestamp with a UTC offset, such as \
\"2026-09-02T05:00:00Z\" ({e})"
))
})
}
fn as_timestamp(
v: &Scalar,
fail: &dyn Fn(String) -> EncodeError,
) -> Encoded<chrono::NaiveDateTime> {
let s = match v {
Scalar::Str(s) => s.trim().to_string(),
other => {
return Err(fail(format!(
"expected a timestamp string, got {}",
other.kind_name()
)));
}
};
chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%d %H:%M:%S%.f")
.or_else(|_| chrono::NaiveDateTime::parse_from_str(&s, "%Y-%m-%dT%H:%M:%S%.f"))
.or_else(|_| {
chrono::DateTime::parse_from_rfc3339(&s)
.map(|dt| dt.with_timezone(&chrono::Utc).naive_utc())
})
.map_err(|e| {
fail(format!(
"expected a timestamp such as \"2026-09-02 05:00:00\" ({e})"
))
})
}
fn as_date(v: &Scalar, fail: &dyn Fn(String) -> EncodeError) -> Encoded<chrono::NaiveDate> {
match v {
Scalar::Str(s) => chrono::NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d")
.map_err(|e| fail(format!("expected a date such as \"2026-09-02\" ({e})"))),
other => Err(fail(format!(
"expected a date string, got {}",
other.kind_name()
))),
}
}
fn as_time(v: &Scalar, fail: &dyn Fn(String) -> EncodeError) -> Encoded<chrono::NaiveTime> {
match v {
Scalar::Str(s) => chrono::NaiveTime::parse_from_str(s.trim(), "%H:%M:%S%.f")
.map_err(|e| fail(format!("expected a time such as \"05:00:00\" ({e})"))),
other => Err(fail(format!(
"expected a time string, got {}",
other.kind_name()
))),
}
}
pub async fn pg_typed_args(
conn: &mut sqlx::PgConnection,
sql: &str,
params: Option<&[Scalar]>,
) -> Result<Bound<PgArguments>, EncodeError> {
use sqlx::{Executor, Statement};
let Some(params) = params else {
return Ok(Bound::Fallback { cache: false });
};
let Ok(statement) = conn
.prepare(sqlx::SqlSafeStr::into_sql_str(sqlx::AssertSqlSafe(sql)))
.await
else {
return Ok(Bound::Fallback { cache: false });
};
let declared = match statement.parameters() {
Some(sqlx::Either::Left(declared)) => declared.to_vec(),
_ => return fall_back(conn, None).await,
};
match pg_arguments(&declared, params)? {
Binding::Typed(args) => Ok(Bound::Typed(args)),
Binding::Unsupported(sql_type) => fall_back(conn, Some(sql_type)).await,
}
}
async fn fall_back(
conn: &mut sqlx::PgConnection,
sql_type: Option<String>,
) -> Result<Bound<PgArguments>, EncodeError> {
use sqlx::Connection;
if let Some(sql_type) = sql_type {
tracing::debug!(
sql_type = %sql_type,
"parameter type has no typed binding; falling back to value-shaped binding"
);
}
if let Err(e) = conn.clear_cached_statements().await {
tracing::debug!(error = %e, "could not clear the statement cache after a fallback");
}
Ok(Bound::Fallback { cache: false })
}
pub fn sea_args_for<DB>(_pool: &sqlx::Pool<DB>, values: sea_query_sqlx::SqlxValues) -> DB::Arguments
where
DB: sqlx::Database,
sea_query_sqlx::SqlxValues: sqlx::IntoArguments<DB>,
{
sqlx::IntoArguments::into_arguments(values)
}
pub async fn mysql_typed_args(
_conn: &mut sqlx::MySqlConnection,
_sql: &str,
_params: Option<&[Scalar]>,
) -> Result<Bound<sqlx::mysql::MySqlArguments>, EncodeError> {
Ok(Bound::Fallback { cache: true })
}
pub async fn sqlite_typed_args(
_conn: &mut sqlx::SqliteConnection,
_sql: &str,
_params: Option<&[Scalar]>,
) -> Result<Bound<sqlx::sqlite::SqliteArguments>, EncodeError> {
Ok(Bound::Fallback { cache: true })
}
#[cfg(test)]
mod tests {
#![allow(clippy::panic)]
use super::*;
use serde_json::json;
fn info<T: sqlx::Type<sqlx::Postgres>>() -> PgTypeInfo {
T::type_info()
}
fn fail_for(sql_type: &str) -> impl Fn(String) -> EncodeError + '_ {
move |detail| EncodeError {
param: 0,
sql_type: sql_type.to_string(),
detail,
}
}
#[test]
fn every_bindable_name_has_a_scalar_and_an_array_arm() {
for name in BINDABLE {
let f = fail_for(name);
let mut args = PgArguments::default();
assert!(
add_scalar(&mut args, name, &Scalar::Null, &f).is_ok(),
"{name} has no scalar arm"
);
let mut args = PgArguments::default();
assert!(
add_array(&mut args, name, &Scalar::Null, &f).is_ok(),
"{name} has no array arm"
);
}
}
#[test]
fn one_placeholder_takes_a_number_and_a_string() {
let declared = vec![info::<i64>()];
for value in [Scalar::Int(5), Scalar::Str("5".to_string())] {
match pg_arguments(&declared, std::slice::from_ref(&value)) {
Ok(Binding::Typed(_)) => {}
_ => panic!("{value:?} did not bind against INT8"),
}
}
let declared = vec![info::<String>()];
for value in [Scalar::Int(5), Scalar::Str("5".to_string())] {
match pg_arguments(&declared, std::slice::from_ref(&value)) {
Ok(Binding::Typed(_)) => {}
_ => panic!("{value:?} did not bind against TEXT"),
}
}
}
#[test]
fn an_int4_slot_refuses_a_value_that_does_not_fit() {
let f = fail_for("INT4");
assert!(as_int::<i32>(&Scalar::Int(i64::from(i32::MAX)), &f).is_ok());
let Err(err) = as_int::<i32>(&Scalar::Int(i64::from(i32::MAX) + 1), &f) else {
panic!("an out-of-range value must be refused");
};
assert!(err.detail.contains("outside the range"), "{}", err.detail);
}
#[test]
fn a_fractional_number_is_not_an_integer() {
let f = fail_for("INT8");
assert_eq!(as_i64(&Scalar::Float(3.0), &f).ok(), Some(3));
assert!(as_i64(&Scalar::Float(3.5), &f).is_err());
}
#[test]
fn a_timestamp_accepts_the_spelling_sql_decode_emits() {
let f = fail_for("TIMESTAMP");
for s in [
"2026-09-02 05:00:00",
"2026-09-02 05:00:00.123",
"2026-09-02T05:00:00",
"2026-09-02T05:00:00Z",
] {
assert!(
as_timestamp(&Scalar::Str(s.to_string()), &f).is_ok(),
"{s} was refused"
);
}
}
#[test]
fn a_timestamptz_requires_an_offset() {
let f = fail_for("TIMESTAMPTZ");
for s in [
"2026-09-02T05:00:00Z",
"2026-09-02T05:00:00+05:30",
"2026-09-02 05:00:00+00",
] {
assert!(
as_timestamptz(&Scalar::Str(s.to_string()), &f).is_ok(),
"{s} was refused"
);
}
let Err(err) = as_timestamptz(&Scalar::Str("2026-09-02 05:00:00".to_string()), &f) else {
panic!("a naive timestamp must be refused for timestamptz");
};
assert!(err.detail.contains("UTC offset"), "{}", err.detail);
}
#[test]
fn numeric_keeps_every_digit_of_a_string() {
let f = fail_for("NUMERIC");
let exact = "1234567890123456789.0123456";
let d = as_decimal(&Scalar::Str(exact.to_string()), &f).expect("decimal");
assert_eq!(d.to_string(), exact);
}
#[test]
fn json_text_is_parsed_rather_than_wrapped() {
let f = fail_for("JSONB");
assert_eq!(
as_json(&Scalar::Str(r#"{"a":1}"#.to_string()), &f).expect("json"),
json!({"a": 1})
);
assert_eq!(
as_json(&Scalar::Json(json!([1, 2])), &f).expect("json"),
json!([1, 2])
);
assert!(as_json(&Scalar::Str("not json".to_string()), &f).is_err());
}
#[test]
fn a_uuid_binds_from_the_string_sql_decode_emits() {
let declared = vec![info::<uuid::Uuid>()];
let id = "11111111-2222-3333-4444-555555555555";
assert!(matches!(
pg_arguments(&declared, &[Scalar::Str(id.to_string())]),
Ok(Binding::Typed(_))
));
assert!(pg_arguments(&declared, &[Scalar::Str("nope".to_string())]).is_err());
}
#[test]
fn an_array_binds_element_wise_including_nulls() {
let declared = vec![info::<Vec<i64>>()];
assert!(matches!(
pg_arguments(&declared, &[Scalar::Json(json!([1, "2", null]))]),
Ok(Binding::Typed(_))
));
assert!(pg_arguments(&declared, &[Scalar::Json(json!(["x"]))]).is_err());
assert!(pg_arguments(&declared, &[Scalar::Int(1)]).is_err());
}
#[test]
fn an_unmapped_declared_type_falls_back_rather_than_failing() {
match pg_arguments(&[info::<Vec<u8>>()], &[Scalar::Str("aGk=".to_string())]) {
Ok(Binding::Unsupported(name)) => assert_eq!(name, "BYTEA"),
other => panic!("BYTEA should fall back, got {other:?}"),
}
}
#[test]
fn the_path_does_not_depend_on_the_values() {
let unmapped = [info::<Vec<u8>>()];
for v in [
Scalar::Null,
Scalar::Int(1),
Scalar::Str("x".to_string()),
Scalar::Json(json!({"a": 1})),
] {
assert!(
matches!(pg_arguments(&unmapped, &[v]), Ok(Binding::Unsupported(_))),
"the fallback decision moved with the value"
);
}
}
#[test]
fn a_parameter_count_mismatch_is_named() {
let Err(err) = pg_arguments(&[info::<i64>(), info::<i64>()], &[Scalar::Int(1)]) else {
panic!("a mismatched parameter count must be refused");
};
assert!(err.detail.contains("placeholder(s)"), "{}", err.detail);
}
#[test]
fn the_message_names_the_placeholder_and_the_remedy() {
let e = EncodeError {
param: 2,
sql_type: "INT8".to_string(),
detail: "expected an integer, got a string".to_string(),
};
let m = e.message("db_read");
assert!(m.contains("parameter $3"), "{m}");
assert!(m.contains("INT8"), "{m}");
assert!(m.contains("($3)::text"), "{m}");
}
#[test]
fn a_huge_unsigned_number_travels_as_text() {
let v = json!(u64::MAX);
assert_eq!(Scalar::from(&v), Scalar::Str(u64::MAX.to_string()));
}
}