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