1use crate::config::{PostgresColumnMapping, PostgresSinkConfig};
4use async_trait::async_trait;
5use faucet_core::FaucetError;
6use faucet_core::util::quote_ident;
7use serde_json::Value;
8use sqlx::postgres::PgPoolOptions;
9use sqlx::{PgPool, Row};
10
11fn pg_bind_text(value: Option<&Value>, udt: &str) -> Option<String> {
28 match value {
29 None | Some(Value::Null) => None,
30 Some(v) => {
31 if udt.eq_ignore_ascii_case("json") || udt.eq_ignore_ascii_case("jsonb") {
32 Some(v.to_string())
33 } else {
34 match v {
35 Value::Bool(b) => Some(b.to_string()),
36 Value::Number(n) => Some(n.to_string()),
37 Value::String(s) => Some(s.clone()),
38 other => Some(other.to_string()),
42 }
43 }
44 }
45 }
46}
47
48fn qualified_table_ref(schema: Option<&str>, table: &str) -> String {
59 match schema {
60 Some(s) => format!("{}.{}", quote_ident(s), quote_ident(table)),
61 None => quote_ident(table),
62 }
63}
64
65fn on_conflict_clause(key: &[String], all_cols: &[String]) -> String {
69 let key_list = key
70 .iter()
71 .map(|k| quote_ident(k))
72 .collect::<Vec<_>>()
73 .join(", ");
74 let updates: Vec<String> = all_cols
75 .iter()
76 .filter(|c| !key.iter().any(|k| k == *c))
77 .map(|c| format!("{q} = EXCLUDED.{q}", q = quote_ident(c)))
78 .collect();
79 if updates.is_empty() {
80 format!("ON CONFLICT ({key_list}) DO NOTHING")
81 } else {
82 format!(
83 "ON CONFLICT ({key_list}) DO UPDATE SET {}",
84 updates.join(", ")
85 )
86 }
87}
88
89fn pg_keyword(t: faucet_core::SqlBaseType) -> &'static str {
94 use faucet_core::SqlBaseType::*;
95 match t {
96 Integer => "bigint",
97 Double => "double precision",
98 Boolean => "boolean",
99 Text => "text",
100 Json => "jsonb",
101 }
102}
103
104fn build_add_column_sql(table_ref: &str, col: &str, t: faucet_core::SqlBaseType) -> String {
107 format!(
108 "ALTER TABLE {table_ref} ADD COLUMN IF NOT EXISTS {} {}",
109 quote_ident(col),
110 pg_keyword(t)
111 )
112}
113
114fn build_alter_type_sql(table_ref: &str, col: &str, t: faucet_core::SqlBaseType) -> String {
118 let q = quote_ident(col);
119 let kw = pg_keyword(t);
120 format!("ALTER TABLE {table_ref} ALTER COLUMN {q} TYPE {kw} USING {q}::{kw}")
121}
122
123fn build_drop_not_null_sql(table_ref: &str, col: &str) -> String {
126 format!(
127 "ALTER TABLE {table_ref} ALTER COLUMN {} DROP NOT NULL",
128 quote_ident(col)
129 )
130}
131
132fn pg_udt_to_json_schema(udt: &str, nullable: bool) -> serde_json::Value {
137 let base = match udt {
138 "int2" | "int4" | "int8" => "integer",
139 "float4" | "float8" | "numeric" => "number",
140 "bool" => "boolean",
141 "json" | "jsonb" => "object",
142 _ => "string",
143 };
144 if nullable {
145 serde_json::json!({ "type": [base, "null"] })
146 } else {
147 serde_json::json!({ "type": base })
148 }
149}
150
151pub struct PostgresSink {
153 config: PostgresSinkConfig,
154 pool: PgPool,
155}
156
157impl PostgresSink {
158 pub async fn new(config: PostgresSinkConfig) -> Result<Self, FaucetError> {
160 config.write.validate()?;
161 if !matches!(config.write.write_mode, faucet_core::WriteMode::Append)
162 && !matches!(config.column_mapping, PostgresColumnMapping::AutoMap)
163 {
164 return Err(FaucetError::Config(
165 "postgres sink: write_mode upsert/delete requires column_mapping: auto_map \
166 (key columns must be real columns, not inside a JSONB blob)"
167 .into(),
168 ));
169 }
170
171 let pool = PgPoolOptions::new()
172 .max_connections(config.max_connections)
173 .connect(&config.connection_url)
174 .await
175 .map_err(|e| FaucetError::Sink(format!("PostgreSQL connection failed: {e}")))?;
176
177 Ok(Self { config, pool })
178 }
179
180 async fn insert_jsonb(
187 &self,
188 conn: &mut sqlx::PgConnection,
189 records: &[Value],
190 column: &str,
191 ) -> Result<usize, FaucetError> {
192 if records.is_empty() {
193 return Ok(0);
194 }
195
196 let json_values: Vec<serde_json::Value> = records.to_vec();
198 let query = format!(
199 "INSERT INTO {} ({}) SELECT * FROM unnest($1::jsonb[])",
200 qualified_table_ref(self.config.schema.as_deref(), &self.config.table_name),
201 quote_ident(column)
202 );
203
204 sqlx::query(&query)
205 .bind(json_values)
206 .execute(&mut *conn)
207 .await
208 .map_err(|e| FaucetError::Sink(format!("PostgreSQL insert failed: {e}")))?;
209
210 Ok(records.len())
211 }
212
213 async fn insert_auto_map_with_conflict(
236 &self,
237 conn: &mut sqlx::PgConnection,
238 records: &[Value],
239 conflict_key: Option<&[String]>,
240 ) -> Result<usize, FaucetError> {
241 if records.is_empty() {
242 return Ok(0);
243 }
244
245 let table_ref = qualified_table_ref(self.config.schema.as_deref(), &self.config.table_name);
260 let columns: Vec<(String, String)> = sqlx::query(
261 "SELECT a.attname::text AS column_name, t.typname::text AS udt_name \
262 FROM pg_catalog.pg_attribute a \
263 JOIN pg_catalog.pg_type t ON t.oid = a.atttypid \
264 WHERE a.attrelid = to_regclass($1)::oid \
265 AND a.attnum > 0 AND NOT a.attisdropped \
266 ORDER BY a.attnum",
267 )
268 .bind(&table_ref)
269 .fetch_all(&mut *conn)
270 .await
271 .map_err(|e| FaucetError::Sink(format!("failed to query table columns: {e}")))?
272 .iter()
273 .map(|row| {
274 (
275 row.get::<String, _>("column_name"),
276 row.get::<String, _>("udt_name"),
277 )
278 })
279 .collect();
280
281 if columns.is_empty() {
282 return Err(FaucetError::Sink(format!(
283 "table {table_ref} has no columns or does not exist"
284 )));
285 }
286
287 let mut matched_rows: Vec<Vec<(&String, &String, &Value)>> =
294 Vec::with_capacity(records.len());
295 let mut used: std::collections::HashSet<&str> = std::collections::HashSet::new();
296
297 for record in records {
298 let obj = record
299 .as_object()
300 .ok_or_else(|| FaucetError::Sink("AutoMap requires JSON object records".into()))?;
301
302 let matching: Vec<(&String, &String, &Value)> = columns
303 .iter()
304 .filter_map(|(col, udt)| obj.get(col).map(|v| (col, udt, v)))
305 .collect();
306
307 if matching.is_empty() {
308 tracing::warn!(
309 record_keys = ?obj.keys().collect::<Vec<_>>(),
310 table_columns = ?columns,
311 "record has no keys matching table columns, skipping"
312 );
313 continue;
314 }
315
316 for (c, _, _) in &matching {
317 used.insert(c.as_str());
318 }
319 matched_rows.push(matching);
320 }
321
322 if matched_rows.is_empty() {
323 return Ok(0);
324 }
325
326 let insert_columns: Vec<(String, String)> = columns
329 .iter()
330 .filter(|(c, _)| used.contains(c.as_str()))
331 .cloned()
332 .collect();
333
334 let num_cols = insert_columns.len();
335 let num_rows = matched_rows.len();
336 let col_names: Vec<String> = insert_columns.iter().map(|(c, _)| quote_ident(c)).collect();
337
338 const MAX_PG_PARAMS: usize = 65535;
343 let max_rows_per_insert = (MAX_PG_PARAMS / num_cols).max(1);
344
345 for sub in matched_rows.chunks(max_rows_per_insert) {
346 let mut value_tuples: Vec<String> = Vec::with_capacity(sub.len());
350 for row_idx in 0..sub.len() {
351 let start = row_idx * num_cols + 1;
352 let placeholders: Vec<String> = (0..num_cols)
353 .map(|c| format!("${}::{}", start + c, insert_columns[c].1))
354 .collect();
355 value_tuples.push(format!("({})", placeholders.join(", ")));
356 }
357
358 let query = format!(
359 "INSERT INTO {} ({}) VALUES {}",
360 table_ref,
361 col_names.join(", "),
362 value_tuples.join(", ")
363 );
364 let query = match conflict_key {
365 Some(key) => format!(
366 "{query} {}",
367 on_conflict_clause(
368 key,
369 &insert_columns
370 .iter()
371 .map(|(c, _)| c.clone())
372 .collect::<Vec<_>>()
373 )
374 ),
375 None => query,
376 };
377
378 let mut q = sqlx::query(&query);
379 for matched in sub {
380 for (col, udt) in &insert_columns {
384 let val = matched
385 .iter()
386 .find(|(c, _, _)| *c == col)
387 .map(|(_, _, v)| *v);
388 q = q.bind(pg_bind_text(val, udt));
389 }
390 }
391
392 q.execute(&mut *conn)
393 .await
394 .map_err(|e| FaucetError::Sink(format!("PostgreSQL insert failed: {e}")))?;
395 }
396
397 Ok(num_rows)
398 }
399
400 async fn insert_auto_map(
407 &self,
408 conn: &mut sqlx::PgConnection,
409 records: &[Value],
410 ) -> Result<usize, FaucetError> {
411 self.insert_auto_map_with_conflict(conn, records, None)
412 .await
413 }
414
415 async fn delete_by_keys(
419 &self,
420 conn: &mut sqlx::PgConnection,
421 deletes: &[faucet_core::KeyTuple],
422 ) -> Result<usize, FaucetError> {
423 if deletes.is_empty() {
424 return Ok(0);
425 }
426 let key = &self.config.write.key;
427 let table_ref = qualified_table_ref(self.config.schema.as_deref(), &self.config.table_name);
428
429 let udts: std::collections::HashMap<String, String> = sqlx::query(
432 "SELECT a.attname::text AS column_name, t.typname::text AS udt_name \
433 FROM pg_catalog.pg_attribute a \
434 JOIN pg_catalog.pg_type t ON t.oid = a.atttypid \
435 WHERE a.attrelid = to_regclass($1)::oid AND a.attnum > 0 AND NOT a.attisdropped",
436 )
437 .bind(&table_ref)
438 .fetch_all(&mut *conn)
439 .await
440 .map_err(|e| FaucetError::Sink(format!("failed to query table columns: {e}")))?
441 .iter()
442 .map(|row| {
443 (
444 row.get::<String, _>("column_name"),
445 row.get::<String, _>("udt_name"),
446 )
447 })
448 .collect();
449 let key_udts: Vec<String> = key
450 .iter()
451 .map(|k| udts.get(k).cloned().unwrap_or_else(|| "text".to_string()))
452 .collect();
453 let col_list = key
454 .iter()
455 .map(|k| quote_ident(k))
456 .collect::<Vec<_>>()
457 .join(", ");
458
459 const MAX_PG_PARAMS: usize = 65535;
460 let per = (MAX_PG_PARAMS / key.len().max(1)).max(1);
461 let mut total = 0usize;
462 for chunk in deletes.chunks(per) {
463 let mut ph = 1usize;
464 let tuples: Vec<String> = chunk
465 .iter()
466 .map(|_| {
467 let group = key_udts
468 .iter()
469 .map(|udt| {
470 let p = format!("${ph}::{udt}");
471 ph += 1;
472 p
473 })
474 .collect::<Vec<_>>()
475 .join(", ");
476 format!("({group})")
477 })
478 .collect();
479 let sql = format!(
480 "DELETE FROM {table_ref} WHERE ({col_list}) IN ({})",
481 tuples.join(", ")
482 );
483 let mut q = sqlx::query(&sql);
484 for kt in chunk {
485 for ((_, v), udt) in kt.0.iter().zip(key_udts.iter()) {
486 q = q.bind(pg_bind_text(Some(v), udt));
487 }
488 }
489 let res = q
490 .execute(&mut *conn)
491 .await
492 .map_err(|e| FaucetError::Sink(format!("PostgreSQL delete failed: {e}")))?;
493 total += res.rows_affected() as usize;
494 }
495 Ok(total)
496 }
497
498 async fn apply_plan(
500 &self,
501 conn: &mut sqlx::PgConnection,
502 plan: &faucet_core::WritePlan,
503 ) -> Result<usize, FaucetError> {
504 let mut affected = 0usize;
505 if !plan.upserts.is_empty() {
506 affected += self
507 .insert_auto_map_with_conflict(conn, &plan.upserts, Some(&self.config.write.key))
508 .await?;
509 }
510 if !plan.deletes.is_empty() {
511 affected += self.delete_by_keys(conn, &plan.deletes).await?;
512 }
513 Ok(affected)
514 }
515
516 async fn ensure_commit_table(&self) -> Result<(), FaucetError> {
518 let sql = format!(
519 "CREATE TABLE IF NOT EXISTS {t} ({s} TEXT PRIMARY KEY, {k} TEXT NOT NULL, updated_at TIMESTAMPTZ DEFAULT now())",
520 t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
521 s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
522 k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
523 );
524 sqlx::query(&sql).execute(&self.pool).await.map_err(|e| {
525 FaucetError::Sink(format!("PostgreSQL commit-table create failed: {e}"))
526 })?;
527 Ok(())
528 }
529}
530
531#[async_trait]
532impl faucet_core::Sink for PostgresSink {
533 fn config_schema(&self) -> serde_json::Value {
534 serde_json::to_value(faucet_core::schema_for!(PostgresSinkConfig))
535 .expect("schema serialization")
536 }
537
538 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
539 &[
540 faucet_core::WriteMode::Append,
541 faucet_core::WriteMode::Upsert,
542 faucet_core::WriteMode::Delete,
543 ]
544 }
545
546 fn dedups_by_key(&self) -> bool {
547 self.config.write.dedups_by_key()
548 }
549
550 fn supports_schema_evolution(&self) -> bool {
551 true
552 }
553
554 async fn current_schema(&self) -> Result<Option<serde_json::Value>, FaucetError> {
562 let table_ref = qualified_table_ref(self.config.schema.as_deref(), &self.config.table_name);
563 let rows: Vec<(String, String, bool)> = sqlx::query(
564 "SELECT a.attname::text AS column_name, t.typname::text AS udt_name, a.attnotnull \
565 FROM pg_catalog.pg_attribute a \
566 JOIN pg_catalog.pg_type t ON t.oid = a.atttypid \
567 WHERE a.attrelid = to_regclass($1)::oid \
568 AND a.attnum > 0 AND NOT a.attisdropped \
569 ORDER BY a.attnum",
570 )
571 .bind(&table_ref)
572 .fetch_all(&self.pool)
573 .await
574 .map_err(|e| FaucetError::Sink(format!("postgres current_schema query failed: {e}")))?
575 .iter()
576 .map(|row| {
577 (
578 row.get::<String, _>("column_name"),
579 row.get::<String, _>("udt_name"),
580 row.get::<bool, _>("attnotnull"),
581 )
582 })
583 .collect();
584
585 if rows.is_empty() {
586 return Ok(None); }
588
589 let mut props = serde_json::Map::new();
590 for (name, udt, notnull) in rows {
591 props.insert(name, pg_udt_to_json_schema(&udt, !notnull));
592 }
593 Ok(Some(
594 serde_json::json!({ "type": "object", "properties": props }),
595 ))
596 }
597
598 async fn evolve_schema(
603 &self,
604 evolution: &faucet_core::SchemaEvolution,
605 ) -> Result<(), FaucetError> {
606 let table_ref = qualified_table_ref(self.config.schema.as_deref(), &self.config.table_name);
607 let mut conn = self
608 .pool
609 .acquire()
610 .await
611 .map_err(|e| FaucetError::Sink(format!("postgres evolve acquire failed: {e}")))?;
612
613 for c in &evolution.additions {
614 let t =
615 faucet_core::json_schema_base_type(&c.to).unwrap_or(faucet_core::SqlBaseType::Text);
616 sqlx::query(&build_add_column_sql(&table_ref, &c.name, t))
617 .execute(&mut *conn)
618 .await
619 .map_err(|e| {
620 FaucetError::Sink(format!("postgres ADD COLUMN {} failed: {e}", c.name))
621 })?;
622 }
623 for c in &evolution.widenings {
624 let t =
625 faucet_core::json_schema_base_type(&c.to).unwrap_or(faucet_core::SqlBaseType::Text);
626 sqlx::query(&build_alter_type_sql(&table_ref, &c.name, t))
627 .execute(&mut *conn)
628 .await
629 .map_err(|e| {
630 FaucetError::Sink(format!("postgres ALTER TYPE {} failed: {e}", c.name))
631 })?;
632 }
633 for col in &evolution.relax_nullability {
634 sqlx::query(&build_drop_not_null_sql(&table_ref, col))
635 .execute(&mut *conn)
636 .await
637 .map_err(|e| {
638 FaucetError::Sink(format!("postgres DROP NOT NULL {col} failed: {e}"))
639 })?;
640 }
641 Ok(())
642 }
643
644 fn dataset_uri(&self) -> String {
645 let table = match &self.config.schema {
646 Some(s) => format!("{}.{}", s, self.config.table_name),
647 None => self.config.table_name.clone(),
648 };
649 format!(
650 "{}?table={}",
651 faucet_core::redact_uri_credentials(&self.config.connection_url),
652 table
653 )
654 }
655
656 async fn check(
662 &self,
663 ctx: &faucet_core::check::CheckContext,
664 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
665 use faucet_core::check::{CheckReport, Probe};
666
667 let started = std::time::Instant::now();
668 let probe =
669 match tokio::time::timeout(ctx.timeout, sqlx::query("SELECT 1").execute(&self.pool))
670 .await
671 {
672 Ok(Ok(_)) => Probe::pass("auth", started.elapsed()),
673 Ok(Err(e)) => Probe::fail_hint(
674 "auth",
675 started.elapsed(),
676 e.to_string(),
677 "check connection_url / credentials / that the database is reachable",
678 ),
679 Err(_) => Probe::fail_hint(
680 "auth",
681 started.elapsed(),
682 "timed out",
683 "check connection_url / credentials / that the database is reachable",
684 ),
685 };
686 Ok(CheckReport::single(probe))
687 }
688
689 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
705 if records.is_empty() {
706 return Ok(0);
707 }
708
709 if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
710 let plan = faucet_core::plan_writes(records, &self.config.write);
711 if let Some((idx, msg)) = plan.failed.first() {
712 return Err(FaucetError::Sink(format!(
713 "postgres {}: row {idx}: {msg}",
714 self.config.write.write_mode.as_str()
715 )));
716 }
717 let mut conn =
718 self.pool.acquire().await.map_err(|e| {
719 FaucetError::Sink(format!("PostgreSQL pool acquire failed: {e}"))
720 })?;
721 return self.apply_plan(&mut conn, &plan).await;
722 }
723
724 let chunks: Vec<&[Value]> = if self.config.batch_size == 0 {
725 vec![records]
729 } else {
730 records.chunks(self.config.batch_size).collect()
731 };
732
733 let mut conn = self
736 .pool
737 .acquire()
738 .await
739 .map_err(|e| FaucetError::Sink(format!("PostgreSQL pool acquire failed: {e}")))?;
740
741 let mut total = 0;
742 for chunk in chunks {
743 total += match &self.config.column_mapping {
744 PostgresColumnMapping::Jsonb { column } => {
745 self.insert_jsonb(&mut conn, chunk, column).await?
746 }
747 PostgresColumnMapping::AutoMap => self.insert_auto_map(&mut conn, chunk).await?,
748 };
749 }
750
751 tracing::info!(
752 table = %self.config.table_name,
753 rows = total,
754 "PostgreSQL write complete"
755 );
756 Ok(total)
757 }
758
759 async fn write_batch_partial(
768 &self,
769 records: &[Value],
770 ) -> Result<Vec<faucet_core::RowOutcome>, FaucetError> {
771 if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
772 self.write_batch(records).await?;
773 return Ok(records.iter().map(|_| Ok(())).collect());
774 }
775
776 let plan = faucet_core::plan_writes(records, &self.config.write);
777 let mut conn = self
778 .pool
779 .acquire()
780 .await
781 .map_err(|e| FaucetError::Sink(format!("PostgreSQL pool acquire failed: {e}")))?;
782 self.apply_plan(&mut conn, &plan).await?;
783
784 let mut outcomes: Vec<faucet_core::RowOutcome> = records.iter().map(|_| Ok(())).collect();
785 for (idx, msg) in &plan.failed {
786 outcomes[*idx] = Err(FaucetError::Sink(format!(
787 "postgres {}: {msg}",
788 self.config.write.write_mode.as_str()
789 )));
790 }
791 Ok(outcomes)
792 }
793
794 fn supports_idempotent_writes(&self) -> bool {
795 true
796 }
797
798 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
799 self.ensure_commit_table().await?;
800 let sql = format!(
801 "SELECT {k} FROM {t} WHERE {s} = $1",
802 t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
803 k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
804 s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
805 );
806 let row = sqlx::query(&sql)
807 .bind(scope)
808 .fetch_optional(&self.pool)
809 .await
810 .map_err(|e| FaucetError::Sink(format!("PostgreSQL token read failed: {e}")))?;
811 Ok(row.map(|r| r.get::<String, _>(0)))
812 }
813
814 async fn write_batch_idempotent(
815 &self,
816 records: &[Value],
817 scope: &str,
818 token: &str,
819 ) -> Result<usize, FaucetError> {
820 self.ensure_commit_table().await?;
821
822 let plan = if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
825 None
826 } else {
827 let plan = faucet_core::plan_writes(records, &self.config.write);
828 if let Some((idx, msg)) = plan.failed.first() {
829 return Err(FaucetError::Sink(format!(
830 "postgres {}: row {idx}: {msg}",
831 self.config.write.write_mode.as_str()
832 )));
833 }
834 Some(plan)
835 };
836
837 let mut tx =
838 self.pool.begin().await.map_err(|e| {
839 FaucetError::Sink(format!("PostgreSQL transaction begin failed: {e}"))
840 })?;
841
842 let written = match &plan {
848 Some(plan) => self.apply_plan(&mut tx, plan).await?,
849 None => match &self.config.column_mapping {
850 PostgresColumnMapping::Jsonb { column } => {
851 self.insert_jsonb(&mut tx, records, column).await?
852 }
853 PostgresColumnMapping::AutoMap => self.insert_auto_map(&mut tx, records).await?,
854 },
855 };
856
857 let upsert = format!(
858 "INSERT INTO {t} ({s}, {k}) VALUES ($1, $2) ON CONFLICT ({s}) DO UPDATE SET {k} = EXCLUDED.{k}, updated_at = now()",
859 t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
860 s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
861 k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
862 );
863 sqlx::query(&upsert)
864 .bind(scope)
865 .bind(token)
866 .execute(&mut *tx)
867 .await
868 .map_err(|e| FaucetError::Sink(format!("PostgreSQL token upsert failed: {e}")))?;
869
870 tx.commit()
871 .await
872 .map_err(|e| FaucetError::Sink(format!("PostgreSQL transaction commit failed: {e}")))?;
873 Ok(written)
874 }
875}
876
877#[cfg(test)]
878mod tests {
879 use super::{
880 build_add_column_sql, build_alter_type_sql, build_drop_not_null_sql, on_conflict_clause,
881 pg_bind_text, pg_udt_to_json_schema, qualified_table_ref,
882 };
883 use serde_json::json;
884
885 #[test]
886 fn pg_add_column_ddl() {
887 let sql = build_add_column_sql("\"public\".\"t\"", "email", faucet_core::SqlBaseType::Text);
888 assert_eq!(
889 sql,
890 "ALTER TABLE \"public\".\"t\" ADD COLUMN IF NOT EXISTS \"email\" text"
891 );
892 }
893
894 #[test]
895 fn pg_widen_column_ddl() {
896 let sql = build_alter_type_sql(
897 "\"public\".\"t\"",
898 "score",
899 faucet_core::SqlBaseType::Double,
900 );
901 assert_eq!(
902 sql,
903 "ALTER TABLE \"public\".\"t\" ALTER COLUMN \"score\" TYPE double precision USING \"score\"::double precision"
904 );
905 }
906
907 #[test]
908 fn pg_drop_not_null_ddl() {
909 let sql = build_drop_not_null_sql("\"t\"", "created_at");
910 assert_eq!(
911 sql,
912 "ALTER TABLE \"t\" ALTER COLUMN \"created_at\" DROP NOT NULL"
913 );
914 }
915
916 #[test]
917 fn pg_udt_round_trips_to_json_schema() {
918 assert_eq!(
919 pg_udt_to_json_schema("int8", false),
920 json!({"type":"integer"})
921 );
922 assert_eq!(
923 pg_udt_to_json_schema("float8", false),
924 json!({"type":"number"})
925 );
926 assert_eq!(
927 pg_udt_to_json_schema("bool", false),
928 json!({"type":"boolean"})
929 );
930 assert_eq!(
931 pg_udt_to_json_schema("jsonb", false),
932 json!({"type":"object"})
933 );
934 assert_eq!(
935 pg_udt_to_json_schema("text", false),
936 json!({"type":"string"})
937 );
938 assert_eq!(
940 pg_udt_to_json_schema("timestamptz", true),
941 json!({"type":["string","null"]})
942 );
943 }
944
945 #[test]
946 fn upsert_on_conflict_clause_for_keys() {
947 let clause = on_conflict_clause(
948 &["id".to_string()],
949 &["id".to_string(), "name".to_string(), "email".to_string()],
950 );
951 assert_eq!(
952 clause,
953 r#"ON CONFLICT ("id") DO UPDATE SET "name" = EXCLUDED."name", "email" = EXCLUDED."email""#
954 );
955 }
956
957 #[test]
958 fn upsert_on_conflict_all_columns_are_key_does_nothing() {
959 let clause = on_conflict_clause(&["id".to_string()], &["id".to_string()]);
960 assert_eq!(clause, r#"ON CONFLICT ("id") DO NOTHING"#);
961 }
962
963 #[test]
964 fn commit_token_table_is_the_shared_constant() {
965 assert_eq!(
966 faucet_core::idempotency::COMMIT_TOKEN_TABLE,
967 "_faucet_commit_token"
968 );
969 }
970
971 #[test]
976 fn qualified_table_ref_unqualified_is_bare_quoted_table() {
977 assert_eq!(qualified_table_ref(None, "events"), "\"events\"");
979 }
980
981 #[test]
982 fn qualified_table_ref_with_schema_is_schema_dot_table() {
983 assert_eq!(
986 qualified_table_ref(Some("analytics"), "events"),
987 "\"analytics\".\"events\""
988 );
989 }
990
991 #[test]
992 fn qualified_table_ref_escapes_embedded_quotes() {
993 assert_eq!(
995 qualified_table_ref(Some("we\"ird"), "ta\"ble"),
996 "\"we\"\"ird\".\"ta\"\"ble\""
997 );
998 }
999
1000 #[test]
1001 fn null_and_absent_bind_sql_null() {
1002 assert_eq!(pg_bind_text(None, "text"), None);
1003 assert_eq!(pg_bind_text(Some(&json!(null)), "int4"), None);
1004 assert_eq!(pg_bind_text(Some(&json!(null)), "jsonb"), None);
1005 }
1006
1007 #[test]
1008 fn scalars_bind_plain_text_for_typed_columns() {
1009 assert_eq!(
1011 pg_bind_text(Some(&json!(42)), "int4").as_deref(),
1012 Some("42")
1013 );
1014 assert_eq!(
1015 pg_bind_text(Some(&json!(1.5)), "numeric").as_deref(),
1016 Some("1.5")
1017 );
1018 assert_eq!(
1019 pg_bind_text(Some(&json!(true)), "bool").as_deref(),
1020 Some("true")
1021 );
1022 assert_eq!(
1023 pg_bind_text(Some(&json!("2025-01-01T00:00:00Z")), "timestamptz").as_deref(),
1024 Some("2025-01-01T00:00:00Z")
1025 );
1026 assert_eq!(
1028 pg_bind_text(Some(&json!("Bob")), "text").as_deref(),
1029 Some("Bob")
1030 );
1031 assert_eq!(
1033 pg_bind_text(Some(&json!(18446744073709551615u64)), "numeric").as_deref(),
1034 Some("18446744073709551615")
1035 );
1036 }
1037
1038 #[test]
1039 fn json_columns_get_json_text_with_quotes_preserved() {
1040 assert_eq!(
1044 pg_bind_text(Some(&json!("Bob")), "jsonb").as_deref(),
1045 Some("\"Bob\"")
1046 );
1047 assert_eq!(
1048 pg_bind_text(Some(&json!({"a": 1})), "jsonb").as_deref(),
1049 Some("{\"a\":1}")
1050 );
1051 assert_eq!(
1052 pg_bind_text(Some(&json!([1, 2])), "json").as_deref(),
1053 Some("[1,2]")
1054 );
1055 assert_eq!(pg_bind_text(Some(&json!(5)), "jsonb").as_deref(), Some("5"));
1056 assert_eq!(
1058 pg_bind_text(Some(&json!("x")), "JSONB").as_deref(),
1059 Some("\"x\"")
1060 );
1061 }
1062
1063 #[test]
1064 fn objects_into_non_json_columns_emit_json_text_so_the_cast_fails_loudly() {
1065 assert_eq!(
1068 pg_bind_text(Some(&json!({"a": 1})), "int4").as_deref(),
1069 Some("{\"a\":1}")
1070 );
1071 }
1072}