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}