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}