Skip to main content

dbkit/
pg_handler.rs

1//! Native-Postgres handler — sqlx [`PgPool`] with full Postgres type support.
2//!
3//! Mirrors [`BaseHandler`](crate::BaseHandler)'s write surface, but binds the
4//! *rich* [`DbValue`] variants (date / timestamp / json / uuid) to their native
5//! Postgres types via sqlx, and returns native [`PgRow`](sqlx::postgres::PgRow)s.
6//! Use this when you need Postgres types the multi-backend `Any` pool can't
7//! represent.
8//!
9//! Reads use the ergonomic row-mapped [`ReadOp`] API over DuckDB (typically
10//! attached live to Postgres via [`with_duckdb_attached_postgres`]).
11//!
12//! [`with_duckdb_attached_postgres`]: PgHandler::with_duckdb_attached_postgres
13
14use crate::DbkitError;
15use crate::base_handler::{FetchMode, QueryResult, WriteOp};
16use crate::value::DbValue;
17use std::fmt::Write as _;
18use sqlx::postgres::{PgArguments, PgRow};
19use sqlx::query::Query;
20use sqlx::{AssertSqlSafe, PgPool, Postgres};
21use tracing::warn;
22use unicode_normalization::UnicodeNormalization;
23
24#[cfg(feature = "duckdb")]
25use crate::analytical::RecordBatch;
26#[cfg(feature = "duckdb")]
27use crate::read::{ReadEngine, duckdb::DuckEngine};
28
29/// A typeless SQL `NULL`. Declares the Postgres parameter type as OID 0 so the
30/// server infers it from context — exactly like a bare `NULL` literal. This lets
31/// a `NULL` [`DbValue`] unify with any column type in `COALESCE` / `CASE` / etc.,
32/// instead of being pinned to one concrete type. (Binding `Option::<i64>::None`
33/// forced `int8`, which broke e.g. `COALESCE($1, external_id)` against a
34/// `varchar` column: "bigint and character varying cannot be matched".)
35struct PgNull;
36
37impl sqlx::Type<Postgres> for PgNull {
38    fn type_info() -> sqlx::postgres::PgTypeInfo {
39        // OID 0 → "unspecified", resolved from context by the server.
40        sqlx::postgres::PgTypeInfo::with_oid(sqlx::postgres::types::Oid(0))
41    }
42}
43
44impl<'q> sqlx::Encode<'q, Postgres> for PgNull {
45    fn encode_by_ref(
46        &self,
47        _buf: &mut sqlx::postgres::PgArgumentBuffer,
48    ) -> Result<sqlx::encode::IsNull, sqlx::error::BoxDynError> {
49        Ok(sqlx::encode::IsNull::Yes)
50    }
51}
52
53/// Bind a slice of [`DbValue`]s onto a sqlx Postgres query, in order, binding
54/// the rich variants to their native Postgres types (no text fallback). Values
55/// are bound by owned copy, so the returned query does not borrow `params`.
56fn bind_pg<'q>(
57    mut q: Query<'q, Postgres, PgArguments>,
58    params: &[DbValue],
59) -> Query<'q, Postgres, PgArguments> {
60    for p in params {
61        q = match p {
62            DbValue::Null => q.bind(PgNull),
63            DbValue::Bool(b) => q.bind(*b),
64            DbValue::Int(i) => q.bind(*i),
65            DbValue::Float(f) => q.bind(*f),
66            DbValue::Text(s) => q.bind(s.clone()),
67            DbValue::Bytes(b) => q.bind(b.clone()),
68            DbValue::Date(d) => q.bind(*d),
69            DbValue::DateTime(dt) => q.bind(*dt),
70            DbValue::TimestampTz(dt) => q.bind(*dt),
71            DbValue::Json(j) => q.bind(j.clone()),
72            DbValue::Uuid(u) => q.bind(*u),
73            DbValue::Time(t) => q.bind(*t),
74            // sqlx binds `Vec<T>` / `Vec<Option<T>>` as native Postgres arrays.
75            DbValue::TextArray(v) => q.bind(v.clone()),
76            DbValue::FloatArray(v) => q.bind(v.clone()),
77            DbValue::OptFloatArray(v) => q.bind(v.clone()),
78        };
79    }
80    q
81}
82
83/// Render one [`DbValue`] as a cell in Postgres `COPY` text format, appending to
84/// `out`. NULL is the `\N` sentinel; all other values are escaped.
85fn copy_render_cell(val: &DbValue, out: &mut String) {
86    match val {
87        DbValue::Null => out.push_str("\\N"),
88        DbValue::Bool(b) => out.push(if *b { 't' } else { 'f' }),
89        // Numbers contain only digits / sign / `.` / `e` — never a COPY escape
90        // char — so format straight into `out`, skipping a throwaway `String`.
91        DbValue::Int(i) => {
92            let _ = write!(out, "{i}");
93        }
94        DbValue::Float(f) => {
95            if f.is_nan() {
96                out.push_str("NaN");
97            } else if f.is_infinite() {
98                out.push_str(if *f > 0.0 { "Infinity" } else { "-Infinity" });
99            } else {
100                let _ = write!(out, "{f}");
101            }
102        }
103        DbValue::Text(s) => copy_escape_into(s, out),
104        DbValue::Bytes(b) => {
105            // bytea hex format `\x<hex>`. The backslash is COPY-escaped to `\\`,
106            // and hex digits never need escaping, so write the escaped form
107            // directly — no temporary `String` or per-byte allocation.
108            out.push_str("\\\\x");
109            for byte in b {
110                out.push(char::from_digit((byte >> 4) as u32, 16).unwrap());
111                out.push(char::from_digit((byte & 0x0f) as u32, 16).unwrap());
112            }
113        }
114        DbValue::Date(d) => copy_escape_into(&d.to_string(), out),
115        DbValue::DateTime(dt) => copy_escape_into(&dt.to_string(), out),
116        DbValue::TimestampTz(dt) => copy_escape_into(&dt.to_rfc3339(), out),
117        DbValue::Json(j) => copy_escape_into(&j.to_string(), out),
118        DbValue::Uuid(u) => copy_escape_into(&u.to_string(), out),
119        DbValue::Time(t) => copy_escape_into(&t.to_string(), out),
120        DbValue::TextArray(v) => copy_escape_into(&crate::value::pg_text_array_literal(v), out),
121        DbValue::FloatArray(v) => {
122            copy_escape_into(&crate::value::pg_float_array_literal(v.iter().map(|x| Some(*x))), out)
123        }
124        DbValue::OptFloatArray(v) => {
125            copy_escape_into(&crate::value::pg_float_array_literal(v.iter().copied()), out)
126        }
127    }
128}
129
130/// Render `rows` as a Postgres `COPY` text-format payload: cells tab-separated,
131/// one row per line. `ncols` is used only to pre-size the buffer.
132fn render_copy_text(rows: &[Vec<DbValue>], ncols: usize) -> String {
133    // Pre-size the buffer to avoid repeated grow-and-copy reallocations as it
134    // fills (~12 bytes/cell + tab/newline is a rough but useful estimate).
135    let mut payload = String::with_capacity(rows.len() * (ncols * 12 + 1));
136    for row in rows {
137        for (i, val) in row.iter().enumerate() {
138            if i > 0 {
139                payload.push('\t');
140            }
141            copy_render_cell(val, &mut payload);
142        }
143        payload.push('\n');
144    }
145    payload
146}
147
148/// Escape a value for Postgres `COPY` text format (backslash, tab, newline, CR).
149fn copy_escape_into(s: &str, out: &mut String) {
150    for c in s.chars() {
151        match c {
152            '\\' => out.push_str("\\\\"),
153            '\t' => out.push_str("\\t"),
154            '\n' => out.push_str("\\n"),
155            '\r' => out.push_str("\\r"),
156            _ => out.push(c),
157        }
158    }
159}
160
161/// Core query executor for native Postgres: rich-typed transactional writes via
162/// sqlx, and row-mapped analytical reads via DuckDB.
163pub struct PgHandler {
164    pool: PgPool,
165    #[cfg(feature = "duckdb")]
166    duck: Option<DuckEngine>,
167}
168
169impl PgHandler {
170    /// Create a handler for writes against the given native Postgres pool.
171    pub fn new(pool: PgPool) -> Self {
172        Self {
173            pool,
174            #[cfg(feature = "duckdb")]
175            duck: None,
176        }
177    }
178
179    /// Create a handler with an in-memory DuckDB analytical read engine.
180    #[cfg(feature = "duckdb")]
181    pub fn with_duckdb(pool: PgPool) -> Result<Self, DbkitError> {
182        Ok(Self {
183            pool,
184            duck: Some(DuckEngine::new_in_memory()?),
185        })
186    }
187
188    /// Create a handler with DuckDB and a live Postgres attachment, so DuckDB
189    /// queries the Postgres tables directly via the `pg` catalog
190    /// (`SELECT … FROM pg.<schema>.<table>`) without an explicit sync.
191    #[cfg(feature = "duckdb")]
192    pub fn with_duckdb_attached_postgres(
193        pool: PgPool,
194        pg_connection_string: &str,
195    ) -> Result<Self, DbkitError> {
196        let duck = DuckEngine::new_in_memory()?;
197        duck.attach_postgres(pg_connection_string)?;
198        Ok(Self {
199            pool,
200            duck: Some(duck),
201        })
202    }
203
204    /// Whether a DuckDB read engine is attached.
205    pub fn has_read_engine(&self) -> bool {
206        #[cfg(feature = "duckdb")]
207        {
208            self.duck.is_some()
209        }
210        #[cfg(not(feature = "duckdb"))]
211        {
212            false
213        }
214    }
215
216    /// Get a reference to the native Postgres write pool.
217    pub fn pool(&self) -> &PgPool {
218        &self.pool
219    }
220
221    /// Unicode NFD normalization — decomposes characters then lowercases.
222    pub fn normalize_name(name: &str) -> String {
223        name.nfd().collect::<String>().to_lowercase()
224    }
225
226    // ==================== UNIFIED WRITE ====================
227
228    /// Execute a write operation against the Postgres pool. Placeholders are
229    /// Postgres-native (`$1, $2, …`).
230    pub async fn execute_write(
231        &self,
232        op: WriteOp<'_>,
233    ) -> Result<QueryResult<PgRow>, DbkitError> {
234        match op {
235            WriteOp::Single {
236                query,
237                params,
238                mode,
239            } => self.query(query, params, mode).await,
240
241            WriteOp::BatchDDL { queries } => {
242                let mut tx = self.pool.begin().await?;
243                for query in queries {
244                    sqlx::query(AssertSqlSafe(*query)).execute(&mut *tx).await?;
245                }
246                tx.commit().await?;
247                Ok(QueryResult::None)
248            }
249
250            WriteOp::BatchParams {
251                query,
252                params_list,
253                isolate_rows,
254            } => {
255                if params_list.is_empty() {
256                    return Ok(QueryResult::None);
257                }
258                let total = params_list.len();
259                let mut tx = self.pool.begin().await?;
260
261                if !isolate_rows {
262                    // Fast path: no per-row SAVEPOINT. The whole batch is one
263                    // transaction, so any error rolls back *everything*
264                    // (all-or-nothing) — the cost of dropping savepoints, but
265                    // ~2× faster than the isolated path below.
266                    //
267                    // Statement reuse: a typeless NULL (`PgNull`, OID 0) lets the
268                    // server pin the cached statement's parameter type from the
269                    // FIRST row, so a later row binding a concrete type for that
270                    // same column fails with 22P03. So reuse one prepared
271                    // statement (`persistent(true)`) only when the batch has no
272                    // NULLs; otherwise re-parse per row to keep each row's param
273                    // types self-consistent (matching the isolated path).
274                    let has_null = params_list
275                        .iter()
276                        .any(|row| row.iter().any(|v| matches!(v, DbValue::Null)));
277                    let persistent = !has_null;
278                    for params in &params_list {
279                        bind_pg(sqlx::query(AssertSqlSafe(query)), params)
280                            .persistent(persistent)
281                            .execute(&mut *tx)
282                            .await?;
283                    }
284                    tx.commit().await?;
285                    return Ok(QueryResult::None);
286                }
287
288                let mut failed = 0usize;
289                for (idx, params) in params_list.iter().enumerate() {
290                    // Wrap each row in a SAVEPOINT so a bad row rolls back on its
291                    // own instead of aborting the whole transaction. Without this,
292                    // Postgres marks the transaction failed on the first error and
293                    // every following row dies with 25P02 ("current transaction is
294                    // aborted"), turning one bad row into a whole failed batch.
295                    sqlx::query(AssertSqlSafe("SAVEPOINT dbkit_row"))
296                        .execute(&mut *tx)
297                        .await?;
298                    // `.persistent(false)` re-parses per row instead of reusing one
299                    // cached prepared statement across the batch. Reuse pins each
300                    // parameter's type from the FIRST row: a row whose value is a
301                    // typeless NULL lets the server resolve that param to the column
302                    // type (e.g. int4), and a later row binding the same column's
303                    // value as int8 then fails with 22P03 ("incorrect binary data
304                    // format"). Per-row parse keeps each row's param types self-consistent.
305                    let q = bind_pg(sqlx::query(AssertSqlSafe(query)), params).persistent(false);
306                    match q.execute(&mut *tx).await {
307                        Ok(_) => {
308                            sqlx::query(AssertSqlSafe("RELEASE SAVEPOINT dbkit_row"))
309                                .execute(&mut *tx)
310                                .await?;
311                        }
312                        Err(e) => {
313                            warn!("BatchParams row {}/{} failed: {:?}", idx + 1, total, e);
314                            failed += 1;
315                            sqlx::query(AssertSqlSafe("ROLLBACK TO SAVEPOINT dbkit_row"))
316                                .execute(&mut *tx)
317                                .await?;
318                            sqlx::query(AssertSqlSafe("RELEASE SAVEPOINT dbkit_row"))
319                                .execute(&mut *tx)
320                                .await?;
321                        }
322                    }
323                }
324                tx.commit().await?;
325                if failed > 0 {
326                    warn!(
327                        "BatchParams: {}/{} succeeded, {} failed",
328                        total - failed,
329                        total,
330                        failed
331                    );
332                }
333                Ok(QueryResult::None)
334            }
335        }
336    }
337
338    /// Bulk-insert rows via Postgres `COPY ... FROM STDIN` (text format).
339    ///
340    /// **The fastest way to load many rows** — one streamed `COPY` instead of a
341    /// parse + execute (+ savepoint) per row like [`WriteOp::BatchParams`].
342    /// Benchmarks at roughly 30–50× the throughput of `BatchParams`. Each row in
343    /// `rows` must align positionally with `columns`. Returns the number of rows
344    /// copied.
345    ///
346    /// # `copy_in` vs [`WriteOp::BatchParams`] — which to use
347    ///
348    /// | Reach for `copy_in` when… | Reach for `BatchParams` when… |
349    /// |---|---|
350    /// | Plain bulk insert into one table | You need `INSERT … ON CONFLICT` (upsert) |
351    /// | Data is trusted; all-or-nothing is fine | You need per-row isolation (skip bad rows) |
352    /// | You want maximum throughput | The statement isn't a plain insert (`UPDATE`, `RETURNING`, computed `VALUES`) |
353    /// | Target is Postgres | Target is a non-Postgres backend (use the `Any` pool) |
354    ///
355    /// `COPY` is **not** an `INSERT` statement, so it does **not** support
356    /// `ON CONFLICT`, `RETURNING`, `DEFAULT` expressions, or `WHERE`, and it is
357    /// **all-or-nothing**: a constraint violation aborts the entire load (it does
358    /// not skip bad rows like `BatchParams`).
359    ///
360    /// To bulk-**upsert**, combine the two: `COPY` into a constraint-free staging
361    /// table, then run one set-based `INSERT … SELECT … ON CONFLICT` — far faster
362    /// than per-row `BatchParams` with `ON CONFLICT`:
363    ///
364    /// ```sql
365    /// CREATE TEMP TABLE stage (LIKE target INCLUDING DEFAULTS) ON COMMIT DROP;
366    /// COPY stage (id, name) FROM STDIN;            -- fast bulk load, no constraints
367    /// INSERT INTO target (id, name)
368    ///   SELECT id, name FROM stage                 -- one set-based upsert
369    ///   ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name;
370    /// ```
371    pub async fn copy_in(
372        &self,
373        table: &str,
374        columns: &[&str],
375        rows: &[Vec<DbValue>],
376    ) -> Result<u64, DbkitError> {
377        use sqlx::postgres::PgPoolCopyExt;
378
379        if rows.is_empty() {
380            return Ok(0);
381        }
382
383        let stmt = format!("COPY {table} ({}) FROM STDIN", columns.join(", "));
384        let payload = render_copy_text(rows, columns.len());
385
386        let mut sink = self.pool.copy_in_raw(&stmt).await?;
387        sink.send(payload.as_bytes()).await?;
388        Ok(sink.finish().await?)
389    }
390
391    /// Bulk-**upsert** rows: `COPY` into a staging table, then one set-based
392    /// `INSERT … SELECT … ON CONFLICT`, all in a single transaction.
393    ///
394    /// This is the fast path for `ON CONFLICT` at scale. Plain [`copy_in`] can't
395    /// do `ON CONFLICT` (it's not an `INSERT`), and per-row
396    /// [`WriteOp::BatchParams`] with `ON CONFLICT` pays per-row overhead. This
397    /// combines both strengths: COPY's bulk ingestion into a constraint-free
398    /// staging table, then a single set-based upsert into the target.
399    ///
400    /// - `columns` — columns present in `rows` (positional), copied into staging.
401    /// - `conflict_columns` — the conflict target (must back a unique/PK index).
402    /// - `update_columns` — columns to overwrite on conflict (set to the incoming
403    ///   `EXCLUDED` value). **Empty** ⇒ `DO NOTHING` (insert-or-ignore).
404    ///
405    /// Returns the number of rows inserted or updated.
406    ///
407    /// The staging table is `CREATE TEMP TABLE … (LIKE target INCLUDING DEFAULTS)
408    /// ON COMMIT DROP`, so it copies no constraints and vanishes at commit. The
409    /// final upsert is all-or-nothing: a non-conflict error (CHECK/FK/type) aborts
410    /// the batch. **Within a single call, `conflict_columns` must be unique across
411    /// `rows`** — duplicate keys make `ON CONFLICT DO UPDATE` error with "command
412    /// cannot affect row a second time"; de-duplicate before calling.
413    ///
414    /// [`copy_in`]: Self::copy_in
415    pub async fn copy_upsert(
416        &self,
417        table: &str,
418        columns: &[&str],
419        conflict_columns: &[&str],
420        update_columns: &[&str],
421        rows: &[Vec<DbValue>],
422    ) -> Result<u64, DbkitError> {
423        if rows.is_empty() {
424            return Ok(0);
425        }
426
427        let cols = columns.join(", ");
428        let stage = "dbkit_copy_stage";
429
430        let on_conflict = if update_columns.is_empty() {
431            format!("ON CONFLICT ({}) DO NOTHING", conflict_columns.join(", "))
432        } else {
433            let set = update_columns
434                .iter()
435                .map(|c| format!("{c} = EXCLUDED.{c}"))
436                .collect::<Vec<_>>()
437                .join(", ");
438            format!("ON CONFLICT ({}) DO UPDATE SET {set}", conflict_columns.join(", "))
439        };
440
441        let mut tx = self.pool.begin().await?;
442
443        // 1. Staging table shaped like the target (no constraints), dropped at
444        //    COMMIT. Temp tables are connection-scoped, so the fixed name is safe
445        //    even under concurrent callers on separate connections.
446        sqlx::query(AssertSqlSafe(format!(
447            "CREATE TEMP TABLE {stage} (LIKE {table} INCLUDING DEFAULTS) ON COMMIT DROP"
448        )))
449        .execute(&mut *tx)
450        .await?;
451
452        // 2. Bulk-load into staging via COPY on the SAME connection (so the temp
453        //    table is visible) — this is where the throughput comes from.
454        let payload = render_copy_text(rows, columns.len());
455        let mut copy = (&mut *tx)
456            .copy_in_raw(&format!("COPY {stage} ({cols}) FROM STDIN"))
457            .await?;
458        copy.send(payload.as_bytes()).await?;
459        copy.finish().await?;
460
461        // 3. One set-based upsert from staging into the target.
462        let result = sqlx::query(AssertSqlSafe(format!(
463            "INSERT INTO {table} ({cols}) SELECT {cols} FROM {stage} {on_conflict}"
464        )))
465        .execute(&mut *tx)
466        .await?;
467
468        tx.commit().await?;
469        Ok(result.rows_affected())
470    }
471
472    // ==================== NATIVE POSTGRES READ ====================
473
474    /// Run a query against the native Postgres pool, returning rows per `mode`.
475    ///
476    /// This is the OLTP read path — single-row lookups and small result sets go
477    /// straight to Postgres (one round-trip → [`PgRow`]), no analytical engine.
478    /// Placeholders are Postgres-native (`$1, $2, …`); read columns off the
479    /// returned [`PgRow`]s with `row.get(i)` / `row.try_get(i)`.
480    pub async fn query(
481        &self,
482        query: &str,
483        params: Vec<DbValue>,
484        mode: FetchMode,
485    ) -> Result<QueryResult<PgRow>, DbkitError> {
486        let q = bind_pg(sqlx::query(AssertSqlSafe(query)), &params);
487        match mode {
488            FetchMode::None => {
489                q.execute(&self.pool).await?;
490                Ok(QueryResult::None)
491            }
492            FetchMode::One => Ok(QueryResult::One(q.fetch_one(&self.pool).await?)),
493            FetchMode::Optional => Ok(QueryResult::Optional(q.fetch_optional(&self.pool).await?)),
494            FetchMode::All => Ok(QueryResult::All(q.fetch_all(&self.pool).await?)),
495        }
496    }
497
498    // ==================== ANALYTICAL READ (DuckDB / Arrow) ====================
499
500    /// Run an analytical query against the attached DuckDB engine, returning
501    /// columnar Arrow [`RecordBatch`]es. For large joins/aggregations consumed as
502    /// DataFrames. Errors with [`DbkitError::NoReadEngine`] if no engine is
503    /// attached. For typed rows, deserialize the batches (see
504    /// [`BaseHandler::execute_read_as`](crate::BaseHandler::execute_read_as)).
505    #[cfg(feature = "duckdb")]
506    pub async fn execute_read(
507        &self,
508        sql: &str,
509        params: &[DbValue],
510    ) -> Result<Vec<RecordBatch>, DbkitError> {
511        self.duck
512            .as_ref()
513            .ok_or(DbkitError::NoReadEngine)?
514            .query_arrow(sql, params)
515            .await
516    }
517
518    /// Like [`execute_read`](Self::execute_read) but deserializes each row into
519    /// `T` via `serde_arrow` — the typed analytical read. `T`'s field names must
520    /// match the query's output column names. Use for DuckDB-side analytical
521    /// reads (large scans / aggregations) that map to typed rows. Errors with
522    /// [`DbkitError::NoReadEngine`] if no engine is attached.
523    #[cfg(feature = "duckdb")]
524    pub async fn execute_read_as<T>(
525        &self,
526        sql: &str,
527        params: &[DbValue],
528    ) -> Result<Vec<T>, DbkitError>
529    where
530        T: serde::de::DeserializeOwned,
531    {
532        let batches = self.execute_read(sql, params).await?;
533        crate::analytical::deserialize_batches(&batches)
534    }
535}