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 ¶ms_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)), ¶ms);
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}