pg_embedded_setup_unpriv/cluster/
connection.rs

1//! Connection helpers for `TestCluster`, including metadata accessors and optional Diesel support.
2
3use camino::{Utf8Path, Utf8PathBuf};
4use color_eyre::eyre::WrapErr;
5use postgres::{Client, NoTls};
6use postgresql_embedded::Settings;
7
8use crate::TestBootstrapSettings;
9use crate::error::BootstrapResult;
10
11/// Escapes a SQL identifier by doubling embedded double quotes.
12///
13/// `PostgreSQL` identifiers are quoted with double quotes. Any embedded
14/// double quote must be escaped by doubling it.
15pub(crate) fn escape_identifier(name: &str) -> String {
16    name.replace('"', "\"\"")
17}
18
19/// Creates a new `PostgreSQL` client connection from the given URL.
20///
21/// This is a shared helper for admin database connections used by both
22/// `TestClusterConnection` and `TemporaryDatabase`.
23pub(crate) fn connect_admin(url: &str) -> BootstrapResult<Client> {
24    Client::connect(url, NoTls)
25        .wrap_err("failed to connect to admin database")
26        .map_err(crate::error::BootstrapError::from)
27}
28
29/// Provides ergonomic accessors for connection-oriented cluster metadata.
30///
31/// # Examples
32/// ```no_run
33/// use pg_embedded_setup_unpriv::TestCluster;
34///
35/// # fn main() -> pg_embedded_setup_unpriv::BootstrapResult<()> {
36/// let cluster = TestCluster::new()?;
37/// let metadata = cluster.connection().metadata();
38/// assert_eq!(metadata.host(), "localhost");
39/// # Ok(())
40/// # }
41/// ```
42#[derive(Debug, Clone)]
43pub struct ConnectionMetadata {
44    settings: Settings,
45    pgpass_file: Utf8PathBuf,
46}
47
48impl ConnectionMetadata {
49    pub(crate) fn from_settings(settings: &TestBootstrapSettings) -> Self {
50        Self {
51            settings: settings.settings.clone(),
52            pgpass_file: settings.environment.pgpass_file.clone(),
53        }
54    }
55
56    /// Returns the configured database host.
57    #[must_use]
58    pub fn host(&self) -> &str {
59        self.settings.host.as_str()
60    }
61
62    /// Returns the configured port.
63    #[must_use]
64    pub const fn port(&self) -> u16 {
65        self.settings.port
66    }
67
68    /// Returns the configured superuser name.
69    #[must_use]
70    pub fn superuser(&self) -> &str {
71        self.settings.username.as_str()
72    }
73
74    /// Returns the generated superuser password.
75    #[must_use]
76    pub fn password(&self) -> &str {
77        self.settings.password.as_str()
78    }
79
80    /// Returns the prepared `.pgpass` file path.
81    #[must_use]
82    pub fn pgpass_file(&self) -> &Utf8Path {
83        self.pgpass_file.as_ref()
84    }
85
86    /// Constructs a libpq-compatible URL for `database` using the underlying
87    /// `postgresql_embedded` helper.
88    #[must_use]
89    pub fn database_url(&self, database: &str) -> String {
90        self.settings.url(database)
91    }
92}
93
94/// Accessor for connection helpers derived from a
95/// [`TestCluster`](crate::TestCluster).
96///
97/// Enable the `diesel-support` feature to call the Diesel connection helper.
98///
99/// # Examples
100/// ```no_run
101/// use pg_embedded_setup_unpriv::TestCluster;
102///
103/// # fn main() -> pg_embedded_setup_unpriv::BootstrapResult<()> {
104/// let cluster = TestCluster::new()?;
105/// let url = cluster.connection().database_url("postgres");
106/// assert!(url.contains("postgresql://"));
107/// # Ok(())
108/// # }
109/// ```
110#[derive(Debug, Clone)]
111pub struct TestClusterConnection {
112    metadata: ConnectionMetadata,
113}
114
115impl TestClusterConnection {
116    pub(crate) fn new(settings: &TestBootstrapSettings) -> Self {
117        Self {
118            metadata: ConnectionMetadata::from_settings(settings),
119        }
120    }
121
122    /// Returns host metadata without exposing internal storage.
123    #[must_use]
124    pub fn host(&self) -> &str {
125        self.metadata.host()
126    }
127
128    /// Returns the configured port.
129    #[must_use]
130    pub const fn port(&self) -> u16 {
131        self.metadata.port()
132    }
133
134    /// Returns the configured superuser account name.
135    #[must_use]
136    pub fn superuser(&self) -> &str {
137        self.metadata.superuser()
138    }
139
140    /// Returns the generated password for the superuser.
141    #[must_use]
142    pub fn password(&self) -> &str {
143        self.metadata.password()
144    }
145
146    /// Returns the `.pgpass` file prepared during bootstrap.
147    #[must_use]
148    pub fn pgpass_file(&self) -> &Utf8Path {
149        self.metadata.pgpass_file()
150    }
151
152    /// Provides an owned snapshot of the connection metadata.
153    #[must_use]
154    pub fn metadata(&self) -> ConnectionMetadata {
155        self.metadata.clone()
156    }
157
158    /// Builds a libpq-compatible database URL for `database`.
159    #[must_use]
160    pub fn database_url(&self, database: &str) -> String {
161        self.metadata.database_url(database)
162    }
163
164    /// Establishes a Diesel connection for the target `database`.
165    ///
166    /// # Errors
167    /// Returns a [`crate::error::BootstrapError`] when Diesel cannot connect.
168    #[cfg(feature = "diesel-support")]
169    pub fn diesel_connection(&self, database: &str) -> BootstrapResult<diesel::PgConnection> {
170        use diesel::Connection;
171
172        diesel::PgConnection::establish(&self.database_url(database))
173            .wrap_err(format!("failed to connect to {database} via Diesel"))
174            .map_err(crate::error::BootstrapError::from)
175    }
176
177    /// Connects to the `postgres` administration database.
178    pub(super) fn admin_client(&self) -> BootstrapResult<Client> {
179        connect_admin(&self.database_url("postgres"))
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use crate::TestBootstrapSettings;
187    use crate::bootstrap::{ExecutionMode, ExecutionPrivileges, TestBootstrapEnvironment};
188    use postgresql_embedded::Settings;
189    use std::time::Duration;
190
191    fn sample_settings() -> TestBootstrapSettings {
192        let settings = Settings {
193            host: "127.0.0.1".into(),
194            port: 55_321,
195            username: "fixture_user".into(),
196            password: "fixture_pass".into(),
197            data_dir: "/tmp/cluster-data".into(),
198            installation_dir: "/tmp/cluster-install".into(),
199            ..Settings::default()
200        };
201
202        TestBootstrapSettings {
203            privileges: ExecutionPrivileges::Unprivileged,
204            execution_mode: ExecutionMode::InProcess,
205            settings,
206            environment: TestBootstrapEnvironment {
207                home: Utf8PathBuf::from("/tmp/home"),
208                xdg_cache_home: Utf8PathBuf::from("/tmp/home/cache"),
209                xdg_runtime_dir: Utf8PathBuf::from("/tmp/home/run"),
210                pgpass_file: Utf8PathBuf::from("/tmp/home/.pgpass"),
211                tz_dir: Some(Utf8PathBuf::from("/usr/share/zoneinfo")),
212                timezone: "UTC".into(),
213            },
214            worker_binary: None,
215            setup_timeout: Duration::from_secs(1),
216            start_timeout: Duration::from_secs(1),
217            shutdown_timeout: Duration::from_secs(1),
218        }
219    }
220
221    #[test]
222    fn metadata_reflects_underlying_settings() {
223        let settings = sample_settings();
224        let connection = TestClusterConnection::new(&settings);
225        let metadata = connection.metadata();
226
227        assert_eq!(metadata.host(), "127.0.0.1");
228        assert_eq!(metadata.port(), 55_321);
229        assert_eq!(metadata.superuser(), "fixture_user");
230        assert_eq!(metadata.password(), "fixture_pass");
231        assert_eq!(metadata.pgpass_file(), Utf8Path::new("/tmp/home/.pgpass"));
232    }
233
234    #[test]
235    fn database_url_matches_postgresql_embedded() {
236        let settings = sample_settings();
237        let connection = TestClusterConnection::new(&settings);
238        let expected = settings.settings.url("postgres");
239
240        assert_eq!(connection.database_url("postgres"), expected);
241    }
242}