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. [`PgHandler`](crate::PgHandler) wraps each row in a
41        ///   `SAVEPOINT`; the multi-backend [`BaseHandler`] warns and continues
42        ///   (note: without savepoints a failed row aborts the Postgres
43        ///   transaction, so following rows fail too — real per-row isolation
44        ///   lives in `PgHandler`).
45        /// - `false` — **all-or-nothing**: no per-row savepoints, so the first
46        ///   error rolls back the whole batch. ~2× faster than the isolated
47        ///   path. Use for trusted bulk inserts where partial success isn't
48        ///   needed. For the fastest plain bulk load, prefer
49        ///   [`PgHandler::copy_in`](crate::PgHandler::copy_in).
50        isolate_rows: bool,
51    },
52}
53
54// ---------------------------------------------------------------------------
55// Query result types
56// ---------------------------------------------------------------------------
57
58/// How many rows to expect from a query.
59#[derive(Debug, Clone, Copy)]
60pub enum FetchMode {
61    None,
62    One,
63    Optional,
64    All,
65}
66
67/// Result wrapper for write queries.
68pub enum QueryResult<T> {
69    None,
70    One(T),
71    Optional(Option<T>),
72    All(Vec<T>),
73}
74
75impl<T> QueryResult<T> {
76    pub fn one(self) -> Result<T, DbkitError> {
77        match self {
78            Self::One(v) => Ok(v),
79            _ => Err(DbkitError::RowCount {
80                expected: "One".into(),
81                actual: 0,
82            }),
83        }
84    }
85
86    pub fn optional(self) -> Result<Option<T>, DbkitError> {
87        match self {
88            Self::Optional(v) => Ok(v),
89            Self::One(v) => Ok(Some(v)),
90            Self::None => Ok(None),
91            _ => Err(DbkitError::RowCount {
92                expected: "Optional".into(),
93                actual: 0,
94            }),
95        }
96    }
97
98    pub fn all(self) -> Result<Vec<T>, DbkitError> {
99        match self {
100            Self::All(v) => Ok(v),
101            _ => Err(DbkitError::RowCount {
102                expected: "All".into(),
103                actual: 0,
104            }),
105        }
106    }
107}
108
109// ---------------------------------------------------------------------------
110// Parameter binding
111// ---------------------------------------------------------------------------
112
113/// Bind a slice of [`DbValue`]s onto a sqlx query, in order.
114///
115/// Values are bound by owned copy, so the returned query does not borrow
116/// `params`.
117fn bind_params<'q>(
118    mut q: Query<'q, Any, AnyArguments>,
119    params: &[DbValue],
120) -> Query<'q, Any, AnyArguments> {
121    for p in params {
122        q = match p {
123            DbValue::Null => q.bind(Option::<i64>::None),
124            DbValue::Bool(b) => q.bind(*b),
125            DbValue::Int(i) => q.bind(*i),
126            DbValue::Float(f) => q.bind(*f),
127            DbValue::Text(s) => q.bind(s.clone()),
128            DbValue::Bytes(b) => q.bind(b.clone()),
129            // The Any driver can't carry native temporal/json/uuid types, so
130            // bind a text rendering — Postgres assignment casts handle the rest.
131            // For native rich-typed binds use `PgHandler` instead.
132            #[cfg(feature = "postgres-native")]
133            DbValue::Date(d) => q.bind(d.to_string()),
134            #[cfg(feature = "postgres-native")]
135            DbValue::DateTime(dt) => q.bind(dt.to_string()),
136            #[cfg(feature = "postgres-native")]
137            DbValue::TimestampTz(dt) => q.bind(dt.to_rfc3339()),
138            #[cfg(feature = "postgres-native")]
139            DbValue::Json(j) => q.bind(j.to_string()),
140            #[cfg(feature = "postgres-native")]
141            DbValue::Uuid(u) => q.bind(u.to_string()),
142            #[cfg(feature = "postgres-native")]
143            DbValue::Time(t) => q.bind(t.to_string()),
144            #[cfg(feature = "postgres-native")]
145            DbValue::TextArray(v) => q.bind(crate::value::pg_text_array_literal(v)),
146            #[cfg(feature = "postgres-native")]
147            DbValue::FloatArray(v) => {
148                q.bind(crate::value::pg_float_array_literal(v.iter().map(|x| Some(*x))))
149            }
150            #[cfg(feature = "postgres-native")]
151            DbValue::OptFloatArray(v) => {
152                q.bind(crate::value::pg_float_array_literal(v.iter().copied()))
153            }
154        };
155    }
156    q
157}
158
159// ---------------------------------------------------------------------------
160// BaseHandler
161// ---------------------------------------------------------------------------
162
163/// Core query executor: transactional writes via sqlx, and optionally
164/// analytical reads via a pluggable [`ReadEngine`] (DuckDB or DataFusion).
165pub struct BaseHandler {
166    pool: AnyPool,
167    #[cfg(any(feature = "duckdb", feature = "datafusion"))]
168    read_engine: Option<Box<dyn ReadEngine>>,
169}
170
171impl BaseHandler {
172    /// Create a handler for writes against the given sqlx pool.
173    pub fn new(pool: AnyPool) -> Self {
174        Self {
175            pool,
176            #[cfg(any(feature = "duckdb", feature = "datafusion"))]
177            read_engine: None,
178        }
179    }
180
181    /// Create a handler with an in-memory DuckDB analytical read engine.
182    #[cfg(feature = "duckdb")]
183    pub fn with_duckdb(pool: AnyPool) -> Result<Self, DbkitError> {
184        let engine = crate::read::duckdb::DuckEngine::new_in_memory()?;
185        Ok(Self {
186            pool,
187            read_engine: Some(Box::new(engine)),
188        })
189    }
190
191    /// Create a handler with DuckDB and a live Postgres attachment.
192    ///
193    /// DuckDB queries the Postgres tables directly via the `pg` catalog
194    /// (`SELECT … FROM pg.<schema>.<table>`) without an explicit sync — the
195    /// pre-rewrite zero-copy `ATTACH` pipeline. You can still also `sync_*`
196    /// tables into local memory for faster repeated analytics.
197    #[cfg(feature = "duckdb")]
198    pub fn with_duckdb_attached_postgres(
199        pool: AnyPool,
200        pg_connection_string: &str,
201    ) -> Result<Self, DbkitError> {
202        let engine = crate::read::duckdb::DuckEngine::new_in_memory()?;
203        engine.attach_postgres(pg_connection_string)?;
204        Ok(Self {
205            pool,
206            read_engine: Some(Box::new(engine)),
207        })
208    }
209
210    /// Create a handler with a DataFusion analytical read engine.
211    #[cfg(feature = "datafusion")]
212    pub fn with_datafusion(pool: AnyPool) -> Result<Self, DbkitError> {
213        let engine = crate::read::datafusion::DataFusionEngine::new();
214        Ok(Self {
215            pool,
216            read_engine: Some(Box::new(engine)),
217        })
218    }
219
220    /// Whether an analytical read engine is attached.
221    pub fn has_read_engine(&self) -> bool {
222        #[cfg(any(feature = "duckdb", feature = "datafusion"))]
223        {
224            self.read_engine.is_some()
225        }
226        #[cfg(not(any(feature = "duckdb", feature = "datafusion")))]
227        {
228            false
229        }
230    }
231
232    /// Get a reference to the write pool.
233    pub fn pool(&self) -> &AnyPool {
234        &self.pool
235    }
236
237    /// Unicode NFD normalization — decomposes characters then lowercases.
238    /// Useful for matching names with different Unicode representations.
239    pub fn normalize_name(name: &str) -> String {
240        name.nfd().collect::<String>().to_lowercase()
241    }
242
243    // ==================== UNIFIED WRITE ====================
244
245    /// Execute a write operation against the transactional pool.
246    ///
247    /// Placeholders are backend-native: `$1, $2, …` for Postgres, `?` for
248    /// MySQL/SQLite. sqlx's `Any` driver does not rewrite them, so write the
249    /// SQL for the backend you connected to.
250    pub async fn execute_write(
251        &self,
252        op: WriteOp<'_>,
253    ) -> Result<QueryResult<AnyRow>, DbkitError> {
254        match op {
255            WriteOp::Single {
256                query,
257                params,
258                mode,
259            } => {
260                let q = bind_params(sqlx::query(AssertSqlSafe(query)), &params);
261                match mode {
262                    FetchMode::None => {
263                        q.execute(&self.pool).await?;
264                        Ok(QueryResult::None)
265                    }
266                    FetchMode::One => {
267                        let row = q.fetch_one(&self.pool).await?;
268                        Ok(QueryResult::One(row))
269                    }
270                    FetchMode::Optional => {
271                        let row = q.fetch_optional(&self.pool).await?;
272                        Ok(QueryResult::Optional(row))
273                    }
274                    FetchMode::All => {
275                        let rows = q.fetch_all(&self.pool).await?;
276                        Ok(QueryResult::All(rows))
277                    }
278                }
279            }
280
281            WriteOp::BatchDDL { queries } => {
282                let mut tx = self.pool.begin().await?;
283                for query in queries {
284                    sqlx::query(AssertSqlSafe(*query)).execute(&mut *tx).await?;
285                }
286                tx.commit().await?;
287                Ok(QueryResult::None)
288            }
289
290            WriteOp::BatchParams {
291                query,
292                params_list,
293                isolate_rows,
294            } => {
295                if params_list.is_empty() {
296                    return Ok(QueryResult::None);
297                }
298
299                let total = params_list.len();
300                let mut tx = self.pool.begin().await?;
301
302                if !isolate_rows {
303                    // All-or-nothing fast path: the first error aborts the whole
304                    // batch (propagated below). No per-row bookkeeping.
305                    for params in &params_list {
306                        bind_params(sqlx::query(AssertSqlSafe(query)), params)
307                            .execute(&mut *tx)
308                            .await?;
309                    }
310                    tx.commit().await?;
311                    return Ok(QueryResult::None);
312                }
313
314                let mut failed = 0usize;
315                for (idx, params) in params_list.iter().enumerate() {
316                    // sqlx caches the prepared statement per query string on the
317                    // connection, so re-issuing the same query reuses it.
318                    let q = bind_params(sqlx::query(AssertSqlSafe(query)), params);
319                    if let Err(e) = q.execute(&mut *tx).await {
320                        warn!("BatchParams row {}/{} failed: {:?}", idx + 1, total, e);
321                        failed += 1;
322                    }
323                }
324
325                tx.commit().await?;
326
327                if failed > 0 {
328                    warn!(
329                        "BatchParams: {}/{} succeeded, {} failed",
330                        total - failed,
331                        total,
332                        failed
333                    );
334                }
335
336                Ok(QueryResult::None)
337            }
338        }
339    }
340
341    // ==================== UNIFIED READ ====================
342
343    /// Execute an analytical query against the attached read engine, returning
344    /// columnar Arrow [`RecordBatch`]es.
345    ///
346    /// Returns [`DbkitError::NoReadEngine`] if no engine is attached.
347    #[cfg(any(feature = "duckdb", feature = "datafusion"))]
348    pub async fn execute_read(
349        &self,
350        sql: &str,
351        params: &[DbValue],
352    ) -> Result<Vec<RecordBatch>, DbkitError> {
353        self.read_engine
354            .as_ref()
355            .ok_or(DbkitError::NoReadEngine)?
356            .query_arrow(sql, params)
357            .await
358    }
359
360    /// Execute an analytical query and deserialize each row into `T`.
361    ///
362    /// This is the typed-read replacement for the old closure-mapped
363    /// `ReadOp::Standard`: instead of a `|row| …` closure, derive
364    /// `serde::Deserialize` on your row struct. Works for any read engine,
365    /// since it deserializes from the Arrow batches via `serde_arrow`.
366    ///
367    /// ```ignore
368    /// #[derive(serde::Deserialize)]
369    /// struct Item { name: String, qty: i64 }
370    /// let items: Vec<Item> = handler.execute_read_as("SELECT name, qty FROM items", &[]).await?;
371    /// ```
372    #[cfg(any(feature = "duckdb", feature = "datafusion"))]
373    pub async fn execute_read_as<T>(
374        &self,
375        sql: &str,
376        params: &[DbValue],
377    ) -> Result<Vec<T>, DbkitError>
378    where
379        T: serde::de::DeserializeOwned,
380    {
381        let batches = self.execute_read(sql, params).await?;
382        crate::analytical::deserialize_batches(&batches)
383    }
384
385    // ==================== SYNC (transactional -> analytical) ====================
386
387    /// Run a query against the transactional pool and load its result into the
388    /// analytical engine as a named in-memory table.
389    ///
390    /// This is the engine-agnostic replacement for the old DuckDB `ATTACH`
391    /// sync: rows are fetched over sqlx, converted to Arrow, and handed to the
392    /// active read engine. Works for any backend × engine combination.
393    #[cfg(any(feature = "duckdb", feature = "datafusion"))]
394    pub async fn sync_query(
395        &self,
396        name: &str,
397        query: &str,
398        params: &[DbValue],
399    ) -> Result<(), DbkitError> {
400        let engine = self.read_engine.as_ref().ok_or(DbkitError::NoReadEngine)?;
401
402        let q = bind_params(sqlx::query(AssertSqlSafe(query.to_string())), params);
403        let rows = q.fetch_all(&self.pool).await?;
404
405        if let Some(batch) = crate::read::rows_to_record_batch(&rows)? {
406            engine.load_table(name, vec![batch]).await?;
407        }
408        Ok(())
409    }
410
411    /// Copy entire tables from the transactional store into the analytical
412    /// engine, one table per name (`SELECT * FROM {table}`).
413    #[cfg(any(feature = "duckdb", feature = "datafusion"))]
414    pub async fn sync_tables(&self, tables: &[&str]) -> Result<(), DbkitError> {
415        for table in tables {
416            self.sync_query(table, &format!("SELECT * FROM {table}"), &[])
417                .await?;
418        }
419        Ok(())
420    }
421}