Skip to main content

faucet_sink_postgres/
sink.rs

1//! PostgreSQL sink implementation.
2
3use 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
12/// Render a JSON value as the text to bind for a PostgreSQL column whose
13/// underlying type is `udt` (`information_schema.columns.udt_name`), or `None`
14/// for SQL `NULL`.
15///
16/// The accompanying placeholder is emitted as `$N::<udt>`, so PostgreSQL runs
17/// the destination column type's input function over this text. That makes
18/// `string → timestamptz/uuid/date`, `number → int4/numeric/float8`,
19/// `bool → bool`, and `json → jsonb` all work — instead of binding every value
20/// as `serde_json::Value` (which sqlx encodes as `jsonb`, so an insert into any
21/// non-`jsonb` column fails at runtime with *"column is of type … but
22/// expression is of type jsonb"*; this was the C1 bug in audit #146).
23///
24/// For `json`/`jsonb` columns the value is bound as its JSON text (so a string
25/// keeps its quotes and objects/arrays round-trip); the `::jsonb` cast then
26/// parses it. For every other type the scalar's plain text form is bound and
27/// the column's input function parses it via the cast.
28pub(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                    // Arrays/objects have no scalar text form for a non-JSON
40                    // column; bind their JSON text so the `::<type>` cast fails
41                    // loudly rather than silently coercing.
42                    other => Some(other.to_string()),
43                }
44            }
45        }
46    }
47}
48
49/// Build the SQL relation reference for the configured table, optionally
50/// schema-qualified.
51///
52/// Both the AutoMap column-discovery probe and the `INSERT` statements use this
53/// single helper, so column discovery is always scoped to the *exact* relation
54/// the `INSERT` targets (#146 M13). With no schema the bare quoted table name
55/// resolves against the connection's `search_path`; with a schema it becomes
56/// `"schema"."table"`, pinning both discovery and insert to that namespace —
57/// otherwise a table of the same name in another schema pollutes the
58/// AutoMap column set (duplicate / wrong columns).
59fn 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
66/// Build the `ON CONFLICT (key) DO UPDATE …` tail for an upsert INSERT.
67/// Non-key columns are SET from EXCLUDED. If every column is a key column,
68/// emit `DO NOTHING`.
69fn 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
90/// Map a [`faucet_core::SqlBaseType`] to the PostgreSQL type keyword used when
91/// adding/widening a column during schema evolution (issue #194). Integers
92/// always widen to `bigint` and floats to `double precision` so a later, wider
93/// value never overflows a narrower column.
94fn 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
105/// `ALTER TABLE <ref> ADD COLUMN IF NOT EXISTS "<col>" <kw>` — idempotent column
106/// addition. `table_ref` is already quoted (`"schema"."table"`).
107fn 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
115/// `ALTER TABLE <ref> ALTER COLUMN "<col>" TYPE <kw> USING "<col>"::<kw>` — widen
116/// an existing column's type. Naturally idempotent (re-running the same TYPE
117/// change is a no-op).
118fn 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
124/// `ALTER TABLE <ref> ALTER COLUMN "<col>" DROP NOT NULL` — relax a NOT NULL
125/// constraint. Naturally idempotent.
126fn 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
133/// Map a PostgreSQL type name (`pg_type.typname`, e.g. `int4`, `float8`, `bool`,
134/// `jsonb`) back to a JSON-Schema type fragment so [`PostgresSink::current_schema`]
135/// round-trips with [`faucet_core::diff_schema`]. `nullable` reflects whether the
136/// column allows NULL (`NOT a.attnotnull`).
137fn 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
152/// A sink that writes JSON records to a PostgreSQL table.
153pub struct PostgresSink {
154    config: PostgresSinkConfig,
155    pool: PgPool,
156}
157
158impl PostgresSink {
159    /// Create a new PostgreSQL sink. Establishes a connection pool.
160    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    /// Discover the target relation's column names and underlying types
191    /// (`pg_type.typname`), scoped to the *exact* relation the writes target
192    /// via `to_regclass` (#146 M13). Shared by the INSERT and COPY paths so
193    /// both see an identical column set. `::text` casts the `name`-typed
194    /// catalog columns so sqlx decodes them as `String`.
195    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    /// Write one chunk via `COPY … FROM STDIN (FORMAT text)` — the bulk-load
230    /// fast-path (issue #308). Append-only (validated at construction); the
231    /// server parses each field with the destination column's input function,
232    /// so type semantics match the `INSERT` path exactly. A bad row fails the
233    /// whole COPY (all-or-nothing, like a failed multi-row `INSERT`).
234    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        // Ship in ~1 MiB slices so a huge page never materializes a second
268        // time inside sqlx's write buffer.
269        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                // Dropping the handle aborts the COPY server-side; surface
273                // the original error.
274                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    /// Insert a batch of records using JSONB column mode, on the given connection.
287    ///
288    /// Accepts `&mut sqlx::PgConnection` so the same logic runs both standalone
289    /// (via a pool-acquired connection, autocommit) and inside the idempotent
290    /// transaction (where `&mut *tx` is passed — `Transaction<'_, Postgres>`
291    /// derefs to `PgConnection`).
292    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        // Use a single INSERT with unnest for efficiency.
303        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    /// Insert a batch of records using auto-mapped columns, on the given connection.
320    ///
321    /// Accepts `&mut sqlx::PgConnection` so the same logic runs both standalone
322    /// (via a pool-acquired connection, autocommit) and inside the idempotent
323    /// transaction (where `&mut *tx` is passed — `Transaction<'_, Postgres>`
324    /// derefs to `PgConnection`). Running the column-discovery query on the same
325    /// connection is harmless and avoids any cross-connection visibility surprise.
326    ///
327    /// Discovers each column's name *and* underlying type (`udt_name`) from the
328    /// table schema and maps top-level JSON fields to columns. Each placeholder
329    /// is emitted as `$N::<udt>` and the value is bound as text (see
330    /// [`pg_bind_text`]), so the destination column's input function parses it —
331    /// numbers, booleans, timestamps, uuids, and JSON all land in their native
332    /// column types. (Previously every value was bound as `serde_json::Value`,
333    /// which sqlx encodes as `jsonb`, so an insert into any non-`jsonb` column
334    /// failed at runtime — audit #146 C1.) Uses a single multi-row INSERT
335    /// (sub-chunked at the 65535-parameter cap) for efficiency.
336    ///
337    /// When `conflict_key` is `Some(key)`, each sub-chunk's INSERT is given an
338    /// `ON CONFLICT (key) DO UPDATE …` tail so it upserts by the key columns
339    /// (last-write-wins within the batch is already handled by the planner's
340    /// dedup, so a single sub-chunk never double-hits the same conflict target).
341    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        // Get column names AND their underlying types for the *exact* relation
352        // the INSERT will target. Scoping by `to_regclass(<qualified ref>)`
353        // resolves the relation the same way the INSERT does — by the configured
354        // schema if set, otherwise by the connection's `search_path` — so a
355        // table of the same name in another schema can no longer pollute the
356        // column set with duplicate/wrong columns (#146 M13). The previous query
357        // filtered `information_schema.columns` by `table_name` alone (no schema
358        // predicate), merging every same-named table across all schemas.
359        //
360        // `pg_type.typname` is the concrete type (`int4`, `timestamptz`,
361        // `numeric`, `jsonb`, `uuid`, `text`, …) — identical to the old
362        // `information_schema.columns.udt_name` — used as the per-placeholder
363        // cast target below.
364        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        // Pre-validate all records and collect matched (column, udt, value)
368        // triples per record. The INSERT column set is the UNION of table
369        // columns present in ANY record (in declared table order), not just the
370        // first record's keys — otherwise a field present only in a later record
371        // of the batch would be silently dropped (audit #146 H1). A row missing
372        // a unioned column binds SQL NULL.
373        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        // Table columns (in declared order, with their udt) present in at least
407        // one record.
408        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        // PostgreSQL caps bind parameters per statement at 65535. A multi-row
419        // INSERT binds `rows × num_cols` parameters, so a wide table at a large
420        // batch_size can exceed it and fail at runtime (#78/#21). Split into
421        // sub-INSERTs of at most floor(MAX_PARAMS / num_cols) rows.
422        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            // Build multi-row VALUES clause with per-column casts so the column
427            // type's input function parses the bound text:
428            //   ($1::int4, $2::timestamptz), ($3::int4, $4::timestamptz), ...
429            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                // Bind values in the fixed column order, as text matching each
461                // column's type. A record missing a column that appeared in the
462                // first record binds SQL NULL.
463                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    /// Insert a batch of records using auto-mapped columns, on the given
481    /// connection, with plain append semantics (no `ON CONFLICT` tail).
482    ///
483    /// Thin wrapper over [`insert_auto_map_with_conflict`](Self::insert_auto_map_with_conflict)
484    /// so the append path and the idempotent-write path keep their original
485    /// signature.
486    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    /// Delete rows whose key columns match any of `deletes`, using
496    /// `DELETE FROM t WHERE (k1, …) IN ((v1, …), …)` with per-column `::udt`
497    /// casts (the key columns' underlying types), chunked at the param cap.
498    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        // Underlying types for the key columns → drives the ::udt casts, same
510        // source the insert path uses.
511        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    /// Delete rows in `scope` whose key was not written by this run (#478).
566    ///
567    /// Uses a temp table + `NOT EXISTS` rather than `key NOT IN (…)` because the
568    /// written-key set routinely exceeds PostgreSQL's 65535 bind-parameter limit
569    /// (the cleanup ceiling defaults to 100k rows). It also makes the whole thing
570    /// one transaction, so the delete is all-or-nothing: a partial delete would
571    /// remove rows the run actually wrote.
572    ///
573    /// An empty `seen` set is meaningful, not a no-op — it means the source
574    /// reported the scope as empty, so every row in it is stale and must go. That
575    /// is the case this feature exists for.
576    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        // Fail with a clear message rather than letting PostgreSQL reject an
602        // unknown column mid-DELETE. The scope is written in *destination* terms,
603        // so a name that isn't a real column is a config error worth naming.
604        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        // Temp table mirroring the key columns' types. `ON COMMIT DROP` scopes it
615        // to this transaction, so concurrent cleanups on other connections cannot
616        // collide on the name.
617        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        // Load the written keys.
630        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        // DELETE everything in scope that isn't in the written-key set.
671        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    /// Apply a planned upsert/delete batch on one connection.
709    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    /// Ensure the commit-token watermark table exists.
727    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        // Column-mapping mode only: the scope + key predicates address real
754        // columns, which a single JSONB payload column does not have.
755        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    /// Read the live destination schema from `pg_catalog` as an
783    /// `infer_schema`-shaped object (`{"type":"object","properties":{…}}`), or
784    /// `None` when the target table does not exist yet (issue #194).
785    ///
786    /// Reuses the AutoMap column-discovery query shape (scoped to the exact
787    /// relation via `to_regclass`), additionally reading `a.attnotnull` so
788    /// nullability round-trips through `pg_udt_to_json_schema`.
789    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); // table does not exist yet
815        }
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    /// Apply an additive schema evolution (new columns, lossless widenings,
827    /// nullability relaxations) to the destination table. Idempotent —
828    /// `ADD COLUMN IF NOT EXISTS`, and re-running the same TYPE / DROP NOT NULL
829    /// is a no-op (issue #194).
830    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    /// Preflight connectivity probe (`faucet doctor`).
885    ///
886    /// Acquires a connection from the existing pool and runs `SELECT 1`. This
887    /// is non-mutating and idempotent — it validates that the database is
888    /// reachable and the credentials are accepted without writing anything.
889    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    /// Write records to PostgreSQL.
918    ///
919    /// When `config.batch_size > 0` and the input slice is larger than
920    /// `batch_size`, the slice is split into chunks of `batch_size` rows and
921    /// each chunk is sent as a separate multi-row `INSERT`. When
922    /// `config.batch_size == 0`, the entire slice is sent in a single
923    /// `INSERT` — useful when upstream `StreamPage`s are already sized for
924    /// Postgres' per-statement bind-parameter limit (~65 535 / num_columns
925    /// in AutoMap mode).
926    ///
927    /// Acquires one connection from the pool and routes all chunks through it.
928    /// Each INSERT executes as its own autocommit statement — identical
929    /// observable behaviour to executing directly on the pool, while keeping the
930    /// same connection for the entire call (avoids repeated pool-checkout
931    /// overhead on large batches).
932    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            // Sentinel: pass the entire upstream page through in a single
954            // INSERT statement. Subject to Postgres' 65 535 bind-parameter
955            // limit in AutoMap mode; JSONB mode binds a single array.
956            vec![records]
957        } else {
958            records.chunks(self.config.batch_size).collect()
959        };
960
961        // Acquire once; reuse for all chunks (each statement autocommits —
962        // no BEGIN is issued, so behaviour is identical to using the pool).
963        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                // Bulk-load fast-path: COPY the chunk instead of a multi-row
973                // INSERT (append-only; validated at construction, #308).
974                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    /// Write a batch and report per-row outcomes.
995    ///
996    /// In append mode this delegates to [`write_batch`](faucet_core::Sink::write_batch) and
997    /// maps a single success onto an all-`Ok(())` vector (the trait default).
998    /// In upsert/delete mode the good rows are applied (upserts + deletes), and
999    /// only the rows whose key could not be extracted (missing / null key) are
1000    /// reported as `Err` so the pipeline routes them to the DLQ per-row instead
1001    /// of sending the whole page.
1002    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        // For upsert/delete modes, plan the page before opening the transaction
1058        // so a key-extraction failure aborts without leaving an open tx.
1059        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        // Data write(s) and the commit-token upsert share ONE transaction so
1078        // the page is committed atomically with its watermark: on crash either
1079        // both land or neither does, which is what makes a replay skip-on-resume
1080        // produce zero duplicates. For upsert/delete this means the planned
1081        // upserts/deletes commit together with the watermark in the same tx.
1082        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        // Unknown types fall back to string; nullable widens the type array.
1174        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    // dataset_uri test is skipped: PostgresSink::new() requires a live pool
1207    // (connects to PostgreSQL in new()), and no offline constructor exists.
1208    // The URI format is covered by unit tests in faucet-core's redact tests.
1209
1210    #[test]
1211    fn qualified_table_ref_unqualified_is_bare_quoted_table() {
1212        // No schema → bare quoted table, resolved against the search_path.
1213        assert_eq!(qualified_table_ref(None, "events"), "\"events\"");
1214    }
1215
1216    #[test]
1217    fn qualified_table_ref_with_schema_is_schema_dot_table() {
1218        // With a schema → "schema"."table", so discovery and INSERT both
1219        // target the same explicit relation (#146 M13).
1220        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        // SQL-injection safety: embedded double-quotes are doubled.
1229        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        // The `$N::<udt>` cast parses these via the column's input function.
1245        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        // A plain string into TEXT keeps NO JSON quotes (the bug bound `"Bob"`).
1262        assert_eq!(
1263            pg_bind_text(Some(&json!("Bob")), "text").as_deref(),
1264            Some("Bob")
1265        );
1266        // Large u64 beyond i64 keeps exact text (no f64 precision loss).
1267        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        // For jsonb/json columns the value is bound as JSON text so the
1276        // `::jsonb` cast parses it: a string keeps its quotes, objects/arrays
1277        // round-trip.
1278        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        // udt match is case-insensitive.
1292        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        // No scalar text form for an object targeting e.g. an int column; the
1301        // `::int4` cast will reject this rather than silently coercing.
1302        assert_eq!(
1303            pg_bind_text(Some(&json!({"a": 1})), "int4").as_deref(),
1304            Some("{\"a\":1}")
1305        );
1306    }
1307}