pgtemp/lib.rs
1#![warn(missing_docs)] // denied in CI
2
3//! pgtemp is a Rust library and cli tool that allows you to easily create temporary PostgreSQL servers for testing without using Docker.
4//!
5//! The pgtemp Rust library allows you to spawn a PostgreSQL server in a temporary directory and get back a full connection URI with the host, port, username, and password.
6//!
7//! The pgtemp cli tool allows you to even more simply make temporary connections, and works with any language: Run pgtemp and then use its connection URI when connecting to the database in your tests. **pgtemp will then spawn a new postgresql process for each connection it receives** and transparently proxy everything over that connection to the temporary database. Note that this means when you make multiple connections in a single test, changes made in one connection will not be visible in the other connections, unless you are using pgtemp's `--single` mode.
8
9use std::collections::HashMap;
10use std::fmt;
11use std::fmt::Debug;
12use std::path::{Path, PathBuf};
13use std::process::Child;
14
15use tempfile::TempDir;
16use tokio::task::spawn_blocking;
17
18mod daemon;
19mod run_db;
20
21pub use daemon::*;
22
23// temp db handle - actual db spawning code is in run_db mod
24
25/// A struct representing a handle to a local PostgreSQL server that is currently running. Upon
26/// drop or calling `shutdown`, the server is shut down and the directory its data is stored in
27/// is deleted. See builder struct [`PgTempDBBuilder`] for options and settings.
28pub struct PgTempDB {
29 dbuser: String,
30 dbpass: String,
31 dbport: u16,
32 dbname: String,
33 /// persist the db data directory after shutdown
34 persist: bool,
35 /// dump the databaset to a script file after shutdown
36 dump_path: Option<PathBuf>,
37 // See shutdown implementation for why these are options
38 temp_dir: Option<TempDir>,
39 postgres_process: Option<Child>,
40}
41
42impl PgTempDB {
43 /// Start a PgTempDB with the parameters configured from a PgTempDBBuilder
44 pub fn from_builder(mut builder: PgTempDBBuilder) -> PgTempDB {
45 let dbuser = builder.get_user();
46 let dbpass = builder.get_password();
47 let dbport = builder.get_port_or_set_random();
48 let dbname = builder.get_dbname();
49 let persist = builder.persist_data_dir;
50 let dump_path = builder.dump_path.clone();
51 let load_path = builder.load_path.clone();
52
53 let temp_dir = run_db::init_db(&mut builder);
54 let postgres_process = Some(run_db::run_db(&temp_dir, builder));
55 let temp_dir = Some(temp_dir);
56
57 let db = PgTempDB {
58 dbuser,
59 dbpass,
60 dbport,
61 dbname,
62 persist,
63 dump_path,
64 temp_dir,
65 postgres_process,
66 };
67
68 if let Some(path) = load_path {
69 db.load_database(path);
70 }
71 db
72 }
73
74 /// Creates a builder that can be used to configure the details of the temporary PostgreSQL
75 /// server
76 pub fn builder() -> PgTempDBBuilder {
77 PgTempDBBuilder::new()
78 }
79
80 /// Creates a new PgTempDB with default configuration and starts a PostgreSQL server.
81 pub fn new() -> PgTempDB {
82 PgTempDBBuilder::new().start()
83 }
84
85 /// Creates a new PgTempDB with default configuration and starts a PostgreSQL server in an
86 /// async context.
87 pub async fn async_new() -> PgTempDB {
88 PgTempDBBuilder::new().start_async().await
89 }
90
91 /// Use [pg_dump](https://www.postgresql.org/docs/current/backup-dump.html) to dump the
92 /// database to the provided path upon drop or [`Self::shutdown`].
93 pub fn dump_database(&self, path: impl AsRef<Path>) {
94 let path_str = path.as_ref().to_str().unwrap();
95
96 let dump_output = std::process::Command::new("pg_dump")
97 .arg(self.connection_uri())
98 .args(["--file", path_str])
99 .output()
100 .expect("failed to start pg_dump. Is it installed and on your path?");
101
102 if !dump_output.status.success() {
103 let stdout = dump_output.stdout;
104 let stderr = dump_output.stderr;
105 panic!(
106 "pg_dump failed! stdout: {}\n\nstderr: {}",
107 String::from_utf8_lossy(&stdout),
108 String::from_utf8_lossy(&stderr)
109 );
110 }
111 }
112
113 /// Use `psql` to load the database from the provided dump file. See [`Self::dump_database`].
114 pub fn load_database(&self, path: impl AsRef<Path>) {
115 let path_str = path.as_ref().to_str().unwrap();
116
117 let load_output = std::process::Command::new("psql")
118 .arg(self.connection_uri())
119 .args(["--file", path_str])
120 .output()
121 .expect("failed to start psql. Is it installed and on your path?");
122
123 if !load_output.status.success() {
124 let stdout = load_output.stdout;
125 let stderr = load_output.stderr;
126 panic!(
127 "psql failed! stdout: {}\n\nstderr: {}",
128 String::from_utf8_lossy(&stdout),
129 String::from_utf8_lossy(&stderr)
130 );
131 }
132 }
133
134 /// Send a signal to the database to shutdown the server, then wait for the process to exit.
135 /// Equivalent to calling drop on this struct.
136 ///
137 /// We send SIGINT to the postgres process to initiate a fast shutdown
138 /// (<https://www.postgresql.org/docs/current/server-shutdown.html>), which causes all transactions to be aborted and
139 /// connections to be terminated.
140 ///
141 /// NOTE: This is currently a blocking function. It sends SIGINT to the postgres server, waits
142 /// for the process to exit, and also does IO to remove the temp directory.
143 ///
144 pub fn shutdown(self) {
145 drop(self);
146 }
147
148 /// See description of [`shutdown`]
149 fn shutdown_internal(&mut self) {
150 // if no process (e.g. due to calling `force_shutdown`), just skip the cleanup operations.
151 if self.postgres_process.is_none() {
152 return;
153 }
154
155 // do the dump while the postgres process is still running
156 if let Some(path) = &self.dump_path {
157 self.dump_database(path);
158 }
159
160 let postgres_process = self
161 .postgres_process
162 .take()
163 .expect("shutdown with no postgres process");
164 let temp_dir = self.temp_dir.take().unwrap();
165
166 // fast (not graceful) shutdown via SIGINT
167 // TODO: graceful shutdown via SIGTERM
168 // was having issues with using graceful shutdown by default and some tests/examples using
169 // pg connection pools - likely what was happening was that at the end of the test we hit
170 // drop for the connection pool, it tries to drop asynchronously (e.g. it probably sends a
171 // async signal), then we block indefinitely on the main thread in PgTempDB::shutdown
172 // waiting for the server to shut down and the pooler never gets a chance to shut down, so
173 // the postgres server says "we're still connected to a client, can't shut down yet" and we
174 // have a deadlock.
175 #[allow(clippy::cast_possible_wrap)]
176 let _ret = unsafe { libc::kill(postgres_process.id() as i32, libc::SIGINT) };
177 let _output = postgres_process
178 .wait_with_output()
179 .expect("postgres server failed to exit cleanly");
180
181 if self.persist {
182 // this prevents the dir from being deleted on drop
183 let _path = temp_dir.into_path();
184 } else {
185 // if we just used the default drop impl, errors would not be surfaced
186 temp_dir.close().expect("failed to clean up temp directory");
187 }
188 }
189
190 /// Returns the path to the data directory being used by this databaset.
191 pub fn data_dir(&self) -> PathBuf {
192 self.temp_dir.as_ref().unwrap().path().join("pg_data_dir")
193 }
194
195 /// Returns the database username used when connecting to the postgres server.
196 pub fn db_user(&self) -> &str {
197 &self.dbuser
198 }
199
200 /// Returns the database password used when connecting to the postgres server.
201 pub fn db_pass(&self) -> &str {
202 &self.dbpass
203 }
204
205 /// Returns the port the postgres server is running on.
206 pub fn db_port(&self) -> u16 {
207 self.dbport
208 }
209
210 /// Returns the the name of the database created.
211 pub fn db_name(&self) -> &str {
212 &self.dbname
213 }
214
215 /// Returns a connection string that can be passed to a libpq connection function.
216 ///
217 /// Example output:
218 /// `host=localhost port=15432 user=pgtemp password=pgtemppw-9485 dbname=pgtempdb-324`
219 pub fn connection_string(&self) -> String {
220 format!(
221 "host=localhost port={} user={} password={} dbname={}",
222 self.db_port(),
223 self.db_user(),
224 self.db_pass(),
225 self.db_name()
226 )
227 }
228
229 /// Returns a generic connection URI that can be passed to most SQL libraries' connect
230 /// methods.
231 ///
232 /// Example output:
233 /// `postgresql://pgtemp:pgtemppw-9485@localhost:15432/pgtempdb-324`
234 pub fn connection_uri(&self) -> String {
235 format!(
236 "postgresql://{}:{}@localhost:{}/{}",
237 self.db_user(),
238 self.db_pass(),
239 self.db_port(),
240 self.db_name()
241 )
242 }
243}
244
245impl Debug for PgTempDB {
246 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247 f.debug_struct("PgTempDB")
248 .field("base directory", self.temp_dir.as_ref().unwrap())
249 .field("connection string", &self.connection_string())
250 .field("persist data dir", &self.persist)
251 .field("dump path", &self.dump_path)
252 .field(
253 "db process",
254 &self.postgres_process.as_ref().map(Child::id).unwrap(),
255 )
256 .finish_non_exhaustive()
257 }
258}
259
260impl Drop for PgTempDB {
261 fn drop(&mut self) {
262 self.shutdown_internal();
263 }
264}
265
266// db config builder functions
267
268/// Builder struct for PgTempDB.
269#[derive(Default, Debug, Clone)]
270pub struct PgTempDBBuilder {
271 /// The directory in which to store the temporary PostgreSQL data directory.
272 pub temp_dir_prefix: Option<PathBuf>,
273 /// The cluster superuser created with `initdb`. Default: `postgres`
274 pub db_user: Option<String>,
275 /// The password for the cluster superuser. Default: `password`
276 pub password: Option<String>,
277 /// The port the server should run on. Default: random unused port.
278 pub port: Option<u16>,
279 /// The name of the database to create on startup. Default: `postgres`.
280 pub dbname: Option<String>,
281 /// Do not delete the data dir when the `PgTempDB` is dropped.
282 pub persist_data_dir: bool,
283 /// The path to dump the database to (via `pg_dump`) when the `PgTempDB` is dropped.
284 pub dump_path: Option<PathBuf>,
285 /// The path to load the database from (via `psql`) when the `PgTempDB` is started.
286 pub load_path: Option<PathBuf>,
287 /// Other server configuration data to be set in `postgresql.conf` via `initdb -c`
288 pub server_configs: HashMap<String, String>,
289 /// Prefix PostgreSQL binary names (`initdb`, `createdb`, and `postgres`) with this path, instead of searching $PATH
290 pub bin_path: Option<PathBuf>,
291}
292
293impl PgTempDBBuilder {
294 /// Create a new [`PgTempDBBuilder`]
295 pub fn new() -> PgTempDBBuilder {
296 PgTempDBBuilder::default()
297 }
298
299 /// Parses the parameters out of a PostgreSQL connection URI and inserts them into the builder.
300 #[must_use]
301 pub fn from_connection_uri(conn_uri: &str) -> Self {
302 let mut builder = PgTempDBBuilder::new();
303
304 let url = url::Url::parse(conn_uri)
305 .expect(&format!("Could not parse connection URI `{}`", conn_uri));
306
307 // TODO: error types
308 assert!(
309 url.scheme() == "postgresql",
310 "connection URI must start with `postgresql://` scheme: `{}`",
311 conn_uri
312 );
313 assert!(
314 url.host_str() == Some("localhost"),
315 "connection URI's host is not localhost: `{}`",
316 conn_uri,
317 );
318
319 let username = url.username();
320 let password = url.password();
321 let port = url.port();
322 let dbname = url.path().strip_prefix('/').unwrap_or("");
323
324 if !username.is_empty() {
325 builder = builder.with_username(username);
326 }
327 if let Some(password) = password {
328 builder = builder.with_password(password);
329 }
330 if let Some(port) = port {
331 builder = builder.with_port(port);
332 }
333 if !dbname.is_empty() {
334 builder = builder.with_dbname(dbname);
335 }
336
337 builder
338 }
339
340 // TODO: make an error type and `try_start` methods (and maybe similar for above shutdown etc
341 // functions)
342
343 /// Creates the temporary data directory and starts the PostgreSQL server with the configured
344 /// parameters.
345 ///
346 /// If the current user is root, will attempt to run the `initdb` and `postgres` commands as
347 /// the `postgres` user.
348 pub fn start(self) -> PgTempDB {
349 PgTempDB::from_builder(self)
350 }
351
352 /// Convenience function for calling `spawn_blocking(self.start())`
353 pub async fn start_async(self) -> PgTempDB {
354 spawn_blocking(move || self.start())
355 .await
356 .expect("failed to start pgtemp server")
357 }
358
359 /// Set the directory in which to put the (temporary) PostgreSQL data directory. This is not
360 /// the data directory itself: a new temporary directory is created inside this one.
361 #[must_use]
362 pub fn with_data_dir_prefix(mut self, prefix: impl AsRef<Path>) -> Self {
363 self.temp_dir_prefix = Some(PathBuf::from(prefix.as_ref()));
364 self
365 }
366
367 /// Set an arbitrary PostgreSQL server configuration parameter that will passed to the
368 /// postgresql process at runtime.
369 #[must_use]
370 pub fn with_config_param(mut self, key: &str, value: &str) -> Self {
371 let _old = self.server_configs.insert(key.into(), value.into());
372 self
373 }
374
375 /// Set the directory that contains binaries like `initdb`, `createdb`, and `postgres`.
376 #[must_use]
377 pub fn with_bin_path(mut self, path: impl AsRef<Path>) -> Self {
378 self.bin_path = Some(PathBuf::from(path.as_ref()));
379 self
380 }
381
382 #[must_use]
383 /// Set the user name
384 pub fn with_username(mut self, username: &str) -> Self {
385 self.db_user = Some(username.to_string());
386 self
387 }
388
389 #[must_use]
390 /// Set the user password
391 pub fn with_password(mut self, password: &str) -> Self {
392 self.password = Some(password.to_string());
393 self
394 }
395
396 #[must_use]
397 /// Set the port
398 pub fn with_port(mut self, port: u16) -> Self {
399 self.port = Some(port);
400 self
401 }
402
403 #[must_use]
404 /// Set the database name
405 pub fn with_dbname(mut self, dbname: &str) -> Self {
406 self.dbname = Some(dbname.to_string());
407 self
408 }
409
410 /// If set, the postgres data directory will not be deleted when the `PgTempDB` is dropped.
411 #[must_use]
412 pub fn persist_data(mut self, persist: bool) -> Self {
413 self.persist_data_dir = persist;
414 self
415 }
416
417 /// If set, the database will be dumped via the `pg_dump` utility to the given location on drop
418 /// or upon calling [`PgTempDB::shutdown`].
419 #[must_use]
420 pub fn dump_database(mut self, path: &Path) -> Self {
421 self.dump_path = Some(path.into());
422 self
423 }
424
425 /// If set, the database will be loaded via `psql` from the given script on startup.
426 #[must_use]
427 pub fn load_database(mut self, path: &Path) -> Self {
428 self.load_path = Some(path.into());
429 self
430 }
431
432 /// Get user if set or return default
433 pub fn get_user(&self) -> String {
434 self.db_user.clone().unwrap_or(String::from("postgres"))
435 }
436
437 /// Get password if set or return default
438 pub fn get_password(&self) -> String {
439 self.password.clone().unwrap_or(String::from("password"))
440 }
441
442 /// Unlike the other getters, this getter will try to open a new socket to find an unused port,
443 /// and then set it as the current port.
444 pub fn get_port_or_set_random(&mut self) -> u16 {
445 let port = self.port.as_ref().copied().unwrap_or_else(get_unused_port);
446
447 self.port = Some(port);
448 port
449 }
450
451 /// Get dbname if set or return default
452 pub fn get_dbname(&self) -> String {
453 self.dbname.clone().unwrap_or(String::from("postgres"))
454 }
455}
456
457fn get_unused_port() -> u16 {
458 // TODO: relies on Rust's stdlib setting SO_REUSEPORT by default so that postgres can still
459 // bind to the port afterwards. Also there's a race condition/TOCTOU because there's lag
460 // between when the port is checked here and when postgres actually tries to bind to it.
461 let sock = std::net::TcpListener::bind("localhost:0")
462 .expect("failed to bind to local port when getting unused port");
463 sock.local_addr()
464 .expect("failed to get local addr from socket")
465 .port()
466}