Skip to main content

prax_postgres/
engine.rs

1//! PostgreSQL query engine implementation.
2
3use std::marker::PhantomData;
4use std::sync::Arc;
5
6use prax_query::QueryResult;
7use prax_query::filter::FilterValue;
8use prax_query::traits::{BoxFuture, Model, QueryEngine};
9use tracing::{error, trace};
10
11use crate::pool::PgPool;
12use crate::types::filter_value_to_sql;
13
14/// PostgreSQL query engine that implements the Prax `QueryEngine`
15/// trait.
16///
17/// Two modes, controlled by the `tx_conn` field:
18///
19/// - **Pool mode** (`tx_conn == None`, the default): each query
20///   acquires a fresh connection from [`PgPool`] and drops it after
21///   the call.
22/// - **Transaction mode** (`tx_conn == Some(conn)`): each query routes
23///   through the single pinned [`deadpool_postgres::Object`]. The
24///   tx-bound engine is built by [`PgEngine::transaction`], which
25///   issues a raw `BEGIN`; the outer future then runs `COMMIT` or
26///   `ROLLBACK` on the same connection based on the closure's
27///   `Ok` / `Err` result.
28///
29/// We lean on raw `BEGIN` / `COMMIT` / `ROLLBACK` strings instead of
30/// `tokio_postgres::Transaction<'_>` because `Transaction<'_>` borrows
31/// from its owning `Client`, and bundling both into a heap cell
32/// requires `mem::transmute` gymnastics to launder the lifetime to
33/// `'static`. Since `Object` implements `Deref<Target = Client>` and
34/// `Client::query` / `execute` take `&self`, an `Arc<Object>` is all
35/// we need — every engine clone can share it freely, and the last
36/// clone drops the `Arc`, which drops the `Object` back to the pool.
37/// This path is explicitly sanctioned by the task plan's "fall back"
38/// guardrail.
39#[derive(Clone)]
40pub struct PgEngine {
41    pool: PgPool,
42    /// Present when this engine is bound to an in-flight transaction.
43    /// `None` in the normal pool-backed case.
44    tx_conn: Option<Arc<deadpool_postgres::Object>>,
45}
46
47impl PgEngine {
48    /// Create a new PostgreSQL engine with the given connection pool.
49    pub fn new(pool: PgPool) -> Self {
50        Self {
51            pool,
52            tx_conn: None,
53        }
54    }
55
56    /// Get a reference to the connection pool.
57    pub fn pool(&self) -> &PgPool {
58        &self.pool
59    }
60
61    /// Convert filter values to PostgreSQL parameters.
62    #[allow(clippy::result_large_err)]
63    fn to_params(
64        values: &[FilterValue],
65    ) -> Result<Vec<Box<dyn tokio_postgres::types::ToSql + Sync + Send>>, prax_query::QueryError>
66    {
67        values
68            .iter()
69            .map(|v| {
70                filter_value_to_sql(v).map_err(|e| {
71                    let msg = e.to_string();
72                    prax_query::QueryError::database(msg).with_source(e)
73                })
74            })
75            .collect()
76    }
77}
78
79/// Map a `tokio_postgres` driver error onto the [`prax_query::QueryError`]
80/// taxonomy, categorizing by SQLSTATE when the server supplied one.
81///
82/// Route a driver-layer error through the SQLSTATE categorization in
83/// [`crate::error`] (`From<PgError> for QueryError`): unique (23505) and
84/// foreign-key (23503) violations become `constraint_violation`, not-null
85/// violations (23502) become `invalid_input`, and anything else stays a
86/// generic database error. The driver error is preserved as the source.
87/// Accepts both `PgError` (pool-mode path) and `tokio_postgres::Error`
88/// (tx-mode path, converted via the `#[from]` variant).
89fn map_driver_err<E: Into<crate::error::PgError>>(e: E) -> prax_query::QueryError {
90    prax_query::QueryError::from(e.into())
91}
92
93/// Panic/cancellation guard for the transaction path.
94///
95/// Armed right after `BEGIN` and disarmed once the closure's future has
96/// resolved and the explicit `COMMIT`/`ROLLBACK` step takes over. If the
97/// guard is dropped while still armed — the closure panicked and the
98/// transaction future is unwinding, or the outer task was cancelled —
99/// the pinned connection would otherwise return to the pool with the
100/// transaction still open: dropping the last `Arc` merely recycles the
101/// `Object`, and the pool's `RecyclingMethod::Fast` only discards
102/// *closed* connections, so the next checkout would silently inherit the
103/// live transaction. The armed guard therefore retires the connection
104/// instead: when it holds the last `Arc` handle it takes the `Object`
105/// out of the pool so the session closes and the server aborts the
106/// transaction; when the caller stashed a tx-engine clone the handle
107/// cannot be reclaimed and it can only log.
108struct TxPanicGuard {
109    tx_conn: Option<Arc<deadpool_postgres::Object>>,
110}
111
112impl TxPanicGuard {
113    fn new(tx_conn: Arc<deadpool_postgres::Object>) -> Self {
114        Self {
115            tx_conn: Some(tx_conn),
116        }
117    }
118
119    /// Disarm the guard and hand the connection back for explicit
120    /// COMMIT/ROLLBACK handling. Call only after the closure's future
121    /// has resolved normally.
122    fn disarm(mut self) -> Arc<deadpool_postgres::Object> {
123        self.tx_conn
124            .take()
125            .expect("TxPanicGuard always holds a connection until disarmed")
126    }
127}
128
129impl Drop for TxPanicGuard {
130    fn drop(&mut self) {
131        let Some(tx_conn) = self.tx_conn.take() else {
132            // Disarmed: the transaction was settled explicitly.
133            return;
134        };
135        match Arc::try_unwrap(tx_conn) {
136            Ok(conn) => {
137                // Last handle: dropping the bare `Client` closes the
138                // session (the server then aborts the open tx) and
139                // shrinks the pool by one instead of recycling a
140                // connection with a live transaction.
141                error!(
142                    "transaction closure panicked (or was cancelled) with BEGIN still \
143                     open; retiring the pinned connection from the pool so the open \
144                     transaction aborts with the session"
145                );
146                let _client = deadpool_postgres::Object::take(conn);
147            }
148            Err(_) => {
149                error!(
150                    "transaction closure panicked (or was cancelled) with BEGIN still \
151                     open, and a cloned tx engine may still reference the pinned \
152                     connection; it cannot be retired, so the open transaction may \
153                     leak back into the pool"
154                );
155            }
156        }
157    }
158}
159
160impl QueryEngine for PgEngine {
161    fn dialect(&self) -> &dyn prax_query::dialect::SqlDialect {
162        &prax_query::dialect::Postgres
163    }
164
165    fn query_many<T: Model + prax_query::row::FromRow + Send + 'static>(
166        &self,
167        sql: &str,
168        params: Vec<FilterValue>,
169    ) -> BoxFuture<'_, QueryResult<Vec<T>>> {
170        let sql = sql.to_string();
171        Box::pin(async move {
172            trace!(sql = %sql, "Executing query_many");
173
174            let pg_params = Self::to_params(&params)?;
175            let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> =
176                pg_params.iter().map(|p| p.as_ref() as _).collect();
177
178            let rows = if let Some(tx) = &self.tx_conn {
179                // Tx mode: drive the pinned connection directly so the
180                // query lands inside the same BEGIN…COMMIT block as
181                // every sibling call.
182                tx.query(&sql, &param_refs).await.map_err(map_driver_err)?
183            } else {
184                let conn = self.pool.get().await.map_err(|e| {
185                    prax_query::QueryError::connection(e.to_string()).with_source(e)
186                })?;
187                conn.query(&sql, &param_refs)
188                    .await
189                    .map_err(map_driver_err)?
190            };
191
192            crate::deserialize::rows_into::<T>(rows)
193        })
194    }
195
196    fn query_one<T: Model + prax_query::row::FromRow + Send + 'static>(
197        &self,
198        sql: &str,
199        params: Vec<FilterValue>,
200    ) -> BoxFuture<'_, QueryResult<T>> {
201        let sql = sql.to_string();
202        Box::pin(async move {
203            trace!(sql = %sql, "Executing query_one");
204
205            let pg_params = Self::to_params(&params)?;
206            let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> =
207                pg_params.iter().map(|p| p.as_ref() as _).collect();
208
209            // Use `query_opt` and detect the zero-row case structurally:
210            // tokio-postgres reports `query_one`'s zero-row outcome as a
211            // `Kind::RowCount` error ("query returned an unexpected number
212            // of rows"), which error-text matching cannot reliably
213            // distinguish from a genuine driver failure.
214            let row = if let Some(tx) = &self.tx_conn {
215                tx.query_opt(&sql, &param_refs)
216                    .await
217                    .map_err(map_driver_err)?
218            } else {
219                let conn = self.pool.get().await.map_err(|e| {
220                    prax_query::QueryError::connection(e.to_string()).with_source(e)
221                })?;
222                conn.query_opt(&sql, &param_refs)
223                    .await
224                    .map_err(map_driver_err)?
225            };
226
227            let row = row.ok_or_else(|| prax_query::QueryError::not_found(T::MODEL_NAME))?;
228            crate::deserialize::row_into::<T>(row)
229        })
230    }
231
232    fn query_optional<T: Model + prax_query::row::FromRow + Send + 'static>(
233        &self,
234        sql: &str,
235        params: Vec<FilterValue>,
236    ) -> BoxFuture<'_, QueryResult<Option<T>>> {
237        let sql = sql.to_string();
238        Box::pin(async move {
239            trace!(sql = %sql, "Executing query_optional");
240
241            let pg_params = Self::to_params(&params)?;
242            let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> =
243                pg_params.iter().map(|p| p.as_ref() as _).collect();
244
245            let row = if let Some(tx) = &self.tx_conn {
246                tx.query_opt(&sql, &param_refs)
247                    .await
248                    .map_err(map_driver_err)?
249            } else {
250                let conn = self.pool.get().await.map_err(|e| {
251                    prax_query::QueryError::connection(e.to_string()).with_source(e)
252                })?;
253                conn.query_opt(&sql, &param_refs)
254                    .await
255                    .map_err(map_driver_err)?
256            };
257
258            row.map(crate::deserialize::row_into::<T>).transpose()
259        })
260    }
261
262    fn execute_insert<T: Model + prax_query::row::FromRow + Send + 'static>(
263        &self,
264        sql: &str,
265        params: Vec<FilterValue>,
266    ) -> BoxFuture<'_, QueryResult<T>> {
267        let sql = sql.to_string();
268        Box::pin(async move {
269            trace!(sql = %sql, "Executing insert");
270
271            let pg_params = Self::to_params(&params)?;
272            let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> =
273                pg_params.iter().map(|p| p.as_ref() as _).collect();
274
275            let row = if let Some(tx) = &self.tx_conn {
276                tx.query_one(&sql, &param_refs)
277                    .await
278                    .map_err(map_driver_err)?
279            } else {
280                let conn = self.pool.get().await.map_err(|e| {
281                    prax_query::QueryError::connection(e.to_string()).with_source(e)
282                })?;
283                conn.query_one(&sql, &param_refs)
284                    .await
285                    .map_err(map_driver_err)?
286            };
287
288            crate::deserialize::row_into::<T>(row)
289        })
290    }
291
292    fn execute_update<T: Model + prax_query::row::FromRow + Send + 'static>(
293        &self,
294        sql: &str,
295        params: Vec<FilterValue>,
296    ) -> BoxFuture<'_, QueryResult<Vec<T>>> {
297        let sql = sql.to_string();
298        Box::pin(async move {
299            trace!(sql = %sql, "Executing update");
300
301            let pg_params = Self::to_params(&params)?;
302            let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> =
303                pg_params.iter().map(|p| p.as_ref() as _).collect();
304
305            let rows = if let Some(tx) = &self.tx_conn {
306                tx.query(&sql, &param_refs).await.map_err(map_driver_err)?
307            } else {
308                let conn = self.pool.get().await.map_err(|e| {
309                    prax_query::QueryError::connection(e.to_string()).with_source(e)
310                })?;
311                conn.query(&sql, &param_refs)
312                    .await
313                    .map_err(map_driver_err)?
314            };
315
316            crate::deserialize::rows_into::<T>(rows)
317        })
318    }
319
320    fn execute_delete(
321        &self,
322        sql: &str,
323        params: Vec<FilterValue>,
324    ) -> BoxFuture<'_, QueryResult<u64>> {
325        let sql = sql.to_string();
326        Box::pin(async move {
327            trace!(sql = %sql, "Executing delete");
328
329            let pg_params = Self::to_params(&params)?;
330            let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> =
331                pg_params.iter().map(|p| p.as_ref() as _).collect();
332
333            if let Some(tx) = &self.tx_conn {
334                tx.execute(&sql, &param_refs).await.map_err(map_driver_err)
335            } else {
336                let conn = self.pool.get().await.map_err(|e| {
337                    prax_query::QueryError::connection(e.to_string()).with_source(e)
338                })?;
339                conn.execute(&sql, &param_refs)
340                    .await
341                    .map_err(map_driver_err)
342            }
343        })
344    }
345
346    fn execute_raw(&self, sql: &str, params: Vec<FilterValue>) -> BoxFuture<'_, QueryResult<u64>> {
347        let sql = sql.to_string();
348        Box::pin(async move {
349            trace!(sql = %sql, "Executing raw SQL");
350
351            let pg_params = Self::to_params(&params)?;
352            let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> =
353                pg_params.iter().map(|p| p.as_ref() as _).collect();
354
355            if let Some(tx) = &self.tx_conn {
356                tx.execute(&sql, &param_refs).await.map_err(map_driver_err)
357            } else {
358                let conn = self.pool.get().await.map_err(|e| {
359                    prax_query::QueryError::connection(e.to_string()).with_source(e)
360                })?;
361                conn.execute(&sql, &param_refs)
362                    .await
363                    .map_err(map_driver_err)
364            }
365        })
366    }
367
368    fn count(&self, sql: &str, params: Vec<FilterValue>) -> BoxFuture<'_, QueryResult<u64>> {
369        let sql = sql.to_string();
370        Box::pin(async move {
371            trace!(sql = %sql, "Executing count");
372
373            let pg_params = Self::to_params(&params)?;
374            let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> =
375                pg_params.iter().map(|p| p.as_ref() as _).collect();
376
377            let row = if let Some(tx) = &self.tx_conn {
378                tx.query_one(&sql, &param_refs)
379                    .await
380                    .map_err(map_driver_err)?
381            } else {
382                let conn = self.pool.get().await.map_err(|e| {
383                    prax_query::QueryError::connection(e.to_string()).with_source(e)
384                })?;
385                conn.query_one(&sql, &param_refs)
386                    .await
387                    .map_err(map_driver_err)?
388            };
389
390            let count: i64 = row.try_get::<_, i64>(0).map_err(map_driver_err)?;
391            Ok(count as u64)
392        })
393    }
394
395    fn aggregate_query(
396        &self,
397        sql: &str,
398        params: Vec<FilterValue>,
399    ) -> BoxFuture<'_, QueryResult<Vec<std::collections::HashMap<String, FilterValue>>>> {
400        let sql = sql.to_string();
401        Box::pin(async move {
402            trace!(sql = %sql, "Executing aggregate_query");
403
404            let pg_params = Self::to_params(&params)?;
405            let param_refs: Vec<&(dyn tokio_postgres::types::ToSql + Sync)> =
406                pg_params.iter().map(|p| p.as_ref() as _).collect();
407
408            let rows = if let Some(tx) = &self.tx_conn {
409                tx.query(&sql, &param_refs).await.map_err(map_driver_err)?
410            } else {
411                let conn = self.pool.get().await.map_err(|e| {
412                    prax_query::QueryError::connection(e.to_string()).with_source(e)
413                })?;
414                conn.query(&sql, &param_refs)
415                    .await
416                    .map_err(map_driver_err)?
417            };
418
419            Ok(rows
420                .into_iter()
421                .map(|row| {
422                    let mut map = std::collections::HashMap::new();
423                    for (i, col) in row.columns().iter().enumerate() {
424                        let name = col.name().to_string();
425                        let value = decode_aggregate_cell(&row, i, col.type_());
426                        map.insert(name, value);
427                    }
428                    map
429                })
430                .collect())
431        })
432    }
433
434    fn transaction<'a, R, Fut, F>(&'a self, f: F) -> BoxFuture<'a, QueryResult<R>>
435    where
436        F: FnOnce(Self) -> Fut + Send + 'a,
437        Fut: std::future::Future<Output = QueryResult<R>> + Send + 'a,
438        R: Send + 'a,
439        Self: Clone,
440    {
441        Box::pin(async move {
442            // Refuse nested transactions until dialect-aware SAVEPOINT
443            // support lands. Users can still run SAVEPOINT / RELEASE
444            // manually via `execute_raw` if they need it.
445            if self.tx_conn.is_some() {
446                return Err(prax_query::QueryError::internal(
447                    "nested transactions not yet implemented \
448                     (call .transaction() on the outer engine only, or \
449                     issue SAVEPOINT via execute_raw)",
450                ));
451            }
452
453            // Acquire a dedicated raw `deadpool_postgres::Object`.
454            // Going through `PgPool::inner()` keeps the connection
455            // pinned to this future — every query the closure emits
456            // will run on the same physical connection.
457            let conn =
458                self.pool.inner().get().await.map_err(|e| {
459                    prax_query::QueryError::connection(e.to_string()).with_source(e)
460                })?;
461
462            // Issue `BEGIN` directly as a batch_execute on the raw
463            // connection. Using `tokio_postgres::Transaction<'_>`
464            // would bundle a borrow back into `conn`; instead we rely
465            // on the connection's session state (postgres tracks the
466            // BEGIN/COMMIT/ROLLBACK on the connection itself, so every
467            // subsequent query on the same `Object` sees the same
468            // transaction). This is the approach sanctioned by the
469            // task plan's fallback guardrail.
470            conn.batch_execute("BEGIN").await.map_err(map_driver_err)?;
471
472            let tx_conn = Arc::new(conn);
473            let tx_engine = PgEngine {
474                pool: self.pool.clone(),
475                tx_conn: Some(tx_conn.clone()),
476            };
477
478            // Arm the panic guard while the closure runs: if the
479            // closure's future panics (or this future is cancelled at
480            // the await) the `BEGIN` is still open, and the guard
481            // retires the pinned connection instead of letting it
482            // recycle into the pool with a live transaction.
483            let guard = TxPanicGuard::new(tx_conn);
484
485            // Run the caller's closure on the tx-bound engine clone.
486            // When the future resolves the closure's engine clone has
487            // dropped, so the guard's `Arc` is the only remaining handle
488            // (unless the caller stashed a clone of the tx engine).
489            let result = f(tx_engine).await;
490
491            // The closure returned normally: take the connection back
492            // from the guard and settle the transaction explicitly.
493            let tx_conn = guard.disarm();
494
495            // Finalise: COMMIT on success, best-effort ROLLBACK on
496            // failure. Preserve the caller's error if rollback fails —
497            // but note the pinned `Object` does *not* close on drop:
498            // dropping the last `Arc` merely returns it to the pool, and
499            // the pool's `RecyclingMethod::Fast` only discards *closed*
500            // connections. A failed ROLLBACK on a still-live connection
501            // would therefore leak a still-open transaction back into
502            // the pool, contaminating whatever checks it out next. To
503            // avoid that, take the connection out of the pool
504            // permanently when rollback fails — possible only when this
505            // is the last `Arc` handle (if the caller stashed a clone of
506            // the tx engine somewhere, the connection recycles as
507            // before, which is the best we can do).
508            match result {
509                Ok(v) => {
510                    tx_conn
511                        .batch_execute("COMMIT")
512                        .await
513                        .map_err(map_driver_err)?;
514                    Ok(v)
515                }
516                Err(e) => {
517                    if tx_conn.batch_execute("ROLLBACK").await.is_err()
518                        && let Ok(conn) = Arc::try_unwrap(tx_conn)
519                    {
520                        // Dropping the bare `Client` closes the session
521                        // (server then aborts the tx) and shrinks the
522                        // pool by one instead of recycling a dirty conn.
523                        let _client = deadpool_postgres::Object::take(conn);
524                    }
525                    Err(e)
526                }
527            }
528        })
529    }
530}
531
532/// A typed query builder that uses the PostgreSQL engine.
533pub struct PgQueryBuilder<T: Model> {
534    engine: PgEngine,
535    _marker: PhantomData<T>,
536}
537
538impl<T: Model> PgQueryBuilder<T> {
539    /// Create a new query builder.
540    pub fn new(engine: PgEngine) -> Self {
541        Self {
542            engine,
543            _marker: PhantomData,
544        }
545    }
546
547    /// Get the underlying engine.
548    pub fn engine(&self) -> &PgEngine {
549        &self.engine
550    }
551}
552
553/// Decode a single aggregate result cell by its Postgres column type.
554///
555/// Aggregate result sets don't have a fixed schema — SUM over an
556/// INT4 column comes back as BIGINT, AVG returns NUMERIC, MIN/MAX
557/// preserves the source column's type, and COUNT is always BIGINT.
558/// Rather than route these through the `FromRow` machinery (which
559/// needs a model whose columns are known at compile time), we
560/// type-dispatch at runtime on `Column::type_()` and project into a
561/// [`FilterValue`].
562///
563/// NULL maps to `FilterValue::Null`. NUMERIC (what AVG returns) is
564/// decoded through `rust_decimal::Decimal` — tokio-postgres has no
565/// `FromSql for String` impl for NUMERIC, so the `db-tokio-postgres`
566/// feature of `rust_decimal` supplies the decoder — and then rendered
567/// to its text form as `FilterValue::String`; the aggregate result
568/// folder's numeric parser reads that text back into a float for the
569/// sum/avg accessors.
570///
571/// Unknown types fall through to `try_get::<String>` so a novel
572/// column type doesn't silently drop. Decoding failures record
573/// `FilterValue::Null` rather than aborting the whole query.
574fn decode_aggregate_cell(
575    row: &tokio_postgres::Row,
576    idx: usize,
577    ty: &tokio_postgres::types::Type,
578) -> FilterValue {
579    use tokio_postgres::types::Type;
580    match *ty {
581        Type::BOOL => row
582            .try_get::<_, Option<bool>>(idx)
583            .ok()
584            .flatten()
585            .map(FilterValue::Bool)
586            .unwrap_or(FilterValue::Null),
587        Type::INT2 => row
588            .try_get::<_, Option<i16>>(idx)
589            .ok()
590            .flatten()
591            .map(|n| FilterValue::Int(n as i64))
592            .unwrap_or(FilterValue::Null),
593        Type::INT4 => row
594            .try_get::<_, Option<i32>>(idx)
595            .ok()
596            .flatten()
597            .map(|n| FilterValue::Int(n as i64))
598            .unwrap_or(FilterValue::Null),
599        Type::INT8 => row
600            .try_get::<_, Option<i64>>(idx)
601            .ok()
602            .flatten()
603            .map(FilterValue::Int)
604            .unwrap_or(FilterValue::Null),
605        Type::FLOAT4 => row
606            .try_get::<_, Option<f32>>(idx)
607            .ok()
608            .flatten()
609            .map(|f| FilterValue::Float(f as f64))
610            .unwrap_or(FilterValue::Null),
611        Type::FLOAT8 => row
612            .try_get::<_, Option<f64>>(idx)
613            .ok()
614            .flatten()
615            .map(FilterValue::Float)
616            .unwrap_or(FilterValue::Null),
617        // NUMERIC has no `FromSql for String` impl in tokio-postgres, so it
618        // must be decoded through `rust_decimal::Decimal` (enabled via the
619        // crate's `db-tokio-postgres` feature) and then rendered to its text
620        // form. The aggregate result folder parses this back into an f64 for
621        // the sum/avg accessors. This is the type AVG() returns, so getting it
622        // wrong silently drops every average.
623        Type::NUMERIC => row
624            .try_get::<_, Option<rust_decimal::Decimal>>(idx)
625            .ok()
626            .flatten()
627            .map(|d| FilterValue::String(d.to_string()))
628            .unwrap_or(FilterValue::Null),
629        Type::TEXT | Type::VARCHAR | Type::CHAR | Type::NAME | Type::BPCHAR => row
630            .try_get::<_, Option<String>>(idx)
631            .ok()
632            .flatten()
633            .map(FilterValue::String)
634            .unwrap_or(FilterValue::Null),
635        Type::JSON | Type::JSONB => row
636            .try_get::<_, Option<serde_json::Value>>(idx)
637            .ok()
638            .flatten()
639            .map(FilterValue::Json)
640            .unwrap_or(FilterValue::Null),
641        _ => row
642            .try_get::<_, Option<String>>(idx)
643            .ok()
644            .flatten()
645            .map(FilterValue::String)
646            .unwrap_or(FilterValue::Null),
647    }
648}
649
650#[cfg(test)]
651mod tests {
652    // Integration tests would require a real PostgreSQL database.
653    // `TxPanicGuard`'s armed-drop path manipulates real pool objects
654    // (`deadpool_postgres::Object` cannot be fabricated without a live
655    // connection), so it is covered only by live integration testing,
656    // not unit tests.
657}