Skip to main content

puerto_sqlx/
lib.rs

1//! The Postgres adapter every Puerto project with a database shares (ADR-0017): the connection
2//! pool, migrations at startup, the readiness ping and the one mapping from driver errors to
3//! [`RepositoryError`].
4//!
5//! The migrations are the project's own: `sqlx::migrate!` embeds a directory at compile time, so
6//! the project declares its [`Migrator`] and hands it over. A generated `infrastructure/src/db.rs`
7//! is
8//!
9//! ```ignore
10//! pub use puerto_sqlx::*;
11//!
12//! pub static MIGRATOR: sqlx::migrate::Migrator = sqlx::migrate!("./migrations");
13//! ```
14//!
15//! and its repositories call `crate::db::map_sqlx_error`.
16
17use puerto_core::RepositoryError;
18use sqlx::migrate::Migrator;
19use sqlx::{PgPool, postgres::PgPoolOptions};
20use std::time::Duration;
21
22/// Why the database could not be used at startup.
23#[derive(Debug, thiserror::Error)]
24#[non_exhaustive]
25pub enum DatabaseError {
26    #[error("database.connection_error")]
27    ConnectionError,
28    #[error("database.migration_error")]
29    MigrationError,
30}
31
32pub async fn create_postgres_pool(database_url: &str) -> Result<PgPool, DatabaseError> {
33    PgPoolOptions::new()
34        .max_connections(5)
35        .acquire_timeout(Duration::from_secs(30))
36        .connect(database_url)
37        .await
38        .map_err(|_| DatabaseError::ConnectionError)
39}
40
41/// Applies the project's pending migrations — `migrator` is its `sqlx::migrate!("./migrations")`.
42pub async fn run_migrations(pool: &PgPool, migrator: &Migrator) -> Result<(), DatabaseError> {
43    migrator
44        .run(pool)
45        .await
46        .map_err(|_| DatabaseError::MigrationError)
47}
48
49/// Whether the database answers a trivial query — what `/health/ready` reports.
50///
51/// Bounded by the pool's acquire timeout, so a database that is down makes the probe slow to
52/// answer rather than hang forever.
53pub async fn ping(pool: &PgPool) -> bool {
54    sqlx::query("SELECT 1").execute(pool).await.is_ok()
55}
56
57/// The one place that knows Postgres error codes: a driver error, as the repository port speaks it.
58/// The caller logs it first, with its own context.
59pub fn map_sqlx_error(err: &sqlx::Error) -> RepositoryError {
60    // 23505 = unique_violation: a `unique` column already taken. (No let-chain: `rust-version`.)
61    let unique_violation = err
62        .as_database_error()
63        .and_then(|db_err| db_err.code())
64        .is_some_and(|code| code == "23505");
65    if unique_violation {
66        RepositoryError::Duplicate
67    } else {
68        RepositoryError::Unavailable
69    }
70}
71
72#[cfg(test)]
73mod tests {
74    use super::*;
75
76    /// A pool that never reaches a server: nothing listens on port 1.
77    fn unreachable_pool() -> PgPool {
78        PgPoolOptions::new()
79            .acquire_timeout(Duration::from_millis(200))
80            .connect_lazy("postgres://puerto@127.0.0.1:1/puerto")
81            .unwrap()
82    }
83
84    /// A driver error carrying a Postgres SQLSTATE, without a server.
85    #[derive(Debug)]
86    struct FakeDbError(&'static str);
87
88    impl std::fmt::Display for FakeDbError {
89        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90            write!(f, "SQLSTATE {}", self.0)
91        }
92    }
93
94    impl std::error::Error for FakeDbError {}
95
96    impl sqlx::error::DatabaseError for FakeDbError {
97        fn message(&self) -> &str {
98            self.0
99        }
100        fn code(&self) -> Option<std::borrow::Cow<'_, str>> {
101            Some(self.0.into())
102        }
103        fn as_error(&self) -> &(dyn std::error::Error + Send + Sync + 'static) {
104            self
105        }
106        fn as_error_mut(&mut self) -> &mut (dyn std::error::Error + Send + Sync + 'static) {
107            self
108        }
109        fn into_error(self: Box<Self>) -> Box<dyn std::error::Error + Send + Sync + 'static> {
110            self
111        }
112        fn kind(&self) -> sqlx::error::ErrorKind {
113            sqlx::error::ErrorKind::Other
114        }
115    }
116
117    fn database_error(code: &'static str) -> sqlx::Error {
118        sqlx::Error::Database(Box::new(FakeDbError(code)))
119    }
120
121    #[test]
122    fn should_map_a_unique_violation_to_duplicate() {
123        assert_eq!(
124            map_sqlx_error(&database_error("23505")),
125            RepositoryError::Duplicate
126        );
127    }
128
129    #[test]
130    fn should_map_another_database_error_to_unavailable() {
131        // 23503 = foreign_key_violation: a constraint, but not a taken unique value.
132        assert_eq!(
133            map_sqlx_error(&database_error("23503")),
134            RepositoryError::Unavailable
135        );
136    }
137
138    #[test]
139    fn should_map_a_non_database_error_to_unavailable() {
140        assert_eq!(
141            map_sqlx_error(&sqlx::Error::PoolTimedOut),
142            RepositoryError::Unavailable
143        );
144    }
145
146    #[tokio::test]
147    async fn should_report_not_ready_when_the_database_is_unreachable() {
148        assert!(!ping(&unreachable_pool()).await);
149    }
150
151    #[tokio::test]
152    async fn should_reject_a_database_url_that_is_not_one() {
153        let result = create_postgres_pool("not a database url").await;
154
155        assert!(matches!(result, Err(DatabaseError::ConnectionError)));
156    }
157
158    #[test]
159    fn should_keep_the_machine_readable_codes() {
160        assert_eq!(
161            DatabaseError::ConnectionError.to_string(),
162            "database.connection_error"
163        );
164        assert_eq!(
165            DatabaseError::MigrationError.to_string(),
166            "database.migration_error"
167        );
168    }
169
170    // ── Against a real server: `make test/db` ─────────────────────────────
171
172    async fn test_database() -> Option<PgPool> {
173        let server = std::env::var("PUERTO_TEST_DATABASE_URL").ok()?;
174        Some(
175            create_postgres_pool(&format!("{server}/postgres"))
176                .await
177                .unwrap(),
178        )
179    }
180
181    #[tokio::test]
182    #[ignore = "needs Postgres: make test/db"]
183    async fn postgres_unique_violation_maps_to_duplicate() {
184        let Some(pool) = test_database().await else {
185            eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
186            return;
187        };
188        let mut conn = pool.acquire().await.unwrap();
189        // A temporary table lives and dies with this connection: nothing to clean up.
190        sqlx::query("CREATE TEMPORARY TABLE skus (sku TEXT UNIQUE)")
191            .execute(&mut *conn)
192            .await
193            .unwrap();
194        sqlx::query("INSERT INTO skus VALUES ('A-1')")
195            .execute(&mut *conn)
196            .await
197            .unwrap();
198
199        let err = sqlx::query("INSERT INTO skus VALUES ('A-1')")
200            .execute(&mut *conn)
201            .await
202            .unwrap_err();
203
204        assert_eq!(map_sqlx_error(&err), RepositoryError::Duplicate);
205    }
206
207    #[tokio::test]
208    #[ignore = "needs Postgres: make test/db"]
209    async fn postgres_ping_reports_ready() {
210        let Some(pool) = test_database().await else {
211            eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
212            return;
213        };
214
215        assert!(ping(&pool).await);
216    }
217}