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(¶ms)?;
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, ¶m_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, ¶m_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(¶ms)?;
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, ¶m_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, ¶m_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(¶ms)?;
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, ¶m_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, ¶m_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(¶ms)?;
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, ¶m_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, ¶m_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(¶ms)?;
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, ¶m_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, ¶m_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(¶ms)?;
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, ¶m_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, ¶m_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(¶ms)?;
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, ¶m_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, ¶m_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(¶ms)?;
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, ¶m_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, ¶m_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(¶ms)?;
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, ¶m_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, ¶m_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}