1use crate::config::{PostgresColumnMapping, PostgresSinkConfig, PostgresWriteMethod};
4use crate::copy::{build_auto_map_payload, build_jsonb_payload, copy_statement};
5use async_trait::async_trait;
6use faucet_core::FaucetError;
7use faucet_core::util::quote_ident;
8use serde_json::Value;
9use sqlx::postgres::PgPoolOptions;
10use sqlx::{PgPool, Row};
11
12pub(crate) fn pg_bind_text(value: Option<&Value>, udt: &str) -> Option<String> {
29 match value {
30 None | Some(Value::Null) => None,
31 Some(v) => {
32 if udt.eq_ignore_ascii_case("json") || udt.eq_ignore_ascii_case("jsonb") {
33 Some(v.to_string())
34 } else {
35 match v {
36 Value::Bool(b) => Some(b.to_string()),
37 Value::Number(n) => Some(n.to_string()),
38 Value::String(s) => Some(s.clone()),
39 other => Some(other.to_string()),
43 }
44 }
45 }
46 }
47}
48
49fn qualified_table_ref(schema: Option<&str>, table: &str) -> String {
60 match schema {
61 Some(s) => format!("{}.{}", quote_ident(s), quote_ident(table)),
62 None => quote_ident(table),
63 }
64}
65
66fn on_conflict_clause(key: &[String], all_cols: &[String]) -> String {
70 let key_list = key
71 .iter()
72 .map(|k| quote_ident(k))
73 .collect::<Vec<_>>()
74 .join(", ");
75 let updates: Vec<String> = all_cols
76 .iter()
77 .filter(|c| !key.iter().any(|k| k == *c))
78 .map(|c| format!("{q} = EXCLUDED.{q}", q = quote_ident(c)))
79 .collect();
80 if updates.is_empty() {
81 format!("ON CONFLICT ({key_list}) DO NOTHING")
82 } else {
83 format!(
84 "ON CONFLICT ({key_list}) DO UPDATE SET {}",
85 updates.join(", ")
86 )
87 }
88}
89
90fn pg_keyword(t: faucet_core::SqlBaseType) -> &'static str {
95 use faucet_core::SqlBaseType::*;
96 match t {
97 Integer => "bigint",
98 Double => "double precision",
99 Boolean => "boolean",
100 Text => "text",
101 Json => "jsonb",
102 }
103}
104
105fn build_add_column_sql(table_ref: &str, col: &str, t: faucet_core::SqlBaseType) -> String {
108 format!(
109 "ALTER TABLE {table_ref} ADD COLUMN IF NOT EXISTS {} {}",
110 quote_ident(col),
111 pg_keyword(t)
112 )
113}
114
115fn build_alter_type_sql(table_ref: &str, col: &str, t: faucet_core::SqlBaseType) -> String {
119 let q = quote_ident(col);
120 let kw = pg_keyword(t);
121 format!("ALTER TABLE {table_ref} ALTER COLUMN {q} TYPE {kw} USING {q}::{kw}")
122}
123
124fn build_drop_not_null_sql(table_ref: &str, col: &str) -> String {
127 format!(
128 "ALTER TABLE {table_ref} ALTER COLUMN {} DROP NOT NULL",
129 quote_ident(col)
130 )
131}
132
133fn pg_udt_to_json_schema(udt: &str, nullable: bool) -> serde_json::Value {
138 let base = match udt {
139 "int2" | "int4" | "int8" => "integer",
140 "float4" | "float8" | "numeric" => "number",
141 "bool" => "boolean",
142 "json" | "jsonb" => "object",
143 _ => "string",
144 };
145 if nullable {
146 serde_json::json!({ "type": [base, "null"] })
147 } else {
148 serde_json::json!({ "type": base })
149 }
150}
151
152pub struct PostgresSink {
154 config: PostgresSinkConfig,
155 pool: PgPool,
156}
157
158impl PostgresSink {
159 pub async fn new(config: PostgresSinkConfig) -> Result<Self, FaucetError> {
161 config.write.validate()?;
162 if !matches!(config.write.write_mode, faucet_core::WriteMode::Append)
163 && !matches!(config.column_mapping, PostgresColumnMapping::AutoMap)
164 {
165 return Err(FaucetError::Config(
166 "postgres sink: write_mode upsert/delete requires column_mapping: auto_map \
167 (key columns must be real columns, not inside a JSONB blob)"
168 .into(),
169 ));
170 }
171 if matches!(config.write_method, PostgresWriteMethod::Copy)
172 && !matches!(config.write.write_mode, faucet_core::WriteMode::Append)
173 {
174 return Err(FaucetError::Config(format!(
175 "postgres sink: write_method: copy is append-only (COPY has no ON CONFLICT); \
176 it cannot be combined with write_mode: {} — use write_method: insert",
177 config.write.write_mode.as_str()
178 )));
179 }
180
181 let pool = PgPoolOptions::new()
182 .max_connections(config.max_connections)
183 .connect(&config.connection_url)
184 .await
185 .map_err(|e| FaucetError::Sink(format!("PostgreSQL connection failed: {e}")))?;
186
187 Ok(Self { config, pool })
188 }
189
190 async fn discover_columns(
196 &self,
197 conn: &mut sqlx::PgConnection,
198 table_ref: &str,
199 ) -> Result<Vec<(String, String)>, FaucetError> {
200 let columns: Vec<(String, String)> = sqlx::query(
201 "SELECT a.attname::text AS column_name, t.typname::text AS udt_name \
202 FROM pg_catalog.pg_attribute a \
203 JOIN pg_catalog.pg_type t ON t.oid = a.atttypid \
204 WHERE a.attrelid = to_regclass($1)::oid \
205 AND a.attnum > 0 AND NOT a.attisdropped \
206 ORDER BY a.attnum",
207 )
208 .bind(table_ref)
209 .fetch_all(&mut *conn)
210 .await
211 .map_err(|e| FaucetError::Sink(format!("failed to query table columns: {e}")))?
212 .iter()
213 .map(|row| {
214 (
215 row.get::<String, _>("column_name"),
216 row.get::<String, _>("udt_name"),
217 )
218 })
219 .collect();
220
221 if columns.is_empty() {
222 return Err(FaucetError::Sink(format!(
223 "table {table_ref} has no columns or does not exist"
224 )));
225 }
226 Ok(columns)
227 }
228
229 async fn copy_batch(
235 &self,
236 conn: &mut sqlx::PgConnection,
237 records: &[Value],
238 ) -> Result<usize, FaucetError> {
239 if records.is_empty() {
240 return Ok(0);
241 }
242 let table_ref = qualified_table_ref(self.config.schema.as_deref(), &self.config.table_name);
243
244 let (statement, payload) = match &self.config.column_mapping {
245 PostgresColumnMapping::Jsonb { column } => {
246 let payload = build_jsonb_payload(records);
247 (
248 copy_statement(&table_ref, std::slice::from_ref(column)),
249 payload,
250 )
251 }
252 PostgresColumnMapping::AutoMap => {
253 let columns = self.discover_columns(&mut *conn, &table_ref).await?;
254 let Some(payload) =
255 build_auto_map_payload(records, &columns).map_err(FaucetError::Sink)?
256 else {
257 return Ok(0);
258 };
259 (copy_statement(&table_ref, &payload.columns), payload)
260 }
261 };
262
263 let mut copy_in = conn
264 .copy_in_raw(&statement)
265 .await
266 .map_err(|e| FaucetError::Sink(format!("PostgreSQL COPY start failed: {e}")))?;
267 const SEND_CHUNK: usize = 1 << 20;
270 for chunk in payload.data.as_bytes().chunks(SEND_CHUNK) {
271 if let Err(e) = copy_in.send(chunk).await {
272 return Err(FaucetError::Sink(format!(
275 "PostgreSQL COPY send failed: {e}"
276 )));
277 }
278 }
279 copy_in
280 .finish()
281 .await
282 .map_err(|e| FaucetError::Sink(format!("PostgreSQL COPY failed: {e}")))?;
283 Ok(payload.rows)
284 }
285
286 async fn insert_jsonb(
293 &self,
294 conn: &mut sqlx::PgConnection,
295 records: &[Value],
296 column: &str,
297 ) -> Result<usize, FaucetError> {
298 if records.is_empty() {
299 return Ok(0);
300 }
301
302 let json_values: Vec<serde_json::Value> = records.to_vec();
304 let query = format!(
305 "INSERT INTO {} ({}) SELECT * FROM unnest($1::jsonb[])",
306 qualified_table_ref(self.config.schema.as_deref(), &self.config.table_name),
307 quote_ident(column)
308 );
309
310 sqlx::query(&query)
311 .bind(json_values)
312 .execute(&mut *conn)
313 .await
314 .map_err(|e| FaucetError::Sink(format!("PostgreSQL insert failed: {e}")))?;
315
316 Ok(records.len())
317 }
318
319 async fn insert_auto_map_with_conflict(
342 &self,
343 conn: &mut sqlx::PgConnection,
344 records: &[Value],
345 conflict_key: Option<&[String]>,
346 ) -> Result<usize, FaucetError> {
347 if records.is_empty() {
348 return Ok(0);
349 }
350
351 let table_ref = qualified_table_ref(self.config.schema.as_deref(), &self.config.table_name);
365 let columns = self.discover_columns(&mut *conn, &table_ref).await?;
366
367 let mut matched_rows: Vec<Vec<(&String, &String, &Value)>> =
374 Vec::with_capacity(records.len());
375 let mut used: std::collections::HashSet<&str> = std::collections::HashSet::new();
376
377 for record in records {
378 let obj = record
379 .as_object()
380 .ok_or_else(|| FaucetError::Sink("AutoMap requires JSON object records".into()))?;
381
382 let matching: Vec<(&String, &String, &Value)> = columns
383 .iter()
384 .filter_map(|(col, udt)| obj.get(col).map(|v| (col, udt, v)))
385 .collect();
386
387 if matching.is_empty() {
388 tracing::warn!(
389 record_keys = ?obj.keys().collect::<Vec<_>>(),
390 table_columns = ?columns,
391 "record has no keys matching table columns, skipping"
392 );
393 continue;
394 }
395
396 for (c, _, _) in &matching {
397 used.insert(c.as_str());
398 }
399 matched_rows.push(matching);
400 }
401
402 if matched_rows.is_empty() {
403 return Ok(0);
404 }
405
406 let insert_columns: Vec<(String, String)> = columns
409 .iter()
410 .filter(|(c, _)| used.contains(c.as_str()))
411 .cloned()
412 .collect();
413
414 let num_cols = insert_columns.len();
415 let num_rows = matched_rows.len();
416 let col_names: Vec<String> = insert_columns.iter().map(|(c, _)| quote_ident(c)).collect();
417
418 const MAX_PG_PARAMS: usize = 65535;
423 let max_rows_per_insert = (MAX_PG_PARAMS / num_cols).max(1);
424
425 for sub in matched_rows.chunks(max_rows_per_insert) {
426 let mut value_tuples: Vec<String> = Vec::with_capacity(sub.len());
430 for row_idx in 0..sub.len() {
431 let start = row_idx * num_cols + 1;
432 let placeholders: Vec<String> = (0..num_cols)
433 .map(|c| format!("${}::{}", start + c, insert_columns[c].1))
434 .collect();
435 value_tuples.push(format!("({})", placeholders.join(", ")));
436 }
437
438 let query = format!(
439 "INSERT INTO {} ({}) VALUES {}",
440 table_ref,
441 col_names.join(", "),
442 value_tuples.join(", ")
443 );
444 let query = match conflict_key {
445 Some(key) => format!(
446 "{query} {}",
447 on_conflict_clause(
448 key,
449 &insert_columns
450 .iter()
451 .map(|(c, _)| c.clone())
452 .collect::<Vec<_>>()
453 )
454 ),
455 None => query,
456 };
457
458 let mut q = sqlx::query(&query);
459 for matched in sub {
460 for (col, udt) in &insert_columns {
464 let val = matched
465 .iter()
466 .find(|(c, _, _)| *c == col)
467 .map(|(_, _, v)| *v);
468 q = q.bind(pg_bind_text(val, udt));
469 }
470 }
471
472 q.execute(&mut *conn)
473 .await
474 .map_err(|e| FaucetError::Sink(format!("PostgreSQL insert failed: {e}")))?;
475 }
476
477 Ok(num_rows)
478 }
479
480 async fn insert_auto_map(
487 &self,
488 conn: &mut sqlx::PgConnection,
489 records: &[Value],
490 ) -> Result<usize, FaucetError> {
491 self.insert_auto_map_with_conflict(conn, records, None)
492 .await
493 }
494
495 async fn delete_by_keys(
499 &self,
500 conn: &mut sqlx::PgConnection,
501 deletes: &[faucet_core::KeyTuple],
502 ) -> Result<usize, FaucetError> {
503 if deletes.is_empty() {
504 return Ok(0);
505 }
506 let key = &self.config.write.key;
507 let table_ref = qualified_table_ref(self.config.schema.as_deref(), &self.config.table_name);
508
509 let udts: std::collections::HashMap<String, String> = self
512 .discover_columns(&mut *conn, &table_ref)
513 .await?
514 .into_iter()
515 .collect();
516 let key_udts: Vec<String> = key
517 .iter()
518 .map(|k| udts.get(k).cloned().unwrap_or_else(|| "text".to_string()))
519 .collect();
520 let col_list = key
521 .iter()
522 .map(|k| quote_ident(k))
523 .collect::<Vec<_>>()
524 .join(", ");
525
526 const MAX_PG_PARAMS: usize = 65535;
527 let per = (MAX_PG_PARAMS / key.len().max(1)).max(1);
528 let mut total = 0usize;
529 for chunk in deletes.chunks(per) {
530 let mut ph = 1usize;
531 let tuples: Vec<String> = chunk
532 .iter()
533 .map(|_| {
534 let group = key_udts
535 .iter()
536 .map(|udt| {
537 let p = format!("${ph}::{udt}");
538 ph += 1;
539 p
540 })
541 .collect::<Vec<_>>()
542 .join(", ");
543 format!("({group})")
544 })
545 .collect();
546 let sql = format!(
547 "DELETE FROM {table_ref} WHERE ({col_list}) IN ({})",
548 tuples.join(", ")
549 );
550 let mut q = sqlx::query(&sql);
551 for kt in chunk {
552 for ((_, v), udt) in kt.0.iter().zip(key_udts.iter()) {
553 q = q.bind(pg_bind_text(Some(v), udt));
554 }
555 }
556 let res = q
557 .execute(&mut *conn)
558 .await
559 .map_err(|e| FaucetError::Sink(format!("PostgreSQL delete failed: {e}")))?;
560 total += res.rows_affected() as usize;
561 }
562 Ok(total)
563 }
564
565 async fn cleanup_scope_impl(
577 &self,
578 scope: &std::collections::BTreeMap<String, Value>,
579 seen: &faucet_core::SeenKeys,
580 ) -> Result<u64, FaucetError> {
581 let key = &self.config.write.key;
582 if key.is_empty() {
583 return Err(FaucetError::Sink(
584 "cleanup requires a non-empty `key`".to_string(),
585 ));
586 }
587 let table_ref = qualified_table_ref(self.config.schema.as_deref(), &self.config.table_name);
588
589 let mut tx = self
590 .pool
591 .begin()
592 .await
593 .map_err(|e| FaucetError::Sink(format!("PostgreSQL begin failed: {e}")))?;
594
595 let udts: std::collections::HashMap<String, String> = self
596 .discover_columns(&mut tx, &table_ref)
597 .await?
598 .into_iter()
599 .collect();
600
601 for col in scope.keys().chain(key.iter()) {
605 if !udts.contains_key(col) {
606 return Err(FaucetError::Sink(format!(
607 "cleanup: column '{col}' does not exist on {table_ref} — the \
608 completeness claim and `key` are in destination column terms"
609 )));
610 }
611 }
612 let udt_of = |c: &str| udts.get(c).cloned().unwrap_or_else(|| "text".to_string());
613
614 let temp_cols = key
618 .iter()
619 .map(|k| format!("{} {}", quote_ident(k), udt_of(k)))
620 .collect::<Vec<_>>()
621 .join(", ");
622 sqlx::query(&format!(
623 "CREATE TEMP TABLE faucet_cleanup_keys ({temp_cols}) ON COMMIT DROP"
624 ))
625 .execute(&mut *tx)
626 .await
627 .map_err(|e| FaucetError::Sink(format!("cleanup: temp table creation failed: {e}")))?;
628
629 const MAX_PG_PARAMS: usize = 65535;
631 let per = (MAX_PG_PARAMS / key.len()).max(1);
632 let key_udts: Vec<String> = key.iter().map(|k| udt_of(k)).collect();
633 let col_list = key
634 .iter()
635 .map(|k| quote_ident(k))
636 .collect::<Vec<_>>()
637 .join(", ");
638 for chunk in seen.keys().chunks(per) {
639 let mut ph = 1usize;
640 let tuples: Vec<String> = chunk
641 .iter()
642 .map(|_| {
643 let group = key_udts
644 .iter()
645 .map(|udt| {
646 let s = format!("${ph}::{udt}");
647 ph += 1;
648 s
649 })
650 .collect::<Vec<_>>()
651 .join(", ");
652 format!("({group})")
653 })
654 .collect();
655 let sql = format!(
656 "INSERT INTO faucet_cleanup_keys ({col_list}) VALUES {}",
657 tuples.join(", ")
658 );
659 let mut q = sqlx::query(&sql);
660 for kt in chunk {
661 for ((_, v), udt) in kt.0.iter().zip(key_udts.iter()) {
662 q = q.bind(pg_bind_text(Some(v), udt));
663 }
664 }
665 q.execute(&mut *tx)
666 .await
667 .map_err(|e| FaucetError::Sink(format!("cleanup: loading keys failed: {e}")))?;
668 }
669
670 let mut ph = 1usize;
672 let scope_pred = scope
673 .keys()
674 .map(|c| {
675 let s = format!("t.{} = ${}::{}", quote_ident(c), ph, udt_of(c));
676 ph += 1;
677 s
678 })
679 .collect::<Vec<_>>()
680 .join(" AND ");
681 let join_pred = key
682 .iter()
683 .map(|k| {
684 let q = quote_ident(k);
685 format!("c.{q} = t.{q}")
686 })
687 .collect::<Vec<_>>()
688 .join(" AND ");
689 let sql = format!(
690 "DELETE FROM {table_ref} t WHERE {scope_pred} \
691 AND NOT EXISTS (SELECT 1 FROM faucet_cleanup_keys c WHERE {join_pred})"
692 );
693 let mut q = sqlx::query(&sql);
694 for (col, v) in scope {
695 q = q.bind(pg_bind_text(Some(v), &udt_of(col)));
696 }
697 let res = q
698 .execute(&mut *tx)
699 .await
700 .map_err(|e| FaucetError::Sink(format!("cleanup: delete failed: {e}")))?;
701
702 tx.commit()
703 .await
704 .map_err(|e| FaucetError::Sink(format!("cleanup: commit failed: {e}")))?;
705 Ok(res.rows_affected())
706 }
707
708 async fn apply_plan(
710 &self,
711 conn: &mut sqlx::PgConnection,
712 plan: &faucet_core::WritePlan,
713 ) -> Result<usize, FaucetError> {
714 let mut affected = 0usize;
715 if !plan.upserts.is_empty() {
716 affected += self
717 .insert_auto_map_with_conflict(conn, &plan.upserts, Some(&self.config.write.key))
718 .await?;
719 }
720 if !plan.deletes.is_empty() {
721 affected += self.delete_by_keys(conn, &plan.deletes).await?;
722 }
723 Ok(affected)
724 }
725
726 async fn ensure_commit_table(&self) -> Result<(), FaucetError> {
728 let sql = format!(
729 "CREATE TABLE IF NOT EXISTS {t} ({s} TEXT PRIMARY KEY, {k} TEXT NOT NULL, updated_at TIMESTAMPTZ DEFAULT now())",
730 t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
731 s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
732 k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
733 );
734 sqlx::query(&sql).execute(&self.pool).await.map_err(|e| {
735 FaucetError::Sink(format!("PostgreSQL commit-table create failed: {e}"))
736 })?;
737 Ok(())
738 }
739}
740
741#[async_trait]
742impl faucet_core::Sink for PostgresSink {
743 fn connector_name(&self) -> &'static str {
744 "postgres"
745 }
746
747 fn config_schema(&self) -> serde_json::Value {
748 serde_json::to_value(faucet_core::schema_for!(PostgresSinkConfig))
749 .expect("schema serialization")
750 }
751
752 fn supports_cleanup(&self) -> bool {
753 matches!(self.config.column_mapping, PostgresColumnMapping::AutoMap)
756 }
757
758 async fn cleanup_scope(
759 &self,
760 scope: &std::collections::BTreeMap<String, Value>,
761 seen: &faucet_core::SeenKeys,
762 ) -> Result<u64, FaucetError> {
763 self.cleanup_scope_impl(scope, seen).await
764 }
765
766 fn supported_write_modes(&self) -> &'static [faucet_core::WriteMode] {
767 &[
768 faucet_core::WriteMode::Append,
769 faucet_core::WriteMode::Upsert,
770 faucet_core::WriteMode::Delete,
771 ]
772 }
773
774 fn dedups_by_key(&self) -> bool {
775 self.config.write.dedups_by_key()
776 }
777
778 fn supports_schema_evolution(&self) -> bool {
779 true
780 }
781
782 async fn current_schema(&self) -> Result<Option<serde_json::Value>, FaucetError> {
790 let table_ref = qualified_table_ref(self.config.schema.as_deref(), &self.config.table_name);
791 let rows: Vec<(String, String, bool)> = sqlx::query(
792 "SELECT a.attname::text AS column_name, t.typname::text AS udt_name, a.attnotnull \
793 FROM pg_catalog.pg_attribute a \
794 JOIN pg_catalog.pg_type t ON t.oid = a.atttypid \
795 WHERE a.attrelid = to_regclass($1)::oid \
796 AND a.attnum > 0 AND NOT a.attisdropped \
797 ORDER BY a.attnum",
798 )
799 .bind(&table_ref)
800 .fetch_all(&self.pool)
801 .await
802 .map_err(|e| FaucetError::Sink(format!("postgres current_schema query failed: {e}")))?
803 .iter()
804 .map(|row| {
805 (
806 row.get::<String, _>("column_name"),
807 row.get::<String, _>("udt_name"),
808 row.get::<bool, _>("attnotnull"),
809 )
810 })
811 .collect();
812
813 if rows.is_empty() {
814 return Ok(None); }
816
817 let mut props = serde_json::Map::new();
818 for (name, udt, notnull) in rows {
819 props.insert(name, pg_udt_to_json_schema(&udt, !notnull));
820 }
821 Ok(Some(
822 serde_json::json!({ "type": "object", "properties": props }),
823 ))
824 }
825
826 async fn evolve_schema(
831 &self,
832 evolution: &faucet_core::SchemaEvolution,
833 ) -> Result<(), FaucetError> {
834 let table_ref = qualified_table_ref(self.config.schema.as_deref(), &self.config.table_name);
835 let mut conn = self
836 .pool
837 .acquire()
838 .await
839 .map_err(|e| FaucetError::Sink(format!("postgres evolve acquire failed: {e}")))?;
840
841 for c in &evolution.additions {
842 let t =
843 faucet_core::json_schema_base_type(&c.to).unwrap_or(faucet_core::SqlBaseType::Text);
844 sqlx::query(&build_add_column_sql(&table_ref, &c.name, t))
845 .execute(&mut *conn)
846 .await
847 .map_err(|e| {
848 FaucetError::Sink(format!("postgres ADD COLUMN {} failed: {e}", c.name))
849 })?;
850 }
851 for c in &evolution.widenings {
852 let t =
853 faucet_core::json_schema_base_type(&c.to).unwrap_or(faucet_core::SqlBaseType::Text);
854 sqlx::query(&build_alter_type_sql(&table_ref, &c.name, t))
855 .execute(&mut *conn)
856 .await
857 .map_err(|e| {
858 FaucetError::Sink(format!("postgres ALTER TYPE {} failed: {e}", c.name))
859 })?;
860 }
861 for col in &evolution.relax_nullability {
862 sqlx::query(&build_drop_not_null_sql(&table_ref, col))
863 .execute(&mut *conn)
864 .await
865 .map_err(|e| {
866 FaucetError::Sink(format!("postgres DROP NOT NULL {col} failed: {e}"))
867 })?;
868 }
869 Ok(())
870 }
871
872 fn dataset_uri(&self) -> String {
873 let table = match &self.config.schema {
874 Some(s) => format!("{}.{}", s, self.config.table_name),
875 None => self.config.table_name.clone(),
876 };
877 format!(
878 "{}?table={}",
879 faucet_core::redact_uri_credentials(&self.config.connection_url),
880 table
881 )
882 }
883
884 async fn check(
890 &self,
891 ctx: &faucet_core::check::CheckContext,
892 ) -> Result<faucet_core::check::CheckReport, FaucetError> {
893 use faucet_core::check::{CheckReport, Probe};
894
895 let started = std::time::Instant::now();
896 let probe =
897 match tokio::time::timeout(ctx.timeout, sqlx::query("SELECT 1").execute(&self.pool))
898 .await
899 {
900 Ok(Ok(_)) => Probe::pass("auth", started.elapsed()),
901 Ok(Err(e)) => Probe::fail_hint(
902 "auth",
903 started.elapsed(),
904 e.to_string(),
905 "check connection_url / credentials / that the database is reachable",
906 ),
907 Err(_) => Probe::fail_hint(
908 "auth",
909 started.elapsed(),
910 "timed out",
911 "check connection_url / credentials / that the database is reachable",
912 ),
913 };
914 Ok(CheckReport::single(probe))
915 }
916
917 async fn write_batch(&self, records: &[Value]) -> Result<usize, FaucetError> {
933 if records.is_empty() {
934 return Ok(0);
935 }
936
937 if !matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
938 let plan = faucet_core::plan_writes(records, &self.config.write);
939 if let Some((idx, msg)) = plan.failed.first() {
940 return Err(FaucetError::Sink(format!(
941 "postgres {}: row {idx}: {msg}",
942 self.config.write.write_mode.as_str()
943 )));
944 }
945 let mut conn =
946 self.pool.acquire().await.map_err(|e| {
947 FaucetError::Sink(format!("PostgreSQL pool acquire failed: {e}"))
948 })?;
949 return self.apply_plan(&mut conn, &plan).await;
950 }
951
952 let chunks: Vec<&[Value]> = if self.config.batch_size == 0 {
953 vec![records]
957 } else {
958 records.chunks(self.config.batch_size).collect()
959 };
960
961 let mut conn = self
964 .pool
965 .acquire()
966 .await
967 .map_err(|e| FaucetError::Sink(format!("PostgreSQL pool acquire failed: {e}")))?;
968
969 let mut total = 0;
970 for chunk in chunks {
971 total += match self.config.write_method {
972 PostgresWriteMethod::Copy => self.copy_batch(&mut conn, chunk).await?,
975 PostgresWriteMethod::Insert => match &self.config.column_mapping {
976 PostgresColumnMapping::Jsonb { column } => {
977 self.insert_jsonb(&mut conn, chunk, column).await?
978 }
979 PostgresColumnMapping::AutoMap => {
980 self.insert_auto_map(&mut conn, chunk).await?
981 }
982 },
983 };
984 }
985
986 tracing::info!(
987 table = %self.config.table_name,
988 rows = total,
989 "PostgreSQL write complete"
990 );
991 Ok(total)
992 }
993
994 async fn write_batch_partial(
1003 &self,
1004 records: &[Value],
1005 ) -> Result<Vec<faucet_core::RowOutcome>, FaucetError> {
1006 if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
1007 self.write_batch(records).await?;
1008 return Ok(records.iter().map(|_| Ok(())).collect());
1009 }
1010
1011 let plan = faucet_core::plan_writes(records, &self.config.write);
1012 let mut conn = self
1013 .pool
1014 .acquire()
1015 .await
1016 .map_err(|e| FaucetError::Sink(format!("PostgreSQL pool acquire failed: {e}")))?;
1017 self.apply_plan(&mut conn, &plan).await?;
1018
1019 let mut outcomes: Vec<faucet_core::RowOutcome> = records.iter().map(|_| Ok(())).collect();
1020 for (idx, msg) in &plan.failed {
1021 outcomes[*idx] = Err(FaucetError::Sink(format!(
1022 "postgres {}: {msg}",
1023 self.config.write.write_mode.as_str()
1024 )));
1025 }
1026 Ok(outcomes)
1027 }
1028
1029 fn supports_idempotent_writes(&self) -> bool {
1030 true
1031 }
1032
1033 async fn last_committed_token(&self, scope: &str) -> Result<Option<String>, FaucetError> {
1034 self.ensure_commit_table().await?;
1035 let sql = format!(
1036 "SELECT {k} FROM {t} WHERE {s} = $1",
1037 t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
1038 k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
1039 s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
1040 );
1041 let row = sqlx::query(&sql)
1042 .bind(scope)
1043 .fetch_optional(&self.pool)
1044 .await
1045 .map_err(|e| FaucetError::Sink(format!("PostgreSQL token read failed: {e}")))?;
1046 Ok(row.map(|r| r.get::<String, _>(0)))
1047 }
1048
1049 async fn write_batch_idempotent(
1050 &self,
1051 records: &[Value],
1052 scope: &str,
1053 token: &str,
1054 ) -> Result<usize, FaucetError> {
1055 self.ensure_commit_table().await?;
1056
1057 let plan = if matches!(self.config.write.write_mode, faucet_core::WriteMode::Append) {
1060 None
1061 } else {
1062 let plan = faucet_core::plan_writes(records, &self.config.write);
1063 if let Some((idx, msg)) = plan.failed.first() {
1064 return Err(FaucetError::Sink(format!(
1065 "postgres {}: row {idx}: {msg}",
1066 self.config.write.write_mode.as_str()
1067 )));
1068 }
1069 Some(plan)
1070 };
1071
1072 let mut tx =
1073 self.pool.begin().await.map_err(|e| {
1074 FaucetError::Sink(format!("PostgreSQL transaction begin failed: {e}"))
1075 })?;
1076
1077 let written = match &plan {
1083 Some(plan) => self.apply_plan(&mut tx, plan).await?,
1084 None => match &self.config.column_mapping {
1085 PostgresColumnMapping::Jsonb { column } => {
1086 self.insert_jsonb(&mut tx, records, column).await?
1087 }
1088 PostgresColumnMapping::AutoMap => self.insert_auto_map(&mut tx, records).await?,
1089 },
1090 };
1091
1092 let upsert = format!(
1093 "INSERT INTO {t} ({s}, {k}) VALUES ($1, $2) ON CONFLICT ({s}) DO UPDATE SET {k} = EXCLUDED.{k}, updated_at = now()",
1094 t = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TABLE),
1095 s = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_SCOPE_COL),
1096 k = quote_ident(faucet_core::idempotency::COMMIT_TOKEN_TOKEN_COL),
1097 );
1098 sqlx::query(&upsert)
1099 .bind(scope)
1100 .bind(token)
1101 .execute(&mut *tx)
1102 .await
1103 .map_err(|e| FaucetError::Sink(format!("PostgreSQL token upsert failed: {e}")))?;
1104
1105 tx.commit()
1106 .await
1107 .map_err(|e| FaucetError::Sink(format!("PostgreSQL transaction commit failed: {e}")))?;
1108 Ok(written)
1109 }
1110}
1111
1112#[cfg(test)]
1113mod tests {
1114 use super::{
1115 build_add_column_sql, build_alter_type_sql, build_drop_not_null_sql, on_conflict_clause,
1116 pg_bind_text, pg_udt_to_json_schema, qualified_table_ref,
1117 };
1118 use serde_json::json;
1119
1120 #[test]
1121 fn pg_add_column_ddl() {
1122 let sql = build_add_column_sql("\"public\".\"t\"", "email", faucet_core::SqlBaseType::Text);
1123 assert_eq!(
1124 sql,
1125 "ALTER TABLE \"public\".\"t\" ADD COLUMN IF NOT EXISTS \"email\" text"
1126 );
1127 }
1128
1129 #[test]
1130 fn pg_widen_column_ddl() {
1131 let sql = build_alter_type_sql(
1132 "\"public\".\"t\"",
1133 "score",
1134 faucet_core::SqlBaseType::Double,
1135 );
1136 assert_eq!(
1137 sql,
1138 "ALTER TABLE \"public\".\"t\" ALTER COLUMN \"score\" TYPE double precision USING \"score\"::double precision"
1139 );
1140 }
1141
1142 #[test]
1143 fn pg_drop_not_null_ddl() {
1144 let sql = build_drop_not_null_sql("\"t\"", "created_at");
1145 assert_eq!(
1146 sql,
1147 "ALTER TABLE \"t\" ALTER COLUMN \"created_at\" DROP NOT NULL"
1148 );
1149 }
1150
1151 #[test]
1152 fn pg_udt_round_trips_to_json_schema() {
1153 assert_eq!(
1154 pg_udt_to_json_schema("int8", false),
1155 json!({"type":"integer"})
1156 );
1157 assert_eq!(
1158 pg_udt_to_json_schema("float8", false),
1159 json!({"type":"number"})
1160 );
1161 assert_eq!(
1162 pg_udt_to_json_schema("bool", false),
1163 json!({"type":"boolean"})
1164 );
1165 assert_eq!(
1166 pg_udt_to_json_schema("jsonb", false),
1167 json!({"type":"object"})
1168 );
1169 assert_eq!(
1170 pg_udt_to_json_schema("text", false),
1171 json!({"type":"string"})
1172 );
1173 assert_eq!(
1175 pg_udt_to_json_schema("timestamptz", true),
1176 json!({"type":["string","null"]})
1177 );
1178 }
1179
1180 #[test]
1181 fn upsert_on_conflict_clause_for_keys() {
1182 let clause = on_conflict_clause(
1183 &["id".to_string()],
1184 &["id".to_string(), "name".to_string(), "email".to_string()],
1185 );
1186 assert_eq!(
1187 clause,
1188 r#"ON CONFLICT ("id") DO UPDATE SET "name" = EXCLUDED."name", "email" = EXCLUDED."email""#
1189 );
1190 }
1191
1192 #[test]
1193 fn upsert_on_conflict_all_columns_are_key_does_nothing() {
1194 let clause = on_conflict_clause(&["id".to_string()], &["id".to_string()]);
1195 assert_eq!(clause, r#"ON CONFLICT ("id") DO NOTHING"#);
1196 }
1197
1198 #[test]
1199 fn commit_token_table_is_the_shared_constant() {
1200 assert_eq!(
1201 faucet_core::idempotency::COMMIT_TOKEN_TABLE,
1202 "_faucet_commit_token"
1203 );
1204 }
1205
1206 #[test]
1211 fn qualified_table_ref_unqualified_is_bare_quoted_table() {
1212 assert_eq!(qualified_table_ref(None, "events"), "\"events\"");
1214 }
1215
1216 #[test]
1217 fn qualified_table_ref_with_schema_is_schema_dot_table() {
1218 assert_eq!(
1221 qualified_table_ref(Some("analytics"), "events"),
1222 "\"analytics\".\"events\""
1223 );
1224 }
1225
1226 #[test]
1227 fn qualified_table_ref_escapes_embedded_quotes() {
1228 assert_eq!(
1230 qualified_table_ref(Some("we\"ird"), "ta\"ble"),
1231 "\"we\"\"ird\".\"ta\"\"ble\""
1232 );
1233 }
1234
1235 #[test]
1236 fn null_and_absent_bind_sql_null() {
1237 assert_eq!(pg_bind_text(None, "text"), None);
1238 assert_eq!(pg_bind_text(Some(&json!(null)), "int4"), None);
1239 assert_eq!(pg_bind_text(Some(&json!(null)), "jsonb"), None);
1240 }
1241
1242 #[test]
1243 fn scalars_bind_plain_text_for_typed_columns() {
1244 assert_eq!(
1246 pg_bind_text(Some(&json!(42)), "int4").as_deref(),
1247 Some("42")
1248 );
1249 assert_eq!(
1250 pg_bind_text(Some(&json!(1.5)), "numeric").as_deref(),
1251 Some("1.5")
1252 );
1253 assert_eq!(
1254 pg_bind_text(Some(&json!(true)), "bool").as_deref(),
1255 Some("true")
1256 );
1257 assert_eq!(
1258 pg_bind_text(Some(&json!("2025-01-01T00:00:00Z")), "timestamptz").as_deref(),
1259 Some("2025-01-01T00:00:00Z")
1260 );
1261 assert_eq!(
1263 pg_bind_text(Some(&json!("Bob")), "text").as_deref(),
1264 Some("Bob")
1265 );
1266 assert_eq!(
1268 pg_bind_text(Some(&json!(18446744073709551615u64)), "numeric").as_deref(),
1269 Some("18446744073709551615")
1270 );
1271 }
1272
1273 #[test]
1274 fn json_columns_get_json_text_with_quotes_preserved() {
1275 assert_eq!(
1279 pg_bind_text(Some(&json!("Bob")), "jsonb").as_deref(),
1280 Some("\"Bob\"")
1281 );
1282 assert_eq!(
1283 pg_bind_text(Some(&json!({"a": 1})), "jsonb").as_deref(),
1284 Some("{\"a\":1}")
1285 );
1286 assert_eq!(
1287 pg_bind_text(Some(&json!([1, 2])), "json").as_deref(),
1288 Some("[1,2]")
1289 );
1290 assert_eq!(pg_bind_text(Some(&json!(5)), "jsonb").as_deref(), Some("5"));
1291 assert_eq!(
1293 pg_bind_text(Some(&json!("x")), "JSONB").as_deref(),
1294 Some("\"x\"")
1295 );
1296 }
1297
1298 #[test]
1299 fn objects_into_non_json_columns_emit_json_text_so_the_cast_fails_loudly() {
1300 assert_eq!(
1303 pg_bind_text(Some(&json!({"a": 1})), "int4").as_deref(),
1304 Some("{\"a\":1}")
1305 );
1306 }
1307}