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