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    /// Accent-insensitive name key: NFD-decompose, DROP combining marks, then
246    /// lowercase — "José Ramírez" and "Jose Ramirez" produce the same key.
247    ///
248    /// NFD alone only equalizes composed vs decomposed representations of the
249    /// SAME accented string; the combining marks survive, so an accented name
250    /// never matched its accent-stripped variant (a common shape across data
251    /// feeds — US sources routinely strip diacritics). Stripping the marks
252    /// after decomposition makes the comparison genuinely accent-insensitive.
253    pub fn normalize_name(name: &str) -> String {
254        use unicode_normalization::char::is_combining_mark;
255        name.nfd()
256            .filter(|c| !is_combining_mark(*c))
257            .collect::<String>()
258            .to_lowercase()
259    }
260
261    // ==================== UNIFIED WRITE ====================
262
263    /// Execute a write operation against the transactional pool.
264    ///
265    /// Placeholders are backend-native: `$1, $2, …` for Postgres, `?` for
266    /// MySQL/SQLite. sqlx's `Any` driver does not rewrite them, so write the
267    /// SQL for the backend you connected to.
268    pub async fn execute_write(
269        &self,
270        op: WriteOp<'_>,
271    ) -> Result<QueryResult<AnyRow>, DbkitError> {
272        match op {
273            WriteOp::Single {
274                query,
275                params,
276                mode,
277            } => {
278                // Statement-reuse guard (same as PgHandler): sqlx caches one
279                // prepared statement per (connection, SQL), pinning parameter
280                // types from the first execution. A NULL here binds as text,
281                // so a call whose NULL/concrete pattern differs from the
282                // cached statement's types would fail (22P03 / type mismatch)
283                // on Postgres. Re-parse NULL-bearing calls; keep caching for
284                // the common no-NULL case.
285                let has_null = params.iter().any(|v| matches!(v, DbValue::Null));
286                let q = bind_params(sqlx::query(AssertSqlSafe(query)), &params)
287                    .persistent(!has_null);
288                match mode {
289                    FetchMode::None => {
290                        q.execute(&self.pool).await?;
291                        Ok(QueryResult::None)
292                    }
293                    FetchMode::One => {
294                        let row = q.fetch_one(&self.pool).await?;
295                        Ok(QueryResult::One(row))
296                    }
297                    FetchMode::Optional => {
298                        let row = q.fetch_optional(&self.pool).await?;
299                        Ok(QueryResult::Optional(row))
300                    }
301                    FetchMode::All => {
302                        let rows = q.fetch_all(&self.pool).await?;
303                        Ok(QueryResult::All(rows))
304                    }
305                }
306            }
307
308            WriteOp::BatchDDL { queries } => {
309                let mut tx = self.pool.begin().await?;
310                for query in queries {
311                    sqlx::query(AssertSqlSafe(*query)).execute(&mut *tx).await?;
312                }
313                tx.commit().await?;
314                Ok(QueryResult::None)
315            }
316
317            WriteOp::BatchParams {
318                query,
319                params_list,
320                isolate_rows,
321            } => {
322                if params_list.is_empty() {
323                    return Ok(QueryResult::None);
324                }
325
326                let total = params_list.len();
327                let mut tx = self.pool.begin().await?;
328
329                if !isolate_rows {
330                    // All-or-nothing fast path: the first error aborts the whole
331                    // batch (propagated below). No per-row bookkeeping.
332                    //
333                    // Statement reuse: re-parse NULL-bearing rows so their param
334                    // types don't collide with the cached statement's pinned
335                    // types (NULL binds as text; see `bind_params`).
336                    for params in &params_list {
337                        let has_null = params.iter().any(|v| matches!(v, DbValue::Null));
338                        bind_params(sqlx::query(AssertSqlSafe(query)), params)
339                            .persistent(!has_null)
340                            .execute(&mut *tx)
341                            .await?;
342                    }
343                    tx.commit().await?;
344                    return Ok(QueryResult::None);
345                }
346
347                let mut failed = 0usize;
348                for (idx, params) in params_list.iter().enumerate() {
349                    // Wrap each row in a SAVEPOINT (standard SQL — Postgres,
350                    // MySQL/InnoDB, and SQLite all support it) so a bad row
351                    // rolls back on its own instead of poisoning the batch.
352                    // Postgres in particular marks the whole transaction failed
353                    // on the first error without this: every following row died
354                    // with 25P02 and the final COMMIT silently became ROLLBACK,
355                    // so one bad row used to lose the entire batch (dbkit < 0.5)
356                    // while still returning Ok.
357                    sqlx::query(AssertSqlSafe("SAVEPOINT dbkit_row"))
358                        .execute(&mut *tx)
359                        .await?;
360                    let has_null = params.iter().any(|v| matches!(v, DbValue::Null));
361                    let q = bind_params(sqlx::query(AssertSqlSafe(query)), params)
362                        .persistent(!has_null);
363                    match q.execute(&mut *tx).await {
364                        Ok(_) => {
365                            sqlx::query(AssertSqlSafe("RELEASE SAVEPOINT dbkit_row"))
366                                .execute(&mut *tx)
367                                .await?;
368                        }
369                        Err(e) => {
370                            warn!("BatchParams row {}/{} failed: {:?}", idx + 1, total, e);
371                            failed += 1;
372                            sqlx::query(AssertSqlSafe("ROLLBACK TO SAVEPOINT dbkit_row"))
373                                .execute(&mut *tx)
374                                .await?;
375                            sqlx::query(AssertSqlSafe("RELEASE SAVEPOINT dbkit_row"))
376                                .execute(&mut *tx)
377                                .await?;
378                        }
379                    }
380                }
381
382                tx.commit().await?;
383
384                if failed > 0 {
385                    warn!(
386                        "BatchParams: {}/{} succeeded, {} failed",
387                        total - failed,
388                        total,
389                        failed
390                    );
391                }
392
393                Ok(QueryResult::None)
394            }
395        }
396    }
397
398    // ==================== UNIFIED READ ====================
399
400    /// Execute an analytical query against the attached read engine, returning
401    /// columnar Arrow [`RecordBatch`]es.
402    ///
403    /// Returns [`DbkitError::NoReadEngine`] if no engine is attached.
404    #[cfg(any(feature = "duckdb", feature = "datafusion"))]
405    pub async fn execute_read(
406        &self,
407        sql: &str,
408        params: &[DbValue],
409    ) -> Result<Vec<RecordBatch>, DbkitError> {
410        self.read_engine
411            .as_ref()
412            .ok_or(DbkitError::NoReadEngine)?
413            .query_arrow(sql, params)
414            .await
415    }
416
417    /// Execute an analytical query and deserialize each row into `T`.
418    ///
419    /// This is the typed-read replacement for the old closure-mapped
420    /// `ReadOp::Standard`: instead of a `|row| …` closure, derive
421    /// `serde::Deserialize` on your row struct. Works for any read engine,
422    /// since it deserializes from the Arrow batches via `serde_arrow`.
423    ///
424    /// ```ignore
425    /// #[derive(serde::Deserialize)]
426    /// struct Item { name: String, qty: i64 }
427    /// let items: Vec<Item> = handler.execute_read_as("SELECT name, qty FROM items", &[]).await?;
428    /// ```
429    #[cfg(any(feature = "duckdb", feature = "datafusion"))]
430    pub async fn execute_read_as<T>(
431        &self,
432        sql: &str,
433        params: &[DbValue],
434    ) -> Result<Vec<T>, DbkitError>
435    where
436        T: serde::de::DeserializeOwned,
437    {
438        let batches = self.execute_read(sql, params).await?;
439        crate::analytical::deserialize_batches(&batches)
440    }
441
442    // ==================== SYNC (transactional -> analytical) ====================
443
444    /// Run a query against the transactional pool and load its result into the
445    /// analytical engine as a named in-memory table.
446    ///
447    /// This is the engine-agnostic replacement for the old DuckDB `ATTACH`
448    /// sync: rows are fetched over sqlx, converted to Arrow, and handed to the
449    /// active read engine. Works for any backend × engine combination.
450    ///
451    /// An **empty result drops the analytical table** (the schema can't be
452    /// inferred from zero rows): reads of a table synced empty error with
453    /// "table not found" rather than silently serving rows from a previous
454    /// sync.
455    #[cfg(any(feature = "duckdb", feature = "datafusion"))]
456    pub async fn sync_query(
457        &self,
458        name: &str,
459        query: &str,
460        params: &[DbValue],
461    ) -> Result<(), DbkitError> {
462        let engine = self.read_engine.as_ref().ok_or(DbkitError::NoReadEngine)?;
463
464        let q = bind_params(sqlx::query(AssertSqlSafe(query.to_string())), params);
465        let rows = q.fetch_all(&self.pool).await?;
466
467        match crate::read::rows_to_record_batch(&rows)? {
468            Some(batch) => engine.load_table(name, vec![batch]).await?,
469            None => engine.drop_table(name).await?,
470        }
471        Ok(())
472    }
473
474    /// Copy entire tables from the transactional store into the analytical
475    /// engine, one table per name (`SELECT * FROM {table}`).
476    #[cfg(any(feature = "duckdb", feature = "datafusion"))]
477    pub async fn sync_tables(&self, tables: &[&str]) -> Result<(), DbkitError> {
478        for table in tables {
479            self.sync_query(table, &format!("SELECT * FROM {table}"), &[])
480                .await?;
481        }
482        Ok(())
483    }
484}