1pub mod events;
22pub mod uow;
23
24use puerto_core::RepositoryError;
25use sqlx::migrate::Migrator;
26use sqlx::postgres::{PgConnectOptions, PgConnection, PgPoolOptions};
27use sqlx::{Connection, PgPool};
28use std::time::Duration;
29
30pub const OUTBOX_MIGRATION: &str = include_str!("../migrations/outbox.sql");
33
34#[derive(Debug, thiserror::Error)]
37#[non_exhaustive]
38pub enum DatabaseError {
39 #[error("cannot connect to the database at {target}: {cause}")]
41 ConnectionError { target: String, cause: String },
42 #[error("cannot apply the database migrations: {cause}")]
43 MigrationError { cause: String },
44}
45
46pub async fn create_postgres_pool(database_url: &str) -> Result<PgPool, DatabaseError> {
47 let failed = |e: sqlx::Error| DatabaseError::ConnectionError {
48 target: describe_url(database_url),
49 cause: e.to_string(),
50 };
51 let options: PgConnectOptions = database_url.parse().map_err(failed)?;
52 let probe = PgConnection::connect_with(&options).await.map_err(failed)?;
55 let _ = probe.close().await;
56 Ok(PgPoolOptions::new()
57 .max_connections(5)
58 .acquire_timeout(Duration::from_secs(30))
59 .connect_lazy_with(options))
60}
61
62fn describe_url(url: &str) -> String {
65 let rest = url.split_once("://").map_or(url, |(_, rest)| rest);
66 let rest = rest.rsplit_once('@').map_or(rest, |(_, rest)| rest);
67 rest.split('?').next().unwrap_or(rest).to_string()
68}
69
70pub async fn run_migrations(pool: &PgPool, migrator: &Migrator) -> Result<(), DatabaseError> {
72 migrator
73 .run(pool)
74 .await
75 .map_err(|e| DatabaseError::MigrationError {
76 cause: e.to_string(),
77 })
78}
79
80pub async fn ping(pool: &PgPool) -> bool {
85 sqlx::query("SELECT 1").execute(pool).await.is_ok()
86}
87
88pub fn map_sqlx_error(err: &sqlx::Error) -> RepositoryError {
91 let unique_violation = err
93 .as_database_error()
94 .and_then(|db_err| db_err.code())
95 .is_some_and(|code| code == "23505");
96 if unique_violation {
97 RepositoryError::Duplicate
98 } else {
99 RepositoryError::Unavailable
100 }
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 fn unreachable_pool() -> PgPool {
109 PgPoolOptions::new()
110 .acquire_timeout(Duration::from_millis(200))
111 .connect_lazy("postgres://puerto@127.0.0.1:1/puerto")
112 .unwrap()
113 }
114
115 #[derive(Debug)]
117 struct FakeDbError(&'static str);
118
119 impl std::fmt::Display for FakeDbError {
120 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
121 write!(f, "SQLSTATE {}", self.0)
122 }
123 }
124
125 impl std::error::Error for FakeDbError {}
126
127 impl sqlx::error::DatabaseError for FakeDbError {
128 fn message(&self) -> &str {
129 self.0
130 }
131 fn code(&self) -> Option<std::borrow::Cow<'_, str>> {
132 Some(self.0.into())
133 }
134 fn as_error(&self) -> &(dyn std::error::Error + Send + Sync + 'static) {
135 self
136 }
137 fn as_error_mut(&mut self) -> &mut (dyn std::error::Error + Send + Sync + 'static) {
138 self
139 }
140 fn into_error(self: Box<Self>) -> Box<dyn std::error::Error + Send + Sync + 'static> {
141 self
142 }
143 fn kind(&self) -> sqlx::error::ErrorKind {
144 sqlx::error::ErrorKind::Other
145 }
146 }
147
148 fn database_error(code: &'static str) -> sqlx::Error {
149 sqlx::Error::Database(Box::new(FakeDbError(code)))
150 }
151
152 #[test]
153 fn should_map_a_unique_violation_to_duplicate() {
154 assert_eq!(
155 map_sqlx_error(&database_error("23505")),
156 RepositoryError::Duplicate
157 );
158 }
159
160 #[test]
161 fn should_map_another_database_error_to_unavailable() {
162 assert_eq!(
164 map_sqlx_error(&database_error("23503")),
165 RepositoryError::Unavailable
166 );
167 }
168
169 #[test]
170 fn should_map_a_non_database_error_to_unavailable() {
171 assert_eq!(
172 map_sqlx_error(&sqlx::Error::PoolTimedOut),
173 RepositoryError::Unavailable
174 );
175 }
176
177 #[tokio::test]
178 async fn should_report_not_ready_when_the_database_is_unreachable() {
179 assert!(!ping(&unreachable_pool()).await);
180 }
181
182 #[tokio::test]
183 async fn should_reject_a_database_url_that_is_not_one() {
184 let result = create_postgres_pool("not a database url").await;
185
186 assert!(matches!(result, Err(DatabaseError::ConnectionError { .. })));
187 }
188
189 #[tokio::test]
190 async fn should_say_where_and_why_the_connection_failed_without_the_password() {
191 let url = "postgres://devuser:s3cret@127.0.0.1:1/shop";
193
194 let started = std::time::Instant::now();
196 let message = create_postgres_pool(url).await.unwrap_err().to_string();
197
198 assert!(message.contains("127.0.0.1:1/shop"), "{message}");
200 assert!(message.to_lowercase().contains("refused"), "{message}");
201 assert!(!message.contains("s3cret"), "{message}");
202 assert!(!message.contains("devuser"), "{message}");
203 assert!(
205 started.elapsed() < Duration::from_secs(5),
206 "{:?}",
207 started.elapsed()
208 );
209 }
210
211 #[test]
212 fn should_describe_a_database_url_by_its_host_and_database_only() {
213 assert_eq!(
214 describe_url("postgres://u:p@db.internal:5432/shop?sslmode=require"),
215 "db.internal:5432/shop"
216 );
217 assert_eq!(describe_url("postgres://localhost/shop"), "localhost/shop");
218 assert_eq!(describe_url("not a database url"), "not a database url");
219 }
220
221 async fn test_database() -> Option<PgPool> {
224 let server = std::env::var("PUERTO_TEST_DATABASE_URL").ok()?;
225 Some(
226 create_postgres_pool(&format!("{server}/postgres"))
227 .await
228 .unwrap(),
229 )
230 }
231
232 #[tokio::test]
233 #[ignore = "needs Postgres: make test/db"]
234 async fn postgres_unique_violation_maps_to_duplicate() {
235 let Some(pool) = test_database().await else {
236 eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
237 return;
238 };
239 let mut conn = pool.acquire().await.unwrap();
240 sqlx::query("CREATE TEMPORARY TABLE skus (sku TEXT UNIQUE)")
242 .execute(&mut *conn)
243 .await
244 .unwrap();
245 sqlx::query("INSERT INTO skus VALUES ('A-1')")
246 .execute(&mut *conn)
247 .await
248 .unwrap();
249
250 let err = sqlx::query("INSERT INTO skus VALUES ('A-1')")
251 .execute(&mut *conn)
252 .await
253 .unwrap_err();
254
255 assert_eq!(map_sqlx_error(&err), RepositoryError::Duplicate);
256 }
257
258 #[tokio::test]
259 #[ignore = "needs Postgres: make test/db"]
260 async fn postgres_ping_reports_ready() {
261 let Some(pool) = test_database().await else {
262 eprintln!("PUERTO_TEST_DATABASE_URL not set — skipping (run `make test/db`)");
263 return;
264 };
265
266 assert!(ping(&pool).await);
267 }
268}