Skip to main content

faucet_sink_postgres/
sink.rs

1//! PostgreSQL sink implementation.
2
3use 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
11/// Render a JSON value as the text to bind for a PostgreSQL column whose
12/// underlying type is `udt` (`information_schema.columns.udt_name`), or `None`
13/// for SQL `NULL`.
14///
15/// The accompanying placeholder is emitted as `$N::<udt>`, so PostgreSQL runs
16/// the destination column type's input function over this text. That makes
17/// `string → timestamptz/uuid/date`, `number → int4/numeric/float8`,
18/// `bool → bool`, and `json → jsonb` all work — instead of binding every value
19/// as `serde_json::Value` (which sqlx encodes as `jsonb`, so an insert into any
20/// non-`jsonb` column fails at runtime with *"column is of type … but
21/// expression is of type jsonb"*; this was the C1 bug in audit #146).
22///
23/// For `json`/`jsonb` columns the value is bound as its JSON text (so a string
24/// keeps its quotes and objects/arrays round-trip); the `::jsonb` cast then
25/// parses it. For every other type the scalar's plain text form is bound and
26/// the column's input function parses it via the cast.
27fn 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                    // Arrays/objects have no scalar text form for a non-JSON
39                    // column; bind their JSON text so the `::<type>` cast fails
40                    // loudly rather than silently coercing.
41                    other => Some(other.to_string()),
42                }
43            }
44        }
45    }
46}
47
48/// Build the SQL relation reference for the configured table, optionally
49/// schema-qualified.
50///
51/// Both the AutoMap column-discovery probe and the `INSERT` statements use this
52/// single helper, so column discovery is always scoped to the *exact* relation
53/// the `INSERT` targets (#146 M13). With no schema the bare quoted table name
54/// resolves against the connection's `search_path`; with a schema it becomes
55/// `"schema"."table"`, pinning both discovery and insert to that namespace —
56/// otherwise a table of the same name in another schema pollutes the
57/// AutoMap column set (duplicate / wrong columns).
58fn 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
65/// Build the `ON CONFLICT (key) DO UPDATE …` tail for an upsert INSERT.
66/// Non-key columns are SET from EXCLUDED. If every column is a key column,
67/// emit `DO NOTHING`.
68fn 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
89/// Map a [`faucet_core::SqlBaseType`] to the PostgreSQL type keyword used when
90/// adding/widening a column during schema evolution (issue #194). Integers
91/// always widen to `bigint` and floats to `double precision` so a later, wider
92/// value never overflows a narrower column.
93fn 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
104/// `ALTER TABLE <ref> ADD COLUMN IF NOT EXISTS "<col>" <kw>` — idempotent column
105/// addition. `table_ref` is already quoted (`"schema"."table"`).
106fn 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
114/// `ALTER TABLE <ref> ALTER COLUMN "<col>" TYPE <kw> USING "<col>"::<kw>` — widen
115/// an existing column's type. Naturally idempotent (re-running the same TYPE
116/// change is a no-op).
117fn 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
123/// `ALTER TABLE <ref> ALTER COLUMN "<col>" DROP NOT NULL` — relax a NOT NULL
124/// constraint. Naturally idempotent.
125fn 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
132/// Map a PostgreSQL type name (`pg_type.typname`, e.g. `int4`, `float8`, `bool`,
133/// `jsonb`) back to a JSON-Schema type fragment so [`PostgresSink::current_schema`]
134/// round-trips with [`faucet_core::diff_schema`]. `nullable` reflects whether the
135/// column allows NULL (`NOT a.attnotnull`).
136fn 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
151/// A sink that writes JSON records to a PostgreSQL table.
152pub struct PostgresSink {
153    config: PostgresSinkConfig,
154    pool: PgPool,
155}
156
157impl PostgresSink {
158    /// Create a new PostgreSQL sink. Establishes a connection pool.
159    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    /// Insert a batch of records using JSONB column mode, on the given connection.
181    ///
182    /// Accepts `&mut sqlx::PgConnection` so the same logic runs both standalone
183    /// (via a pool-acquired connection, autocommit) and inside the idempotent
184    /// transaction (where `&mut *tx` is passed — `Transaction<'_, Postgres>`
185    /// derefs to `PgConnection`).
186    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        // Use a single INSERT with unnest for efficiency.
197        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    /// Insert a batch of records using auto-mapped columns, on the given connection.
214    ///
215    /// Accepts `&mut sqlx::PgConnection` so the same logic runs both standalone
216    /// (via a pool-acquired connection, autocommit) and inside the idempotent
217    /// transaction (where `&mut *tx` is passed — `Transaction<'_, Postgres>`
218    /// derefs to `PgConnection`). Running the column-discovery query on the same
219    /// connection is harmless and avoids any cross-connection visibility surprise.
220    ///
221    /// Discovers each column's name *and* underlying type (`udt_name`) from the
222    /// table schema and maps top-level JSON fields to columns. Each placeholder
223    /// is emitted as `$N::<udt>` and the value is bound as text (see
224    /// [`pg_bind_text`]), so the destination column's input function parses it —
225    /// numbers, booleans, timestamps, uuids, and JSON all land in their native
226    /// column types. (Previously every value was bound as `serde_json::Value`,
227    /// which sqlx encodes as `jsonb`, so an insert into any non-`jsonb` column
228    /// failed at runtime — audit #146 C1.) Uses a single multi-row INSERT
229    /// (sub-chunked at the 65535-parameter cap) for efficiency.
230    ///
231    /// When `conflict_key` is `Some(key)`, each sub-chunk's INSERT is given an
232    /// `ON CONFLICT (key) DO UPDATE …` tail so it upserts by the key columns
233    /// (last-write-wins within the batch is already handled by the planner's
234    /// dedup, so a single sub-chunk never double-hits the same conflict target).
235    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        // Get column names AND their underlying types for the *exact* relation
246        // the INSERT will target. Scoping by `to_regclass(<qualified ref>)`
247        // resolves the relation the same way the INSERT does — by the configured
248        // schema if set, otherwise by the connection's `search_path` — so a
249        // table of the same name in another schema can no longer pollute the
250        // column set with duplicate/wrong columns (#146 M13). The previous query
251        // filtered `information_schema.columns` by `table_name` alone (no schema
252        // predicate), merging every same-named table across all schemas.
253        //
254        // `pg_type.typname` is the concrete type (`int4`, `timestamptz`,
255        // `numeric`, `jsonb`, `uuid`, `text`, …) — identical to the old
256        // `information_schema.columns.udt_name` — used as the per-placeholder
257        // cast target below. `::text` casts the `name`-typed catalog columns so
258        // sqlx decodes them as `String`.
259        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        // Pre-validate all records and collect matched (column, udt, value)
288        // triples per record. The INSERT column set is the UNION of table
289        // columns present in ANY record (in declared table order), not just the
290        // first record's keys — otherwise a field present only in a later record
291        // of the batch would be silently dropped (audit #146 H1). A row missing
292        // a unioned column binds SQL NULL.
293        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        // Table columns (in declared order, with their udt) present in at least
327        // one record.
328        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        // PostgreSQL caps bind parameters per statement at 65535. A multi-row
339        // INSERT binds `rows × num_cols` parameters, so a wide table at a large
340        // batch_size can exceed it and fail at runtime (#78/#21). Split into
341        // sub-INSERTs of at most floor(MAX_PARAMS / num_cols) rows.
342        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            // Build multi-row VALUES clause with per-column casts so the column
347            // type's input function parses the bound text:
348            //   ($1::int4, $2::timestamptz), ($3::int4, $4::timestamptz), ...
349            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                // Bind values in the fixed column order, as text matching each
381                // column's type. A record missing a column that appeared in the
382                // first record binds SQL NULL.
383                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    /// Insert a batch of records using auto-mapped columns, on the given
401    /// connection, with plain append semantics (no `ON CONFLICT` tail).
402    ///
403    /// Thin wrapper over [`insert_auto_map_with_conflict`](Self::insert_auto_map_with_conflict)
404    /// so the append path and the idempotent-write path keep their original
405    /// signature.
406    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    /// Delete rows whose key columns match any of `deletes`, using
416    /// `DELETE FROM t WHERE (k1, …) IN ((v1, …), …)` with per-column `::udt`
417    /// casts (the key columns' underlying types), chunked at the param cap.
418    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        // Underlying types for the key columns → drives the ::udt casts, same
430        // source the insert path uses.
431        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    /// Apply a planned upsert/delete batch on one connection.
499    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    /// Ensure the commit-token watermark table exists.
517    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    /// Read the live destination schema from `pg_catalog` as an
555    /// `infer_schema`-shaped object (`{"type":"object","properties":{…}}`), or
556    /// `None` when the target table does not exist yet (issue #194).
557    ///
558    /// Reuses the AutoMap column-discovery query shape (scoped to the exact
559    /// relation via `to_regclass`), additionally reading `a.attnotnull` so
560    /// nullability round-trips through `pg_udt_to_json_schema`.
561    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); // table does not exist yet
587        }
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    /// Apply an additive schema evolution (new columns, lossless widenings,
599    /// nullability relaxations) to the destination table. Idempotent —
600    /// `ADD COLUMN IF NOT EXISTS`, and re-running the same TYPE / DROP NOT NULL
601    /// is a no-op (issue #194).
602    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    /// Preflight connectivity probe (`faucet doctor`).
657    ///
658    /// Acquires a connection from the existing pool and runs `SELECT 1`. This
659    /// is non-mutating and idempotent — it validates that the database is
660    /// reachable and the credentials are accepted without writing anything.
661    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    /// Write records to PostgreSQL.
690    ///
691    /// When `config.batch_size > 0` and the input slice is larger than
692    /// `batch_size`, the slice is split into chunks of `batch_size` rows and
693    /// each chunk is sent as a separate multi-row `INSERT`. When
694    /// `config.batch_size == 0`, the entire slice is sent in a single
695    /// `INSERT` — useful when upstream `StreamPage`s are already sized for
696    /// Postgres' per-statement bind-parameter limit (~65 535 / num_columns
697    /// in AutoMap mode).
698    ///
699    /// Acquires one connection from the pool and routes all chunks through it.
700    /// Each INSERT executes as its own autocommit statement — identical
701    /// observable behaviour to executing directly on the pool, while keeping the
702    /// same connection for the entire call (avoids repeated pool-checkout
703    /// overhead on large batches).
704    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            // Sentinel: pass the entire upstream page through in a single
726            // INSERT statement. Subject to Postgres' 65 535 bind-parameter
727            // limit in AutoMap mode; JSONB mode binds a single array.
728            vec![records]
729        } else {
730            records.chunks(self.config.batch_size).collect()
731        };
732
733        // Acquire once; reuse for all chunks (each statement autocommits —
734        // no BEGIN is issued, so behaviour is identical to using the pool).
735        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    /// Write a batch and report per-row outcomes.
760    ///
761    /// In append mode this delegates to [`write_batch`](faucet_core::Sink::write_batch) and
762    /// maps a single success onto an all-`Ok(())` vector (the trait default).
763    /// In upsert/delete mode the good rows are applied (upserts + deletes), and
764    /// only the rows whose key could not be extracted (missing / null key) are
765    /// reported as `Err` so the pipeline routes them to the DLQ per-row instead
766    /// of sending the whole page.
767    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        // For upsert/delete modes, plan the page before opening the transaction
823        // so a key-extraction failure aborts without leaving an open tx.
824        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        // Data write(s) and the commit-token upsert share ONE transaction so
843        // the page is committed atomically with its watermark: on crash either
844        // both land or neither does, which is what makes a replay skip-on-resume
845        // produce zero duplicates. For upsert/delete this means the planned
846        // upserts/deletes commit together with the watermark in the same tx.
847        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        // Unknown types fall back to string; nullable widens the type array.
939        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    // dataset_uri test is skipped: PostgresSink::new() requires a live pool
972    // (connects to PostgreSQL in new()), and no offline constructor exists.
973    // The URI format is covered by unit tests in faucet-core's redact tests.
974
975    #[test]
976    fn qualified_table_ref_unqualified_is_bare_quoted_table() {
977        // No schema → bare quoted table, resolved against the search_path.
978        assert_eq!(qualified_table_ref(None, "events"), "\"events\"");
979    }
980
981    #[test]
982    fn qualified_table_ref_with_schema_is_schema_dot_table() {
983        // With a schema → "schema"."table", so discovery and INSERT both
984        // target the same explicit relation (#146 M13).
985        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        // SQL-injection safety: embedded double-quotes are doubled.
994        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        // The `$N::<udt>` cast parses these via the column's input function.
1010        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        // A plain string into TEXT keeps NO JSON quotes (the bug bound `"Bob"`).
1027        assert_eq!(
1028            pg_bind_text(Some(&json!("Bob")), "text").as_deref(),
1029            Some("Bob")
1030        );
1031        // Large u64 beyond i64 keeps exact text (no f64 precision loss).
1032        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        // For jsonb/json columns the value is bound as JSON text so the
1041        // `::jsonb` cast parses it: a string keeps its quotes, objects/arrays
1042        // round-trip.
1043        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        // udt match is case-insensitive.
1057        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        // No scalar text form for an object targeting e.g. an int column; the
1066        // `::int4` cast will reject this rather than silently coercing.
1067        assert_eq!(
1068            pg_bind_text(Some(&json!({"a": 1})), "int4").as_deref(),
1069            Some("{\"a\":1}")
1070        );
1071    }
1072}