1use std::{
20 ops::{Deref, DerefMut},
21 time::Duration,
22};
23
24pub use sqlx;
25use sqlx::{
26 AssertSqlSafe, PgConnection, PgPool, Postgres, Row,
27 migrate::{MigrateError, Migrator},
28 pool::PoolConnection,
29 postgres::PgPoolOptions,
30};
31
32#[derive(Clone, PartialEq, Eq)]
34pub struct PoolConfig {
35 pub uri: String,
37 pub min_connections: u32,
39 pub max_connections: u32,
41 pub connect_timeout_ms: u64,
43 pub idle_timeout_ms: u64,
45}
46
47impl std::fmt::Debug for PoolConfig {
48 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 f.debug_struct("PoolConfig")
51 .field("min_connections", &self.min_connections)
52 .field("max_connections", &self.max_connections)
53 .field("connect_timeout_ms", &self.connect_timeout_ms)
54 .field("idle_timeout_ms", &self.idle_timeout_ms)
55 .finish_non_exhaustive()
56 }
57}
58
59impl PoolConfig {
60 #[must_use]
62 pub fn new(uri: impl Into<String>) -> Self {
63 Self {
64 uri: uri.into(),
65 min_connections: 1,
66 max_connections: 10,
67 connect_timeout_ms: 5_000,
68 idle_timeout_ms: 600_000,
69 }
70 }
71
72 #[must_use]
74 pub fn options(&self) -> PgPoolOptions {
75 PgPoolOptions::new()
76 .min_connections(self.min_connections)
77 .max_connections(self.max_connections)
78 .acquire_timeout(Duration::from_millis(self.connect_timeout_ms))
79 .idle_timeout(Duration::from_millis(self.idle_timeout_ms))
80 }
81}
82
83pub async fn connect(config: &PoolConfig) -> Result<PgPool, sqlx::Error> {
89 config.options().connect(&config.uri).await
90}
91
92pub async fn ping(pool: &PgPool) -> Result<(), sqlx::Error> {
98 sqlx::query("SELECT 1").execute(pool).await?;
99 Ok(())
100}
101
102pub enum Db<'a> {
119 Pool(&'a PgPool),
121 Conn(&'a mut PgConnection),
123}
124
125impl<'a> From<&'a PgPool> for Db<'a> {
126 fn from(pool: &'a PgPool) -> Self {
127 Self::Pool(pool)
128 }
129}
130
131impl<'a> From<&'a mut PgConnection> for Db<'a> {
132 fn from(conn: &'a mut PgConnection) -> Self {
133 Self::Conn(conn)
134 }
135}
136
137impl<'a> Db<'a> {
138 pub async fn acquire(self) -> Result<Conn<'a>, sqlx::Error> {
144 match self {
145 Self::Pool(pool) => Ok(Conn::Pooled(pool.acquire().await?)),
146 Self::Conn(conn) => Ok(Conn::Borrowed(conn)),
147 }
148 }
149}
150
151pub enum Conn<'a> {
153 Pooled(PoolConnection<Postgres>),
155 Borrowed(&'a mut PgConnection),
157}
158
159impl Deref for Conn<'_> {
160 type Target = PgConnection;
161
162 fn deref(&self) -> &PgConnection {
163 match self {
164 Self::Pooled(conn) => conn,
165 Self::Borrowed(conn) => conn,
166 }
167 }
168}
169
170impl DerefMut for Conn<'_> {
171 fn deref_mut(&mut self) -> &mut PgConnection {
172 match self {
173 Self::Pooled(conn) => conn,
174 Self::Borrowed(conn) => conn,
175 }
176 }
177}
178
179#[must_use]
182pub fn is_unique_violation(error: &sqlx::Error) -> bool {
183 error
184 .as_database_error()
185 .is_some_and(sqlx::error::DatabaseError::is_unique_violation)
186}
187
188#[must_use]
200pub fn quote_identifier(name: &str) -> Option<String> {
201 let mut chars = name.chars();
202 let first = chars.next()?;
203 let valid = (first.is_ascii_lowercase() || first == '_')
204 && chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
205 valid.then(|| format!("\"{name}\""))
206}
207
208fn quote_all<S: AsRef<str>>(names: &[S]) -> Result<Vec<String>, sqlx::Error> {
210 names
211 .iter()
212 .map(|name| {
213 quote_identifier(name.as_ref()).ok_or_else(|| {
214 sqlx::Error::Protocol(format!("invalid identifier: {}", name.as_ref()))
215 })
216 })
217 .collect()
218}
219
220pub async fn migrate(pool: &PgPool, migrator: &Migrator) -> Result<(), MigrateError> {
227 migrator.run(pool).await
228}
229
230async fn applied_versions(pool: &PgPool) -> Result<Vec<i64>, sqlx::Error> {
233 let exists: bool = sqlx::query("SELECT to_regclass('_sqlx_migrations') IS NOT NULL AS found")
234 .fetch_one(pool)
235 .await?
236 .try_get("found")?;
237 if !exists {
238 return Ok(Vec::new());
239 }
240 let rows =
241 sqlx::query("SELECT version FROM _sqlx_migrations WHERE success ORDER BY version DESC")
242 .fetch_all(pool)
243 .await?;
244 rows.iter().map(|row| row.try_get("version")).collect()
245}
246
247pub async fn revert(pool: &PgPool, migrator: &Migrator, steps: u32) -> Result<(), MigrateError> {
253 let applied = applied_versions(pool).await?;
254 let steps = usize::try_from(steps).unwrap_or(usize::MAX);
255 let target = applied.get(steps).copied().unwrap_or(0);
258 migrator.undo(pool, target).await
259}
260
261#[derive(Debug, Clone, PartialEq, Eq)]
263pub struct MigrationStatus {
264 pub version: i64,
266 pub description: String,
268 pub applied: bool,
270}
271
272pub async fn status(
278 pool: &PgPool,
279 migrator: &Migrator,
280) -> Result<Vec<MigrationStatus>, sqlx::Error> {
281 let applied = applied_versions(pool).await?;
282 Ok(migrator
283 .iter()
284 .filter(|migration| !migration.migration_type.is_down_migration())
285 .map(|migration| MigrationStatus {
286 version: migration.version,
287 description: migration.description.to_string(),
288 applied: applied.contains(&migration.version),
289 })
290 .collect())
291}
292
293pub async fn truncate<S: AsRef<str>>(pool: &PgPool, tables: &[S]) -> Result<(), sqlx::Error> {
300 if tables.is_empty() {
301 return Ok(());
302 }
303 let tables = quote_all(tables)?.join(", ");
304 let sql = format!("TRUNCATE {tables} RESTART IDENTITY CASCADE");
305 sqlx::query(AssertSqlSafe(sql)).execute(pool).await?;
306 Ok(())
307}
308
309pub async fn dangerously_recreate(pool: &PgPool, migrator: &Migrator) -> Result<(), MigrateError> {
324 let tables: Vec<String> =
325 sqlx::query("SELECT tablename::text AS name FROM pg_tables WHERE schemaname = 'public'")
326 .fetch_all(pool)
327 .await?
328 .iter()
329 .map(|row| row.try_get("name"))
330 .collect::<Result<_, _>>()?;
331 let types: Vec<String> = sqlx::query(
333 "SELECT t.typname::text AS name FROM pg_type t
334 JOIN pg_namespace n ON n.oid = t.typnamespace
335 WHERE t.typtype = 'e' AND n.nspname = 'public'
336 AND NOT EXISTS (
337 SELECT 1 FROM pg_depend d WHERE d.objid = t.oid AND d.deptype = 'e'
338 )",
339 )
340 .fetch_all(pool)
341 .await?
342 .iter()
343 .map(|row| row.try_get("name"))
344 .collect::<Result<_, _>>()?;
345
346 if !tables.is_empty() {
347 let names = quote_all(&tables)?.join(", ");
348 let sql = format!("DROP TABLE {names} CASCADE");
349 sqlx::query(AssertSqlSafe(sql)).execute(pool).await?;
350 }
351 for name in quote_all(&types)? {
352 let sql = format!("DROP TYPE {name} CASCADE");
353 sqlx::query(AssertSqlSafe(sql)).execute(pool).await?;
354 }
355 migrate(pool, migrator).await
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn debug_text_leaves_out_the_connection_string() {
364 let text = format!("{:?}", PoolConfig::new("postgres://me:hunter2@db/app"));
365 assert!(!text.contains("hunter2"), "{text}");
366 assert!(text.contains("max_connections"), "{text}");
367 }
368
369 #[test]
370 fn identifiers_are_quoted() {
371 assert_eq!(quote_identifier("users").as_deref(), Some("\"users\""));
372 assert_eq!(quote_identifier("_a1").as_deref(), Some("\"_a1\""));
373 }
374
375 #[test]
376 fn unsafe_identifiers_are_refused() {
377 for name in [
378 "", "1users", "Users", "a b", "a\"b", "a;b", "a.b", "users--", "é",
379 ] {
380 assert_eq!(quote_identifier(name), None, "{name:?}");
381 }
382 }
383
384 #[test]
385 fn quote_all_fails_on_any_bad_name() {
386 assert!(quote_all(&["good", "also_good"]).is_ok());
387 assert!(quote_all(&["good", "bad name"]).is_err());
388 }
389
390 #[test]
391 fn pool_settings_reach_the_options() {
392 let config = PoolConfig {
393 min_connections: 2,
394 max_connections: 7,
395 connect_timeout_ms: 1_500,
396 idle_timeout_ms: 9_000,
397 ..PoolConfig::new("postgres://x")
398 };
399 let options = config.options();
400 assert_eq!(options.get_min_connections(), 2);
401 assert_eq!(options.get_max_connections(), 7);
402 assert_eq!(options.get_acquire_timeout(), Duration::from_millis(1_500));
403 assert_eq!(options.get_idle_timeout(), Some(Duration::from_secs(9)));
404 }
405
406 #[test]
407 fn errors_that_are_not_from_the_database_are_not_unique_violations() {
408 assert!(!is_unique_violation(&sqlx::Error::RowNotFound));
409 }
410}