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}