Skip to main content

dbkit/
base_handler.rs

1use crate::DbkitError;
2use crate::value::DbValue;
3use sqlx::any::{AnyArguments, AnyRow};
4use sqlx::query::Query;
5use sqlx::{Any, AnyPool, AssertSqlSafe};
6use tracing::warn;
7use unicode_normalization::UnicodeNormalization;
8
9#[cfg(any(feature = "duckdb", feature = "datafusion"))]
10use crate::analytical::RecordBatch;
11#[cfg(any(feature = "duckdb", feature = "datafusion"))]
12use crate::read::ReadEngine;
13
14// ---------------------------------------------------------------------------
15// Write operations
16// ---------------------------------------------------------------------------
17
18/// Unified write operation types.
19pub enum WriteOp<'a> {
20    /// Single query with optional return.
21    Single {
22        query: &'a str,
23        params: Vec<DbValue>,
24        mode: FetchMode,
25    },
26    /// Batch of DDL statements executed in a single transaction.
27    BatchDDL { queries: &'a [&'a str] },
28    /// Same query executed once per parameter set, in a single transaction.
29    ///
30    /// Use for batched `INSERT … ON CONFLICT`, `UPDATE`s, or any non-Postgres
31    /// backend. For a plain high-volume insert into one table,
32    /// [`PgHandler::copy_in`](crate::PgHandler::copy_in) is ~30–50× faster — see
33    /// its docs for a full `copy_in`-vs-`BatchParams` decision guide.
34    BatchParams {
35        query: &'a str,
36        params_list: Vec<Vec<DbValue>>,
37        /// Per-row error isolation.
38        ///
39        /// - `true` — a bad row is contained and the rest of the batch still
40        ///   commits. Both [`PgHandler`](crate::PgHandler) and the
41        ///   multi-backend [`BaseHandler`] wrap each row in a `SAVEPOINT`
42        ///   (standard SQL: Postgres, MySQL/InnoDB, SQLite), so a failed row
43        ///   rolls back alone instead of aborting the transaction.
44        /// - `false` — **all-or-nothing**: no per-row savepoints, so the first
45        ///   error rolls back the whole batch. ~2× faster than the isolated
46        ///   path. Use for trusted bulk inserts where partial success isn't
47        ///   needed. For the fastest plain bulk load, prefer
48        ///   [`PgHandler::copy_in`](crate::PgHandler::copy_in).
49        isolate_rows: bool,
50    },
51}
52
53// ---------------------------------------------------------------------------
54// Query result types
55// ---------------------------------------------------------------------------
56
57/// How many rows to expect from a query.
58#[derive(Debug, Clone, Copy)]
59pub enum FetchMode {
60    None,
61    One,
62    Optional,
63    All,
64}
65
66/// Result wrapper for write queries.
67pub enum QueryResult<T> {
68    None,
69    One(T),
70    Optional(Option<T>),
71    All(Vec<T>),
72}
73
74impl<T> QueryResult<T> {
75    pub fn one(self) -> Result<T, DbkitError> {
76        match self {
77            Self::One(v) => Ok(v),
78            _ => Err(DbkitError::RowCount {
79                expected: "One".into(),
80                actual: 0,
81            }),
82        }
83    }
84
85    pub fn optional(self) -> Result<Option<T>, DbkitError> {
86        match self {
87            Self::Optional(v) => Ok(v),
88            Self::One(v) => Ok(Some(v)),
89            Self::None => Ok(None),
90            _ => Err(DbkitError::RowCount {
91                expected: "Optional".into(),
92                actual: 0,
93            }),
94        }
95    }
96
97    pub fn all(self) -> Result<Vec<T>, DbkitError> {
98        match self {
99            Self::All(v) => Ok(v),
100            _ => Err(DbkitError::RowCount {
101                expected: "All".into(),
102                actual: 0,
103            }),
104        }
105    }
106}
107
108// ---------------------------------------------------------------------------
109// Parameter binding
110// ---------------------------------------------------------------------------
111
112/// Bind a slice of [`DbValue`]s onto a sqlx query, in order.
113///
114/// Values are bound by owned copy, so the returned query does not borrow
115/// `params`.
116fn bind_params<'q>(
117    mut q: Query<'q, Any, AnyArguments>,
118    params: &[DbValue],
119) -> Query<'q, Any, AnyArguments> {
120    for p in params {
121        q = match p {
122            // A text-typed NULL, matching the text fallback used for the rich
123            // variants below, so a nullable column behaves the same whether a
124            // given row's value is NULL or not. (Binding `Option::<i64>::None`
125            // — as dbkit < 0.5 did — declared the parameter as `int8` on
126            // Postgres, so NULLs into varchar/date/json columns failed with
127            // "column is of type X but expression is of type bigint".) For
128            // non-text Postgres columns, cast explicitly in SQL (`$1::date`) —
129            // the same rule as the rich-type text fallback. For native typed
130            // NULL inference use `PgHandler`.
131            DbValue::Null => q.bind(Option::<String>::None),
132            DbValue::Bool(b) => q.bind(*b),
133            DbValue::Int(i) => q.bind(*i),
134            DbValue::Float(f) => q.bind(*f),
135            DbValue::Text(s) => q.bind(s.clone()),
136            DbValue::Bytes(b) => q.bind(b.clone()),
137            // The Any driver can't carry native temporal/json/uuid types, so
138            // bind a text rendering — Postgres assignment casts handle the rest.
139            // For native rich-typed binds use `PgHandler` instead.
140            #[cfg(feature = "postgres-native")]
141            DbValue::Date(d) => q.bind(d.to_string()),
142            #[cfg(feature = "postgres-native")]
143            DbValue::DateTime(dt) => q.bind(dt.to_string()),
144            #[cfg(feature = "postgres-native")]
145            DbValue::TimestampTz(dt) => q.bind(dt.to_rfc3339()),
146            #[cfg(feature = "postgres-native")]
147            DbValue::Json(j) => q.bind(j.to_string()),
148            #[cfg(feature = "postgres-native")]
149            DbValue::Uuid(u) => q.bind(u.to_string()),
150            #[cfg(feature = "postgres-native")]
151            DbValue::Time(t) => q.bind(t.to_string()),
152            #[cfg(feature = "postgres-native")]
153            DbValue::TextArray(v) => q.bind(crate::value::pg_text_array_literal(v)),
154            #[cfg(feature = "postgres-native")]
155            DbValue::FloatArray(v) => {
156                q.bind(crate::value::pg_float_array_literal(v.iter().map(|x| Some(*x))))
157            }
158            #[cfg(feature = "postgres-native")]
159            DbValue::OptFloatArray(v) => {
160                q.bind(crate::value::pg_float_array_literal(v.iter().copied()))
161            }
162        };
163    }
164    q
165}
166
167// ---------------------------------------------------------------------------
168// BaseHandler
169// ---------------------------------------------------------------------------
170
171/// Core query executor: transactional writes via sqlx, and optionally
172/// analytical reads via a pluggable [`ReadEngine`] (DuckDB or DataFusion).
173pub struct BaseHandler {
174    pool: AnyPool,
175    #[cfg(any(feature = "duckdb", feature = "datafusion"))]
176    read_engine: Option<Box<dyn ReadEngine>>,
177}
178
179impl BaseHandler {
180    /// Create a handler for writes against the given sqlx pool.
181    pub fn new(pool: AnyPool) -> Self {
182        Self {
183            pool,
184            #[cfg(any(feature = "duckdb", feature = "datafusion"))]
185            read_engine: None,
186        }
187    }
188
189    /// Create a handler with an in-memory DuckDB analytical read engine.
190    #[cfg(feature = "duckdb")]
191    pub fn with_duckdb(pool: AnyPool) -> Result<Self, DbkitError> {
192        let engine = crate::read::duckdb::DuckEngine::new_in_memory()?;
193        Ok(Self {
194            pool,
195            read_engine: Some(Box::new(engine)),
196        })
197    }
198
199    /// Create a handler with DuckDB and a live Postgres attachment.
200    ///
201    /// DuckDB queries the Postgres tables directly via the `pg` catalog
202    /// (`SELECT … FROM pg.<schema>.<table>`) without an explicit sync — the
203    /// pre-rewrite zero-copy `ATTACH` pipeline. You can still also `sync_*`
204    /// tables into local memory for faster repeated analytics.
205    #[cfg(feature = "duckdb")]
206    pub fn with_duckdb_attached_postgres(
207        pool: AnyPool,
208        pg_connection_string: &str,
209    ) -> Result<Self, DbkitError> {
210        let engine = crate::read::duckdb::DuckEngine::new_in_memory()?;
211        engine.attach_postgres(pg_connection_string)?;
212        Ok(Self {
213            pool,
214            read_engine: Some(Box::new(engine)),
215        })
216    }
217
218    /// Create a handler with a DataFusion analytical read engine.
219    #[cfg(feature = "datafusion")]
220    pub fn with_datafusion(pool: AnyPool) -> Result<Self, DbkitError> {
221        let engine = crate::read::datafusion::DataFusionEngine::new();
222        Ok(Self {
223            pool,
224            read_engine: Some(Box::new(engine)),
225        })
226    }
227
228    /// Whether an analytical read engine is attached.
229    pub fn has_read_engine(&self) -> bool {
230        #[cfg(any(feature = "duckdb", feature = "datafusion"))]
231        {
232            self.read_engine.is_some()
233        }
234        #[cfg(not(any(feature = "duckdb", feature = "datafusion")))]
235        {
236            false
237        }
238    }
239
240    /// Get a reference to the write pool.
241    pub fn pool(&self) -> &AnyPool {
242        &self.pool
243    }
244
245    /// Unicode NFD normalization — decomposes characters then lowercases.
246    /// Useful for matching names with different Unicode representations.
247    pub fn normalize_name(name: &str) -> String {
248        name.nfd().collect::<String>().to_lowercase()
249    }
250
251    // ==================== UNIFIED WRITE ====================
252
253    /// Execute a write operation against the transactional pool.
254    ///
255    /// Placeholders are backend-native: `$1, $2, …` for Postgres, `?` for
256    /// MySQL/SQLite. sqlx's `Any` driver does not rewrite them, so write the
257    /// SQL for the backend you connected to.
258    pub async fn execute_write(
259        &self,
260        op: WriteOp<'_>,
261    ) -> Result<QueryResult<AnyRow>, DbkitError> {
262        match op {
263            WriteOp::Single {
264                query,
265                params,
266                mode,
267            } => {
268                // Statement-reuse guard (same as PgHandler): sqlx caches one
269                // prepared statement per (connection, SQL), pinning parameter
270                // types from the first execution. A NULL here binds as text,
271                // so a call whose NULL/concrete pattern differs from the
272                // cached statement's types would fail (22P03 / type mismatch)
273                // on Postgres. Re-parse NULL-bearing calls; keep caching for
274                // the common no-NULL case.
275                let has_null = params.iter().any(|v| matches!(v, DbValue::Null));
276                let q = bind_params(sqlx::query(AssertSqlSafe(query)), &params)
277                    .persistent(!has_null);
278                match mode {
279                    FetchMode::None => {
280                        q.execute(&self.pool).await?;
281                        Ok(QueryResult::None)
282                    }
283                    FetchMode::One => {
284                        let row = q.fetch_one(&self.pool).await?;
285                        Ok(QueryResult::One(row))
286                    }
287                    FetchMode::Optional => {
288                        let row = q.fetch_optional(&self.pool).await?;
289                        Ok(QueryResult::Optional(row))
290                    }
291                    FetchMode::All => {
292                        let rows = q.fetch_all(&self.pool).await?;
293                        Ok(QueryResult::All(rows))
294                    }
295                }
296            }
297
298            WriteOp::BatchDDL { queries } => {
299                let mut tx = self.pool.begin().await?;
300                for query in queries {
301                    sqlx::query(AssertSqlSafe(*query)).execute(&mut *tx).await?;
302                }
303                tx.commit().await?;
304                Ok(QueryResult::None)
305            }
306
307            WriteOp::BatchParams {
308                query,
309                params_list,
310                isolate_rows,
311            } => {
312                if params_list.is_empty() {
313                    return Ok(QueryResult::None);
314                }
315
316                let total = params_list.len();
317                let mut tx = self.pool.begin().await?;
318
319                if !isolate_rows {
320                    // All-or-nothing fast path: the first error aborts the whole
321                    // batch (propagated below). No per-row bookkeeping.
322                    //
323                    // Statement reuse: re-parse NULL-bearing rows so their param
324                    // types don't collide with the cached statement's pinned
325                    // types (NULL binds as text; see `bind_params`).
326                    for params in &params_list {
327                        let has_null = params.iter().any(|v| matches!(v, DbValue::Null));
328                        bind_params(sqlx::query(AssertSqlSafe(query)), params)
329                            .persistent(!has_null)
330                            .execute(&mut *tx)
331                            .await?;
332                    }
333                    tx.commit().await?;
334                    return Ok(QueryResult::None);
335                }
336
337                let mut failed = 0usize;
338                for (idx, params) in params_list.iter().enumerate() {
339                    // Wrap each row in a SAVEPOINT (standard SQL — Postgres,
340                    // MySQL/InnoDB, and SQLite all support it) so a bad row
341                    // rolls back on its own instead of poisoning the batch.
342                    // Postgres in particular marks the whole transaction failed
343                    // on the first error without this: every following row died
344                    // with 25P02 and the final COMMIT silently became ROLLBACK,
345                    // so one bad row used to lose the entire batch (dbkit < 0.5)
346                    // while still returning Ok.
347                    sqlx::query(AssertSqlSafe("SAVEPOINT dbkit_row"))
348                        .execute(&mut *tx)
349                        .await?;
350                    let has_null = params.iter().any(|v| matches!(v, DbValue::Null));
351                    let q = bind_params(sqlx::query(AssertSqlSafe(query)), params)
352                        .persistent(!has_null);
353                    match q.execute(&mut *tx).await {
354                        Ok(_) => {
355                            sqlx::query(AssertSqlSafe("RELEASE SAVEPOINT dbkit_row"))
356                                .execute(&mut *tx)
357                                .await?;
358                        }
359                        Err(e) => {
360                            warn!("BatchParams row {}/{} failed: {:?}", idx + 1, total, e);
361                            failed += 1;
362                            sqlx::query(AssertSqlSafe("ROLLBACK TO SAVEPOINT dbkit_row"))
363                                .execute(&mut *tx)
364                                .await?;
365                            sqlx::query(AssertSqlSafe("RELEASE SAVEPOINT dbkit_row"))
366                                .execute(&mut *tx)
367                                .await?;
368                        }
369                    }
370                }
371
372                tx.commit().await?;
373
374                if failed > 0 {
375                    warn!(
376                        "BatchParams: {}/{} succeeded, {} failed",
377                        total - failed,
378                        total,
379                        failed
380                    );
381                }
382
383                Ok(QueryResult::None)
384            }
385        }
386    }
387
388    // ==================== UNIFIED READ ====================
389
390    /// Execute an analytical query against the attached read engine, returning
391    /// columnar Arrow [`RecordBatch`]es.
392    ///
393    /// Returns [`DbkitError::NoReadEngine`] if no engine is attached.
394    #[cfg(any(feature = "duckdb", feature = "datafusion"))]
395    pub async fn execute_read(
396        &self,
397        sql: &str,
398        params: &[DbValue],
399    ) -> Result<Vec<RecordBatch>, DbkitError> {
400        self.read_engine
401            .as_ref()
402            .ok_or(DbkitError::NoReadEngine)?
403            .query_arrow(sql, params)
404            .await
405    }
406
407    /// Execute an analytical query and deserialize each row into `T`.
408    ///
409    /// This is the typed-read replacement for the old closure-mapped
410    /// `ReadOp::Standard`: instead of a `|row| …` closure, derive
411    /// `serde::Deserialize` on your row struct. Works for any read engine,
412    /// since it deserializes from the Arrow batches via `serde_arrow`.
413    ///
414    /// ```ignore
415    /// #[derive(serde::Deserialize)]
416    /// struct Item { name: String, qty: i64 }
417    /// let items: Vec<Item> = handler.execute_read_as("SELECT name, qty FROM items", &[]).await?;
418    /// ```
419    #[cfg(any(feature = "duckdb", feature = "datafusion"))]
420    pub async fn execute_read_as<T>(
421        &self,
422        sql: &str,
423        params: &[DbValue],
424    ) -> Result<Vec<T>, DbkitError>
425    where
426        T: serde::de::DeserializeOwned,
427    {
428        let batches = self.execute_read(sql, params).await?;
429        crate::analytical::deserialize_batches(&batches)
430    }
431
432    // ==================== SYNC (transactional -> analytical) ====================
433
434    /// Run a query against the transactional pool and load its result into the
435    /// analytical engine as a named in-memory table.
436    ///
437    /// This is the engine-agnostic replacement for the old DuckDB `ATTACH`
438    /// sync: rows are fetched over sqlx, converted to Arrow, and handed to the
439    /// active read engine. Works for any backend × engine combination.
440    ///
441    /// An **empty result drops the analytical table** (the schema can't be
442    /// inferred from zero rows): reads of a table synced empty error with
443    /// "table not found" rather than silently serving rows from a previous
444    /// sync.
445    #[cfg(any(feature = "duckdb", feature = "datafusion"))]
446    pub async fn sync_query(
447        &self,
448        name: &str,
449        query: &str,
450        params: &[DbValue],
451    ) -> Result<(), DbkitError> {
452        let engine = self.read_engine.as_ref().ok_or(DbkitError::NoReadEngine)?;
453
454        let q = bind_params(sqlx::query(AssertSqlSafe(query.to_string())), params);
455        let rows = q.fetch_all(&self.pool).await?;
456
457        match crate::read::rows_to_record_batch(&rows)? {
458            Some(batch) => engine.load_table(name, vec![batch]).await?,
459            None => engine.drop_table(name).await?,
460        }
461        Ok(())
462    }
463
464    /// Copy entire tables from the transactional store into the analytical
465    /// engine, one table per name (`SELECT * FROM {table}`).
466    #[cfg(any(feature = "duckdb", feature = "datafusion"))]
467    pub async fn sync_tables(&self, tables: &[&str]) -> Result<(), DbkitError> {
468        for table in tables {
469            self.sync_query(table, &format!("SELECT * FROM {table}"), &[])
470                .await?;
471        }
472        Ok(())
473    }
474}