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