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