Skip to main content

minco_sqlx_postgres/
lib.rs

1//! Bounded `SQLx` `PostgreSQL` pools for local servers and serverless runtimes.
2#![forbid(unsafe_code)]
3
4use serde::{Deserialize, Serialize};
5pub use sqlx::PgPool;
6use sqlx::postgres::PgPoolOptions;
7use std::{path::Path, time::Duration};
8use thiserror::Error;
9
10pub mod plugin_adapters;
11
12#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct PostgresPoolConfig {
14    pub url: String,
15    pub max_connections: u32,
16    pub acquire_timeout_seconds: u64,
17    pub idle_timeout_seconds: u64,
18}
19
20impl std::fmt::Debug for PostgresPoolConfig {
21    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
22        formatter
23            .debug_struct("PostgresPoolConfig")
24            .field("url", &"[REDACTED DATABASE URL]")
25            .field("max_connections", &self.max_connections)
26            .field("acquire_timeout_seconds", &self.acquire_timeout_seconds)
27            .field("idle_timeout_seconds", &self.idle_timeout_seconds)
28            .finish()
29    }
30}
31
32impl PostgresPoolConfig {
33    pub fn serverless(url: impl Into<String>) -> Self {
34        Self {
35            url: url.into(),
36            max_connections: 2,
37            acquire_timeout_seconds: 5,
38            idle_timeout_seconds: 60,
39        }
40    }
41    pub fn validate(&self) -> Result<(), PostgresError> {
42        if self.url.trim().is_empty() {
43            return Err(PostgresError::InvalidConfig("database URL is empty".into()));
44        }
45        if self.max_connections == 0 {
46            return Err(PostgresError::InvalidConfig(
47                "max_connections must be at least 1".into(),
48            ));
49        }
50        Ok(())
51    }
52}
53
54pub async fn connect(config: &PostgresPoolConfig) -> Result<PgPool, PostgresError> {
55    config.validate()?;
56    Ok(PgPoolOptions::new()
57        .min_connections(0)
58        .max_connections(config.max_connections)
59        .acquire_timeout(Duration::from_secs(config.acquire_timeout_seconds))
60        .idle_timeout(Some(Duration::from_secs(config.idle_timeout_seconds)))
61        .connect(&config.url)
62        .await?)
63}
64
65pub fn connect_lazy(config: &PostgresPoolConfig) -> Result<PgPool, PostgresError> {
66    config.validate()?;
67    Ok(PgPoolOptions::new()
68        .min_connections(0)
69        .max_connections(config.max_connections)
70        .acquire_timeout(Duration::from_secs(config.acquire_timeout_seconds))
71        .idle_timeout(Some(Duration::from_secs(config.idle_timeout_seconds)))
72        .connect_lazy(&config.url)?)
73}
74
75pub async fn migrate(pool: &PgPool, path: impl AsRef<Path>) -> Result<(), PostgresError> {
76    let migrator = sqlx::migrate::Migrator::new(path.as_ref()).await?;
77    migrator.run(pool).await?;
78    Ok(())
79}
80
81pub async fn migrate_with_history_table(
82    pool: &PgPool,
83    path: impl AsRef<Path>,
84    history_table: &'static str,
85) -> Result<(), PostgresError> {
86    validate_identifier(history_table, "migration history table")?;
87    let mut migrator = sqlx::migrate::Migrator::new(path.as_ref()).await?;
88    migrator.dangerous_set_table_name(history_table);
89    migrator.run(pool).await?;
90    Ok(())
91}
92
93pub async fn ready(pool: &PgPool) -> bool {
94    matches!(
95        sqlx::query_scalar::<_, i32>("SELECT 1")
96            .fetch_one(pool)
97            .await,
98        Ok(1)
99    )
100}
101
102fn validate_identifier(value: &str, description: &str) -> Result<(), PostgresError> {
103    let mut bytes = value.bytes();
104    let valid_start = bytes
105        .next()
106        .is_some_and(|byte| byte.is_ascii_alphabetic() || byte == b'_');
107    if !valid_start
108        || value.len() > 63
109        || !bytes.all(|byte| byte.is_ascii_alphanumeric() || byte == b'_')
110    {
111        return Err(PostgresError::InvalidConfig(format!(
112            "{description} must be a PostgreSQL identifier of at most 63 ASCII characters"
113        )));
114    }
115    Ok(())
116}
117
118#[derive(Debug, Error)]
119pub enum PostgresError {
120    #[error("invalid PostgreSQL configuration: {0}")]
121    InvalidConfig(String),
122    #[error("PostgreSQL error: {0}")]
123    Sqlx(#[from] sqlx::Error),
124    #[error("PostgreSQL migration error: {0}")]
125    Migration(#[from] sqlx::migrate::MigrateError),
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    #[test]
132    fn serverless_defaults_bound_connection_pressure() {
133        let config = PostgresPoolConfig::serverless("postgres://example.invalid/db");
134        assert_eq!(config.max_connections, 2);
135        assert_eq!(config.acquire_timeout_seconds, 5);
136    }
137
138    #[test]
139    fn pool_configuration_debug_redacts_database_credentials() {
140        let config =
141            PostgresPoolConfig::serverless("postgres://minco:secret-password@example.invalid/db");
142        let debug = format!("{config:?}");
143        assert!(!debug.contains("secret-password"));
144        assert!(!debug.contains("postgres://"));
145    }
146
147    #[tokio::test]
148    async fn migration_history_table_rejects_dynamic_sql_tokens() {
149        let config = PostgresPoolConfig::serverless("postgres://example.invalid/db");
150        let pool = connect_lazy(&config).expect("lazy pool");
151        let result =
152            migrate_with_history_table(&pool, Path::new("missing"), "_migrations;DROP").await;
153        assert!(matches!(result, Err(PostgresError::InvalidConfig(_))));
154    }
155}