evergreen/
db.rs

1//! Create, connect, and manage database connections.
2use crate::result::EgResult;
3use getopts;
4use log::debug;
5use postgres as pg;
6use std::cell::RefCell;
7use std::env;
8use std::rc::Rc;
9
10const DEFAULT_DB_PORT: u16 = 5432;
11const DEFAULT_DB_HOST: &str = "localhost";
12const DEFAULT_DB_USER: &str = "evergreen";
13const DEFAULT_DB_NAME: &str = "evergreen";
14
15const SUPPORTED_OPERATORS: [&str; 20] = [
16    "IS",
17    "IS NOT",
18    "IN",
19    "NOT IN",
20    "LIKE",
21    "ILIKE",
22    "<",
23    "<=",
24    ">",
25    ">=",
26    "<>",
27    "!=",
28    "~",
29    "=",
30    "!~",
31    "!~*",
32    "~*",
33    "SIMILAR TO",
34    "IS DISTINCT FROM",
35    "IS NOT DISTINCT FROM",
36];
37
38/// For compiling a set of connection parameters
39///
40/// Values are applied like so:
41///
42/// 1. Manually applying a value via set_* method
43/// 2. Values provided via getopts::Matches struct.
44/// 3. Values pulled from the environment (e.g. PGHOST) where possible.
45/// 4. Default values defined in this module.
46#[derive(Default)]
47pub struct DatabaseConnectionBuilder {
48    host: Option<String>,
49    port: Option<u16>,
50    user: Option<String>,
51    password: Option<String>,
52    database: Option<String>,
53    application: Option<String>,
54}
55
56impl DatabaseConnectionBuilder {
57    /// Create a new database connection with default values
58    ///
59    /// # Example
60    ///
61    /// ```
62    /// use evergreen::db::DatabaseConnectionBuilder;
63    ///
64    /// let builder = DatabaseConnectionBuilder::new();
65    /// let connection = builder.build();
66    /// assert_eq!(connection.dsn(), "host=localhost port=5432 user=evergreen dbname=evergreen");
67    /// ```
68    pub fn new() -> Self {
69        Default::default()
70    }
71
72    /// Set connection values via getopts matches.
73    ///
74    /// Values are only applied where values do not already exist.
75    /// This generally means a set_* method has higher precedence
76    /// than a set of getopts matches.
77    ///
78    /// Supported options:
79    ///     --db-host
80    ///     --db-port
81    ///     --db-user
82    ///     --db-name
83    ///
84    /// # Example
85    ///
86    /// ```
87    /// use evergreen::db::{DatabaseConnectionBuilder, DatabaseConnection};
88    ///
89    /// let mut options = getopts::Options::new();
90    /// DatabaseConnection::append_options(&mut options);
91    /// let matches = options.parse([
92    ///   "--db-host=my-host",
93    ///   "--db-port=1234",
94    ///   "--db-user=my-user",
95    ///   "--db-pass=my-password",
96    ///   "--db-name=evergreen-db"
97    /// ]).unwrap();
98    /// let mut builder = DatabaseConnectionBuilder::new();
99    ///
100    /// builder.set_opts(&matches);
101    ///
102    /// let connection = builder.build();
103    /// assert_eq!(connection.dsn(), "host=my-host port=1234 user=my-user dbname=evergreen-db password=my-password");
104    /// ```
105    pub fn set_opts(&mut self, params: &getopts::Matches) {
106        if self.host.is_none() && params.opt_defined("db-host") {
107            self.host = params.opt_str("db-host");
108        }
109
110        if self.user.is_none() && params.opt_defined("db-user") {
111            self.user = params.opt_str("db-user");
112        }
113
114        if self.password.is_none() && params.opt_defined("db-pass") {
115            self.password = params.opt_str("db-pass");
116        }
117
118        if self.database.is_none() && params.opt_defined("db-name") {
119            self.database = params.opt_str("db-name");
120        }
121
122        if self.port.is_none() && params.opt_defined("db-port") {
123            if let Some(v) = params.opt_str("db-port") {
124                self.port = Some(v.parse::<u16>().unwrap());
125            }
126        }
127    }
128
129    /// # Example
130    ///
131    /// ```
132    /// use evergreen::db::DatabaseConnectionBuilder;
133    ///
134    /// let mut builder = DatabaseConnectionBuilder::new();
135    ///
136    /// builder.set_host("/var/run/postgres"); // The host can be a domain socket or a hostname
137    ///
138    /// let connection = builder.build();
139    /// assert_eq!(connection.dsn(), "host=/var/run/postgres port=5432 user=evergreen dbname=evergreen");
140    /// ```
141    pub fn set_host(&mut self, host: &str) {
142        self.host = Some(host.to_string())
143    }
144
145    pub fn set_port(&mut self, port: u16) {
146        self.port = Some(port);
147    }
148
149    pub fn set_user(&mut self, user: &str) {
150        self.user = Some(user.to_string());
151    }
152
153    pub fn set_password(&mut self, password: &str) {
154        self.password = Some(password.to_string());
155    }
156
157    pub fn set_database(&mut self, database: &str) {
158        self.database = Some(database.to_string());
159    }
160
161    /// Set an application for your database connection, so that it's
162    /// easy to identify while monitoring and troubleshooting.
163    ///
164    /// ```
165    /// use evergreen::db::DatabaseConnectionBuilder;
166    ///
167    /// let mut builder = DatabaseConnectionBuilder::new();
168    ///
169    /// builder.set_application("open-ils.booking@appserver3(thread_123)");
170    ///
171    /// let connection = builder.build();
172    /// assert_eq!(
173    ///   connection.dsn(),
174    ///   "host=localhost port=5432 user=evergreen dbname=evergreen application_name=open-ils.booking@appserver3(thread_123)"
175    /// );
176    /// ```
177    pub fn set_application(&mut self, application: &str) {
178        self.application = Some(application.to_string());
179    }
180
181    fn from_env(name: &str) -> Option<String> {
182        env::vars()
183            .find(|(k, _)| k == name)
184            .map(|(_, v)| v.to_string())
185    }
186
187    /// Create the final database connection object from the collected
188    /// parameters.
189    pub fn build(self) -> DatabaseConnection {
190        let host = match self.host {
191            Some(h) => h,
192            None => match DatabaseConnectionBuilder::from_env("PGHOST") {
193                Some(h) => h,
194                None => DEFAULT_DB_HOST.to_string(),
195            },
196        };
197
198        let mut pass = self.password;
199
200        if pass.is_none() {
201            pass = DatabaseConnectionBuilder::from_env("PGPASS");
202        };
203
204        let user = match self.user {
205            Some(h) => h,
206            None => match DatabaseConnectionBuilder::from_env("PGUSER") {
207                Some(h) => h,
208                None => DEFAULT_DB_USER.to_string(),
209            },
210        };
211
212        let database = match self.database {
213            Some(h) => h,
214            None => match DatabaseConnectionBuilder::from_env("PGDATABASE") {
215                Some(h) => h,
216                None => DEFAULT_DB_NAME.to_string(),
217            },
218        };
219
220        let port = match self.port {
221            Some(h) => h,
222            None => match DatabaseConnectionBuilder::from_env("PGPORT") {
223                Some(h) => h.parse::<u16>().unwrap(),
224                None => DEFAULT_DB_PORT,
225            },
226        };
227
228        DatabaseConnection {
229            host,
230            port,
231            user,
232            database,
233            password: pass,
234            application: self.application,
235            client: None,
236            in_transaction: false,
237        }
238    }
239}
240
241/// Wrapper for a postgres::Client with connection metadata.
242pub struct DatabaseConnection {
243    client: Option<pg::Client>,
244    host: String,
245    port: u16,
246    user: String,
247    password: Option<String>,
248    database: String,
249    application: Option<String>,
250    in_transaction: bool,
251}
252
253impl Drop for DatabaseConnection {
254    fn drop(&mut self) {
255        // This is probably unnecessary, since I expect the PG backend
256        // will automatically rollback, but let's make it official.
257        // The pg::Client will close its own connection once it's dropped.
258        if self.in_transaction {
259            self.xact_rollback().ok();
260        }
261    }
262}
263
264impl DatabaseConnection {
265    /// Add options to an in-progress getopts::Options related to creating
266    /// a database connection.
267    ///
268    /// # Examples
269    ///
270    /// Without using append_options:
271    ///
272    /// ```should_panic
273    /// let options = getopts::Options::new();
274    ///
275    /// // The following line will panic because options doesn't know about --db-host
276    /// let matches = options.parse(["--db-host=my-host"]).unwrap(); // panics!
277    /// ```
278    ///
279    /// With append_options:
280    ///
281    /// ```
282    /// use evergreen::db::DatabaseConnection;
283    ///
284    /// let mut options = getopts::Options::new();
285    /// DatabaseConnection::append_options(&mut options);
286    ///
287    /// // The following line is okay, since append_options taught options about --db-host
288    /// let matches = options.parse(["--db-host=my-host"]).unwrap();
289    /// assert_eq!(matches.opt_str("db-host").unwrap(), "my-host");
290    /// ```
291    pub fn append_options(options: &mut getopts::Options) {
292        options.optopt("", "db-host", "Database Host", "DB_HOST");
293        options.optopt("", "db-port", "Database Port", "DB_PORT");
294        options.optopt("", "db-user", "Database User", "DB_USER");
295        options.optopt("", "db-pass", "Database Password", "DB_PASSWORD");
296        options.optopt("", "db-name", "Database Name", "DB_NAME");
297    }
298
299    pub fn builder() -> DatabaseConnectionBuilder {
300        DatabaseConnectionBuilder::new()
301    }
302
303    /// Create a new DB connection from a set of gettops matches.
304    ///
305    /// # Example
306    ///
307    /// ```
308    /// use evergreen::db::DatabaseConnection;
309    ///
310    /// let mut options = getopts::Options::new();
311    /// DatabaseConnection::append_options(&mut options);
312    /// let matches = options.parse([
313    ///   "--db-host=my-host",
314    ///   "--db-port=1234",
315    ///   "--db-user=my-user",
316    ///   "--db-pass=my-password",
317    ///   "--db-name=evergreen-db"
318    /// ]).unwrap();
319    ///
320    /// let mut connection = DatabaseConnection::new_from_options(&matches);
321    ///
322    /// assert_eq!(connection.dsn(), "host=my-host port=1234 user=my-user dbname=evergreen-db password=my-password");
323    /// assert_eq!(connection.in_transaction(), false);
324    /// ```
325    pub fn new_from_options(params: &getopts::Matches) -> Self {
326        let mut builder = DatabaseConnectionBuilder::new();
327        builder.set_opts(params);
328        builder.build()
329    }
330
331    /// Our database connection string.
332    ///
333    /// * `redact` - Set the password to "[REDACTED]" in the connection string.
334    fn dsn_internal(&self, redact: bool) -> String {
335        let mut dsn = format!(
336            "host={} port={} user={} dbname={}",
337            self.host, self.port, self.user, self.database
338        );
339
340        if let Some(ref app) = self.application {
341            dsn += &format!(" application_name={}", app);
342        }
343
344        if redact {
345            dsn += " password=[REDACTED]";
346        } else if let Some(ref p) = self.password {
347            dsn += &format!(" password={}", p);
348        }
349
350        dsn
351    }
352
353    /// Our database connection string, including the password if available.
354    pub fn dsn(&self) -> String {
355        self.dsn_internal(false)
356    }
357
358    /// Our database connection string minus the password
359    fn redacted_dsn(&self) -> String {
360        self.dsn_internal(true)
361    }
362
363    /// Mutable client ref
364    ///
365    /// Panics if the client is not yet connected / created.
366    pub fn client(&mut self) -> &mut pg::Client {
367        if self.client.is_none() {
368            panic!("DatabaseConnection is not connected!");
369        }
370
371        self.client.as_mut().unwrap()
372    }
373
374    /// Connect to the database
375    ///
376    /// Non-TLS connections only supported at present.
377    pub fn connect(&mut self) -> EgResult<()> {
378        let dsn = self.dsn();
379        debug!("Connecting to DB {dsn}");
380
381        match pg::Client::connect(&dsn, pg::NoTls) {
382            Ok(c) => {
383                self.client = Some(c);
384                Ok(())
385            }
386            Err(e) => Err(format!(
387                "Error connecting to database with params {}: {e}",
388                self.redacted_dsn()
389            )
390            .into()),
391        }
392    }
393
394    pub fn disconnect(&mut self) {
395        debug!("Disconnecting from DB {}", self.dsn());
396        self.client = None;
397    }
398
399    /// Disconect + connect to PG.
400    ///
401    /// Useful for releasing PG resources mid-script.
402    pub fn reconnect(&mut self) -> EgResult<()> {
403        self.disconnect();
404        self.connect()
405    }
406
407    /// Clone everything except the actual PG client.
408    ///
409    /// Let the caller decide when/if/how a new connection to the database
410    /// is created.
411    pub fn partial_clone(&self) -> DatabaseConnection {
412        DatabaseConnection {
413            client: None,
414            host: self.host.clone(),
415            port: self.port,
416            user: self.user.clone(),
417            password: self.password.clone(),
418            database: self.database.clone(),
419            application: self.application.clone(),
420            in_transaction: false,
421        }
422    }
423
424    pub fn in_transaction(&self) -> bool {
425        self.in_transaction
426    }
427
428    /// Start a new transaction on this database connection.
429    ///
430    /// There is a pg::Transaction object we could use instead, but it's
431    /// a wrapper around the pg::Client, which we also have a reference
432    /// to, so it causes all kinds of hassle with lifetimes and RefCell
433    /// borrows.  This means we can only have one open transaction per
434    /// DatabaseConnection.
435    pub fn xact_begin(&mut self) -> EgResult<()> {
436        if self.in_transaction {
437            return Err("DatabaseConnection is already in a transaction".into());
438        }
439        self.in_transaction = true;
440        match self.client().execute("BEGIN", &[]) {
441            Ok(_) => Ok(()),
442            Err(e) => Err(format!("BEGIN transaction error: {e}").into()),
443        }
444    }
445
446    pub fn xact_commit(&mut self) -> EgResult<()> {
447        if !self.in_transaction {
448            return Err("DatabaseConnection has no transaction to commit".to_string())?;
449        }
450        self.in_transaction = false;
451        match self.client().execute("COMMIT", &[]) {
452            Ok(_) => Ok(()),
453            Err(e) => Err(format!("COMMIT transaction error: {e}").into()),
454        }
455    }
456
457    pub fn xact_rollback(&mut self) -> EgResult<()> {
458        if !self.in_transaction {
459            log::warn!("No transaction to roll back");
460            return Ok(()); // error as well?
461        }
462        self.in_transaction = false;
463        match self.client().execute("ROLLBACK", &[]) {
464            Ok(_) => Ok(()),
465            Err(e) => Err(format!("ROLLBACK transaction error: {e}").into()),
466        }
467    }
468
469    pub fn into_shared(self) -> Rc<RefCell<DatabaseConnection>> {
470        Rc::new(RefCell::new(self))
471    }
472}
473
474/// Determine whether a string is potentially a valid SQL identifier.
475pub fn is_identifier(s: &str) -> bool {
476    let s = s.trim();
477    for c in s.chars() {
478        // NOTE: is the 'ascii' check to strict?
479        if c.is_ascii_alphanumeric() || c == '$' || c == '_' || c == '.' {
480            // OK
481        } else {
482            return false;
483        }
484    }
485    !s.is_empty()
486}
487
488/// Verify a query operator provided by the caller is allowed.
489pub fn is_supported_operator(op: &str) -> bool {
490    SUPPORTED_OPERATORS.contains(&op.to_uppercase().as_str())
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    #[test]
498    fn test_it_can_redact_dsn() {
499        let mut builder = DatabaseConnection::builder();
500        builder.set_password("my_secret_password");
501        let connection = builder.build();
502
503        assert_eq!(
504            connection.redacted_dsn(),
505            "host=localhost port=5432 user=evergreen dbname=evergreen password=[REDACTED]"
506        );
507        assert!(connection.dsn().contains("my_secret_password"));
508        assert!(!connection.redacted_dsn().contains("my_secret_password"));
509    }
510}