Skip to main content

pg_embedded_setup_unpriv/cluster/
temporary_database.rs

1//! RAII guard for automatic database cleanup.
2//!
3//! `TemporaryDatabase` drops its associated database when the guard goes out of
4//! scope, mirroring the `TestCluster` lifecycle semantics.
5
6use color_eyre::eyre::WrapErr;
7use tracing::info_span;
8
9use super::connection::{connect_admin, escape_identifier};
10use crate::{error::BootstrapResult, observability::LOG_TARGET};
11
12/// RAII guard that drops a database when it goes out of scope.
13///
14/// The guard stores the database name and connection URL rather than borrowing
15/// a connection, avoiding lifetime issues and allowing reconnection in `Drop`.
16///
17/// # Examples
18///
19/// ```no_run
20/// use pg_embedded_setup_unpriv::TestCluster;
21///
22/// # fn main() -> pg_embedded_setup_unpriv::BootstrapResult<()> {
23/// let cluster = TestCluster::new()?;
24///
25/// // Create a temporary database that is dropped when the guard is dropped
26/// let temp_db = cluster.connection().temporary_database("my_temp_db")?;
27///
28/// // Use the database
29/// let url = temp_db.url();
30/// // ... run queries ...
31///
32/// // Database is dropped automatically when temp_db goes out of scope
33/// drop(temp_db);
34/// # Ok(())
35/// # }
36/// ```
37#[derive(Debug)]
38pub struct TemporaryDatabase {
39    name: String,
40    admin_url: String,
41    database_url: String,
42}
43
44impl TemporaryDatabase {
45    /// Creates a new `TemporaryDatabase` guard.
46    ///
47    /// This constructor is intended for internal use. Prefer using
48    /// [`TestClusterConnection::temporary_database`] or
49    /// [`TestClusterConnection::temporary_database_from_template`].
50    pub(crate) const fn new(name: String, admin_url: String, database_url: String) -> Self {
51        Self {
52            name,
53            admin_url,
54            database_url,
55        }
56    }
57
58    /// Returns the database name.
59    #[must_use]
60    pub fn name(&self) -> &str { &self.name }
61
62    /// Returns the connection URL for this database.
63    #[must_use]
64    pub fn url(&self) -> &str { &self.database_url }
65
66    /// Drops the database, failing if connections exist.
67    ///
68    /// This mirrors `PostgreSQL`'s native behaviour where `DROP DATABASE` fails
69    /// if there are active connections to the database.
70    ///
71    /// # Errors
72    ///
73    /// Returns an error if:
74    /// - The database has active connections
75    /// - The database does not exist
76    /// - The connection to the admin database fails
77    ///
78    /// # Examples
79    ///
80    /// ```no_run
81    /// use pg_embedded_setup_unpriv::TestCluster;
82    ///
83    /// # fn main() -> pg_embedded_setup_unpriv::BootstrapResult<()> {
84    /// let cluster = TestCluster::new()?;
85    /// let temp_db = cluster.connection().temporary_database("my_temp_db")?;
86    ///
87    /// // Explicitly drop (consumes the guard)
88    /// temp_db.drop_database()?;
89    /// # Ok(())
90    /// # }
91    /// ```
92    pub fn drop_database(self) -> BootstrapResult<()> { self.try_drop() }
93
94    /// Drops the database, terminating any active connections first.
95    ///
96    /// This is useful when you need to ensure the database is dropped even if
97    /// there are lingering connections (e.g., from connection pools that
98    /// haven't been drained).
99    ///
100    /// # Errors
101    ///
102    /// Returns an error if:
103    /// - The database does not exist
104    /// - The connection to the admin database fails
105    /// - Terminating connections fails
106    ///
107    /// # Examples
108    ///
109    /// ```no_run
110    /// use pg_embedded_setup_unpriv::TestCluster;
111    ///
112    /// # fn main() -> pg_embedded_setup_unpriv::BootstrapResult<()> {
113    /// let cluster = TestCluster::new()?;
114    /// let temp_db = cluster.connection().temporary_database("my_temp_db")?;
115    ///
116    /// // Force drop even if connections exist
117    /// temp_db.force_drop()?;
118    /// # Ok(())
119    /// # }
120    /// ```
121    pub fn force_drop(self) -> BootstrapResult<()> {
122        let _span = info_span!("force_drop_database", db = %self.name).entered();
123        let mut client = connect_admin(&self.admin_url)?;
124
125        // Terminate active connections using parameterized query
126        client
127            .execute(
128                "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1 AND \
129                 pid <> pg_backend_pid()",
130                &[&self.name],
131            )
132            .wrap_err(format!(
133                "failed to terminate connections to database '{}'",
134                self.name
135            ))
136            .map_err(crate::error::BootstrapError::from)?;
137
138        // Drop the database with escaped identifier
139        let escaped = escape_identifier(&self.name);
140        let drop_sql = format!("DROP DATABASE \"{escaped}\"");
141        client
142            .batch_execute(&drop_sql)
143            .wrap_err(format!("failed to drop database '{}'", self.name))
144            .map_err(crate::error::BootstrapError::from)?;
145
146        Ok(())
147    }
148
149    /// Attempts to drop the database without consuming self.
150    ///
151    /// Used by the `Drop` implementation for best-effort cleanup.
152    fn try_drop(&self) -> BootstrapResult<()> {
153        let _span = info_span!("drop_database", db = %self.name).entered();
154        let mut client = connect_admin(&self.admin_url)?;
155
156        let escaped = escape_identifier(&self.name);
157        let sql = format!("DROP DATABASE \"{escaped}\"");
158        client
159            .batch_execute(&sql)
160            .wrap_err(format!("failed to drop database '{}'", self.name))
161            .map_err(crate::error::BootstrapError::from)?;
162
163        Ok(())
164    }
165}
166
167impl Drop for TemporaryDatabase {
168    fn drop(&mut self) {
169        if let Err(e) = self.try_drop() {
170            tracing::warn!(
171                target: LOG_TARGET,
172                db = %self.name,
173                error = ?e,
174                "failed to drop temporary database"
175            );
176        }
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    //! Tests for temporary database handling.
183    use super::*;
184
185    #[test]
186    fn temporary_database_accessors() {
187        let temp = TemporaryDatabase::new(
188            "test_db".to_owned(),
189            "postgresql://user:pass@localhost:5432/postgres".to_owned(),
190            "postgresql://user:pass@localhost:5432/test_db".to_owned(),
191        );
192
193        assert_eq!(temp.name(), "test_db");
194        assert!(temp.url().contains("test_db"));
195    }
196
197    #[test]
198    fn drop_database_returns_error_on_connection_failure() {
199        let temp = TemporaryDatabase::new(
200            "test_db".to_owned(),
201            "postgresql://user:pass@localhost:59999/postgres".to_owned(),
202            "postgresql://user:pass@localhost:59999/test_db".to_owned(),
203        );
204
205        let result = temp.drop_database();
206        let Err(err) = result else {
207            panic!("expected error when database unreachable");
208        };
209        let err_str = err.to_string();
210        assert!(
211            err_str.contains("failed to connect"),
212            "expected connection failure, got: {err_str}"
213        );
214    }
215
216    #[test]
217    fn force_drop_returns_error_on_connection_failure() {
218        let temp = TemporaryDatabase::new(
219            "test_db".to_owned(),
220            "postgresql://user:pass@localhost:59999/postgres".to_owned(),
221            "postgresql://user:pass@localhost:59999/test_db".to_owned(),
222        );
223
224        let result = temp.force_drop();
225        let Err(err) = result else {
226            panic!("expected error when database unreachable");
227        };
228        let err_str = err.to_string();
229        assert!(
230            err_str.contains("failed to connect"),
231            "expected connection failure, got: {err_str}"
232        );
233    }
234
235    #[test]
236    fn drop_trait_does_not_panic_on_connection_failure() {
237        // Create a TemporaryDatabase with an unreachable URL
238        let temp = TemporaryDatabase::new(
239            "test_db".to_owned(),
240            "postgresql://user:pass@localhost:59999/postgres".to_owned(),
241            "postgresql://user:pass@localhost:59999/test_db".to_owned(),
242        );
243
244        // Dropping should not panic even when cleanup fails
245        // The Drop impl logs a warning but does not propagate errors
246        drop(temp);
247    }
248}