use crate::config::{PostgresColumnMapping, PostgresSinkConfig};
use async_trait::async_trait;
use faucet_core::FaucetError;
use faucet_core::util::quote_ident;
use serde_json::Value;
use sqlx::postgres::PgPoolOptions;
use sqlx::{PgPool, Row};
fn pg_bind_text(value: Option<&Value>, udt: &str) -> Option<String> {
match value {
None | Some(Value::Null) => None,
Some(v) => {
if udt.eq_ignore_ascii_case("json") || udt.eq_ignore_ascii_case("jsonb") {
Some(v.to_string())
} else {
match v {
Value::Bool(b) => Some(b.to_string()),
Value::Number(n) => Some(n.to_string()),
Value::String(s) => Some(s.clone()),
other => Some(other.to_string()),
}
}
}
}
}
fn qualified_table_ref(schema: Option<&str>, table: &str) -> String {
match schema {
Some(s) => format!("{}.{}", quote_ident(s), quote_ident(table)),
None => quote_ident(table),
}
}
fn on_conflict_clause(key: &[String], all_cols: &[String]) -> String {
let key_list = key
.iter()
.map(|k| quote_ident(k))
.collect::<Vec<_>>()
.join(", ");
let updates: Vec<String> = all_cols
.iter()
.filter(|c| !key.iter().any(|k| k == *c))
.map(|c| format!("{q} = EXCLUDED.{q}", q = quote_ident(c)))
.collect();
if updates.is_empty() {
format!("ON CONFLICT ({key_list}) DO NOTHING")
} else {
format!(
"ON CONFLICT ({key_list}) DO UPDATE SET {}",
updates.join(", ")
)
}
}
fn pg_keyword(t: faucet_core::SqlBaseType) -> &'static str {
use faucet_core::SqlBaseType::*;
match t {
Integer => "bigint",
Double => "double precision",
Boolean => "boolean",
Text => "text",
Json => "jsonb",
}
}
fn build_add_column_sql(table_ref: &str, col: &str, t: faucet_core::SqlBaseType) -> String {
format!(
"ALTER TABLE {table_ref} ADD COLUMN IF NOT EXISTS {} {}",
quote_ident(col),
pg_keyword(t)
)
}
fn build_alter_type_sql(table_ref: &str, col: &str, t: faucet_core::SqlBaseType) -> String {
let q = quote_ident(col);
let kw = pg_keyword(t);
format!("ALTER TABLE {table_ref} ALTER COLUMN {q} TYPE {kw} USING {q}::{kw}")
}
fn build_drop_not_null_sql(table_ref: &str, col: &str) -> String {
format!(
"ALTER TABLE {table_ref} ALTER COLUMN {} DROP NOT NULL",
quote_ident(col)
)
}
fn pg_udt_to_json_schema(udt: &str, nullable: bool) -> serde_json::Value {
let base = match udt {
"int2" | "int4" | "int8" => "integer",
"float4" | "float8" | "numeric" => "number",
"bool" => "boolean",
"json" | "jsonb" => "object",
_ => "string",
};
if nullable {
serde_json::json!({ "type": [base, "null"] })
} else {
serde_json::json!({ "type": base })
}
}
pub struct PostgresSink {
config: PostgresSinkConfig,
pool: PgPool,
}
impl PostgresSink {
pub async fn new(config: PostgresSinkConfig) -> Result<Self, FaucetError> {
config.write.validate()?;
if !matches!(config.write.write_mode, faucet_core::WriteMode::Append)
&& !matches!(config.column_mapping, PostgresColumnMapping::AutoMap)
{
return Err(FaucetError::Config(
"postgres sink: write_mode upsert/delete requires column_mapping: auto_map \
(key columns must be real columns, not inside a JSONB blob)"
.into(),
));
}
let pool = PgPoolOptions::new()
.max_connections(config.max_connections)
.connect(&config.connection_url)
.await
.map_err(|e| FaucetError::Sink(format!("PostgreSQL connection failed: {e}")))?;
Ok(Self { config, pool })
}
async fn insert_jsonb(
&self,
conn: &mut sqlx::PgConnection,
records: &[Value],
column: &str,
) -> Result<usize, FaucetError> {
if records.is_empty() {
return Ok(0);
}
let json_values: Vec<serde_json::Value> = records.to_vec();
let query = format!(
"INSERT INTO {} ({}) SELECT * FROM unnest($1::jsonb[])",
qualified_table_ref(self.config.schema.as_deref(), &self.config.table_name),
quote_ident(column)
);
sqlx::query(&query)
.bind(json_values)
.execute(&mut *conn)
.await
.map_err(|e| FaucetError::Sink(format!("PostgreSQL insert failed: {e}")))?;
Ok(records.len())
}
async fn insert_auto_map_with_conflict(
&self,
conn: &mut sqlx::PgConnection,
records: &[Value],
conflict_key: Option<&[String]>,
) -> Result<usize, FaucetError> {
if records.is_empty() {
return Ok(0);
}
let table_ref = qualified_table_ref(self.config.schema.as_deref(), &self.config.table_name);
let columns: Vec<(String, String)> = sqlx::query(
"SELECT a.attname::text AS column_name, t.typname::text AS udt_name \
FROM pg_catalog.pg_attribute a \
JOIN pg_catalog.pg_type t ON t.oid = a.atttypid \
WHERE a.attrelid = to_regclass($1)::oid \
AND a.attnum > 0 AND NOT a.attisdropped \
ORDER BY a.attnum",
)
.bind(&table_ref)
.fetch_all(&mut *conn)
.await
.map_err(|e| FaucetError::Sink(format!("failed to query table columns: {e}")))?
.iter()
.map(|row| {
(
row.get::<String, _>("column_name"),
row.get::<String, _>("udt_name"),
)
})
.collect();
if columns.is_empty() {
return Err(FaucetError::Sink(format!(
"table {table_ref} has no columns or does not exist"
)));
}
let mut matched_rows: Vec<Vec<(&String, &String, &Value)>> =
Vec::with_capacity(records.len());
let mut used: std::collections::HashSet<&str> = std::collections::HashSet::new();
for record in records {
let obj = record
.as_object()
.ok_or_else(|| FaucetError::Sink("AutoMap requires JSON object records".into()))?;
let matching: Vec<(&String, &String, &Value)> = columns
.iter()
.filter_map(|(col, udt)| obj.get(col).map(|v| (col, udt, v)))
.collect();
if matching.is_empty() {
tracing::warn!(
record_keys = ?obj.keys().collect::<Vec<_>>(),
table_columns = ?columns,
"record has no keys matching table columns, skipping"
);
continue;
}
for (c, _, _) in &matching {
used.insert(c.as_str());
}
matched_rows.push(matching);
}
if matched_rows.is_empty() {
return Ok(0);
}
let insert_columns: Vec<(String, String)> = columns
.iter()
.filter(|(c, _)| used.contains(c.as_str()))
.cloned()
.collect();
let num_cols = insert_columns.len();
let num_rows = matched_rows.len();
let col_names: Vec<String> = insert_columns.iter().map(|(c, _)| quote_ident(c)).collect();
const MAX_PG_PARAMS: usize = 65535;
let max_rows_per_insert = (MAX_PG_PARAMS / num_cols).max(1);
for sub in matched_rows.chunks(max_rows_per_insert) {
let mut value_tuples: Vec<String> = Vec::with_capacity(sub.len());
for row_idx in 0..sub.len() {
let start = row_idx * num_cols + 1;
let placeholders: Vec<String> = (0..num_cols)
.map(|c| format!("${}::{}", start + c, insert_columns[c].1))
.collect();
value_tuples.push(format!("({})", placeholders.join(", ")));
}
let query = format!(
"INSERT INTO {} ({}) VALUES {}",
table_ref,
col_names.join(", "),
value_tuples.join(", ")
);
let query = match conflict_key {
Some(key) => format!(
"{query} {}",
on_conflict_clause(
key,
&insert_columns
.iter()
.map(|(c, _)| c.clone())
.collect::<Vec<_>>()
)
),
None => query,
};
let mut q = sqlx::query(&query);
for matched in sub {
for (col, udt) in &insert_columns {
let val = matched
.iter()
.find(|(c, _, _)| *c == col)
.map(|(_, _, v)| *v);
q = q.bind(pg_bind_text(val, udt));
}
}
q.execute(&mut *conn)
.await
.map_err(|e| FaucetError::Sink(format!("PostgreSQL insert failed: {e}")))?;
}
Ok(num_rows)
}
async fn insert_auto_map(
&self,
conn: &mut sqlx::PgConnection,
records: &[Value],
) -> Result<usize, FaucetError> {
self.insert_auto_map_with_conflict(conn, records, None)
.await
}
async fn delete_by_keys(
&self,
conn: &mut sqlx::PgConnection,
deletes: &[faucet_core::KeyTuple],
) -> Result<usize, FaucetError> {
if deletes.is_empty() {
return Ok(0);
}
let key = &self.config.write.key;
let table_ref = qualified_table_ref(self.config.schema.as_deref(), &self.config.table_name);
let udts: std::collections::HashMap<String, String> = sqlx::query(
"SELECT a.attname::text AS column_name, t.typname::text AS udt_name \
FROM pg_catalog.pg_attribute a \
JOIN pg_catalog.pg_type t ON t.oid = a.atttypid \
WHERE a.attrelid = to_regclass($1)::oid AND a.attnum > 0 AND NOT a.attisdropped",
)
.bind(&table_ref)
.fetch_all(&mut *conn)
.await
.map_err(|e| FaucetError::Sink(format!("failed to query table columns: {e}")))?
.iter()
.map(|row| {
(
row.get::<String, _>("column_name"),
row.get::<String, _>("udt_name"),
)
})
.collect();
let key_udts: Vec<String> = key
.iter()
.map(|k| udts.get(k).cloned().unwrap_or_else(|| "text".to_string()))
.collect();
let col_list = key
.iter()
.map(|k| quote_ident(k))
.collect::<Vec<_>>()
.join(", ");
const MAX_PG_PARAMS: usize = 65535;
let per = (MAX_PG_PARAMS / key.len().max(1)).max(1);
let mut total = 0usize;
for chunk in deletes.chunks(per) {
let mut ph = 1usize;
let tuples: Vec<String> = chunk
.iter()
.map(|_| {
let group = key_udts
.iter()
.map(|udt| {
let p = format!("${ph}::{udt}");
ph += 1;
p
})
.collect::<Vec<_>>()
.join(", ");
format!("({group})")
})
.collect();
let sql = format!(
"DELETE FROM {table_ref} WHERE ({col_list}) IN ({})",
tuples.join(", ")
);
let mut q = sqlx::query(&sql);
for kt in chunk {
for ((_, v), udt) in kt.0.iter().zip(key_udts.iter()) {
q = q.bind(pg_bind_text(Some(v), udt));
}
}
let res = q
.execute(&mut *conn)
.await
.map_err(|e| FaucetError::Sink(format!("PostgreSQL delete failed: {e}")))?;
total += res.rows_affected() as usize;
}
Ok(total)
}
async fn apply_plan(
&self,
conn: &mut sqlx::PgConnection,
plan: &faucet_core::WritePlan,
) -> Result<usize, FaucetError> {
let mut affected = 0usize;
if !plan.upserts.is_empty() {
affected += self
.insert_auto_map_with_conflict(conn, &plan.upserts, Some(&self.config.write.key))
.await?;
}
if !plan.deletes.is_empty() {
affected += self.delete_by_keys(conn, &plan.deletes).await?;
}
Ok(affected)
}
async fn ensure_commit_table(&self) -> Result<(), FaucetError> {
let sql = format!(
"CREATE TABLE IF NOT EXISTS {t} ({s} TEXT PRIMARY KEY, {k} TEXT NOT NULL, updated_at TIMESTAMPTZ DEFAULT now())",
t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
);
sqlx::query(&sql).execute(&self.pool).await.map_err(|e| {
FaucetError::Sink(format!("PostgreSQL commit-table create failed: {e}"))
})?;
Ok(())
}
}
#[async_trait]
impl faucet_core::Sink for PostgresSink {
fn config_schema(&self) -> serde_json::Value {
serde_json::to_value(faucet_core::schema_for!(PostgresSinkConfig))
.expect("schema serialization")
}
fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
&[
faucet_core::WriteMode::Append,
faucet_core::WriteMode::Upsert,
faucet_core::WriteMode::Delete,
]
}
fn dedups_by_key(&self) -> bool {
self.config.write.dedups_by_key()
}
fn supports_schema_evolution(&self) -> bool {
true
}
async fn current_schema(&self) -> Result<Option<serde_json::Value>, FaucetError> {
let table_ref = qualified_table_ref(self.config.schema.as_deref(), &self.config.table_name);
let rows: Vec<(String, String, bool)> = sqlx::query(
"SELECT a.attname::text AS column_name, t.typname::text AS udt_name, a.attnotnull \
FROM pg_catalog.pg_attribute a \
JOIN pg_catalog.pg_type t ON t.oid = a.atttypid \
WHERE a.attrelid = to_regclass($1)::oid \
AND a.attnum > 0 AND NOT a.attisdropped \
ORDER BY a.attnum",
)
.bind(&table_ref)
.fetch_all(&self.pool)
.await
.map_err(|e| FaucetError::Sink(format!("postgres current_schema query failed: {e}")))?
.iter()
.map(|row| {
(
row.get::<String, _>("column_name"),
row.get::<String, _>("udt_name"),
row.get::<bool, _>("attnotnull"),
)
})
.collect();
if rows.is_empty() {
return Ok(None); }
let mut props = serde_json::Map::new();
for (name, udt, notnull) in rows {
props.insert(name, pg_udt_to_json_schema(&udt, !notnull));
}
Ok(Some(
serde_json::json!({ "type": "object", "properties": props }),
))
}
async fn evolve_schema(
&self,
evolution: &faucet_core::SchemaEvolution,
) -> Result<(), FaucetError> {
let table_ref = qualified_table_ref(self.config.schema.as_deref(), &self.config.table_name);
let mut conn = self
.pool
.acquire()
.await
.map_err(|e| FaucetError::Sink(format!("postgres evolve acquire failed: {e}")))?;
for c in &evolution.additions {
let t =
faucet_core::json_schema_base_type(&c.to).unwrap_or(faucet_core::SqlBaseType::Text);
sqlx::query(&build_add_column_sql(&table_ref, &c.name, t))
.execute(&mut *conn)
.await
.map_err(|e| {
FaucetError::Sink(format!("postgres ADD COLUMN {} failed: {e}", c.name))
})?;
}
for c in &evolution.widenings {
let t =
faucet_core::json_schema_base_type(&c.to).unwrap_or(faucet_core::SqlBaseType::Text);
sqlx::query(&build_alter_type_sql(&table_ref, &c.name, t))
.execute(&mut *conn)
.await
.map_err(|e| {
FaucetError::Sink(format!("postgres ALTER TYPE {} failed: {e}", c.name))
})?;
}
for col in &evolution.relax_nullability {
sqlx::query(&build_drop_not_null_sql(&table_ref, col))
.execute(&mut *conn)
.await
.map_err(|e| {
FaucetError::Sink(format!("postgres DROP NOT NULL {col} failed: {e}"))
})?;
}
Ok(())
}
fn dataset_uri(&self) -> String {
let table = match &self.config.schema {
Some(s) => format!("{}.{}", s, self.config.table_name),
None => self.config.table_name.clone(),
};
format!(
"{}?table={}",
faucet_core::redact_uri_credentials(&self.config.connection_url),
table
)
}
async fn check(
&self,
ctx: &faucet_core::check::CheckContext,
) -> Result<faucet_core::check::CheckReport, FaucetError> {
use faucet_core::check::{CheckReport, Probe};
let started = std::time::Instant::now();
let probe =
match tokio::time::timeout(ctx.timeout, sqlx::query("SELECT 1").execute(&self.pool))
.await
{
Ok(Ok(_)) => Probe::pass("auth", started.elapsed()),
Ok(Err(e)) => Probe::fail_hint(
"auth",
started.elapsed(),
e.to_string(),
"check connection_url / credentials / that the database is reachable",
),
Err(_) => Probe::fail_hint(
"auth",
started.elapsed(),
"timed out",
"check connection_url / credentials / that the database is reachable",
),
};
Ok(CheckReport::single(probe))
}
async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
if records.is_empty() {
return Ok(0);
}
if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
let plan = faucet_core::plan_writes(records, &self.config.write);
if let Some((idx, msg)) = plan.failed.first() {
return Err(FaucetError::Sink(format!(
"postgres {}: row {idx}: {msg}",
self.config.write.write_mode.as_str()
)));
}
let mut conn =
self.pool.acquire().await.map_err(|e| {
FaucetError::Sink(format!("PostgreSQL pool acquire failed: {e}"))
})?;
return self.apply_plan(&mut conn, &plan).await;
}
let chunks: Vec<&[Value]> = if self.config.batch_size == 0 {
vec![records]
} else {
records.chunks(self.config.batch_size).collect()
};
let mut conn = self
.pool
.acquire()
.await
.map_err(|e| FaucetError::Sink(format!("PostgreSQL pool acquire failed: {e}")))?;
let mut total = 0;
for chunk in chunks {
total += match &self.config.column_mapping {
PostgresColumnMapping::Jsonb { column } => {
self.insert_jsonb(&mut conn, chunk, column).await?
}
PostgresColumnMapping::AutoMap => self.insert_auto_map(&mut conn, chunk).await?,
};
}
tracing::info!(
table = %self.config.table_name,
rows = total,
"PostgreSQL write complete"
);
Ok(total)
}
async fn write_batch_partial(
&self,
records: &[Value],
) -> Result<Vec<faucet_core::RowOutcome>, FaucetError> {
if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
self.write_batch(records).await?;
return Ok(records.iter().map(|_| Ok(())).collect());
}
let plan = faucet_core::plan_writes(records, &self.config.write);
let mut conn = self
.pool
.acquire()
.await
.map_err(|e| FaucetError::Sink(format!("PostgreSQL pool acquire failed: {e}")))?;
self.apply_plan(&mut conn, &plan).await?;
let mut outcomes: Vec<faucet_core::RowOutcome> = records.iter().map(|_| Ok(())).collect();
for (idx, msg) in &plan.failed {
outcomes[*idx] = Err(FaucetError::Sink(format!(
"postgres {}: {msg}",
self.config.write.write_mode.as_str()
)));
}
Ok(outcomes)
}
fn supports_idempotent_writes(&self) -> bool {
true
}
async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
self.ensure_commit_table().await?;
let sql = format!(
"SELECT {k} FROM {t} WHERE {s} = $1",
t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
);
let row = sqlx::query(&sql)
.bind(scope)
.fetch_optional(&self.pool)
.await
.map_err(|e| FaucetError::Sink(format!("PostgreSQL token read failed: {e}")))?;
Ok(row.map(|r| r.get::<String, _>(0)))
}
async fn write_batch_idempotent(
&self,
records: &[Value],
scope: &str,
token: &str,
) -> Result<usize, FaucetError> {
self.ensure_commit_table().await?;
let plan = if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
None
} else {
let plan = faucet_core::plan_writes(records, &self.config.write);
if let Some((idx, msg)) = plan.failed.first() {
return Err(FaucetError::Sink(format!(
"postgres {}: row {idx}: {msg}",
self.config.write.write_mode.as_str()
)));
}
Some(plan)
};
let mut tx =
self.pool.begin().await.map_err(|e| {
FaucetError::Sink(format!("PostgreSQL transaction begin failed: {e}"))
})?;
let written = match &plan {
Some(plan) => self.apply_plan(&mut tx, plan).await?,
None => match &self.config.column_mapping {
PostgresColumnMapping::Jsonb { column } => {
self.insert_jsonb(&mut tx, records, column).await?
}
PostgresColumnMapping::AutoMap => self.insert_auto_map(&mut tx, records).await?,
},
};
let upsert = format!(
"INSERT INTO {t} ({s}, {k}) VALUES ($1, $2) ON CONFLICT ({s}) DO UPDATE SET {k} = EXCLUDED.{k}, updated_at = now()",
t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
);
sqlx::query(&upsert)
.bind(scope)
.bind(token)
.execute(&mut *tx)
.await
.map_err(|e| FaucetError::Sink(format!("PostgreSQL token upsert failed: {e}")))?;
tx.commit()
.await
.map_err(|e| FaucetError::Sink(format!("PostgreSQL transaction commit failed: {e}")))?;
Ok(written)
}
}
#[cfg(test)]
mod tests {
use super::{
build_add_column_sql, build_alter_type_sql, build_drop_not_null_sql, on_conflict_clause,
pg_bind_text, pg_udt_to_json_schema, qualified_table_ref,
};
use serde_json::json;
#[test]
fn pg_add_column_ddl() {
let sql = build_add_column_sql("\"public\".\"t\"", "email", faucet_core::SqlBaseType::Text);
assert_eq!(
sql,
"ALTER TABLE \"public\".\"t\" ADD COLUMN IF NOT EXISTS \"email\" text"
);
}
#[test]
fn pg_widen_column_ddl() {
let sql = build_alter_type_sql(
"\"public\".\"t\"",
"score",
faucet_core::SqlBaseType::Double,
);
assert_eq!(
sql,
"ALTER TABLE \"public\".\"t\" ALTER COLUMN \"score\" TYPE double precision USING \"score\"::double precision"
);
}
#[test]
fn pg_drop_not_null_ddl() {
let sql = build_drop_not_null_sql("\"t\"", "created_at");
assert_eq!(
sql,
"ALTER TABLE \"t\" ALTER COLUMN \"created_at\" DROP NOT NULL"
);
}
#[test]
fn pg_udt_round_trips_to_json_schema() {
assert_eq!(
pg_udt_to_json_schema("int8", false),
json!({"type":"integer"})
);
assert_eq!(
pg_udt_to_json_schema("float8", false),
json!({"type":"number"})
);
assert_eq!(
pg_udt_to_json_schema("bool", false),
json!({"type":"boolean"})
);
assert_eq!(
pg_udt_to_json_schema("jsonb", false),
json!({"type":"object"})
);
assert_eq!(
pg_udt_to_json_schema("text", false),
json!({"type":"string"})
);
assert_eq!(
pg_udt_to_json_schema("timestamptz", true),
json!({"type":["string","null"]})
);
}
#[test]
fn upsert_on_conflict_clause_for_keys() {
let clause = on_conflict_clause(
&["id".to_string()],
&["id".to_string(), "name".to_string(), "email".to_string()],
);
assert_eq!(
clause,
r#"ON CONFLICT ("id") DO UPDATE SET "name" = EXCLUDED."name", "email" = EXCLUDED."email""#
);
}
#[test]
fn upsert_on_conflict_all_columns_are_key_does_nothing() {
let clause = on_conflict_clause(&["id".to_string()], &["id".to_string()]);
assert_eq!(clause, r#"ON CONFLICT ("id") DO NOTHING"#);
}
#[test]
fn commit_token_table_is_the_shared_constant() {
assert_eq!(
faucet_core::idempotency::COMMIT_TOKEN_TABLE,
"_faucet_commit_token"
);
}
#[test]
fn qualified_table_ref_unqualified_is_bare_quoted_table() {
assert_eq!(qualified_table_ref(None, "events"), "\"events\"");
}
#[test]
fn qualified_table_ref_with_schema_is_schema_dot_table() {
assert_eq!(
qualified_table_ref(Some("analytics"), "events"),
"\"analytics\".\"events\""
);
}
#[test]
fn qualified_table_ref_escapes_embedded_quotes() {
assert_eq!(
qualified_table_ref(Some("we\"ird"), "ta\"ble"),
"\"we\"\"ird\".\"ta\"\"ble\""
);
}
#[test]
fn null_and_absent_bind_sql_null() {
assert_eq!(pg_bind_text(None, "text"), None);
assert_eq!(pg_bind_text(Some(&json!(null)), "int4"), None);
assert_eq!(pg_bind_text(Some(&json!(null)), "jsonb"), None);
}
#[test]
fn scalars_bind_plain_text_for_typed_columns() {
assert_eq!(
pg_bind_text(Some(&json!(42)), "int4").as_deref(),
Some("42")
);
assert_eq!(
pg_bind_text(Some(&json!(1.5)), "numeric").as_deref(),
Some("1.5")
);
assert_eq!(
pg_bind_text(Some(&json!(true)), "bool").as_deref(),
Some("true")
);
assert_eq!(
pg_bind_text(Some(&json!("2025-01-01T00:00:00Z")), "timestamptz").as_deref(),
Some("2025-01-01T00:00:00Z")
);
assert_eq!(
pg_bind_text(Some(&json!("Bob")), "text").as_deref(),
Some("Bob")
);
assert_eq!(
pg_bind_text(Some(&json!(18446744073709551615u64)), "numeric").as_deref(),
Some("18446744073709551615")
);
}
#[test]
fn json_columns_get_json_text_with_quotes_preserved() {
assert_eq!(
pg_bind_text(Some(&json!("Bob")), "jsonb").as_deref(),
Some("\"Bob\"")
);
assert_eq!(
pg_bind_text(Some(&json!({"a": 1})), "jsonb").as_deref(),
Some("{\"a\":1}")
);
assert_eq!(
pg_bind_text(Some(&json!([1, 2])), "json").as_deref(),
Some("[1,2]")
);
assert_eq!(pg_bind_text(Some(&json!(5)), "jsonb").as_deref(), Some("5"));
assert_eq!(
pg_bind_text(Some(&json!("x")), "JSONB").as_deref(),
Some("\"x\"")
);
}
#[test]
fn objects_into_non_json_columns_emit_json_text_so_the_cast_fails_loudly() {
assert_eq!(
pg_bind_text(Some(&json!({"a": 1})), "int4").as_deref(),
Some("{\"a\":1}")
);
}
}