Skip to main content

prax_postgres/
connection.rs

1//! PostgreSQL connection wrapper.
2
3use std::sync::Arc;
4
5use deadpool_postgres::Object;
6use tokio_postgres::Row;
7use tracing::{debug, trace};
8
9use prax_query::sql::is_valid_sql_identifier;
10
11use crate::error::{PgError, PgResult};
12use crate::statement::PreparedStatementCache;
13
14/// A wrapper around a PostgreSQL connection with statement caching.
15pub struct PgConnection {
16    client: Object,
17    statement_cache: Arc<PreparedStatementCache>,
18}
19
20/// Whether a driver error is PostgreSQL's `0A000 "cached plan must not change
21/// result type"`.
22///
23/// This is raised when a server-side prepared statement is executed after DDL
24/// altered the result columns of a table it references (e.g. a pooled
25/// connection that prepared the statement before an `ALTER TABLE … ADD
26/// COLUMN`). It is transient: re-preparing against the current schema resolves
27/// it. `0A000` is the shared `FEATURE_NOT_SUPPORTED` class, so the specific
28/// message is required — other `0A000` conditions (genuinely unsupported
29/// features) are terminal and must not trigger recovery.
30fn is_stale_cached_plan(err: &tokio_postgres::Error) -> bool {
31    // The human-readable message lives in the DbError, not in `Display`, which
32    // renders a DB error as just "db error". Reading `to_string()` here would
33    // never match the cached-plan text.
34    match err.as_db_error() {
35        Some(db) => {
36            db.code() == &tokio_postgres::error::SqlState::FEATURE_NOT_SUPPORTED
37                && is_stale_cached_plan_message(db.message())
38        }
39        None => false,
40    }
41}
42
43/// The message half of [`is_stale_cached_plan`], split out so the gate can be
44/// unit-tested without constructing a `tokio_postgres::Error` (which cannot be
45/// built with a chosen SQLSTATE via the public API).
46fn is_stale_cached_plan_message(msg: &str) -> bool {
47    msg.contains("cached plan must not change result type")
48}
49
50impl PgConnection {
51    /// Create a new connection wrapper.
52    pub(crate) fn new(client: Object, statement_cache: Arc<PreparedStatementCache>) -> Self {
53        Self {
54            client,
55            statement_cache,
56        }
57    }
58
59    /// Execute a query and return all rows.
60    pub async fn query(
61        &self,
62        sql: &str,
63        params: &[&(dyn tokio_postgres::types::ToSql + Sync)],
64    ) -> PgResult<Vec<Row>> {
65        trace!(sql = %sql, "Executing query");
66
67        // Try to get a cached prepared statement
68        let stmt = self
69            .statement_cache
70            .get_or_prepare(&self.client, sql)
71            .await?;
72
73        match self.client.query(&stmt, params).await {
74            Ok(rows) => Ok(rows),
75            Err(e) if is_stale_cached_plan(&e) => {
76                let stmt = self.reprepare_after_stale_plan(sql).await?;
77                let rows = self.client.query(&stmt, params).await?;
78                Ok(rows)
79            }
80            Err(e) => Err(e.into()),
81        }
82    }
83
84    /// Execute a query and return exactly one row.
85    pub async fn query_one(
86        &self,
87        sql: &str,
88        params: &[&(dyn tokio_postgres::types::ToSql + Sync)],
89    ) -> PgResult<Row> {
90        trace!(sql = %sql, "Executing query_one");
91
92        let stmt = self
93            .statement_cache
94            .get_or_prepare(&self.client, sql)
95            .await?;
96
97        match self.client.query_one(&stmt, params).await {
98            Ok(row) => Ok(row),
99            Err(e) if is_stale_cached_plan(&e) => {
100                let stmt = self.reprepare_after_stale_plan(sql).await?;
101                let row = self.client.query_one(&stmt, params).await?;
102                Ok(row)
103            }
104            Err(e) => Err(e.into()),
105        }
106    }
107
108    /// Execute a query and return zero or one row.
109    pub async fn query_opt(
110        &self,
111        sql: &str,
112        params: &[&(dyn tokio_postgres::types::ToSql + Sync)],
113    ) -> PgResult<Option<Row>> {
114        trace!(sql = %sql, "Executing query_opt");
115
116        let stmt = self
117            .statement_cache
118            .get_or_prepare(&self.client, sql)
119            .await?;
120
121        match self.client.query_opt(&stmt, params).await {
122            Ok(row) => Ok(row),
123            Err(e) if is_stale_cached_plan(&e) => {
124                let stmt = self.reprepare_after_stale_plan(sql).await?;
125                let row = self.client.query_opt(&stmt, params).await?;
126                Ok(row)
127            }
128            Err(e) => Err(e.into()),
129        }
130    }
131
132    /// Execute a statement and return the number of affected rows.
133    pub async fn execute(
134        &self,
135        sql: &str,
136        params: &[&(dyn tokio_postgres::types::ToSql + Sync)],
137    ) -> PgResult<u64> {
138        trace!(sql = %sql, "Executing statement");
139
140        let stmt = self
141            .statement_cache
142            .get_or_prepare(&self.client, sql)
143            .await?;
144
145        match self.client.execute(&stmt, params).await {
146            Ok(count) => Ok(count),
147            Err(e) if is_stale_cached_plan(&e) => {
148                let stmt = self.reprepare_after_stale_plan(sql).await?;
149                let count = self.client.execute(&stmt, params).await?;
150                Ok(count)
151            }
152            Err(e) => Err(e.into()),
153        }
154    }
155
156    /// Recover from a stale cached plan (`0A000`): drop the SQL from the
157    /// statement cache and prepare it afresh, bypassing deadpool's per-
158    /// connection cache so the new plan is built against the current schema.
159    ///
160    /// `prepare_cached` would hand back the same invalidated statement, so the
161    /// retry must use the uncached `prepare`. The freshly prepared statement is
162    /// what the caller re-executes; the cache is left empty for this SQL so the
163    /// next ordinary call re-primes it via `get_or_prepare`.
164    async fn reprepare_after_stale_plan(&self, sql: &str) -> PgResult<tokio_postgres::Statement> {
165        debug!(
166            sql = %sql,
167            "Recovering from stale cached plan (0A000): re-preparing statement"
168        );
169        self.statement_cache.evict(sql);
170        let stmt = self.client.prepare(sql).await?;
171        Ok(stmt)
172    }
173
174    /// Execute a batch of statements in a single round-trip.
175    pub async fn batch_execute(&self, sql: &str) -> PgResult<()> {
176        trace!(sql = %sql, "Executing batch");
177        self.client.batch_execute(sql).await?;
178        Ok(())
179    }
180
181    /// Begin a transaction.
182    pub async fn transaction(&mut self) -> PgResult<PgTransaction<'_>> {
183        debug!("Beginning transaction");
184        let txn = self.client.transaction().await?;
185        Ok(PgTransaction {
186            txn,
187            statement_cache: self.statement_cache.clone(),
188        })
189    }
190
191    /// Get the underlying tokio-postgres client.
192    ///
193    /// This is useful for advanced operations not covered by this wrapper.
194    pub fn inner(&self) -> &Object {
195        &self.client
196    }
197
198    /// Execute a query using the prepared statement cache.
199    ///
200    /// This is an alias for `query` that makes it explicit that statement caching
201    /// is being used. All query methods already use prepared statement caching,
202    /// but this method name makes it more explicit for benchmark comparisons.
203    #[inline]
204    pub async fn query_cached(
205        &self,
206        sql: &str,
207        params: &[&(dyn tokio_postgres::types::ToSql + Sync)],
208    ) -> PgResult<Vec<Row>> {
209        self.query(sql, params).await
210    }
211
212    /// Execute a raw query without using the prepared statement cache.
213    ///
214    /// This is useful for one-off queries where the overhead of preparing
215    /// a statement isn't worth it.
216    pub async fn query_raw(
217        &self,
218        sql: &str,
219        params: &[&(dyn tokio_postgres::types::ToSql + Sync)],
220    ) -> PgResult<Vec<Row>> {
221        trace!(sql = %sql, "Executing raw query (no statement cache)");
222        let rows = self.client.query(sql, params).await?;
223        Ok(rows)
224    }
225
226    /// Execute a raw query and return zero or one row without using statement cache.
227    pub async fn query_opt_raw(
228        &self,
229        sql: &str,
230        params: &[&(dyn tokio_postgres::types::ToSql + Sync)],
231    ) -> PgResult<Option<Row>> {
232        trace!(sql = %sql, "Executing raw query_opt (no statement cache)");
233        let row = self.client.query_opt(sql, params).await?;
234        Ok(row)
235    }
236}
237
238/// Maximum allowed savepoint name length (matches PostgreSQL's `NAMEDATALEN - 1`).
239const MAX_SAVEPOINT_NAME_LEN: usize = 63;
240
241/// Validate a savepoint name before it is interpolated into SQL.
242///
243/// Savepoint identifiers cannot be parameterized, so they must match the
244/// whitelist pattern `^[A-Za-z_][A-Za-z0-9_]*$` to prevent SQL injection.
245fn validate_savepoint_name(name: &str) -> PgResult<()> {
246    let valid = name.len() <= MAX_SAVEPOINT_NAME_LEN && is_valid_sql_identifier(name);
247    if !valid {
248        return Err(PgError::query(format!("invalid savepoint name: {name:?}")));
249    }
250    Ok(())
251}
252
253/// A PostgreSQL transaction.
254pub struct PgTransaction<'a> {
255    txn: deadpool_postgres::Transaction<'a>,
256    statement_cache: Arc<PreparedStatementCache>,
257}
258
259impl<'a> PgTransaction<'a> {
260    // A stale cached plan (`0A000`) is NOT transparently retried inside a
261    // transaction: the error aborts the transaction, so any subsequent
262    // statement on it fails with `25P02 in_failed_sql_transaction`. Re-running
263    // the one statement cannot succeed here. The error is still classified
264    // retryable (via `classify_sqlstate`), so a caller that retries the whole
265    // transaction recovers on a fresh statement. Only the non-transactional
266    // `PgConnection` methods above self-heal in place.
267
268    /// Execute a query and return all rows.
269    pub async fn query(
270        &self,
271        sql: &str,
272        params: &[&(dyn tokio_postgres::types::ToSql + Sync)],
273    ) -> PgResult<Vec<Row>> {
274        trace!(sql = %sql, "Executing query in transaction");
275
276        let stmt = self
277            .statement_cache
278            .get_or_prepare_in_txn(&self.txn, sql)
279            .await?;
280
281        let rows = self.txn.query(&stmt, params).await?;
282        Ok(rows)
283    }
284
285    /// Execute a query and return exactly one row.
286    pub async fn query_one(
287        &self,
288        sql: &str,
289        params: &[&(dyn tokio_postgres::types::ToSql + Sync)],
290    ) -> PgResult<Row> {
291        let stmt = self
292            .statement_cache
293            .get_or_prepare_in_txn(&self.txn, sql)
294            .await?;
295
296        let row = self.txn.query_one(&stmt, params).await?;
297        Ok(row)
298    }
299
300    /// Execute a query and return zero or one row.
301    pub async fn query_opt(
302        &self,
303        sql: &str,
304        params: &[&(dyn tokio_postgres::types::ToSql + Sync)],
305    ) -> PgResult<Option<Row>> {
306        let stmt = self
307            .statement_cache
308            .get_or_prepare_in_txn(&self.txn, sql)
309            .await?;
310
311        let row = self.txn.query_opt(&stmt, params).await?;
312        Ok(row)
313    }
314
315    /// Execute a statement and return the number of affected rows.
316    pub async fn execute(
317        &self,
318        sql: &str,
319        params: &[&(dyn tokio_postgres::types::ToSql + Sync)],
320    ) -> PgResult<u64> {
321        let stmt = self
322            .statement_cache
323            .get_or_prepare_in_txn(&self.txn, sql)
324            .await?;
325
326        let count = self.txn.execute(&stmt, params).await?;
327        Ok(count)
328    }
329
330    /// Create a savepoint.
331    pub async fn savepoint(&mut self, name: &str) -> PgResult<()> {
332        validate_savepoint_name(name)?;
333        debug!(name = %name, "Creating savepoint");
334        self.txn
335            .batch_execute(&format!("SAVEPOINT {}", name))
336            .await?;
337        Ok(())
338    }
339
340    /// Rollback to a savepoint.
341    pub async fn rollback_to(&mut self, name: &str) -> PgResult<()> {
342        validate_savepoint_name(name)?;
343        debug!(name = %name, "Rolling back to savepoint");
344        self.txn
345            .batch_execute(&format!("ROLLBACK TO SAVEPOINT {}", name))
346            .await?;
347        Ok(())
348    }
349
350    /// Release a savepoint.
351    pub async fn release_savepoint(&mut self, name: &str) -> PgResult<()> {
352        validate_savepoint_name(name)?;
353        debug!(name = %name, "Releasing savepoint");
354        self.txn
355            .batch_execute(&format!("RELEASE SAVEPOINT {}", name))
356            .await?;
357        Ok(())
358    }
359
360    /// Commit the transaction.
361    pub async fn commit(self) -> PgResult<()> {
362        debug!("Committing transaction");
363        self.txn.commit().await?;
364        Ok(())
365    }
366
367    /// Rollback the transaction.
368    pub async fn rollback(self) -> PgResult<()> {
369        debug!("Rolling back transaction");
370        self.txn.rollback().await?;
371        Ok(())
372    }
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378
379    // Integration tests would require a real PostgreSQL connection
380    // Unit tests for connection wrapper are limited without mocking
381
382    #[test]
383    fn test_stale_cached_plan_message_gate() {
384        // The exact PostgreSQL wording is recognized.
385        assert!(is_stale_cached_plan_message(
386            "db error: ERROR: cached plan must not change result type"
387        ));
388        // Other 0A000 (FEATURE_NOT_SUPPORTED) messages are not the stale-plan
389        // case and must not trigger recovery.
390        assert!(!is_stale_cached_plan_message(
391            "ERROR: cannot insert into view \"v\""
392        ));
393        assert!(!is_stale_cached_plan_message("some unrelated error"));
394    }
395
396    #[test]
397    fn test_validate_savepoint_name_accepts_valid_names() {
398        assert!(validate_savepoint_name("sp1").is_ok());
399        assert!(validate_savepoint_name("my_savepoint").is_ok());
400        assert!(validate_savepoint_name("_private").is_ok());
401        assert!(validate_savepoint_name("SP_2").is_ok());
402        assert!(validate_savepoint_name("a").is_ok());
403        // 63 chars (the max) is accepted
404        let max_name = "a".repeat(MAX_SAVEPOINT_NAME_LEN);
405        assert!(validate_savepoint_name(&max_name).is_ok());
406    }
407
408    #[test]
409    fn test_validate_savepoint_name_rejects_invalid_names() {
410        assert!(validate_savepoint_name("sp1; DROP TABLE").is_err());
411        assert!(validate_savepoint_name("my savepoint").is_err());
412        assert!(validate_savepoint_name("\"quoted\"").is_err());
413        assert!(validate_savepoint_name("").is_err());
414        assert!(validate_savepoint_name("1leading_digit").is_err());
415        assert!(validate_savepoint_name("has-dash").is_err());
416        assert!(validate_savepoint_name("has.dot").is_err());
417        // 64 chars exceeds the limit
418        let too_long = "a".repeat(MAX_SAVEPOINT_NAME_LEN + 1);
419        assert!(validate_savepoint_name(&too_long).is_err());
420    }
421}