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}