use std::{
ops::{Deref, DerefMut},
time::Duration,
};
pub use sqlx;
use sqlx::{
AssertSqlSafe, PgConnection, PgPool, Postgres, Row,
migrate::{MigrateError, Migrator},
pool::PoolConnection,
postgres::PgPoolOptions,
};
pub type Pool = PgPool;
pub type Error = sqlx::Error;
#[derive(Clone, PartialEq, Eq)]
pub struct PoolConfig {
pub uri: String,
pub min_connections: u32,
pub max_connections: u32,
pub connect_timeout_ms: u64,
pub idle_timeout_ms: u64,
}
impl std::fmt::Debug for PoolConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("PoolConfig")
.field("min_connections", &self.min_connections)
.field("max_connections", &self.max_connections)
.field("connect_timeout_ms", &self.connect_timeout_ms)
.field("idle_timeout_ms", &self.idle_timeout_ms)
.finish_non_exhaustive()
}
}
impl PoolConfig {
#[must_use]
pub fn new(uri: impl Into<String>) -> Self {
Self {
uri: uri.into(),
min_connections: 1,
max_connections: 10,
connect_timeout_ms: 5_000,
idle_timeout_ms: 600_000,
}
}
#[must_use]
pub fn options(&self) -> PgPoolOptions {
PgPoolOptions::new()
.min_connections(self.min_connections)
.max_connections(self.max_connections)
.acquire_timeout(Duration::from_millis(self.connect_timeout_ms))
.idle_timeout(Duration::from_millis(self.idle_timeout_ms))
}
}
pub async fn connect(config: &PoolConfig) -> Result<PgPool, sqlx::Error> {
config.options().connect(&config.uri).await
}
pub async fn ping(pool: &PgPool) -> Result<(), sqlx::Error> {
sqlx::query("SELECT 1").execute(pool).await?;
Ok(())
}
pub enum Db<'a> {
Pool(&'a PgPool),
Conn(&'a mut PgConnection),
}
impl<'a> From<&'a PgPool> for Db<'a> {
fn from(pool: &'a PgPool) -> Self {
Self::Pool(pool)
}
}
impl<'a> From<&'a mut PgConnection> for Db<'a> {
fn from(conn: &'a mut PgConnection) -> Self {
Self::Conn(conn)
}
}
impl<'a> Db<'a> {
pub async fn acquire(self) -> Result<Conn<'a>, sqlx::Error> {
match self {
Self::Pool(pool) => Ok(Conn::Pooled(pool.acquire().await?)),
Self::Conn(conn) => Ok(Conn::Borrowed(conn)),
}
}
}
pub enum Conn<'a> {
Pooled(PoolConnection<Postgres>),
Borrowed(&'a mut PgConnection),
}
impl Deref for Conn<'_> {
type Target = PgConnection;
fn deref(&self) -> &PgConnection {
match self {
Self::Pooled(conn) => conn,
Self::Borrowed(conn) => conn,
}
}
}
impl DerefMut for Conn<'_> {
fn deref_mut(&mut self) -> &mut PgConnection {
match self {
Self::Pooled(conn) => conn,
Self::Borrowed(conn) => conn,
}
}
}
#[must_use]
pub fn is_unique_violation(error: &sqlx::Error) -> bool {
error
.as_database_error()
.is_some_and(sqlx::error::DatabaseError::is_unique_violation)
}
#[must_use]
pub fn quote_identifier(name: &str) -> Option<String> {
let mut chars = name.chars();
let first = chars.next()?;
let valid = (first.is_ascii_lowercase() || first == '_')
&& chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_');
valid.then(|| format!("\"{name}\""))
}
fn quote_all<S: AsRef<str>>(names: &[S]) -> Result<Vec<String>, sqlx::Error> {
names
.iter()
.map(|name| {
quote_identifier(name.as_ref()).ok_or_else(|| {
sqlx::Error::Protocol(format!("invalid identifier: {}", name.as_ref()))
})
})
.collect()
}
pub async fn migrate(pool: &PgPool, migrator: &Migrator) -> Result<(), MigrateError> {
migrator.run(pool).await
}
async fn applied_versions(pool: &PgPool) -> Result<Vec<i64>, sqlx::Error> {
let exists: bool = sqlx::query("SELECT to_regclass('_sqlx_migrations') IS NOT NULL AS found")
.fetch_one(pool)
.await?
.try_get("found")?;
if !exists {
return Ok(Vec::new());
}
let rows =
sqlx::query("SELECT version FROM _sqlx_migrations WHERE success ORDER BY version DESC")
.fetch_all(pool)
.await?;
rows.iter().map(|row| row.try_get("version")).collect()
}
pub async fn revert(pool: &PgPool, migrator: &Migrator, steps: u32) -> Result<(), MigrateError> {
let applied = applied_versions(pool).await?;
let steps = usize::try_from(steps).unwrap_or(usize::MAX);
let target = applied.get(steps).copied().unwrap_or(0);
migrator.undo(pool, target).await
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MigrationStatus {
pub version: i64,
pub description: String,
pub applied: bool,
}
pub async fn status(
pool: &PgPool,
migrator: &Migrator,
) -> Result<Vec<MigrationStatus>, sqlx::Error> {
let applied = applied_versions(pool).await?;
Ok(migrator
.iter()
.filter(|migration| !migration.migration_type.is_down_migration())
.map(|migration| MigrationStatus {
version: migration.version,
description: migration.description.to_string(),
applied: applied.contains(&migration.version),
})
.collect())
}
pub async fn pending(pool: &PgPool, migrator: &Migrator) -> Result<Vec<String>, sqlx::Error> {
Ok(status(pool, migrator)
.await?
.into_iter()
.filter(|migration| !migration.applied)
.map(|migration| format!("{}_{}", migration.version, migration.description))
.collect())
}
pub async fn truncate<S: AsRef<str>>(pool: &PgPool, tables: &[S]) -> Result<(), sqlx::Error> {
if tables.is_empty() {
return Ok(());
}
let tables = quote_all(tables)?.join(", ");
let sql = format!("TRUNCATE {tables} RESTART IDENTITY CASCADE");
sqlx::query(AssertSqlSafe(sql)).execute(pool).await?;
Ok(())
}
pub async fn dangerously_recreate(pool: &PgPool, migrator: &Migrator) -> Result<(), MigrateError> {
let tables: Vec<String> =
sqlx::query("SELECT tablename::text AS name FROM pg_tables WHERE schemaname = 'public'")
.fetch_all(pool)
.await?
.iter()
.map(|row| row.try_get("name"))
.collect::<Result<_, _>>()?;
let types: Vec<String> = sqlx::query(
"SELECT t.typname::text AS name FROM pg_type t
JOIN pg_namespace n ON n.oid = t.typnamespace
WHERE t.typtype = 'e' AND n.nspname = 'public'
AND NOT EXISTS (
SELECT 1 FROM pg_depend d WHERE d.objid = t.oid AND d.deptype = 'e'
)",
)
.fetch_all(pool)
.await?
.iter()
.map(|row| row.try_get("name"))
.collect::<Result<_, _>>()?;
if !tables.is_empty() {
let names = quote_all(&tables)?.join(", ");
let sql = format!("DROP TABLE {names} CASCADE");
sqlx::query(AssertSqlSafe(sql)).execute(pool).await?;
}
for name in quote_all(&types)? {
let sql = format!("DROP TYPE {name} CASCADE");
sqlx::query(AssertSqlSafe(sql)).execute(pool).await?;
}
migrate(pool, migrator).await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn debug_text_leaves_out_the_connection_string() {
let text = format!("{:?}", PoolConfig::new("postgres://me:hunter2@db/app"));
assert!(!text.contains("hunter2"), "{text}");
assert!(text.contains("max_connections"), "{text}");
}
#[test]
fn identifiers_are_quoted() {
assert_eq!(quote_identifier("users").as_deref(), Some("\"users\""));
assert_eq!(quote_identifier("_a1").as_deref(), Some("\"_a1\""));
}
#[test]
fn unsafe_identifiers_are_refused() {
for name in [
"", "1users", "Users", "a b", "a\"b", "a;b", "a.b", "users--", "é",
] {
assert_eq!(quote_identifier(name), None, "{name:?}");
}
}
#[test]
fn quote_all_fails_on_any_bad_name() {
assert!(quote_all(&["good", "also_good"]).is_ok());
assert!(quote_all(&["good", "bad name"]).is_err());
}
#[test]
fn pool_settings_reach_the_options() {
let config = PoolConfig {
min_connections: 2,
max_connections: 7,
connect_timeout_ms: 1_500,
idle_timeout_ms: 9_000,
..PoolConfig::new("postgres://x")
};
let options = config.options();
assert_eq!(options.get_min_connections(), 2);
assert_eq!(options.get_max_connections(), 7);
assert_eq!(options.get_acquire_timeout(), Duration::from_millis(1_500));
assert_eq!(options.get_idle_timeout(), Some(Duration::from_secs(9)));
}
#[test]
fn errors_that_are_not_from_the_database_are_not_unique_violations() {
assert!(!is_unique_violation(&sqlx::Error::RowNotFound));
}
}