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